From a26e85639885cc531bf1544fb471f74670cd8539 Mon Sep 17 00:00:00 2001 From: wehub-resource-sync Date: Mon, 13 Jul 2026 12:25:07 +0800 Subject: [PATCH] chore: import upstream snapshot with attribution --- .cargo/config.toml | 46 + .clang-format | 235 + .clang-tidy | 18 + .devcontainer/devcontainer.json | 21 + .devcontainer/postcreate.sh | 20 + .gitattributes | 15 + .github/ISSUE_TEMPLATE/Bug_report.md | 124 + .github/ISSUE_TEMPLATE/Feature_request.md | 22 + .github/PULL_REQUEST_TEMPLATE.md | 23 + .github/actions/config/action.yaml | 122 + .github/actions/install-deps/action.yaml | 247 + .github/actions/install-wheel/action.yaml | 42 + .github/workflows/build.yaml | 1087 ++ .gitignore | 71 + .husky/pre-push | 3 + .pnpmrc | 1 + .prettierignore | 14 + .prettierrc.json | 18 + CHANGELOG.md | 3810 ++++++ CODE_OF_CONDUCT.md | 6 + CONTRIBUTING.md | 130 + Cargo.lock | 4345 +++++++ Cargo.toml | 56 + DEVELOPMENT.md | 230 + GOVERNANCE.md | 15 + LICENSE.md | 193 + PROJECT_CHARTER.md | 72 + README.md | 135 + README.wehub.md | 7 + SECURITY.md | 78 + docs/book.toml | 39 + docs/build.config.mjs | 176 + docs/build.mjs | 157 + docs/compose.yml | 16 + docs/deploy.mjs | 68 + docs/index.html | 1 + docs/md/FAQ.md | 571 + docs/md/SUMMARY.md | 83 + docs/md/api_reference.md | 22 + docs/md/explanation.md | 4 + docs/md/explanation/architecture.md | 15 + .../explanation/architecture/architecture.dot | 197 + .../architecture/architecture.sub1.dot | 66 + .../architecture/architecture.sub1.svg | 60 + .../architecture/architecture.sub2.dot | 66 + .../architecture/architecture.sub2.svg | 67 + .../architecture/architecture.sub3.dot | 97 + .../architecture/architecture.sub3.svg | 97 + .../explanation/architecture/architecture.svg | 250 + .../explanation/architecture/client_only.md | 39 + .../explanation/architecture/client_server.md | 58 + .../explanation/architecture/server_only.md | 33 + docs/md/explanation/join.md | 12 + docs/md/explanation/join/join_types.md | 25 + docs/md/explanation/join/options.md | 35 + docs/md/explanation/join/reactivity.md | 46 + docs/md/explanation/python.md | 77 + docs/md/explanation/table.md | 14 + .../md/explanation/table/clear_and_replace.md | 23 + .../explanation/table/constructing_schema.md | 47 + docs/md/explanation/table/loading_data.md | 87 + docs/md/explanation/table/options.md | 59 + docs/md/explanation/table/schema.md | 41 + .../md/explanation/table/update_and_remove.md | 88 + docs/md/explanation/view.md | 70 + docs/md/explanation/view/advanced.md | 231 + .../md/explanation/view/config/expressions.md | 149 + .../view/config/grouping_and_pivots.md | 147 + .../view/config/selection_and_ordering.md | 138 + docs/md/explanation/view/querying.md | 50 + docs/md/explanation/virtual_servers.md | 83 + docs/md/getting_started.md | 5 + docs/md/how_to.md | 3 + docs/md/how_to/javascript.md | 4 + docs/md/how_to/javascript/custom_worker.md | 73 + docs/md/how_to/javascript/deleting.md | 25 + docs/md/how_to/javascript/events.md | 40 + docs/md/how_to/javascript/importing.md | 172 + docs/md/how_to/javascript/installation.md | 54 + docs/md/how_to/javascript/join.md | 65 + docs/md/how_to/javascript/loading_data.md | 76 + docs/md/how_to/javascript/nodejs_server.md | 38 + docs/md/how_to/javascript/plugin_settings.md | 31 + docs/md/how_to/javascript/react.md | 57 + docs/md/how_to/javascript/save_restore.md | 99 + docs/md/how_to/javascript/serializing.md | 21 + docs/md/how_to/javascript/theming.md | 96 + docs/md/how_to/javascript/viewer.md | 62 + docs/md/how_to/javascript/virtual_server.md | 19 + .../javascript/virtual_server/clickhouse.md | 43 + .../javascript/virtual_server/custom.md | 80 + .../javascript/virtual_server/duckdb.md | 49 + docs/md/how_to/javascript/worker.md | 44 + docs/md/how_to/python.md | 4 + docs/md/how_to/python/callbacks.md | 34 + docs/md/how_to/python/installation.md | 27 + docs/md/how_to/python/join.md | 64 + docs/md/how_to/python/jupyterlab.md | 61 + docs/md/how_to/python/multithreading.md | 67 + docs/md/how_to/python/table.md | 90 + docs/md/how_to/python/table_data.md | 81 + docs/md/how_to/python/virtual_server.md | 20 + .../python/virtual_server/clickhouse.md | 52 + .../md/how_to/python/virtual_server/custom.md | 64 + .../md/how_to/python/virtual_server/duckdb.md | 53 + .../md/how_to/python/virtual_server/polars.md | 49 + docs/md/how_to/python/websocket.md | 129 + docs/md/how_to/rust.md | 89 + docs/md/javascript.md | 3 + docs/md/perspective.css | 15 + docs/md/perspective.js | 29 + docs/md/perspective.md | 1 + docs/md/tutorials/python/tornado.md | 109 + docs/package.json | 32 + docs/server.mjs | 25 + docs/src/block.html | 58 + docs/src/block.ts | 93 + docs/src/components/demo.ts | 132 + docs/src/components/gallery.ts | 77 + docs/src/components/theme.ts | 107 + docs/src/css/style.css | 653 + docs/src/data/features.js | 1326 ++ docs/src/data/layouts.ts | 122 + docs/src/data/random.ts | 75 + docs/src/data/superstore.ts | 25 + docs/src/data/worker.ts | 34 + docs/src/examples.html | 59 + docs/src/examples.ts | 75 + docs/src/index.html | 354 + docs/src/index.ts | 37 + docs/static/.nojekyll | 0 docs/static/404.html | 0 docs/static/CNAME | 1 + docs/static/img/jupyterlab.png | Bin 0 -> 19207 bytes docs/static/img/logo/logo-dark.png | Bin 0 -> 2737 bytes docs/static/img/logo/logo-light.png | Bin 0 -> 2849 bytes docs/static/img/mtg_preview.png | Bin 0 -> 220481 bytes docs/static/img/mtg_thumbnail.png | Bin 0 -> 22981 bytes ...penjs_foundation-logo-horizontal-black.png | Bin 0 -> 64541 bytes ...penjs_foundation-logo-horizontal-white.png | Bin 0 -> 57903 bytes .../openjs_foundation-logo-stacked-black.svg | 67 + .../openjs_foundation-logo-stacked-white.svg | 67 + docs/static/svg/feature1.svg | 12 + docs/static/svg/feature2.svg | 5 + docs/static/svg/feature3.svg | 20 + docs/static/svg/perspective-logo-dark.svg | 5 + docs/static/svg/perspective-logo-light.svg | 3 + docs/static/svg/perspective_logo.svg | 73 + docs/template.html | 42 + docs/test/js/examples.spec.mts | 99 + eslint.config.mjs | 101 + examples/README.md | 26 + examples/blocks/README.md | 5 + examples/blocks/examples.js | 52 + examples/blocks/index.mjs | 115 + examples/blocks/package.json | 26 + examples/blocks/server.mjs | 77 + examples/blocks/src/covid/.block | 1 + examples/blocks/src/covid/README.md | 16 + examples/blocks/src/covid/index.html | 62 + examples/blocks/src/covid/preview.png | Bin 0 -> 251530 bytes examples/blocks/src/covid/thumbnail.png | Bin 0 -> 27479 bytes examples/blocks/src/dataset/README.md | 3 + examples/blocks/src/dataset/index.html | 230 + examples/blocks/src/dataset/layout.json | 103 + examples/blocks/src/dataset/preview.png | Bin 0 -> 109680 bytes examples/blocks/src/dataset/thumbnail.png | Bin 0 -> 20998 bytes 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 + examples/blocks/src/duckdb/preview.png | Bin 0 -> 421247 bytes examples/blocks/src/duckdb/thumbnail.png | Bin 0 -> 47900 bytes examples/blocks/src/editable/.block | 1 + examples/blocks/src/editable/README.md | 6 + examples/blocks/src/editable/index.html | 37 + examples/blocks/src/editable/preview.png | Bin 0 -> 325351 bytes examples/blocks/src/editable/thumbnail.png | Bin 0 -> 25074 bytes examples/blocks/src/evictions/.block | 1 + examples/blocks/src/evictions/README.md | 7 + examples/blocks/src/evictions/index.html | 84 + examples/blocks/src/evictions/layout.json | 23 + examples/blocks/src/evictions/preview.png | Bin 0 -> 375057 bytes examples/blocks/src/evictions/thumbnail.png | Bin 0 -> 31447 bytes examples/blocks/src/file/.block | 1 + examples/blocks/src/file/README.md | 13 + examples/blocks/src/file/file.js | 108 + examples/blocks/src/file/index.css | 71 + examples/blocks/src/file/index.html | 21 + examples/blocks/src/file/preview.png | Bin 0 -> 239819 bytes examples/blocks/src/file/thumbnail.png | Bin 0 -> 30993 bytes examples/blocks/src/fractal/.block | 2 + examples/blocks/src/fractal/README.md | 12 + examples/blocks/src/fractal/index.css | 224 + examples/blocks/src/fractal/index.html | 37 + examples/blocks/src/fractal/index.js | 163 + examples/blocks/src/fractal/preview.png | Bin 0 -> 430901 bytes examples/blocks/src/fractal/thumbnail.png | Bin 0 -> 34823 bytes examples/blocks/src/market/README.md | 55 + examples/blocks/src/market/index.css | 93 + examples/blocks/src/market/index.html | 24 + examples/blocks/src/market/layouts.json | 290 + examples/blocks/src/market/market.js | 292 + examples/blocks/src/market/preview.png | Bin 0 -> 256126 bytes examples/blocks/src/market/thumbnail.png | Bin 0 -> 40931 bytes examples/blocks/src/movies/.block | 2 + examples/blocks/src/movies/README.md | 11 + examples/blocks/src/movies/index.html | 77 + examples/blocks/src/movies/layout.json | 133 + examples/blocks/src/movies/preview.png | Bin 0 -> 135265 bytes examples/blocks/src/movies/thumbnail.png | Bin 0 -> 33742 bytes examples/blocks/src/nypd/README.md | 3 + examples/blocks/src/nypd/build.mjs | 78 + examples/blocks/src/nypd/dark.css | 14 + examples/blocks/src/nypd/index.css | 73 + examples/blocks/src/nypd/index.html | 16 + examples/blocks/src/nypd/index.js | 186 + examples/blocks/src/nypd/layout.json | 426 + examples/blocks/src/nypd/light.css | 8 + examples/blocks/src/nypd/preview.png | Bin 0 -> 223752 bytes examples/blocks/src/nypd/thumbnail.png | Bin 0 -> 36788 bytes examples/blocks/src/olympics/.block | 2 + examples/blocks/src/olympics/README.md | 6 + examples/blocks/src/olympics/build.mjs | 63 + examples/blocks/src/olympics/index.html | 90 + examples/blocks/src/olympics/preview.png | Bin 0 -> 231933 bytes examples/blocks/src/olympics/thumbnail.png | Bin 0 -> 32214 bytes examples/blocks/src/raycasting/.block | 2 + examples/blocks/src/raycasting/README.md | 7 + examples/blocks/src/raycasting/index.css | 41 + examples/blocks/src/raycasting/index.html | 14 + examples/blocks/src/raycasting/index.js | 145 + examples/blocks/src/raycasting/preview.png | Bin 0 -> 130610 bytes examples/blocks/src/raycasting/thumbnail.png | Bin 0 -> 26461 bytes examples/blocks/src/streaming/.block | 1 + examples/blocks/src/streaming/README.md | 8 + examples/blocks/src/streaming/index.css | 20 + examples/blocks/src/streaming/index.html | 14 + examples/blocks/src/streaming/preview.png | Bin 0 -> 229259 bytes examples/blocks/src/streaming/streaming.js | 109 + examples/blocks/src/streaming/thumbnail.png | Bin 0 -> 26755 bytes examples/blocks/src/superstore/.block | 1 + examples/blocks/src/superstore/README.md | 9 + examples/blocks/src/superstore/index.html | 50 + examples/blocks/src/superstore/index.json | 169 + examples/blocks/src/superstore/layout.json | 88 + examples/blocks/src/superstore/preview.png | Bin 0 -> 142958 bytes examples/blocks/src/superstore/thumbnail.png | Bin 0 -> 21798 bytes examples/blocks/src/webcam/README.md | 2 + examples/blocks/src/webcam/index.css | 93 + examples/blocks/src/webcam/index.html | 38 + examples/blocks/src/webcam/layouts.json | 152 + examples/blocks/src/webcam/preview.png | Bin 0 -> 140278 bytes examples/blocks/src/webcam/thumbnail.png | Bin 0 -> 34887 bytes examples/blocks/src/webcam/webcam.js | 95 + examples/esbuild-clickhouse-virtual/build.js | 43 + .../esbuild-clickhouse-virtual/package.json | 25 + .../esbuild-clickhouse-virtual/server.mjs | 29 + .../esbuild-clickhouse-virtual/src/index.html | 25 + .../esbuild-clickhouse-virtual/src/index.ts | 47 + examples/esbuild-duckdb-virtual/build.js | 43 + examples/esbuild-duckdb-virtual/package.json | 25 + examples/esbuild-duckdb-virtual/server.mjs | 29 + .../esbuild-duckdb-virtual/src/index.html | 25 + examples/esbuild-duckdb-virtual/src/index.ts | 87 + examples/esbuild-example/README.md | 5 + examples/esbuild-example/build.js | 38 + examples/esbuild-example/package.json | 23 + examples/esbuild-example/src/index.css | 20 + examples/esbuild-example/src/index.html | 17 + examples/esbuild-example/src/index.js | 38 + examples/esbuild-remote/build.js | 38 + examples/esbuild-remote/client/index.css | 21 + examples/esbuild-remote/client/index.html | 13 + examples/esbuild-remote/client/index.js | 50 + examples/esbuild-remote/package.json | 21 + examples/esbuild-remote/server/index.mjs | 106 + examples/esbuild-remote/server/securities.mjs | 153 + examples/python-aiohttp/README.md | 8 + examples/python-aiohttp/package.json | 11 + examples/python-aiohttp/server.py | 55 + examples/python-clickhouse-virtual/index.html | 29 + .../python-clickhouse-virtual/package.json | 22 + examples/python-clickhouse-virtual/server.py | 103 + examples/python-duckdb-virtual/index.html | 29 + examples/python-duckdb-virtual/package.json | 22 + examples/python-duckdb-virtual/server.py | 70 + examples/python-polars-virtual/index.html | 29 + examples/python-polars-virtual/package.json | 22 + examples/python-polars-virtual/server.py | 61 + examples/python-starlette/README.md | 8 + examples/python-starlette/package.json | 11 + examples/python-starlette/server.py | 60 + examples/python-tornado-streaming/index.html | 61 + .../python-tornado-streaming/package.json | 22 + examples/python-tornado-streaming/server.py | 136 + examples/python-tornado/README.md | 16 + .../python-tornado/client_server_editing.html | 99 + examples/python-tornado/index.html | 44 + examples/python-tornado/package.json | 22 + examples/python-tornado/server.py | 65 + examples/python-tornado/server_mode.html | 38 + examples/react-example/build.js | 38 + examples/react-example/globals.d.ts | 21 + examples/react-example/package.json | 29 + examples/react-example/src/index.css | 69 + examples/react-example/src/index.html | 19 + examples/react-example/src/index.tsx | 167 + examples/react-example/tsconfig.json | 36 + examples/rust-axum/Cargo.toml | 38 + examples/rust-axum/package.json | 15 + examples/rust-axum/src/index.html | 38 + examples/rust-axum/src/main.rs | 91 + examples/vite-example/README.md | 1 + examples/vite-example/index.html | 14 + examples/vite-example/package.json | 20 + examples/vite-example/src/index.css | 20 + examples/vite-example/src/index.js | 36 + examples/vite-example/vite.config.js | 19 + examples/webpack-example/README.md | 3 + examples/webpack-example/package.json | 27 + examples/webpack-example/src/index.css | 20 + examples/webpack-example/src/index.js | 40 + examples/webpack-example/webpack.config.js | 55 + package.json | 100 + packages/cli/README.md | 24 + packages/cli/package.json | 36 + packages/cli/perspective | 3 + packages/cli/src/html/index.html | 55 + packages/cli/src/js/index.js | 178 + packages/cli/src/js/utils.js | 74 + packages/cli/test/csv/test.csv | 4 + packages/cli/test/js/superstore.spec.js | 51 + packages/jupyterlab/DEVELOPMENT.md | 16 + packages/jupyterlab/README.md | 21 + packages/jupyterlab/build.mjs | 142 + packages/jupyterlab/clean.mjs | 24 + packages/jupyterlab/install.json | 5 + packages/jupyterlab/package.json | 65 + packages/jupyterlab/src/css/index.css | 54 + packages/jupyterlab/src/js/index.js | 43 + packages/jupyterlab/src/js/model.js | 43 + packages/jupyterlab/src/js/notebook/css.js | 20 + .../jupyterlab/src/js/notebook/extension.js | 32 + packages/jupyterlab/src/js/notebook/index.js | 26 + packages/jupyterlab/src/js/plugin.js | 38 + packages/jupyterlab/src/js/psp_widget.js | 199 + packages/jupyterlab/src/js/renderer.js | 374 + packages/jupyterlab/src/js/utils.js | 15 + packages/jupyterlab/src/js/version.js | 14 + packages/jupyterlab/src/js/view.js | 420 + packages/jupyterlab/src/js/widget.js | 54 + packages/jupyterlab/test/arrow/test.arrow | Bin 0 -> 3680 bytes .../test/config/jupyter/globalSetup.ts | 26 + .../test/config/jupyter/globalTeardown.ts | 19 + .../test/config/jupyter/jlab_start.ts | 118 + .../jupyter/jupyter_notebook_config.json | 18 + .../config/jupyter/jupyter_server_config.json | 10 + .../shortcuts.jupyterlab-settings | 12 + packages/jupyterlab/test/html/resize.html | 26 + packages/jupyterlab/test/js/resize.spec.mjs | 114 + .../test/jupyter/notebook_template.json | 24 + packages/jupyterlab/test/jupyter/utils.mjs | 202 + .../jupyterlab/test/jupyter/widget.spec.mjs | 598 + packages/jupyterlab/webpack.config.js | 24 + packages/react/build.mjs | 44 + packages/react/clean.mjs | 15 + packages/react/docs.mjs | 15 + packages/react/package.json | 51 + packages/react/src/index.tsx | 24 + packages/react/src/utils.tsx | 27 + packages/react/src/viewer.tsx | 86 + packages/react/src/workspace.tsx | 95 + packages/react/test/js/basic.story.tsx | 106 + packages/react/test/js/index.css | 68 + packages/react/test/js/react.spec.tsx | 93 + packages/react/test/js/workspace.story.tsx | 154 + packages/react/tsconfig.json | 17 + packages/viewer-charts/build.mjs | 192 + packages/viewer-charts/clean.mjs | 15 + packages/viewer-charts/package.json | 47 + .../src/css/perspective-viewer-charts.css | 95 + .../src/ts/axis/axis-primitives.ts | 125 + .../viewer-charts/src/ts/axis/bar-axis.ts | 374 + packages/viewer-charts/src/ts/axis/canvas.ts | 64 + .../src/ts/axis/categorical-axis-core.ts | 125 + .../src/ts/axis/categorical-axis.ts | 714 ++ .../viewer-charts/src/ts/axis/facet-chrome.ts | 42 + .../src/ts/axis/label-geometry.ts | 188 + packages/viewer-charts/src/ts/axis/legend.ts | 218 + .../viewer-charts/src/ts/axis/numeric-axis.ts | 353 + .../charts/candlestick/candlestick-build.ts | 516 + .../candlestick/candlestick-interact.ts | 256 + .../charts/candlestick/candlestick-render.ts | 390 + .../src/ts/charts/candlestick/candlestick.ts | 348 + .../candlestick/glyphs/draw-candlesticks.ts | 466 + .../ts/charts/candlestick/glyphs/draw-ohlc.ts | 351 + .../src/ts/charts/canvas-types.ts | 30 + .../ts/charts/cartesian/cartesian-build.ts | 750 ++ .../ts/charts/cartesian/cartesian-interact.ts | 355 + .../ts/charts/cartesian/cartesian-render.ts | 1123 ++ .../src/ts/charts/cartesian/cartesian.ts | 508 + .../src/ts/charts/cartesian/glyph.ts | 81 + .../src/ts/charts/cartesian/glyphs/density.ts | 1258 ++ .../src/ts/charts/cartesian/glyphs/lines.ts | 318 + .../src/ts/charts/cartesian/glyphs/points.ts | 234 + .../src/ts/charts/cartesian/label-interner.ts | 56 + .../src/ts/charts/cartesian/tooltip-lines.ts | 80 + .../viewer-charts/src/ts/charts/chart-base.ts | 854 ++ packages/viewer-charts/src/ts/charts/chart.ts | 427 + .../src/ts/charts/common/band-layout.ts | 63 + .../ts/charts/common/categorical-y-chart.ts | 81 + .../charts/common/category-axis-resolver.ts | 448 + .../src/ts/charts/common/chrome-cache.ts | 79 + .../src/ts/charts/common/draw-tooltip-box.ts | 84 + .../src/ts/charts/common/expand-domain.ts | 40 + .../src/ts/charts/common/leaf-color.ts | 92 + .../src/ts/charts/common/node-store.ts | 235 + .../src/ts/charts/common/tree-chart.ts | 92 + .../src/ts/charts/common/tree-chrome.ts | 208 + .../src/ts/charts/common/tree-data.ts | 623 + .../src/ts/charts/common/tree-interact.ts | 209 + .../src/ts/charts/common/visible-extent.ts | 112 + .../src/ts/charts/heatmap/heatmap-build.ts | 426 + .../src/ts/charts/heatmap/heatmap-interact.ts | 274 + .../src/ts/charts/heatmap/heatmap-render.ts | 813 ++ .../src/ts/charts/heatmap/heatmap-y-axis.ts | 351 + .../src/ts/charts/heatmap/heatmap.ts | 368 + .../viewer-charts/src/ts/charts/map/map.ts | 201 + .../viewer-charts/src/ts/charts/registry.ts | 65 + .../src/ts/charts/series/glyphs/draw-areas.ts | 360 + .../src/ts/charts/series/glyphs/draw-bars.ts | 113 + .../src/ts/charts/series/glyphs/draw-lines.ts | 395 + .../ts/charts/series/glyphs/draw-scatter.ts | 328 + .../src/ts/charts/series/series-build.ts | 1221 ++ .../src/ts/charts/series/series-interact.ts | 604 + .../src/ts/charts/series/series-render.ts | 1230 ++ .../src/ts/charts/series/series-type.ts | 119 + .../src/ts/charts/series/series.ts | 789 ++ .../ts/charts/sunburst/sunburst-interact.ts | 316 + .../src/ts/charts/sunburst/sunburst-layout.ts | 238 + .../src/ts/charts/sunburst/sunburst-render.ts | 822 ++ .../src/ts/charts/sunburst/sunburst.ts | 234 + .../src/ts/charts/treemap/treemap-interact.ts | 278 + .../src/ts/charts/treemap/treemap-layout.ts | 328 + .../src/ts/charts/treemap/treemap-render.ts | 859 ++ .../src/ts/charts/treemap/treemap.ts | 232 + packages/viewer-charts/src/ts/config.ts | 61 + .../viewer-charts/src/ts/data/lazy-row.ts | 140 + .../viewer-charts/src/ts/data/split-groups.ts | 97 + .../viewer-charts/src/ts/data/view-reader.ts | 107 + packages/viewer-charts/src/ts/event-detail.ts | 44 + packages/viewer-charts/src/ts/index.ts | 53 + .../src/ts/interaction/hit-test.ts | 106 + .../src/ts/interaction/host-sink-dom.ts | 85 + .../src/ts/interaction/host-sink-message.ts | 86 + .../src/ts/interaction/lazy-tooltip.ts | 102 + .../src/ts/interaction/raw-event-forwarder.ts | 173 + .../src/ts/interaction/spatial-grid.ts | 100 + .../src/ts/interaction/tooltip-controller.ts | 407 + .../src/ts/interaction/zoom-controller.ts | 505 + .../src/ts/interaction/zoom-router.ts | 107 + .../viewer-charts/src/ts/layout/facet-grid.ts | 346 + .../src/ts/layout/plot-layout.ts | 277 + packages/viewer-charts/src/ts/layout/ticks.ts | 168 + packages/viewer-charts/src/ts/map/mercator.ts | 204 + .../viewer-charts/src/ts/map/tile-cache.ts | 96 + .../viewer-charts/src/ts/map/tile-layer.ts | 382 + .../viewer-charts/src/ts/map/tile-loader.ts | 143 + .../viewer-charts/src/ts/map/tile-source.ts | 156 + .../viewer-charts/src/ts/plugin/charts.ts | 286 + .../viewer-charts/src/ts/plugin/plugin.ts | 648 + .../viewer-charts/src/ts/render/scheduler.ts | 422 + .../src/ts/shaders/area.frag.glsl | 20 + .../src/ts/shaders/area.vert.glsl | 19 + .../src/ts/shaders/bar.frag.glsl | 25 + .../src/ts/shaders/bar.vert.glsl | 60 + .../src/ts/shaders/candlestick-body.frag.glsl | 19 + .../src/ts/shaders/candlestick-body.vert.glsl | 34 + .../src/ts/shaders/density-extreme.frag.glsl | 30 + .../src/ts/shaders/density-mrt.frag.glsl | 44 + .../src/ts/shaders/density-mrt.vert.glsl | 48 + .../src/ts/shaders/density-resolve.frag.glsl | 89 + .../src/ts/shaders/density-resolve.vert.glsl | 23 + .../src/ts/shaders/density-splat.frag.glsl | 34 + .../src/ts/shaders/density-splat.vert.glsl | 52 + .../src/ts/shaders/gridline.frag.glsl | 18 + .../src/ts/shaders/gridline.vert.glsl | 18 + .../src/ts/shaders/heatmap.frag.glsl | 23 + .../src/ts/shaders/heatmap.vert.glsl | 42 + .../src/ts/shaders/line-uniform.frag.glsl | 27 + .../src/ts/shaders/line-uniform.vert.glsl | 73 + .../src/ts/shaders/line.frag.glsl | 28 + .../src/ts/shaders/line.vert.glsl | 87 + .../src/ts/shaders/scatter.frag.glsl | 39 + .../src/ts/shaders/scatter.vert.glsl | 67 + .../src/ts/shaders/sunburst-arc.frag.glsl | 19 + .../src/ts/shaders/sunburst-arc.vert.glsl | 79 + .../src/ts/shaders/tile.frag.glsl | 27 + .../src/ts/shaders/tile.vert.glsl | 35 + .../src/ts/shaders/treemap.frag.glsl | 19 + .../src/ts/shaders/treemap.vert.glsl | 25 + .../src/ts/shaders/y-scatter.frag.glsl | 30 + .../src/ts/shaders/y-scatter.vert.glsl | 31 + .../viewer-charts/src/ts/theme/gradient.ts | 312 + .../viewer-charts/src/ts/theme/palette.ts | 61 + .../src/ts/theme/theme-snapshot.ts | 66 + packages/viewer-charts/src/ts/theme/theme.ts | 166 + .../src/ts/transport/protocol.ts | 492 + .../src/ts/transport/renderer-transport.ts | 788 ++ packages/viewer-charts/src/ts/utils/css.ts | 36 + .../src/ts/utils/font-snapshot.ts | 159 + .../viewer-charts/src/ts/webgl/buffer-pool.ts | 163 + .../src/ts/webgl/context-manager.ts | 420 + .../src/ts/webgl/context-pool.ts | 78 + .../viewer-charts/src/ts/webgl/gl-context.ts | 191 + .../src/ts/webgl/gradient-texture.ts | 84 + .../src/ts/webgl/instanced-attrs.ts | 139 + .../viewer-charts/src/ts/webgl/plot-frame.ts | 91 + .../src/ts/webgl/program-cache.ts | 46 + .../src/ts/webgl/shader-manifest.ts | 148 + .../src/ts/webgl/shader-registry.ts | 97 + packages/viewer-charts/src/ts/worker/boot.ts | 22 + .../viewer-charts/src/ts/worker/dispatch.ts | 98 + .../src/ts/worker/font-loader.ts | 89 + .../src/ts/worker/renderer.worker.ts | 741 ++ .../src/ts/worker/session-host.ts | 147 + .../test/ts/chart-type-switch.spec.ts | 154 + .../test/ts/context-leak.spec.ts | 142 + .../test/ts/context-pool.spec.ts | 101 + packages/viewer-charts/test/ts/events.spec.ts | 568 + .../test/ts/frame-timing.spec.ts | 397 + packages/viewer-charts/test/ts/helpers.ts | 764 ++ .../viewer-charts/test/ts/multi-chart.spec.ts | 218 + .../test/ts/plugin-config-toggle.spec.ts | 100 + .../test/ts/snapshot/candlestick.spec.ts | 62 + .../snapshot/categorical-value-axis.spec.ts | 75 + .../ts/snapshot/datetime-subsecond.spec.ts | 76 + .../test/ts/snapshot/gradient-heatmap.spec.ts | 86 + .../test/ts/snapshot/heatmap.spec.ts | 159 + .../test/ts/snapshot/line.spec.ts | 46 + .../test/ts/snapshot/numeric-axis.spec.ts | 230 + .../test/ts/snapshot/pan.spec.ts | 46 + .../test/ts/snapshot/regressions.spec.ts | 104 + .../test/ts/snapshot/scatter.spec.ts | 79 + .../test/ts/snapshot/sunburst.spec.ts | 110 + .../test/ts/snapshot/tooltip.spec.ts | 51 + .../test/ts/snapshot/treemap.spec.ts | 138 + .../test/ts/snapshot/x-bar.spec.ts | 53 + .../test/ts/snapshot/y-area.spec.ts | 37 + .../test/ts/snapshot/y-bar.spec.ts | 53 + .../test/ts/snapshot/y-line.spec.ts | 42 + .../test/ts/snapshot/y-ohlc.spec.ts | 45 + .../test/ts/snapshot/y-scatter.spec.ts | 37 + .../test/ts/snapshot/zoom.spec.ts | 138 + packages/viewer-charts/test/ts/tsconfig.json | 8 + .../viewer-charts/test/ts/xy_symbols.spec.ts | 126 + packages/viewer-charts/tsconfig.json | 17 + packages/viewer-charts/types.d.ts | 21 + packages/viewer-datagrid/build.mjs | 85 + packages/viewer-datagrid/clean.mjs | 15 + packages/viewer-datagrid/index.d.ts | 37 + packages/viewer-datagrid/package.json | 48 + .../src/css/mitered-headers.css | 64 + .../src/css/perspective-viewer-datagrid.css | 6 + packages/viewer-datagrid/src/css/pro.css | 197 + .../viewer-datagrid/src/css/regular_table.css | 625 + .../viewer-datagrid/src/css/row-hover.css | 107 + .../viewer-datagrid/src/css/scrollbar.css | 35 + .../src/css/sub-cell-scroll.css | 83 + packages/viewer-datagrid/src/css/toolbar.css | 219 + .../viewer-datagrid/src/ts/color_utils.ts | 234 + .../src/ts/custom_elements/datagrid.ts | 269 + .../src/ts/custom_elements/toolbar.ts | 74 + .../src/ts/data_listener/format_cell.ts | 103 + .../ts/data_listener/format_tree_header.ts | 98 + .../src/ts/data_listener/formatter_cache.ts | 104 + .../src/ts/data_listener/index.ts | 264 + .../src/ts/event_handlers/click.ts | 63 + .../src/ts/event_handlers/click/edit_click.ts | 80 + .../src/ts/event_handlers/dispatch_click.ts | 44 + .../src/ts/event_handlers/expand_collapse.ts | 48 + .../src/ts/event_handlers/focus.ts | 66 + .../src/ts/event_handlers/header_click.ts | 130 + .../ts/event_handlers/keydown/edit_keydown.ts | 219 + .../src/ts/event_handlers/select_region.ts | 461 + .../src/ts/event_handlers/sort.ts | 116 + .../viewer-datagrid/src/ts/get_cell_config.ts | 76 + packages/viewer-datagrid/src/ts/index.ts | 49 + .../src/ts/model/column_overrides.ts | 119 + .../viewer-datagrid/src/ts/model/create.ts | 257 + .../viewer-datagrid/src/ts/model/index.ts | 19 + .../src/ts/model/meta_columns.ts | 47 + .../viewer-datagrid/src/ts/model/toolbar.ts | 89 + .../viewer-datagrid/src/ts/plugin/activate.ts | 265 + .../src/ts/plugin/column_config_schema.ts | 187 + .../viewer-datagrid/src/ts/plugin/draw.ts | 71 + .../viewer-datagrid/src/ts/plugin/restore.ts | 114 + .../viewer-datagrid/src/ts/plugin/save.ts | 42 + .../src/ts/style_handlers/body.ts | 227 + .../src/ts/style_handlers/column_header.ts | 196 + .../src/ts/style_handlers/consolidated.ts | 118 + .../src/ts/style_handlers/editable.ts | 100 + .../src/ts/style_handlers/focus.ts | 102 + .../src/ts/style_handlers/group_header.ts | 88 + .../ts/style_handlers/table_cell/boolean.ts | 36 + .../style_handlers/table_cell/cell_flash.ts | 75 + .../ts/style_handlers/table_cell/datetime.ts | 64 + .../ts/style_handlers/table_cell/numeric.ts | 185 + .../style_handlers/table_cell/row_header.ts | 54 + .../ts/style_handlers/table_cell/string.ts | 104 + .../src/ts/style_handlers/types.ts | 31 + packages/viewer-datagrid/src/ts/types.ts | 297 + .../test/js/column_settings.spec.ts | 318 + .../test/js/column_style.spec.ts | 717 ++ .../test/js/number_string_format.spec.ts | 204 + .../viewer-datagrid/test/js/scroll.spec.ts | 443 + .../test/js/superstore.spec.ts | 276 + .../viewer-datagrid/test/js/tsconfig.json | 9 + packages/viewer-datagrid/tsconfig.json | 17 + packages/viewer-datagrid/types.d.ts | 21 + packages/workspace/build.mjs | 124 + packages/workspace/clean.mjs | 15 + packages/workspace/package.json | 56 + packages/workspace/src/css/dockpanel.css | 110 + packages/workspace/src/css/injected.css | 15 + packages/workspace/src/css/menu.css | 130 + packages/workspace/src/css/tabbar.css | 378 + packages/workspace/src/css/viewer.css | 112 + packages/workspace/src/css/widget.css | 44 + packages/workspace/src/css/workspace.css | 84 + packages/workspace/src/html/workspace.html | 11 + packages/workspace/src/svg/bookmark-icon.svg | 4 + packages/workspace/src/svg/drag-handle.svg | 10 + packages/workspace/src/themes/pro-dark.css | 125 + packages/workspace/src/themes/pro.css | 121 + packages/workspace/src/ts/extensions.ts | 82 + packages/workspace/src/ts/external.d.ts | 21 + packages/workspace/src/ts/external.js | 14 + .../workspace/src/ts/perspective-workspace.ts | 266 + .../workspace/src/ts/utils/custom_elements.ts | 95 + .../workspace/src/ts/utils/observable_map.ts | 39 + .../workspace/src/ts/workspace/commands.ts | 272 + .../workspace/src/ts/workspace/dockpanel.ts | 156 + packages/workspace/src/ts/workspace/index.ts | 13 + packages/workspace/src/ts/workspace/menu.ts | 213 + packages/workspace/src/ts/workspace/tabbar.ts | 243 + .../src/ts/workspace/tabbarrenderer.ts | 74 + packages/workspace/src/ts/workspace/widget.ts | 107 + .../workspace/src/ts/workspace/workspace.ts | 1313 ++ packages/workspace/test/csv/superstore.csv | 100 + .../workspace/test/js/context_menu.spec.js | 136 + packages/workspace/test/js/delete.spec.js | 81 + packages/workspace/test/js/dom.spec.js | 78 + .../workspace/test/js/global_filter.spec.js | 472 + packages/workspace/test/js/html.spec.js | 117 + packages/workspace/test/js/restore.spec.js | 260 + packages/workspace/test/js/table.spec.js | 164 + packages/workspace/test/js/visibility.spec.js | 113 + packages/workspace/tsconfig.json | 14 + pnpm-lock.yaml | 10278 ++++++++++++++++ pnpm-workspace.yaml | 98 + rust-toolchain.toml | 16 + rust/bundle/Cargo.toml | 29 + rust/bundle/build.rs | 18 + rust/bundle/main.rs | 139 + rust/lint/Cargo.toml | 30 + rust/lint/main.rs | 52 + rust/metadata/Cargo.toml | 45 + rust/metadata/build.mjs | 27 + rust/metadata/main.rs | 109 + rust/metadata/package.json | 18 + rust/perspective-client/Cargo.toml | 97 + rust/perspective-client/build.rs | 92 + rust/perspective-client/package.json | 15 + rust/perspective-client/perspective.proto | 584 + rust/perspective-client/src/rust/client.rs | 809 ++ .../src/rust/config/aggregates.rs | 81 + .../src/rust/config/column_type.rs | 74 + .../src/rust/config/expressions.rs | 685 + .../src/rust/config/filters.rs | 202 + .../perspective-client/src/rust/config/mod.rs | 37 + .../src/rust/config/plugin.rs | 69 + .../src/rust/config/sort.rs | 146 + .../src/rust/config/view_config.rs | 479 + rust/perspective-client/src/rust/lib.rs | 149 + rust/perspective-client/src/rust/session.rs | 110 + rust/perspective-client/src/rust/table.rs | 620 + .../perspective-client/src/rust/table_data.rs | 79 + rust/perspective-client/src/rust/table_ref.rs | 53 + .../src/rust/utils/clone.rs | 141 + .../src/rust/utils/logging.rs | 156 + rust/perspective-client/src/rust/utils/mod.rs | 200 + .../src/rust/utils/rand_sequence.rs | 87 + .../src/rust/utils/talc_allocator.rs | 25 + .../src/rust/utils/tests/clone.rs | 83 + .../src/rust/utils/tests/mod.rs | 13 + rust/perspective-client/src/rust/view.rs | 638 + .../src/rust/virtual_server/data.rs | 1129 ++ .../src/rust/virtual_server/error.rs | 58 + .../src/rust/virtual_server/features.rs | 120 + .../rust/virtual_server/generic_sql_model.rs | 341 + .../generic_sql_model/table_make_view.rs | 649 + .../virtual_server/generic_sql_model/tests.rs | 620 + .../src/rust/virtual_server/handler.rs | 173 + .../src/rust/virtual_server/mod.rs | 33 + .../src/rust/virtual_server/server.rs | 451 + rust/perspective-js/Cargo.toml | 110 + rust/perspective-js/README.md | 3 + rust/perspective-js/build.mjs | 159 + rust/perspective-js/clean.mjs | 17 + rust/perspective-js/docs.mjs | 17 + rust/perspective-js/docs/index.html | 26 + rust/perspective-js/package.json | 96 + rust/perspective-js/src/rust/client.rs | 589 + .../src/rust/generic_sql_model.rs | 205 + rust/perspective-js/src/rust/lib.rs | 88 + rust/perspective-js/src/rust/table.rs | 365 + rust/perspective-js/src/rust/table_data.rs | 164 + rust/perspective-js/src/rust/typed_array.rs | 243 + rust/perspective-js/src/rust/utils/browser.rs | 35 + .../src/rust/utils/console_logger.rs | 237 + rust/perspective-js/src/rust/utils/errors.rs | 274 + rust/perspective-js/src/rust/utils/futures.rs | 179 + rust/perspective-js/src/rust/utils/json.rs | 252 + .../src/rust/utils/local_poll_loop.rs | 63 + rust/perspective-js/src/rust/utils/mod.rs | 32 + rust/perspective-js/src/rust/utils/serde.rs | 46 + .../src/rust/utils/trace_allocator.rs | 98 + rust/perspective-js/src/rust/view.rs | 402 + .../perspective-js/src/rust/virtual_server.rs | 817 ++ .../src/ts/perspective-server.worker.ts | 64 + .../src/ts/perspective.browser.ts | 242 + rust/perspective-js/src/ts/perspective.cdn.ts | 22 + .../src/ts/perspective.inline.ts | 27 + .../perspective-js/src/ts/perspective.node.ts | 524 + rust/perspective-js/src/ts/virtual_server.ts | 121 + .../src/ts/virtual_servers/clickhouse.ts | 352 + .../src/ts/virtual_servers/duckdb.ts | 312 + rust/perspective-js/src/ts/wasm/browser.ts | 131 + rust/perspective-js/src/ts/wasm/decompress.ts | 64 + .../src/ts/wasm/emscripten_api.ts | 88 + rust/perspective-js/src/ts/wasm/engine.ts | 316 + .../src/ts/wasm/perspective-server.poly.ts | 414 + rust/perspective-js/src/ts/websocket.ts | 95 + .../test/arrow/all_types_small.arrow | Bin 0 -> 3680 bytes .../test/arrow/all_types_small_multi.arrow | Bin 0 -> 5952 bytes .../test/arrow/bad_row_count.arrow | Bin 0 -> 392 bytes rust/perspective-js/test/arrow/bool.arrow | Bin 0 -> 450 bytes rust/perspective-js/test/arrow/chunked.arrow | Bin 0 -> 6114 bytes rust/perspective-js/test/arrow/date32.arrow | Bin 0 -> 1112 bytes rust/perspective-js/test/arrow/date64.arrow | Bin 0 -> 1576 bytes rust/perspective-js/test/arrow/dict.arrow | Bin 0 -> 936 bytes .../test/arrow/dict_update.arrow | Bin 0 -> 920 bytes rust/perspective-js/test/arrow/float32.arrow | Bin 0 -> 792 bytes .../test/arrow/int_float_str.arrow | Bin 0 -> 584 bytes .../test/arrow/int_float_str_file.arrow | Bin 0 -> 866 bytes .../test/arrow/int_float_str_update.arrow | Bin 0 -> 544 bytes rust/perspective-js/test/arrow/lists.arrow | Bin 0 -> 2790 bytes .../test/arrow/number_types.arrow | Bin 0 -> 1592 bytes rust/perspective-js/test/arrow/partial.arrow | Bin 0 -> 424 bytes .../test/arrow/partial_missing_rows.arrow | Bin 0 -> 408 bytes rust/perspective-js/test/arrow/test.arrow | Bin 0 -> 4874 bytes .../perspective-js/test/arrow/test_null.arrow | Bin 0 -> 5178 bytes rust/perspective-js/test/arrow/untitled.json | 1 + rust/perspective-js/test/arrow/untitled2.json | 1 + rust/perspective-js/test/arrow/untitled3.json | 1 + rust/perspective-js/test/arrow/untitled4.json | 1 + rust/perspective-js/test/arrow/untitled5.json | 1 + .../test/html/service_worker.html | 22 + .../test/html/shared_worker.html | 18 + rust/perspective-js/test/html/test.html | 4 + rust/perspective-js/test/html/worker.html | 18 + .../perspective-js/test/js/aggregates.spec.js | 2485 ++++ rust/perspective-js/test/js/clear.spec.ts | 209 + .../test/js/constructors.spec.js | 2175 ++++ .../test/js/constructors/arrow.spec.ts | 191 + .../test/js/constructors/csv.spec.ts | 66 + .../js/constructors/date_timezone.spec.ts | 111 + .../test/js/constructors/json.spec.ts | 90 + rust/perspective-js/test/js/delete.spec.js | 121 + rust/perspective-js/test/js/delta.spec.js | 1699 +++ .../test/js/duckdb/client.spec.js | 22 + .../test/js/duckdb/combined.spec.js | 286 + .../test/js/duckdb/data_types.spec.js | 100 + .../test/js/duckdb/expressions.spec.js | 89 + .../test/js/duckdb/filter.spec.js | 184 + .../test/js/duckdb/group_by.spec.js | 194 + .../test/js/duckdb/group_rollup_mode.spec.js | 256 + .../test/js/duckdb/min_max.spec.js | 69 + rust/perspective-js/test/js/duckdb/setup.js | 90 + .../test/js/duckdb/sort.spec.js | 95 + .../test/js/duckdb/split_by.spec.js | 124 + .../test/js/duckdb/table.spec.js | 78 + .../test/js/duckdb/view.spec.js | 91 + .../test/js/duckdb/viewport.spec.js | 53 + .../test/js/expressions/common.js | 163 + .../test/js/expressions/conversions.spec.js | 1033 ++ .../test/js/expressions/datetime.spec.js | 1636 +++ .../test/js/expressions/deltas.spec.js | 591 + .../test/js/expressions/functionality.spec.js | 3374 +++++ .../test/js/expressions/invariant.js | 385 + .../js/expressions/multiple_views.spec.js | 1472 +++ .../test/js/expressions/numeric.spec.js | 1862 +++ .../test/js/expressions/parsing.spec.js | 71 + .../test/js/expressions/string.spec.js | 2545 ++++ .../test/js/expressions/string_slices.spec.js | 143 + .../test/js/expressions/updates.spec.js | 1470 +++ .../test/js/expressions/vectors.spec.js | 154 + rust/perspective-js/test/js/filters.spec.js | 993 ++ .../test/js/get_min_max.spec.js | 197 + rust/perspective-js/test/js/group_by.spec.js | 1205 ++ .../test/js/group_rollup_mode.spec.js | 624 + .../test/js/hosted_tables.spec.js | 59 + rust/perspective-js/test/js/internal.spec.js | 60 + .../test/js/joins/inner_join.spec.ts | 276 + .../test/js/joins/inner_join_indexed.spec.ts | 248 + .../test/js/joins/left_join.spec.ts | 183 + .../test/js/joins/outer_join.spec.ts | 208 + .../test/js/joins/right_on.spec.ts | 235 + rust/perspective-js/test/js/leaks.spec.js | 317 + rust/perspective-js/test/js/limit.spec.js | 102 + rust/perspective-js/test/js/lz4.spec.js | 54 + .../test/js/multi_server.spec.js | 66 + rust/perspective-js/test/js/multiple.spec.js | 122 + .../test/js/page_to_disk.spec.js | 136 + .../test/js/perspective_client.ts | 19 + .../test/js/pivot_nulls.spec.js | 336 + rust/perspective-js/test/js/ports.spec.js | 839 ++ .../test/js/proxy_session.spec.js | 102 + rust/perspective-js/test/js/remote.spec.js | 250 + rust/perspective-js/test/js/removes.spec.js | 490 + rust/perspective-js/test/js/sort.spec.js | 896 ++ rust/perspective-js/test/js/sync_load.spec.js | 22 + rust/perspective-js/test/js/test_arrows.js | 127 + .../test/js/to_format/_shared.js | 63 + .../test/js/to_format/data_slice.spec.js | 344 + .../test/js/to_format/leaves_only.spec.js | 135 + .../test/js/to_format/to_arrow.spec.js | 1132 ++ .../js/to_format/to_column_string.spec.js | 25 + .../test/js/to_format/to_csv.spec.ts | 108 + .../to_format/to_format_regressions.spec.js | 62 + .../js/to_format/to_format_viewport.spec.js | 292 + .../test/js/to_format/to_json.spec.js | 98 + .../test/js/to_format/to_ndjson.spec.js | 63 + .../to_format/with_column_typed_array.spec.ts | 825 ++ rust/perspective-js/test/js/updates.spec.js | 3531 ++++++ .../test/js/view_config.spec.js | 179 + rust/perspective-js/test/js/workers.spec.js | 114 + rust/perspective-js/tsconfig.browser.json | 16 + rust/perspective-js/tsconfig.json | 22 + rust/perspective-js/tsconfig.node.json | 16 + .../tsconfig.virtual_servers.json | 19 + rust/perspective-python/Cargo.toml | 88 + rust/perspective-python/README.md | 3 + rust/perspective-python/bench/__init__.py | 11 + .../bench/runtime/__init__.py | 15 + .../perspective-python/bench/runtime/bench.py | 349 + .../bench/runtime/benchmark-python.html | 39 + .../bench/runtime/benchmark_hosted.html | 43 + .../bench/runtime/perspective_benchmark.py | 242 + .../runtime/run_perspective_benchmark.py | 80 + rust/perspective-python/build.mjs | 210 + rust/perspective-python/build.rs | 24 + rust/perspective-python/clean.mjs | 31 + rust/perspective-python/docs.mjs | 52 + rust/perspective-python/docs/index.html | 25 + rust/perspective-python/docs/lib.md | 327 + rust/perspective-python/package.json | 28 + .../perspective/__init__.py | 403 + .../finos-perspective-nbextension.json | 5 + .../perspective/handlers/__init__.py | 11 + .../perspective/handlers/aiohttp.py | 69 + .../perspective/handlers/starlette.py | 63 + .../perspective/handlers/tornado.py | 192 + .../templates/exported_widget.html.template | 35 + .../perspective/tests/__init__.py | 11 + .../tests/async/test_async_client.py | 83 + .../tests/async/test_websocket_client.py | 124 + .../perspective/tests/conftest.py | 276 + .../perspective/tests/core/__init__.py | 11 + .../perspective/tests/core/test_async.py | 351 + .../tests/multi_threaded/__init__.py | 11 + .../multi_threaded/test_multi_threaded.py | 201 + .../perspective/tests/server/__init__.py | 11 + .../perspective/tests/server/test_server.py | 1016 ++ .../perspective/tests/server/test_session.py | 110 + .../perspective/tests/table/__init__.py | 11 + .../tests/table/arrow/date32.arrow | Bin 0 -> 1112 bytes .../tests/table/arrow/date64.arrow | Bin 0 -> 1576 bytes .../perspective/tests/table/arrow/dict.arrow | Bin 0 -> 936 bytes .../tests/table/arrow/dict_update.arrow | Bin 0 -> 920 bytes .../tests/table/arrow/int_float_str.arrow | Bin 0 -> 584 bytes .../table/arrow/int_float_str_file.arrow | Bin 0 -> 866 bytes .../table/arrow/int_float_str_update.arrow | Bin 0 -> 544 bytes .../tests/table/object_sequence.py | 402 + .../tests/table/test_column_paths.py | 89 + .../perspective/tests/table/test_delete.py | 124 + .../perspective/tests/table/test_exception.py | 65 + .../perspective/tests/table/test_join.py | 115 + .../perspective/tests/table/test_leaks.py | 105 + .../perspective/tests/table/test_ports.py | 178 + .../perspective/tests/table/test_remove.py | 102 + .../perspective/tests/table/test_table.py | 641 + .../tests/table/test_table_arrow.py | 500 + .../tests/table/test_table_datetime.py | 2544 ++++ .../tests/table/test_table_infer.py | 201 + .../tests/table/test_table_limit.py | 45 + .../tests/table/test_table_numpy.py | 1022 ++ .../tests/table/test_table_page_to_disk.py | 304 + .../tests/table/test_table_pandas.py | 1018 ++ .../tests/table/test_table_polars.py | 251 + .../tests/table/test_table_view_table.py | 130 + .../perspective/tests/table/test_to_arrow.py | 417 + .../tests/table/test_to_arrow_lz4.py | 32 + .../perspective/tests/table/test_to_format.py | 1024 ++ .../perspective/tests/table/test_to_polars.py | 26 + .../perspective/tests/table/test_update.py | 536 + .../tests/table/test_update_arrow.py | 980 ++ .../tests/table/test_update_pandas.py | 211 + .../perspective/tests/table/test_view.py | 2444 ++++ .../tests/table/test_view_expression.py | 1940 +++ .../perspective/tests/test_dependencies.py | 53 + .../perspective/tests/viewer/__init__.py | 11 + .../perspective/tests/viewer/test_viewer.py | 246 + .../tests/virtual_servers/__init__.py | 12 + .../virtual_servers/test_coerce_types.py | 209 + .../tests/virtual_servers/test_duckdb.py | 908 ++ .../tests/virtual_servers/test_polars.py | 1032 ++ .../perspective/tests/widget/__init__.py | 11 + .../perspective/tests/widget/test_widget.py | 278 + .../tests/widget/test_widget_pandas.py | 453 + .../perspective/virtual_servers/__init__.py | 142 + .../perspective/virtual_servers/clickhouse.py | 240 + .../perspective/virtual_servers/duckdb.py | 236 + .../perspective/virtual_servers/polars.py | 710 ++ .../perspective/widget/__init__.py | 349 + .../perspective/widget/viewer/__init__.py | 15 + .../perspective/widget/viewer/validate.py | 22 + .../perspective/widget/viewer/viewer.py | 343 + .../widget/viewer/viewer_traitlets.py | 101 + .../pyodide-tests/README.md | 43 + .../pyodide-tests/tests/__init__.py | 11 + .../pyodide-tests/tests/conftest.py | 23 + .../pyodide-tests/tests/test_smoke.py | 221 + rust/perspective-python/pyproject.toml | 66 + .../requirements-pyodide.txt | 5 + rust/perspective-python/requirements.txt | 25 + .../src/client/client_async.rs | 1015 ++ .../src/client/client_sync.rs | 819 ++ rust/perspective-python/src/client/mod.rs | 20 + rust/perspective-python/src/client/pandas.rs | 110 + rust/perspective-python/src/client/polars.rs | 109 + .../src/client/proxy_session.rs | 74 + rust/perspective-python/src/client/pyarrow.rs | 64 + .../src/client/table_data.rs | 92 + .../src/client/update_data.rs | 113 + rust/perspective-python/src/lib.rs | 81 + rust/perspective-python/src/py_async.rs | 108 + rust/perspective-python/src/py_err.rs | 31 + .../src/server/generic_sql_model.rs | 184 + rust/perspective-python/src/server/mod.rs | 23 + .../src/server/server_async.rs | 95 + .../src/server/server_sync.rs | 126 + .../src/server/session_async.rs | 98 + .../src/server/session_sync.rs | 101 + .../src/server/virtual_server_sync.rs | 639 + rust/perspective-python/test.mjs | 66 + rust/perspective-server/Cargo.toml | 71 + rust/perspective-server/build.mjs | 94 + rust/perspective-server/build.rs | 184 + rust/perspective-server/clean.mjs | 15 + rust/perspective-server/cmake/Boost.txt.in | 18 + rust/perspective-server/cmake/Pybind.txt.in | 15 + rust/perspective-server/cmake/arrow.txt.in | 24 + rust/perspective-server/cmake/date.txt.in | 15 + rust/perspective-server/cmake/exprtk.txt.in | 15 + .../perspective-server/cmake/hopscotch.txt.in | 15 + .../cmake/modules/.clangd.in | 3 + .../cmake/modules/FindColor.cmake | 13 + .../cmake/modules/FindExprTk.cmake | 76 + .../cmake/modules/FindInstallDependency.cmake | 100 + .../cmake/modules/FindProtoc.cmake | 126 + .../cmake/modules/FindRe2.cmake | 70 + .../cmake/modules/LLVMToolchain.cmake | 9 + .../cmake/modules/SetupClangd.cmake | 38 + .../cmake/ordered-map.txt.in | 15 + rust/perspective-server/cmake/protobuf.txt.in | 23 + .../perspective-server/cmake/rapidjson.txt.in | 15 + rust/perspective-server/cmake/re2.txt.in | 20 + .../cmake/re2/CMakeLists.txt | 176 + .../cmake/toolchains/darwin-arm64.cmake | 24 + .../cmake/toolchains/darwin-x86_64.cmake | 24 + .../cpp/perspective/.clangd.in | 3 + .../cpp/perspective/CMakeLists.txt | 736 ++ .../perspective-server/cpp/perspective/env.js | 15 + .../perspective/patches/arrow_strptime.patch | 53 + .../patches/fix_arrow_libtool.patch | 59 + .../cpp/perspective/src/cpp/aggregate.cpp | 465 + .../cpp/perspective/src/cpp/aggspec.cpp | 468 + .../cpp/perspective/src/cpp/arg_sort.cpp | 113 + .../cpp/perspective/src/cpp/arrow_csv.cpp | 639 + .../cpp/perspective/src/cpp/arrow_loader.cpp | 1001 ++ .../cpp/perspective/src/cpp/arrow_writer.cpp | 81 + .../cpp/perspective/src/cpp/base.cpp | 821 ++ .../perspective/src/cpp/base_impl_linux.cpp | 26 + .../cpp/perspective/src/cpp/base_impl_osx.cpp | 26 + .../perspective/src/cpp/base_impl_wasm.cpp | 26 + .../cpp/perspective/src/cpp/base_impl_win.cpp | 47 + .../cpp/perspective/src/cpp/binding.cpp | 16 + .../cpp/perspective/src/cpp/binding_api.cpp | 180 + .../cpp/perspective/src/cpp/column.cpp | 1071 ++ .../cpp/perspective/src/cpp/comparators.cpp | 16 + .../cpp/perspective/src/cpp/compat.cpp | 38 + .../perspective/src/cpp/compat_impl_linux.cpp | 231 + .../perspective/src/cpp/compat_impl_osx.cpp | 234 + .../perspective/src/cpp/compat_impl_wasm.cpp | 168 + .../perspective/src/cpp/compat_impl_win.cpp | 312 + .../src/cpp/computed_expression.cpp | 687 ++ .../perspective/src/cpp/computed_function.cpp | 2397 ++++ .../cpp/perspective/src/cpp/config.cpp | 491 + .../cpp/perspective/src/cpp/context_base.cpp | 16 + .../src/cpp/context_grouped_pkey.cpp | 939 ++ .../perspective/src/cpp/context_handle.cpp | 52 + .../perspective/src/cpp/context_iterators.cpp | 246 + .../cpp/perspective/src/cpp/context_one.cpp | 944 ++ .../cpp/perspective/src/cpp/context_two.cpp | 1507 +++ .../cpp/perspective/src/cpp/context_unit.cpp | 497 + .../cpp/perspective/src/cpp/context_zero.cpp | 1009 ++ .../cpp/perspective/src/cpp/data.cpp | 30 + .../cpp/perspective/src/cpp/data_slice.cpp | 201 + .../cpp/perspective/src/cpp/data_table.cpp | 936 ++ .../cpp/perspective/src/cpp/date.cpp | 172 + .../cpp/perspective/src/cpp/date_parser.cpp | 54 + .../cpp/perspective/src/cpp/dense_nodes.cpp | 46 + .../cpp/perspective/src/cpp/dense_tree.cpp | 503 + .../src/cpp/dense_tree_context.cpp | 290 + .../cpp/perspective/src/cpp/dependency.cpp | 82 + .../perspective/src/cpp/expression_tables.cpp | 198 + .../perspective/src/cpp/expression_vocab.cpp | 81 + .../perspective/src/cpp/extract_aggregate.cpp | 124 + .../cpp/perspective/src/cpp/filter.cpp | 168 + .../perspective/src/cpp/flat_traversal.cpp | 497 + .../perspective/src/cpp/get_data_extents.cpp | 48 + .../cpp/perspective/src/cpp/gnode.cpp | 1818 +++ .../cpp/perspective/src/cpp/gnode_state.cpp | 886 ++ .../perspective/src/cpp/heap_instruments.cpp | 234 + .../cpp/perspective/src/cpp/init.cpp | 24 + .../cpp/perspective/src/cpp/join_engine.cpp | 451 + .../cpp/perspective/src/cpp/kernel_engine.cpp | 24 + .../cpp/perspective/src/cpp/main.cpp | 20 + .../cpp/perspective/src/cpp/mask.cpp | 151 + .../cpp/perspective/src/cpp/multi_sort.cpp | 204 + .../cpp/perspective/src/cpp/none.cpp | 59 + .../cpp/perspective/src/cpp/path.cpp | 31 + .../cpp/perspective/src/cpp/pivot.cpp | 59 + .../cpp/perspective/src/cpp/pool.cpp | 360 + .../cpp/perspective/src/cpp/port.cpp | 113 + .../cpp/perspective/src/cpp/process_state.cpp | 46 + .../cpp/perspective/src/cpp/pyutils.cpp | 28 + .../cpp/perspective/src/cpp/raii.cpp | 47 + .../perspective/src/cpp/raii_impl_linux.cpp | 52 + .../cpp/perspective/src/cpp/raii_impl_osx.cpp | 52 + .../cpp/perspective/src/cpp/raii_impl_win.cpp | 51 + .../cpp/perspective/src/cpp/range.cpp | 75 + .../cpp/perspective/src/cpp/regex.cpp | 40 + .../cpp/perspective/src/cpp/residency.cpp | 176 + .../cpp/perspective/src/cpp/rlookup.cpp | 29 + .../cpp/perspective/src/cpp/scalar.cpp | 1750 +++ .../cpp/perspective/src/cpp/schema.cpp | 214 + .../cpp/perspective/src/cpp/schema_column.cpp | 26 + .../cpp/perspective/src/cpp/server.cpp | 3255 +++++ .../perspective/src/cpp/simple_bitmask.cpp | 20 + .../cpp/perspective/src/cpp/slice.cpp | 132 + .../src/cpp/sort_specification.cpp | 82 + .../cpp/perspective/src/cpp/sparse_tree.cpp | 2920 +++++ .../perspective/src/cpp/sparse_tree_node.cpp | 92 + .../cpp/perspective/src/cpp/step_delta.cpp | 85 + .../cpp/perspective/src/cpp/storage.cpp | 726 ++ .../src/cpp/storage_impl_linux.cpp | 132 + .../perspective/src/cpp/storage_impl_osx.cpp | 135 + .../perspective/src/cpp/storage_impl_wasm.cpp | 127 + .../perspective/src/cpp/storage_impl_win.cpp | 185 + .../cpp/perspective/src/cpp/sym_table.cpp | 128 + .../cpp/perspective/src/cpp/table.cpp | 1863 +++ .../cpp/perspective/src/cpp/time.cpp | 289 + .../cpp/perspective/src/cpp/tracing.cpp | 64 + .../src/cpp/tracing_impl_linux.cpp | 139 + .../perspective/src/cpp/tracing_impl_osx.cpp | 139 + .../cpp/perspective/src/cpp/traversal.cpp | 911 ++ .../perspective/src/cpp/traversal_nodes.cpp | 34 + .../src/cpp/tree_context_common.cpp | 287 + .../cpp/perspective/src/cpp/update_task.cpp | 49 + .../cpp/perspective/src/cpp/utils.cpp | 195 + .../cpp/perspective/src/cpp/view.cpp | 2834 +++++ .../cpp/perspective/src/cpp/view_config.cpp | 477 + .../cpp/perspective/src/cpp/vocab.cpp | 273 + .../src/include/perspective/aggregate.h | 350 + .../src/include/perspective/aggspec.h | 120 + .../src/include/perspective/arg_sort.h | 95 + .../src/include/perspective/arrow_csv.h | 36 + .../src/include/perspective/arrow_loader.h | 111 + .../src/include/perspective/arrow_writer.h | 356 + .../src/include/perspective/base.h | 562 + .../src/include/perspective/binding.h | 476 + .../src/include/perspective/column.h | 492 + .../src/include/perspective/comparators.h | 47 + .../src/include/perspective/compat.h | 61 + .../include/perspective/computed_expression.h | 266 + .../include/perspective/computed_function.h | 439 + .../src/include/perspective/config.h | 254 + .../src/include/perspective/context_base.h | 319 + .../perspective/context_common_decls.h | 147 + .../perspective/context_grouped_pkey.h | 86 + .../src/include/perspective/context_handle.h | 40 + .../src/include/perspective/context_one.h | 69 + .../src/include/perspective/context_two.h | 106 + .../src/include/perspective/context_unit.h | 170 + .../src/include/perspective/context_zero.h | 113 + .../src/include/perspective/data.h | 30 + .../src/include/perspective/data_accessor.h | 26 + .../src/include/perspective/data_slice.h | 144 + .../src/include/perspective/data_table.h | 583 + .../src/include/perspective/date.h | 148 + .../src/include/perspective/date_parser.h | 31 + .../src/include/perspective/debug_helpers.h | 47 + .../src/include/perspective/defaults.h | 49 + .../src/include/perspective/dense_nodes.h | 46 + .../src/include/perspective/dense_tree.h | 100 + .../include/perspective/dense_tree_context.h | 63 + .../src/include/perspective/dependency.h | 58 + .../perspective/emscripten_api_utils.h | 32 + .../src/include/perspective/env_vars.h | 173 + .../src/include/perspective/exception.h | 43 + .../src/include/perspective/exports.h | 42 + .../include/perspective/expression_tables.h | 73 + .../include/perspective/expression_vocab.h | 70 + .../src/include/perspective/exprtk.h | 1916 +++ .../include/perspective/extract_aggregate.h | 28 + .../src/include/perspective/filter.h | 212 + .../src/include/perspective/filter_utils.h | 35 + .../src/include/perspective/first.h | 69 + .../src/include/perspective/flat_traversal.h | 171 + .../src/include/perspective/flatten.h | 20 + .../src/include/perspective/fragments.h | 19 + .../include/perspective/get_data_extents.h | 36 + .../src/include/perspective/gnode.h | 662 + .../src/include/perspective/gnode_state.h | 339 + .../include/perspective/heap_instruments.h | 59 + .../src/include/perspective/index.h | 21 + .../src/include/perspective/init.h | 19 + .../src/include/perspective/iterator.h | 150 + .../src/include/perspective/join_engine.h | 101 + .../src/include/perspective/kernel_engine.h | 63 + .../src/include/perspective/last.h | 33 + .../src/include/perspective/mask.h | 81 + .../src/include/perspective/multi_sort.h | 311 + .../src/include/perspective/node_processor.h | 162 + .../perspective/node_processor_types.h | 47 + .../src/include/perspective/none.h | 79 + .../src/include/perspective/opfs.h | 43 + .../src/include/perspective/parallel_for.h | 54 + .../src/include/perspective/partition.h | 145 + .../src/include/perspective/path.h | 30 + .../src/include/perspective/pivot.h | 48 + .../src/include/perspective/pool.h | 119 + .../src/include/perspective/port.h | 51 + .../src/include/perspective/portable.h | 55 + .../src/include/perspective/process_state.h | 65 + .../src/include/perspective/pyutils.h | 57 + .../src/include/perspective/raii.h | 45 + .../src/include/perspective/range.h | 63 + .../src/include/perspective/raw_types.h | 159 + .../src/include/perspective/regex.h | 45 + .../src/include/perspective/residency.h | 90 + .../src/include/perspective/rlookup.h | 27 + .../src/include/perspective/scalar.h | 490 + .../src/include/perspective/schema.h | 68 + .../src/include/perspective/schema_column.h | 35 + .../src/include/perspective/server.h | 697 ++ .../src/include/perspective/simple_bitmask.h | 104 + .../src/include/perspective/slice.h | 66 + .../include/perspective/sort_specification.h | 58 + .../src/include/perspective/sparse_tree.h | 492 + .../include/perspective/sparse_tree_node.h | 87 + .../src/include/perspective/step_delta.h | 127 + .../src/include/perspective/storage.h | 451 + .../src/include/perspective/sym_table.h | 45 + .../src/include/perspective/table.h | 329 + .../src/include/perspective/time.h | 158 + .../src/include/perspective/tracing.h | 56 + .../include/perspective/tracing_impl_linux.h | 21 + .../include/perspective/tracing_impl_osx.h | 21 + .../include/perspective/tracing_impl_win.h | 11 + .../src/include/perspective/traversal.h | 282 + .../src/include/perspective/traversal_nodes.h | 60 + .../include/perspective/tree_context_common.h | 127 + .../src/include/perspective/tree_iterator.h | 204 + .../src/include/perspective/update_task.h | 32 + .../src/include/perspective/utils.h | 213 + .../perspective/src/include/perspective/val.h | 198 + .../src/include/perspective/view.h | 457 + .../src/include/perspective/view_config.h | 249 + .../src/include/perspective/vocab.h | 101 + .../cpp/protos/CMakeLists.txt | 36 + rust/perspective-server/package.json | 22 + rust/perspective-server/src/ffi.rs | 169 + rust/perspective-server/src/lib.rs | 23 + rust/perspective-server/src/local_client.rs | 118 + rust/perspective-server/src/local_session.rs | 76 + rust/perspective-server/src/server.rs | 175 + rust/perspective-viewer/Cargo.toml | 168 + rust/perspective-viewer/README.md | 4 + rust/perspective-viewer/build.mjs | 192 + rust/perspective-viewer/build.rs | 41 + rust/perspective-viewer/clean.mjs | 16 + rust/perspective-viewer/docs.mjs | 15 + rust/perspective-viewer/docs/index.html | 26 + rust/perspective-viewer/docs/viewer.md | 312 + rust/perspective-viewer/package.json | 80 + .../src/css/_column_dropdown.css | 14 + .../src/css/_dropdown_menu.css | 14 + .../src/css/_filter_dropdown.css | 14 + .../src/css/_function_dropdown.css | 14 + .../src/css/_viewer_bundle.css | 41 + .../src/css/aggregate-selector.css | 75 + .../src/css/column-dropdown.css | 109 + .../src/css/column-selector.css | 554 + .../src/css/column-settings-panel.css | 301 + .../src/css/column-style.css | 338 + .../src/css/column-symbol-attributes.css | 74 + .../src/css/config-selector.css | 441 + .../src/css/containers/dropdown-menu.css | 95 + .../src/css/containers/pairs-list.css | 47 + .../src/css/containers/scroll-panel.css | 28 + .../src/css/containers/split-panel.css | 81 + .../src/css/containers/tabs.css | 44 + .../src/css/dom/checkbox.css | 98 + .../src/css/dom/scrollbar.css | 35 + .../perspective-viewer/src/css/dom/select.css | 62 + .../src/css/empty-column.css | 73 + .../src/css/expression-editor.css | 153 + .../src/css/filter-dropdown.css | 54 + .../src/css/filter-item.css | 127 + .../src/css/form/code-editor.css | 134 + .../perspective-viewer/src/css/form/debug.css | 82 + .../src/css/function-dropdown.css | 46 + .../src/css/plugin-selector.css | 294 + .../src/css/plugin-settings-panel.css | 99 + .../src/css/render-warning.css | 82 + .../perspective-viewer/src/css/status-bar.css | 611 + rust/perspective-viewer/src/css/type-icon.css | 116 + rust/perspective-viewer/src/css/viewer.css | 556 + .../src/rust/components/column_dropdown.rs | 274 + .../src/rust/components/column_selector.rs | 535 + .../column_selector/active_column.rs | 489 + .../column_selector/add_expression_button.rs | 65 + .../column_selector/aggregate_selector.rs | 197 + .../column_selector/config_selector.rs | 869 ++ .../column_selector/empty_column.rs | 136 + .../column_selector/expr_edit_button.rs | 58 + .../column_selector/filter_column.rs | 539 + .../column_selector/inactive_column.rs | 243 + .../column_selector/invalid_column.rs | 22 + .../column_selector/pivot_column.rs | 109 + .../components/column_selector/sort_column.rs | 147 + .../components/column_settings_sidebar.rs | 451 + .../column_settings_sidebar/attributes_tab.rs | 34 + .../column_settings_sidebar/save_settings.rs | 70 + .../column_settings_sidebar/style_tab.rs | 343 + .../style_tab/agg_depth_selector.rs | 66 + .../style_tab/primitive_field.rs | 395 + .../style_tab/symbol.rs | 143 + .../style_tab/symbol/row_selector.rs | 101 + .../style_tab/symbol/symbol_pairs.rs | 91 + .../style_tab/symbol/symbol_pairs_item.rs | 112 + .../style_tab/symbol/symbol_selector.rs | 54 + .../components/containers/dragdrop_list.rs | 375 + .../components/containers/dropdown_menu.rs | 100 + .../src/rust/components/containers/mod.rs | 24 + .../components/containers/scroll_panel.rs | 330 + .../containers/scroll_panel_item.rs | 111 + .../src/rust/components/containers/select.rs | 271 + .../src/rust/components/containers/sidebar.rs | 75 + .../containers/sidebar_close_button.rs | 30 + .../rust/components/containers/split_panel.rs | 492 + .../rust/components/containers/tab_list.rs | 95 + .../src/rust/components/copy_dropdown.rs | 111 + .../rust/components/datetime_column_style.rs | 220 + .../datetime_column_style/custom.rs | 222 + .../datetime_column_style/simple.rs | 121 + .../src/rust/components/editable_header.rs | 204 + .../src/rust/components/empty_row.rs | 166 + .../src/rust/components/export_dropdown.rs | 126 + .../src/rust/components/expression_editor.rs | 179 + .../src/rust/components/filter_dropdown.rs | 289 + .../src/rust/components/font_loader.rs | 239 + .../src/rust/components/form/code_editor.rs | 299 + .../components/form/color_range_selector.rs | 137 + .../rust/components/form/color_selector.rs | 56 + .../src/rust/components/form/debug.rs | 277 + .../src/rust/components/form/highlight.rs | 52 + .../src/rust/components/form/mod.rs | 30 + .../src/rust/components/form/number_field.rs | 72 + .../src/rust/components/form/number_input.rs | 45 + .../components/form/number_range_field.rs | 77 + .../rust/components/form/optional_field.rs | 53 + .../rust/components/form/select_enum_field.rs | 59 + .../components/form/select_value_field.rs | 64 + .../src/rust/components/function_dropdown.rs | 224 + .../src/rust/components/main_panel.rs | 160 + .../src/rust/components/mod.rs | 46 + .../src/rust/components/modal.rs | 177 + .../rust/components/number_series_style.rs | 158 + .../src/rust/components/plugin_selector.rs | 132 + .../src/rust/components/plugin_tab.rs | 216 + .../src/rust/components/portal.rs | 277 + .../src/rust/components/render_warning.rs | 127 + .../src/rust/components/settings_panel.rs | 308 + .../src/rust/components/status_bar.rs | 511 + .../src/rust/components/status_bar_counter.rs | 124 + .../src/rust/components/status_indicator.rs | 126 + .../rust/components/string_column_style.rs | 121 + .../src/rust/components/style/mod.rs | 85 + .../rust/components/style/style_provider.rs | 84 + .../src/rust/components/style_controls/mod.rs | 15 + .../style_controls/number_string_format.rs | 235 + .../number_string_format/digits_section.rs | 140 + .../number_string_format/misc_section.rs | 56 + .../number_string_format/style_section.rs | 69 + .../number_string_format/types.rs | 31 + .../src/rust/components/type_icon.rs | 53 + .../src/rust/components/viewer.rs | 911 ++ .../src/rust/config/column_config_schema.rs | 195 + .../src/rust/config/columns_config.rs | 39 + .../src/rust/config/datetime_column_style.rs | 94 + .../datetime_column_style/color_mode.rs | 62 + .../config/datetime_column_style/custom.rs | 121 + .../datetime_column_style/custom_format.rs | 90 + .../config/datetime_column_style/simple.rs | 55 + .../datetime_column_style/simple_format.rs | 86 + .../src/rust/config/export_method.rs | 151 + .../src/rust/config/kvpair.rs | 47 + .../perspective-viewer/src/rust/config/mod.rs | 37 + .../src/rust/config/number_series_style.rs | 79 + .../src/rust/config/number_string_format.rs | 292 + .../rust/config/number_string_format/enums.rs | 298 + .../src/rust/config/plugin_static_config.rs | 144 + .../src/rust/config/string_column_style.rs | 135 + .../src/rust/config/view_config.rs | 24 + .../src/rust/config/viewer_config.rs | 196 + .../src/rust/custom_elements/copy_dropdown.rs | 217 + .../src/rust/custom_elements/debug_plugin.rs | 112 + .../rust/custom_elements/export_dropdown.rs | 214 + .../src/rust/custom_elements/mod.rs | 20 + .../src/rust/custom_elements/modal.rs | 299 + .../src/rust/custom_elements/viewer.rs | 1137 ++ .../src/rust/custom_events.rs | 215 + .../src/rust/exprtk/cursor.rs | 90 + .../perspective-viewer/src/rust/exprtk/mod.rs | 19 + .../src/rust/exprtk/tokenize.rs | 207 + .../src/rust/exprtk/tokenize/comment.rs | 21 + .../src/rust/exprtk/tokenize/number.rs | 44 + .../src/rust/exprtk/tokenize/string.rs | 98 + .../src/rust/exprtk/tokenize/symbol.rs | 26 + .../src/rust/js/clipboard.rs | 77 + .../src/rust/js/clipboard_item.rs | 21 + .../src/rust/js/intersection_observer.rs | 32 + .../src/rust/js/mimetype.rs | 37 + rust/perspective-viewer/src/rust/js/mod.rs | 28 + rust/perspective-viewer/src/rust/js/plugin.rs | 121 + .../src/rust/js/resize_observer.rs | 37 + rust/perspective-viewer/src/rust/lib.rs | 163 + .../src/rust/presentation.rs | 562 + .../src/rust/presentation/column_locator.rs | 90 + .../src/rust/presentation/drag_helpers.rs | 206 + .../src/rust/presentation/props.rs | 47 + .../src/rust/presentation/sheets.rs | 78 + .../src/rust/queries/column_locator.rs | 77 + .../src/rust/queries/column_values.rs | 59 + .../src/rust/queries/columns_iter_set.rs | 325 + .../src/rust/queries/export_app.rs | 59 + .../src/rust/queries/exports.rs | 96 + .../src/rust/queries/fetch_column_stats.rs | 94 + .../src/rust/queries/get_viewer_config.rs | 54 + .../src/rust/queries/is_invalid_drop.rs | 36 + .../src/rust/queries/mod.rs | 44 + .../src/rust/queries/plugin_column_styles.rs | 101 + .../src/rust/queries/validate_expression.rs | 39 + rust/perspective-viewer/src/rust/renderer.rs | 1211 ++ .../src/rust/renderer/activate.rs | 65 + .../src/rust/renderer/limits.rs | 85 + .../src/rust/renderer/plugin_store.rs | 57 + .../src/rust/renderer/props.rs | 59 + .../src/rust/renderer/registry.rs | 167 + .../src/rust/renderer/render_timer.rs | 185 + rust/perspective-viewer/src/rust/root.rs | 50 + rust/perspective-viewer/src/rust/session.rs | 856 ++ .../rust/session/column_defaults_update.rs | 151 + .../src/rust/session/drag_drop_update.rs | 179 + .../src/rust/session/metadata.rs | 340 + .../src/rust/session/props.rs | 187 + .../rust/session/replace_expression_update.rs | 131 + .../src/rust/session/view_subscription.rs | 199 + .../src/rust/tasks/copy_export.rs | 214 + .../src/rust/tasks/dismiss_render_warning.rs | 33 + .../src/rust/tasks/edit_expression.rs | 96 + .../src/rust/tasks/eject.rs | 44 + .../src/rust/tasks/intersection_observer.rs | 98 + rust/perspective-viewer/src/rust/tasks/mod.rs | 50 + .../src/rust/tasks/reset_all.rs | 98 + .../src/rust/tasks/resize_observer.rs | 136 + .../src/rust/tasks/restore_and_render.rs | 157 + .../src/rust/tasks/send_column_config.rs | 54 + .../src/rust/tasks/send_plugin_config.rs | 51 + .../src/rust/tasks/update_and_render.rs | 110 + .../src/rust/tasks/update_theme.rs | 51 + .../src/rust/tasks/validate_expression.rs | 61 + .../src/rust/utils/browser/blob.rs | 63 + .../src/rust/utils/browser/download.rs | 27 + .../src/rust/utils/browser/dragdrop.rs | 26 + .../src/rust/utils/browser/mod.rs | 28 + .../utils/browser/request_animation_frame.rs | 76 + .../src/rust/utils/browser/selection.rs | 84 + .../src/rust/utils/browser/tests/debounce.rs | 114 + .../src/rust/utils/browser/tests/mod.rs | 13 + .../src/rust/utils/custom_element.rs | 100 + .../src/rust/utils/datetime.rs | 54 + .../src/rust/utils/debounce.rs | 60 + .../src/rust/utils/hooks/mod.rs | 15 + .../rust/utils/hooks/use_async_callback.rs | 43 + rust/perspective-viewer/src/rust/utils/mod.rs | 43 + .../src/rust/utils/modal_position.rs | 110 + .../src/rust/utils/number_format.rs | 49 + .../src/rust/utils/ptr_eq_rc.rs | 74 + .../src/rust/utils/pubsub.rs | 237 + .../src/rust/utils/tests/mod.rs | 14 + .../src/rust/utils/tests/pubsub.rs | 95 + .../utils/tests/request_animation_frame.rs | 42 + .../src/rust/utils/weak_scope.rs | 49 + .../src/svg/align-scroll-icon.svg | 7 + .../perspective-viewer/src/svg/bg-pattern.png | Bin 0 -> 547 bytes .../src/svg/bookmark-icon.svg | 4 + .../src/svg/boolean-type.svg | 3 + .../src/svg/checkbox-checked-icon.svg | 7 + .../src/svg/checkbox-hover.svg | 4 + .../src/svg/checkbox-off.svg | 4 + .../src/svg/checkbox-on.svg | 4 + .../src/svg/checkbox-unchecked-icon.svg | 3 + .../perspective-viewer/src/svg/close-icon.svg | 6 + .../src/svg/column-settings-icon.svg | 6 + .../src/svg/datagrid-select-column.svg | 15 + .../src/svg/datagrid-select-region.svg | 15 + .../src/svg/datagrid-select-row-tree.svg | 13 + .../src/svg/datagrid-select-row.svg | 13 + rust/perspective-viewer/src/svg/datagrid.svg | 13 + rust/perspective-viewer/src/svg/date-type.svg | 23 + .../src/svg/downloading.gif | Bin 0 -> 5293 bytes .../src/svg/drag-handle copy.svg | 10 + .../src/svg/drag-handle.svg | 10 + .../src/svg/drawer-bg-pattern-hidpi.png | Bin 0 -> 5201 bytes .../src/svg/drawer-tab-hover.svg | 6 + .../src/svg/drawer-tab-invert-hover.svg | 5 + .../src/svg/drawer-tab-invert.svg | 5 + .../perspective-viewer/src/svg/drawer-tab.svg | 5 + .../src/svg/dropdown-selector-light.svg | 3 + .../src/svg/dropdown-selector.svg | 3 + .../src/svg/duplicate-icon.svg | 4 + .../src/svg/editable-icon.svg | 5 + .../src/svg/export-icon.svg | 4 + .../perspective-viewer/src/svg/expression.svg | 10 + .../src/svg/free-scroll-icon.svg | 5 + .../src/svg/inactive-mouseover.svg | 4 + .../src/svg/mega-menu-icons-candlestick.svg | 38 + .../src/svg/mega-menu-icons-datagrid.svg | 37 + .../src/svg/mega-menu-icons-density.svg | 23 + .../src/svg/mega-menu-icons-heatmap.svg | 60 + .../src/svg/mega-menu-icons-map-density.svg | 24 + .../src/svg/mega-menu-icons-map-line.svg | 19 + .../src/svg/mega-menu-icons-map-scatter.svg | 43 + .../src/svg/mega-menu-icons-ohlc.svg | 40 + .../src/svg/mega-menu-icons-sunburst.svg | 39 + .../src/svg/mega-menu-icons-treemap.svg | 44 + .../src/svg/mega-menu-icons-x-bar.svg | 36 + .../src/svg/mega-menu-icons-x-y-line.svg | 38 + .../src/svg/mega-menu-icons-x-y-scatter.svg | 72 + .../src/svg/mega-menu-icons-y-area.svg | 73 + .../src/svg/mega-menu-icons-y-bar.svg | 36 + .../src/svg/mega-menu-icons-y-line.svg | 36 + .../src/svg/mega-menu-icons-y-scatter.svg | 72 + .../src/svg/number-type.svg | 4 + .../src/svg/radio-hover.svg | 4 + rust/perspective-viewer/src/svg/radio-off.svg | 3 + rust/perspective-viewer/src/svg/radio-on.svg | 4 + .../src/svg/read-only-icon.svg | 6 + .../src/svg/revert-icon.svg | 4 + .../src/svg/sort-abs-asc-icon.svg | 3 + .../src/svg/sort-abs-col-asc-icon.svg | 3 + .../src/svg/sort-abs-col-desc-icon.svg | 3 + .../src/svg/sort-abs-desc-icon.svg | 3 + .../src/svg/sort-asc-icon.svg | 4 + .../src/svg/sort-col-asc-icon.svg | 4 + .../src/svg/sort-col-desc-icon.svg | 4 + .../src/svg/sort-desc-icon.svg | 4 + .../src/svg/sort-none-icon.svg | 3 + .../src/svg/status_error.svg | 70 + rust/perspective-viewer/src/svg/status_ok.svg | 26 + .../src/svg/string-type.svg | 8 + .../perspective-viewer/src/svg/theme-icon.svg | 5 + rust/perspective-viewer/src/svg/updating.gif | Bin 0 -> 5261 bytes .../src/themes/botanical.css | 133 + .../src/themes/defaults.css | 145 + .../perspective-viewer/src/themes/dracula.css | 217 + .../src/themes/gruvbox-dark.css | 237 + .../perspective-viewer/src/themes/gruvbox.css | 122 + rust/perspective-viewer/src/themes/icons.css | 112 + rust/perspective-viewer/src/themes/intl.css | 136 + .../perspective-viewer/src/themes/intl/de.css | 139 + .../perspective-viewer/src/themes/intl/es.css | 139 + .../perspective-viewer/src/themes/intl/fr.css | 139 + .../perspective-viewer/src/themes/intl/ja.css | 139 + .../perspective-viewer/src/themes/intl/pt.css | 139 + .../perspective-viewer/src/themes/intl/zh.css | 139 + .../perspective-viewer/src/themes/monokai.css | 219 + .../src/themes/phosphor.css | 177 + .../src/themes/pro-dark.css | 151 + rust/perspective-viewer/src/themes/pro.css | 18 + .../src/themes/solarized-dark.css | 120 + .../src/themes/solarized.css | 85 + rust/perspective-viewer/src/themes/themes.css | 23 + .../src/themes/vaporwave.css | 224 + rust/perspective-viewer/src/ts/bootstrap.ts | 37 + .../src/ts/column-format.ts | 162 + rust/perspective-viewer/src/ts/extensions.ts | 254 + .../src/ts/perspective-viewer.cdn.ts | 19 + .../src/ts/perspective-viewer.inline.ts | 23 + .../src/ts/perspective-viewer.ts | 57 + .../src/ts/perspective-viewer.worker.ts | 14 + rust/perspective-viewer/src/ts/plugin.ts | 203 + .../test/arrow/int_float_str.arrow | Bin 0 -> 584 bytes rust/perspective-viewer/test/css/demo.css | 22 + .../test/csv/superstore.csv | 100 + rust/perspective-viewer/test/html/blank.html | 16 + .../test/html/column-selector-modes.html | 44 + .../test/html/column-settings-enabled.html | 48 + .../test/html/debug_plugins.js | 141 + .../perspective-viewer/test/html/filters.html | 48 + .../test/html/plugin-priority-order.html | 43 + .../test/html/plugin-resize.html | 49 + .../test/html/superstore-all.html | 16 + .../test/html/superstore-debug.html | 12 + .../test/html/superstore-inline.html | 28 + .../test/html/superstore-single-threaded.html | 21 + .../test/html/superstore.html | 13 + .../test/html/superstore_lazy_viewer.html | 11 + .../test/html/superstore_with_input.html | 19 + .../js/column_settings/attributes_tab.spec.ts | 209 + .../js/column_settings/interactions.spec.ts | 647 + .../test/js/column_settings/sidebar.spec.ts | 307 + .../test/js/dragdrop/cancel.spec.ts | 200 + .../test/js/dragdrop/dragdrop_test_utils.ts | 103 + .../test/js/dragdrop/dragover.spec.ts | 189 + .../test/js/dragdrop/drop.spec.ts | 182 + .../js/dragdrop/drop_column_settings.spec.ts | 209 + .../js/dragdrop/drop_named_columns.spec.ts | 229 + rust/perspective-viewer/test/js/helpers.ts | 129 + .../test/js/helpers/standard_tests.ts | 200 + .../test/js/settings_panel/close.spec.ts | 87 + .../test/js/settings_panel/plugins.spec.ts | 66 + .../test/js/settings_panel/toggle.spec.ts | 59 + .../test/js/stability/export.spec.ts | 56 + .../test/js/stability/leaks.spec.ts | 163 + .../test/js/superstore/inline.spec.ts | 47 + .../test/js/superstore/standard.spec.ts | 47 + rust/perspective-viewer/test/js/tsconfig.json | 10 + .../test/js/viewer_api/delete.spec.ts | 66 + .../test/js/viewer_api/dom.spec.ts | 337 + .../test/js/viewer_api/events.spec.ts | 119 + .../test/js/viewer_api/export.spec.ts | 123 + .../test/js/viewer_api/flush.spec.ts | 137 + .../test/js/viewer_api/focus.spec.ts | 47 + .../test/js/viewer_api/load.spec.ts | 142 + .../test/js/viewer_api/reset.spec.ts | 89 + .../test/js/viewer_api/save_restore.spec.ts | 198 + .../test/js/viewer_api/theme.spec.ts | 90 + .../test/js/viewer_api/view_lifecycle.spec.ts | 95 + .../test/js/viewer_config/cancellable.spec.ts | 51 + .../js/viewer_config/custom_elements.spec.ts | 45 + .../test/js/viewer_config/expressions.spec.ts | 417 + .../test/js/viewer_config/filter.spec.ts | 134 + .../js/viewer_config/localization.spec.ts | 69 + rust/perspective-viewer/tools.mjs | 88 + rust/perspective-viewer/tsconfig.json | 16 + rust/perspective/Cargo.toml | 54 + rust/perspective/build.mjs | 41 + rust/perspective/package.json | 17 + rust/perspective/src/axum.rs | 120 + rust/perspective/src/lib.rs | 66 + rust/perspective/src/virtual_server.rs | 79 + rust/perspective/tests/concurrent_test.rs | 66 + rustfmt.toml | 42 + tools/bench/basic_suite.mjs | 75 + tools/bench/charts_cases.mjs | 111 + tools/bench/charts_page_harness.js | 59 + tools/bench/charts_suite.mjs | 135 + tools/bench/cross_platform_suite.mjs | 344 + tools/bench/package.json | 74 + tools/bench/perspective-bench | 4 + tools/bench/puppeteer_suite.mjs | 85 + tools/bench/python_suite.mjs | 79 + tools/bench/remote_suite.js | 80 + tools/bench/src/html/charts-bench.html | 113 + tools/bench/src/html/empty.html | 0 tools/bench/src/html/index.html | 38 + tools/bench/src/js/benchmark.mjs | 359 + tools/bench/src/js/servers/python.mjs | 86 + tools/bench/src/js/superstore.mjs | 78 + tools/bench/src/python/server.py | 101 + tools/esbuild-plugin/README.md | 30 + tools/esbuild-plugin/amd.js | 51 + tools/esbuild-plugin/build.js | 50 + tools/esbuild-plugin/external.js | 30 + tools/esbuild-plugin/index.js | 46 + tools/esbuild-plugin/package.json | 22 + tools/esbuild-plugin/resolve.js | 55 + tools/esbuild-plugin/wasm.js | 138 + tools/esbuild-plugin/worker.js | 314 + tools/scripts/bench.mjs | 31 + tools/scripts/build.mjs | 25 + tools/scripts/clean.mjs | 56 + tools/scripts/docs.mjs | 21 + tools/scripts/fix.mjs | 26 + tools/scripts/install_emsdk.mjs | 66 + tools/scripts/install_llvm.mjs | 72 + tools/scripts/install_pyodide.mjs | 57 + tools/scripts/lint.mjs | 76 + tools/scripts/lint_headers.mjs | 119 + tools/scripts/package.json | 28 + tools/scripts/prepush.mjs | 70 + tools/scripts/publish.mjs | 144 + tools/scripts/pyodide.mjs | 41 + tools/scripts/repack_wheel.mjs | 32 + tools/scripts/setup.mjs | 253 + tools/scripts/sh_perspective.mjs | 115 + tools/scripts/test.mjs | 24 + tools/scripts/test_js.mjs | 166 + tools/scripts/version.mjs | 246 + tools/scripts/workspace.mjs | 57 + tools/test/assets/all_types_small_multi.arrow | Bin 0 -> 5952 bytes tools/test/assets/superstore.csv | 100 + ...load-viewer-all-types-small-multi-arrow.js | 33 + tools/test/load-viewer-csv-single-threaded.js | 30 + tools/test/load-viewer-csv.js | 33 + tools/test/load-viewer-superstore.js | 41 + tools/test/load-viewer-two-csv.js | 42 + tools/test/load-viewer.js | 22 + tools/test/load-workspace-arrow.js | 36 + tools/test/package.json | 32 + tools/test/playwright.config.ts | 256 + tools/test/playwright/index.html | 12 + tools/test/playwright/index.tsx | 11 + .../all-types-small-multi-arrow-test.html | 32 + tools/test/src/html/basic-test.html | 31 + tools/test/src/html/superstore-test.html | 32 + tools/test/src/html/two-chart-test.html | 56 + tools/test/src/html/workspace-test.html | 48 + tools/test/src/js/fixture.ts | 102 + tools/test/src/js/global_startup.ts | 19 + tools/test/src/js/global_teardown.ts | 19 + tools/test/src/js/index.ts | 16 + tools/test/src/js/models/column_settings.ts | 170 + tools/test/src/js/models/page.ts | 168 + tools/test/src/js/models/plugins.ts | 15 + tools/test/src/js/models/plugins/datagrid.ts | 78 + tools/test/src/js/models/settings_panel.ts | 422 + tools/test/src/js/set_timezone.js | 25 + tools/test/src/js/simple_viewer_tests.ts | 249 + tools/test/src/js/snapshot-sync.ts | 155 + tools/test/src/js/start_test_server.ts | 94 + tools/test/src/js/utils.ts | 398 + tools/test/tsconfig.json | 17 + 1681 files changed, 296950 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .clang-format create mode 100644 .clang-tidy create mode 100644 .devcontainer/devcontainer.json create mode 100755 .devcontainer/postcreate.sh create mode 100644 .gitattributes create mode 100644 .github/ISSUE_TEMPLATE/Bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/Feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/actions/config/action.yaml create mode 100644 .github/actions/install-deps/action.yaml create mode 100644 .github/actions/install-wheel/action.yaml create mode 100644 .github/workflows/build.yaml create mode 100644 .gitignore create mode 100755 .husky/pre-push create mode 100644 .pnpmrc create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 DEVELOPMENT.md create mode 100644 GOVERNANCE.md create mode 100644 LICENSE.md create mode 100644 PROJECT_CHARTER.md create mode 100644 README.md create mode 100644 README.wehub.md create mode 100644 SECURITY.md create mode 100644 docs/book.toml create mode 100644 docs/build.config.mjs create mode 100644 docs/build.mjs create mode 100644 docs/compose.yml create mode 100644 docs/deploy.mjs create mode 100644 docs/index.html create mode 100644 docs/md/FAQ.md create mode 100644 docs/md/SUMMARY.md create mode 100644 docs/md/api_reference.md create mode 100644 docs/md/explanation.md create mode 100644 docs/md/explanation/architecture.md create mode 100644 docs/md/explanation/architecture/architecture.dot create mode 100644 docs/md/explanation/architecture/architecture.sub1.dot create mode 100644 docs/md/explanation/architecture/architecture.sub1.svg create mode 100644 docs/md/explanation/architecture/architecture.sub2.dot create mode 100644 docs/md/explanation/architecture/architecture.sub2.svg create mode 100644 docs/md/explanation/architecture/architecture.sub3.dot create mode 100644 docs/md/explanation/architecture/architecture.sub3.svg create mode 100644 docs/md/explanation/architecture/architecture.svg create mode 100644 docs/md/explanation/architecture/client_only.md create mode 100644 docs/md/explanation/architecture/client_server.md create mode 100644 docs/md/explanation/architecture/server_only.md create mode 100644 docs/md/explanation/join.md create mode 100644 docs/md/explanation/join/join_types.md create mode 100644 docs/md/explanation/join/options.md create mode 100644 docs/md/explanation/join/reactivity.md create mode 100644 docs/md/explanation/python.md create mode 100644 docs/md/explanation/table.md create mode 100644 docs/md/explanation/table/clear_and_replace.md create mode 100644 docs/md/explanation/table/constructing_schema.md create mode 100644 docs/md/explanation/table/loading_data.md create mode 100644 docs/md/explanation/table/options.md create mode 100644 docs/md/explanation/table/schema.md create mode 100644 docs/md/explanation/table/update_and_remove.md create mode 100644 docs/md/explanation/view.md create mode 100644 docs/md/explanation/view/advanced.md create mode 100644 docs/md/explanation/view/config/expressions.md create mode 100644 docs/md/explanation/view/config/grouping_and_pivots.md create mode 100644 docs/md/explanation/view/config/selection_and_ordering.md create mode 100644 docs/md/explanation/view/querying.md create mode 100644 docs/md/explanation/virtual_servers.md create mode 100644 docs/md/getting_started.md create mode 100644 docs/md/how_to.md create mode 100644 docs/md/how_to/javascript.md create mode 100644 docs/md/how_to/javascript/custom_worker.md create mode 100644 docs/md/how_to/javascript/deleting.md create mode 100644 docs/md/how_to/javascript/events.md create mode 100644 docs/md/how_to/javascript/importing.md create mode 100644 docs/md/how_to/javascript/installation.md create mode 100644 docs/md/how_to/javascript/join.md create mode 100644 docs/md/how_to/javascript/loading_data.md create mode 100644 docs/md/how_to/javascript/nodejs_server.md create mode 100644 docs/md/how_to/javascript/plugin_settings.md create mode 100644 docs/md/how_to/javascript/react.md create mode 100644 docs/md/how_to/javascript/save_restore.md create mode 100644 docs/md/how_to/javascript/serializing.md create mode 100644 docs/md/how_to/javascript/theming.md create mode 100644 docs/md/how_to/javascript/viewer.md create mode 100644 docs/md/how_to/javascript/virtual_server.md create mode 100644 docs/md/how_to/javascript/virtual_server/clickhouse.md create mode 100644 docs/md/how_to/javascript/virtual_server/custom.md create mode 100644 docs/md/how_to/javascript/virtual_server/duckdb.md create mode 100644 docs/md/how_to/javascript/worker.md create mode 100644 docs/md/how_to/python.md create mode 100644 docs/md/how_to/python/callbacks.md create mode 100644 docs/md/how_to/python/installation.md create mode 100644 docs/md/how_to/python/join.md create mode 100644 docs/md/how_to/python/jupyterlab.md create mode 100644 docs/md/how_to/python/multithreading.md create mode 100644 docs/md/how_to/python/table.md create mode 100644 docs/md/how_to/python/table_data.md create mode 100644 docs/md/how_to/python/virtual_server.md create mode 100644 docs/md/how_to/python/virtual_server/clickhouse.md create mode 100644 docs/md/how_to/python/virtual_server/custom.md create mode 100644 docs/md/how_to/python/virtual_server/duckdb.md create mode 100644 docs/md/how_to/python/virtual_server/polars.md create mode 100644 docs/md/how_to/python/websocket.md create mode 100644 docs/md/how_to/rust.md create mode 100644 docs/md/javascript.md create mode 100644 docs/md/perspective.css create mode 100644 docs/md/perspective.js create mode 100644 docs/md/perspective.md create mode 100644 docs/md/tutorials/python/tornado.md create mode 100644 docs/package.json create mode 100644 docs/server.mjs create mode 100644 docs/src/block.html create mode 100644 docs/src/block.ts create mode 100644 docs/src/components/demo.ts create mode 100644 docs/src/components/gallery.ts create mode 100644 docs/src/components/theme.ts create mode 100644 docs/src/css/style.css create mode 100644 docs/src/data/features.js create mode 100644 docs/src/data/layouts.ts create mode 100644 docs/src/data/random.ts create mode 100644 docs/src/data/superstore.ts create mode 100644 docs/src/data/worker.ts create mode 100644 docs/src/examples.html create mode 100644 docs/src/examples.ts create mode 100644 docs/src/index.html create mode 100644 docs/src/index.ts create mode 100644 docs/static/.nojekyll create mode 100644 docs/static/404.html create mode 100644 docs/static/CNAME create mode 100644 docs/static/img/jupyterlab.png create mode 100644 docs/static/img/logo/logo-dark.png create mode 100644 docs/static/img/logo/logo-light.png create mode 100644 docs/static/img/mtg_preview.png create mode 100644 docs/static/img/mtg_thumbnail.png create mode 100644 docs/static/img/openjs_foundation-logo-horizontal-black.png create mode 100644 docs/static/img/openjs_foundation-logo-horizontal-white.png create mode 100644 docs/static/img/openjs_foundation-logo-stacked-black.svg create mode 100644 docs/static/img/openjs_foundation-logo-stacked-white.svg create mode 100644 docs/static/svg/feature1.svg create mode 100644 docs/static/svg/feature2.svg create mode 100644 docs/static/svg/feature3.svg create mode 100644 docs/static/svg/perspective-logo-dark.svg create mode 100644 docs/static/svg/perspective-logo-light.svg create mode 100644 docs/static/svg/perspective_logo.svg create mode 100644 docs/template.html create mode 100644 docs/test/js/examples.spec.mts create mode 100644 eslint.config.mjs create mode 100644 examples/README.md create mode 100644 examples/blocks/README.md create mode 100644 examples/blocks/examples.js create mode 100644 examples/blocks/index.mjs create mode 100644 examples/blocks/package.json create mode 100644 examples/blocks/server.mjs create mode 100644 examples/blocks/src/covid/.block create mode 100644 examples/blocks/src/covid/README.md create mode 100644 examples/blocks/src/covid/index.html create mode 100644 examples/blocks/src/covid/preview.png create mode 100644 examples/blocks/src/covid/thumbnail.png create mode 100644 examples/blocks/src/dataset/README.md create mode 100644 examples/blocks/src/dataset/index.html create mode 100644 examples/blocks/src/dataset/layout.json create mode 100644 examples/blocks/src/dataset/preview.png create mode 100644 examples/blocks/src/dataset/thumbnail.png 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 create mode 100644 examples/blocks/src/duckdb/preview.png create mode 100644 examples/blocks/src/duckdb/thumbnail.png create mode 100644 examples/blocks/src/editable/.block create mode 100644 examples/blocks/src/editable/README.md create mode 100644 examples/blocks/src/editable/index.html create mode 100644 examples/blocks/src/editable/preview.png create mode 100644 examples/blocks/src/editable/thumbnail.png create mode 100644 examples/blocks/src/evictions/.block create mode 100644 examples/blocks/src/evictions/README.md create mode 100644 examples/blocks/src/evictions/index.html create mode 100644 examples/blocks/src/evictions/layout.json create mode 100644 examples/blocks/src/evictions/preview.png create mode 100644 examples/blocks/src/evictions/thumbnail.png create mode 100644 examples/blocks/src/file/.block create mode 100644 examples/blocks/src/file/README.md create mode 100644 examples/blocks/src/file/file.js create mode 100644 examples/blocks/src/file/index.css create mode 100644 examples/blocks/src/file/index.html create mode 100644 examples/blocks/src/file/preview.png create mode 100644 examples/blocks/src/file/thumbnail.png create mode 100644 examples/blocks/src/fractal/.block create mode 100644 examples/blocks/src/fractal/README.md create mode 100644 examples/blocks/src/fractal/index.css create mode 100644 examples/blocks/src/fractal/index.html create mode 100644 examples/blocks/src/fractal/index.js create mode 100644 examples/blocks/src/fractal/preview.png create mode 100644 examples/blocks/src/fractal/thumbnail.png create mode 100644 examples/blocks/src/market/README.md create mode 100644 examples/blocks/src/market/index.css create mode 100644 examples/blocks/src/market/index.html create mode 100644 examples/blocks/src/market/layouts.json create mode 100644 examples/blocks/src/market/market.js create mode 100644 examples/blocks/src/market/preview.png create mode 100644 examples/blocks/src/market/thumbnail.png create mode 100644 examples/blocks/src/movies/.block create mode 100644 examples/blocks/src/movies/README.md create mode 100644 examples/blocks/src/movies/index.html create mode 100644 examples/blocks/src/movies/layout.json create mode 100644 examples/blocks/src/movies/preview.png create mode 100644 examples/blocks/src/movies/thumbnail.png create mode 100644 examples/blocks/src/nypd/README.md create mode 100644 examples/blocks/src/nypd/build.mjs create mode 100644 examples/blocks/src/nypd/dark.css create mode 100644 examples/blocks/src/nypd/index.css create mode 100644 examples/blocks/src/nypd/index.html create mode 100644 examples/blocks/src/nypd/index.js create mode 100644 examples/blocks/src/nypd/layout.json create mode 100644 examples/blocks/src/nypd/light.css create mode 100644 examples/blocks/src/nypd/preview.png create mode 100644 examples/blocks/src/nypd/thumbnail.png create mode 100644 examples/blocks/src/olympics/.block create mode 100644 examples/blocks/src/olympics/README.md create mode 100644 examples/blocks/src/olympics/build.mjs create mode 100644 examples/blocks/src/olympics/index.html create mode 100644 examples/blocks/src/olympics/preview.png create mode 100644 examples/blocks/src/olympics/thumbnail.png create mode 100644 examples/blocks/src/raycasting/.block create mode 100644 examples/blocks/src/raycasting/README.md create mode 100644 examples/blocks/src/raycasting/index.css create mode 100644 examples/blocks/src/raycasting/index.html create mode 100644 examples/blocks/src/raycasting/index.js create mode 100644 examples/blocks/src/raycasting/preview.png create mode 100644 examples/blocks/src/raycasting/thumbnail.png create mode 100644 examples/blocks/src/streaming/.block create mode 100644 examples/blocks/src/streaming/README.md create mode 100644 examples/blocks/src/streaming/index.css create mode 100644 examples/blocks/src/streaming/index.html create mode 100644 examples/blocks/src/streaming/preview.png create mode 100644 examples/blocks/src/streaming/streaming.js create mode 100644 examples/blocks/src/streaming/thumbnail.png create mode 100644 examples/blocks/src/superstore/.block create mode 100644 examples/blocks/src/superstore/README.md create mode 100644 examples/blocks/src/superstore/index.html create mode 100644 examples/blocks/src/superstore/index.json create mode 100644 examples/blocks/src/superstore/layout.json create mode 100644 examples/blocks/src/superstore/preview.png create mode 100644 examples/blocks/src/superstore/thumbnail.png create mode 100644 examples/blocks/src/webcam/README.md create mode 100644 examples/blocks/src/webcam/index.css create mode 100644 examples/blocks/src/webcam/index.html create mode 100644 examples/blocks/src/webcam/layouts.json create mode 100644 examples/blocks/src/webcam/preview.png create mode 100644 examples/blocks/src/webcam/thumbnail.png create mode 100644 examples/blocks/src/webcam/webcam.js create mode 100644 examples/esbuild-clickhouse-virtual/build.js create mode 100644 examples/esbuild-clickhouse-virtual/package.json create mode 100644 examples/esbuild-clickhouse-virtual/server.mjs create mode 100644 examples/esbuild-clickhouse-virtual/src/index.html create mode 100644 examples/esbuild-clickhouse-virtual/src/index.ts create mode 100644 examples/esbuild-duckdb-virtual/build.js create mode 100644 examples/esbuild-duckdb-virtual/package.json create mode 100644 examples/esbuild-duckdb-virtual/server.mjs create mode 100644 examples/esbuild-duckdb-virtual/src/index.html create mode 100644 examples/esbuild-duckdb-virtual/src/index.ts create mode 100644 examples/esbuild-example/README.md create mode 100644 examples/esbuild-example/build.js create mode 100644 examples/esbuild-example/package.json create mode 100644 examples/esbuild-example/src/index.css create mode 100644 examples/esbuild-example/src/index.html create mode 100644 examples/esbuild-example/src/index.js create mode 100644 examples/esbuild-remote/build.js create mode 100644 examples/esbuild-remote/client/index.css create mode 100644 examples/esbuild-remote/client/index.html create mode 100644 examples/esbuild-remote/client/index.js create mode 100644 examples/esbuild-remote/package.json create mode 100644 examples/esbuild-remote/server/index.mjs create mode 100644 examples/esbuild-remote/server/securities.mjs create mode 100644 examples/python-aiohttp/README.md create mode 100644 examples/python-aiohttp/package.json create mode 100644 examples/python-aiohttp/server.py create mode 100644 examples/python-clickhouse-virtual/index.html create mode 100644 examples/python-clickhouse-virtual/package.json create mode 100644 examples/python-clickhouse-virtual/server.py 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 examples/python-polars-virtual/index.html create mode 100644 examples/python-polars-virtual/package.json create mode 100644 examples/python-polars-virtual/server.py create mode 100644 examples/python-starlette/README.md create mode 100644 examples/python-starlette/package.json create mode 100644 examples/python-starlette/server.py create mode 100644 examples/python-tornado-streaming/index.html create mode 100644 examples/python-tornado-streaming/package.json create mode 100644 examples/python-tornado-streaming/server.py create mode 100644 examples/python-tornado/README.md create mode 100644 examples/python-tornado/client_server_editing.html create mode 100644 examples/python-tornado/index.html create mode 100644 examples/python-tornado/package.json create mode 100644 examples/python-tornado/server.py create mode 100644 examples/python-tornado/server_mode.html create mode 100644 examples/react-example/build.js create mode 100644 examples/react-example/globals.d.ts create mode 100644 examples/react-example/package.json create mode 100644 examples/react-example/src/index.css create mode 100644 examples/react-example/src/index.html create mode 100644 examples/react-example/src/index.tsx create mode 100644 examples/react-example/tsconfig.json create mode 100644 examples/rust-axum/Cargo.toml create mode 100644 examples/rust-axum/package.json create mode 100644 examples/rust-axum/src/index.html create mode 100644 examples/rust-axum/src/main.rs create mode 100644 examples/vite-example/README.md create mode 100644 examples/vite-example/index.html create mode 100644 examples/vite-example/package.json create mode 100644 examples/vite-example/src/index.css create mode 100644 examples/vite-example/src/index.js create mode 100644 examples/vite-example/vite.config.js create mode 100644 examples/webpack-example/README.md create mode 100644 examples/webpack-example/package.json create mode 100644 examples/webpack-example/src/index.css create mode 100644 examples/webpack-example/src/index.js create mode 100644 examples/webpack-example/webpack.config.js create mode 100644 package.json create mode 100644 packages/cli/README.md create mode 100644 packages/cli/package.json create mode 100755 packages/cli/perspective create mode 100644 packages/cli/src/html/index.html create mode 100755 packages/cli/src/js/index.js create mode 100644 packages/cli/src/js/utils.js create mode 100644 packages/cli/test/csv/test.csv create mode 100644 packages/cli/test/js/superstore.spec.js create mode 100644 packages/jupyterlab/DEVELOPMENT.md create mode 100644 packages/jupyterlab/README.md create mode 100644 packages/jupyterlab/build.mjs create mode 100644 packages/jupyterlab/clean.mjs create mode 100644 packages/jupyterlab/install.json create mode 100644 packages/jupyterlab/package.json create mode 100644 packages/jupyterlab/src/css/index.css create mode 100644 packages/jupyterlab/src/js/index.js create mode 100644 packages/jupyterlab/src/js/model.js create mode 100644 packages/jupyterlab/src/js/notebook/css.js create mode 100644 packages/jupyterlab/src/js/notebook/extension.js create mode 100644 packages/jupyterlab/src/js/notebook/index.js create mode 100644 packages/jupyterlab/src/js/plugin.js create mode 100644 packages/jupyterlab/src/js/psp_widget.js create mode 100644 packages/jupyterlab/src/js/renderer.js create mode 100644 packages/jupyterlab/src/js/utils.js create mode 100644 packages/jupyterlab/src/js/version.js create mode 100644 packages/jupyterlab/src/js/view.js create mode 100644 packages/jupyterlab/src/js/widget.js create mode 100644 packages/jupyterlab/test/arrow/test.arrow create mode 100644 packages/jupyterlab/test/config/jupyter/globalSetup.ts create mode 100644 packages/jupyterlab/test/config/jupyter/globalTeardown.ts create mode 100644 packages/jupyterlab/test/config/jupyter/jlab_start.ts create mode 100644 packages/jupyterlab/test/config/jupyter/jupyter_notebook_config.json create mode 100644 packages/jupyterlab/test/config/jupyter/jupyter_server_config.json create mode 100644 packages/jupyterlab/test/config/jupyter/user_settings/@jupyterlab/shortcuts-extension/shortcuts.jupyterlab-settings create mode 100644 packages/jupyterlab/test/html/resize.html create mode 100644 packages/jupyterlab/test/js/resize.spec.mjs create mode 100644 packages/jupyterlab/test/jupyter/notebook_template.json create mode 100644 packages/jupyterlab/test/jupyter/utils.mjs create mode 100644 packages/jupyterlab/test/jupyter/widget.spec.mjs create mode 100644 packages/jupyterlab/webpack.config.js create mode 100644 packages/react/build.mjs create mode 100644 packages/react/clean.mjs create mode 100644 packages/react/docs.mjs create mode 100644 packages/react/package.json create mode 100644 packages/react/src/index.tsx create mode 100644 packages/react/src/utils.tsx create mode 100644 packages/react/src/viewer.tsx create mode 100644 packages/react/src/workspace.tsx create mode 100644 packages/react/test/js/basic.story.tsx create mode 100644 packages/react/test/js/index.css create mode 100644 packages/react/test/js/react.spec.tsx create mode 100644 packages/react/test/js/workspace.story.tsx create mode 100644 packages/react/tsconfig.json create mode 100644 packages/viewer-charts/build.mjs create mode 100644 packages/viewer-charts/clean.mjs create mode 100644 packages/viewer-charts/package.json create mode 100644 packages/viewer-charts/src/css/perspective-viewer-charts.css create mode 100644 packages/viewer-charts/src/ts/axis/axis-primitives.ts create mode 100644 packages/viewer-charts/src/ts/axis/bar-axis.ts create mode 100644 packages/viewer-charts/src/ts/axis/canvas.ts create mode 100644 packages/viewer-charts/src/ts/axis/categorical-axis-core.ts create mode 100644 packages/viewer-charts/src/ts/axis/categorical-axis.ts create mode 100644 packages/viewer-charts/src/ts/axis/facet-chrome.ts create mode 100644 packages/viewer-charts/src/ts/axis/label-geometry.ts create mode 100644 packages/viewer-charts/src/ts/axis/legend.ts create mode 100644 packages/viewer-charts/src/ts/axis/numeric-axis.ts create mode 100644 packages/viewer-charts/src/ts/charts/candlestick/candlestick-build.ts create mode 100644 packages/viewer-charts/src/ts/charts/candlestick/candlestick-interact.ts create mode 100644 packages/viewer-charts/src/ts/charts/candlestick/candlestick-render.ts create mode 100644 packages/viewer-charts/src/ts/charts/candlestick/candlestick.ts create mode 100644 packages/viewer-charts/src/ts/charts/candlestick/glyphs/draw-candlesticks.ts create mode 100644 packages/viewer-charts/src/ts/charts/candlestick/glyphs/draw-ohlc.ts create mode 100644 packages/viewer-charts/src/ts/charts/canvas-types.ts create mode 100644 packages/viewer-charts/src/ts/charts/cartesian/cartesian-build.ts create mode 100644 packages/viewer-charts/src/ts/charts/cartesian/cartesian-interact.ts create mode 100644 packages/viewer-charts/src/ts/charts/cartesian/cartesian-render.ts create mode 100644 packages/viewer-charts/src/ts/charts/cartesian/cartesian.ts create mode 100644 packages/viewer-charts/src/ts/charts/cartesian/glyph.ts create mode 100644 packages/viewer-charts/src/ts/charts/cartesian/glyphs/density.ts create mode 100644 packages/viewer-charts/src/ts/charts/cartesian/glyphs/lines.ts create mode 100644 packages/viewer-charts/src/ts/charts/cartesian/glyphs/points.ts create mode 100644 packages/viewer-charts/src/ts/charts/cartesian/label-interner.ts create mode 100644 packages/viewer-charts/src/ts/charts/cartesian/tooltip-lines.ts create mode 100644 packages/viewer-charts/src/ts/charts/chart-base.ts create mode 100644 packages/viewer-charts/src/ts/charts/chart.ts create mode 100644 packages/viewer-charts/src/ts/charts/common/band-layout.ts create mode 100644 packages/viewer-charts/src/ts/charts/common/categorical-y-chart.ts create mode 100644 packages/viewer-charts/src/ts/charts/common/category-axis-resolver.ts create mode 100644 packages/viewer-charts/src/ts/charts/common/chrome-cache.ts create mode 100644 packages/viewer-charts/src/ts/charts/common/draw-tooltip-box.ts create mode 100644 packages/viewer-charts/src/ts/charts/common/expand-domain.ts create mode 100644 packages/viewer-charts/src/ts/charts/common/leaf-color.ts create mode 100644 packages/viewer-charts/src/ts/charts/common/node-store.ts create mode 100644 packages/viewer-charts/src/ts/charts/common/tree-chart.ts create mode 100644 packages/viewer-charts/src/ts/charts/common/tree-chrome.ts create mode 100644 packages/viewer-charts/src/ts/charts/common/tree-data.ts create mode 100644 packages/viewer-charts/src/ts/charts/common/tree-interact.ts create mode 100644 packages/viewer-charts/src/ts/charts/common/visible-extent.ts create mode 100644 packages/viewer-charts/src/ts/charts/heatmap/heatmap-build.ts create mode 100644 packages/viewer-charts/src/ts/charts/heatmap/heatmap-interact.ts create mode 100644 packages/viewer-charts/src/ts/charts/heatmap/heatmap-render.ts create mode 100644 packages/viewer-charts/src/ts/charts/heatmap/heatmap-y-axis.ts create mode 100644 packages/viewer-charts/src/ts/charts/heatmap/heatmap.ts create mode 100644 packages/viewer-charts/src/ts/charts/map/map.ts create mode 100644 packages/viewer-charts/src/ts/charts/registry.ts create mode 100644 packages/viewer-charts/src/ts/charts/series/glyphs/draw-areas.ts create mode 100644 packages/viewer-charts/src/ts/charts/series/glyphs/draw-bars.ts create mode 100644 packages/viewer-charts/src/ts/charts/series/glyphs/draw-lines.ts create mode 100644 packages/viewer-charts/src/ts/charts/series/glyphs/draw-scatter.ts create mode 100644 packages/viewer-charts/src/ts/charts/series/series-build.ts create mode 100644 packages/viewer-charts/src/ts/charts/series/series-interact.ts create mode 100644 packages/viewer-charts/src/ts/charts/series/series-render.ts create mode 100644 packages/viewer-charts/src/ts/charts/series/series-type.ts create mode 100644 packages/viewer-charts/src/ts/charts/series/series.ts create mode 100644 packages/viewer-charts/src/ts/charts/sunburst/sunburst-interact.ts create mode 100644 packages/viewer-charts/src/ts/charts/sunburst/sunburst-layout.ts create mode 100644 packages/viewer-charts/src/ts/charts/sunburst/sunburst-render.ts create mode 100644 packages/viewer-charts/src/ts/charts/sunburst/sunburst.ts create mode 100644 packages/viewer-charts/src/ts/charts/treemap/treemap-interact.ts create mode 100644 packages/viewer-charts/src/ts/charts/treemap/treemap-layout.ts create mode 100644 packages/viewer-charts/src/ts/charts/treemap/treemap-render.ts create mode 100644 packages/viewer-charts/src/ts/charts/treemap/treemap.ts create mode 100644 packages/viewer-charts/src/ts/config.ts create mode 100644 packages/viewer-charts/src/ts/data/lazy-row.ts create mode 100644 packages/viewer-charts/src/ts/data/split-groups.ts create mode 100644 packages/viewer-charts/src/ts/data/view-reader.ts create mode 100644 packages/viewer-charts/src/ts/event-detail.ts create mode 100644 packages/viewer-charts/src/ts/index.ts create mode 100644 packages/viewer-charts/src/ts/interaction/hit-test.ts create mode 100644 packages/viewer-charts/src/ts/interaction/host-sink-dom.ts create mode 100644 packages/viewer-charts/src/ts/interaction/host-sink-message.ts create mode 100644 packages/viewer-charts/src/ts/interaction/lazy-tooltip.ts create mode 100644 packages/viewer-charts/src/ts/interaction/raw-event-forwarder.ts create mode 100644 packages/viewer-charts/src/ts/interaction/spatial-grid.ts create mode 100644 packages/viewer-charts/src/ts/interaction/tooltip-controller.ts create mode 100644 packages/viewer-charts/src/ts/interaction/zoom-controller.ts create mode 100644 packages/viewer-charts/src/ts/interaction/zoom-router.ts create mode 100644 packages/viewer-charts/src/ts/layout/facet-grid.ts create mode 100644 packages/viewer-charts/src/ts/layout/plot-layout.ts create mode 100644 packages/viewer-charts/src/ts/layout/ticks.ts create mode 100644 packages/viewer-charts/src/ts/map/mercator.ts create mode 100644 packages/viewer-charts/src/ts/map/tile-cache.ts create mode 100644 packages/viewer-charts/src/ts/map/tile-layer.ts create mode 100644 packages/viewer-charts/src/ts/map/tile-loader.ts create mode 100644 packages/viewer-charts/src/ts/map/tile-source.ts create mode 100644 packages/viewer-charts/src/ts/plugin/charts.ts create mode 100644 packages/viewer-charts/src/ts/plugin/plugin.ts create mode 100644 packages/viewer-charts/src/ts/render/scheduler.ts create mode 100644 packages/viewer-charts/src/ts/shaders/area.frag.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/area.vert.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/bar.frag.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/bar.vert.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/candlestick-body.frag.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/candlestick-body.vert.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/density-extreme.frag.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/density-mrt.frag.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/density-mrt.vert.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/density-resolve.frag.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/density-resolve.vert.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/density-splat.frag.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/density-splat.vert.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/gridline.frag.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/gridline.vert.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/heatmap.frag.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/heatmap.vert.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/line-uniform.frag.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/line-uniform.vert.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/line.frag.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/line.vert.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/scatter.frag.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/scatter.vert.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/sunburst-arc.frag.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/sunburst-arc.vert.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/tile.frag.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/tile.vert.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/treemap.frag.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/treemap.vert.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/y-scatter.frag.glsl create mode 100644 packages/viewer-charts/src/ts/shaders/y-scatter.vert.glsl create mode 100644 packages/viewer-charts/src/ts/theme/gradient.ts create mode 100644 packages/viewer-charts/src/ts/theme/palette.ts create mode 100644 packages/viewer-charts/src/ts/theme/theme-snapshot.ts create mode 100644 packages/viewer-charts/src/ts/theme/theme.ts create mode 100644 packages/viewer-charts/src/ts/transport/protocol.ts create mode 100644 packages/viewer-charts/src/ts/transport/renderer-transport.ts create mode 100644 packages/viewer-charts/src/ts/utils/css.ts create mode 100644 packages/viewer-charts/src/ts/utils/font-snapshot.ts create mode 100644 packages/viewer-charts/src/ts/webgl/buffer-pool.ts create mode 100644 packages/viewer-charts/src/ts/webgl/context-manager.ts create mode 100644 packages/viewer-charts/src/ts/webgl/context-pool.ts create mode 100644 packages/viewer-charts/src/ts/webgl/gl-context.ts create mode 100644 packages/viewer-charts/src/ts/webgl/gradient-texture.ts create mode 100644 packages/viewer-charts/src/ts/webgl/instanced-attrs.ts create mode 100644 packages/viewer-charts/src/ts/webgl/plot-frame.ts create mode 100644 packages/viewer-charts/src/ts/webgl/program-cache.ts create mode 100644 packages/viewer-charts/src/ts/webgl/shader-manifest.ts create mode 100644 packages/viewer-charts/src/ts/webgl/shader-registry.ts create mode 100644 packages/viewer-charts/src/ts/worker/boot.ts create mode 100644 packages/viewer-charts/src/ts/worker/dispatch.ts create mode 100644 packages/viewer-charts/src/ts/worker/font-loader.ts create mode 100644 packages/viewer-charts/src/ts/worker/renderer.worker.ts create mode 100644 packages/viewer-charts/src/ts/worker/session-host.ts create mode 100644 packages/viewer-charts/test/ts/chart-type-switch.spec.ts create mode 100644 packages/viewer-charts/test/ts/context-leak.spec.ts create mode 100644 packages/viewer-charts/test/ts/context-pool.spec.ts create mode 100644 packages/viewer-charts/test/ts/events.spec.ts create mode 100644 packages/viewer-charts/test/ts/frame-timing.spec.ts create mode 100644 packages/viewer-charts/test/ts/helpers.ts create mode 100644 packages/viewer-charts/test/ts/multi-chart.spec.ts create mode 100644 packages/viewer-charts/test/ts/plugin-config-toggle.spec.ts create mode 100644 packages/viewer-charts/test/ts/snapshot/candlestick.spec.ts create mode 100644 packages/viewer-charts/test/ts/snapshot/categorical-value-axis.spec.ts create mode 100644 packages/viewer-charts/test/ts/snapshot/datetime-subsecond.spec.ts create mode 100644 packages/viewer-charts/test/ts/snapshot/gradient-heatmap.spec.ts create mode 100644 packages/viewer-charts/test/ts/snapshot/heatmap.spec.ts create mode 100644 packages/viewer-charts/test/ts/snapshot/line.spec.ts create mode 100644 packages/viewer-charts/test/ts/snapshot/numeric-axis.spec.ts create mode 100644 packages/viewer-charts/test/ts/snapshot/pan.spec.ts create mode 100644 packages/viewer-charts/test/ts/snapshot/regressions.spec.ts create mode 100644 packages/viewer-charts/test/ts/snapshot/scatter.spec.ts create mode 100644 packages/viewer-charts/test/ts/snapshot/sunburst.spec.ts create mode 100644 packages/viewer-charts/test/ts/snapshot/tooltip.spec.ts create mode 100644 packages/viewer-charts/test/ts/snapshot/treemap.spec.ts create mode 100644 packages/viewer-charts/test/ts/snapshot/x-bar.spec.ts create mode 100644 packages/viewer-charts/test/ts/snapshot/y-area.spec.ts create mode 100644 packages/viewer-charts/test/ts/snapshot/y-bar.spec.ts create mode 100644 packages/viewer-charts/test/ts/snapshot/y-line.spec.ts create mode 100644 packages/viewer-charts/test/ts/snapshot/y-ohlc.spec.ts create mode 100644 packages/viewer-charts/test/ts/snapshot/y-scatter.spec.ts create mode 100644 packages/viewer-charts/test/ts/snapshot/zoom.spec.ts create mode 100644 packages/viewer-charts/test/ts/tsconfig.json create mode 100644 packages/viewer-charts/test/ts/xy_symbols.spec.ts create mode 100644 packages/viewer-charts/tsconfig.json create mode 100644 packages/viewer-charts/types.d.ts create mode 100644 packages/viewer-datagrid/build.mjs create mode 100644 packages/viewer-datagrid/clean.mjs create mode 100644 packages/viewer-datagrid/index.d.ts create mode 100644 packages/viewer-datagrid/package.json create mode 100644 packages/viewer-datagrid/src/css/mitered-headers.css create mode 100644 packages/viewer-datagrid/src/css/perspective-viewer-datagrid.css create mode 100644 packages/viewer-datagrid/src/css/pro.css create mode 100644 packages/viewer-datagrid/src/css/regular_table.css create mode 100644 packages/viewer-datagrid/src/css/row-hover.css create mode 100644 packages/viewer-datagrid/src/css/scrollbar.css create mode 100644 packages/viewer-datagrid/src/css/sub-cell-scroll.css create mode 100644 packages/viewer-datagrid/src/css/toolbar.css create mode 100644 packages/viewer-datagrid/src/ts/color_utils.ts create mode 100644 packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts create mode 100644 packages/viewer-datagrid/src/ts/custom_elements/toolbar.ts create mode 100644 packages/viewer-datagrid/src/ts/data_listener/format_cell.ts create mode 100644 packages/viewer-datagrid/src/ts/data_listener/format_tree_header.ts create mode 100644 packages/viewer-datagrid/src/ts/data_listener/formatter_cache.ts create mode 100644 packages/viewer-datagrid/src/ts/data_listener/index.ts create mode 100644 packages/viewer-datagrid/src/ts/event_handlers/click.ts create mode 100644 packages/viewer-datagrid/src/ts/event_handlers/click/edit_click.ts create mode 100644 packages/viewer-datagrid/src/ts/event_handlers/dispatch_click.ts create mode 100644 packages/viewer-datagrid/src/ts/event_handlers/expand_collapse.ts create mode 100644 packages/viewer-datagrid/src/ts/event_handlers/focus.ts create mode 100644 packages/viewer-datagrid/src/ts/event_handlers/header_click.ts create mode 100644 packages/viewer-datagrid/src/ts/event_handlers/keydown/edit_keydown.ts create mode 100644 packages/viewer-datagrid/src/ts/event_handlers/select_region.ts create mode 100644 packages/viewer-datagrid/src/ts/event_handlers/sort.ts create mode 100644 packages/viewer-datagrid/src/ts/get_cell_config.ts create mode 100644 packages/viewer-datagrid/src/ts/index.ts create mode 100644 packages/viewer-datagrid/src/ts/model/column_overrides.ts create mode 100644 packages/viewer-datagrid/src/ts/model/create.ts create mode 100644 packages/viewer-datagrid/src/ts/model/index.ts create mode 100644 packages/viewer-datagrid/src/ts/model/meta_columns.ts create mode 100644 packages/viewer-datagrid/src/ts/model/toolbar.ts create mode 100644 packages/viewer-datagrid/src/ts/plugin/activate.ts create mode 100644 packages/viewer-datagrid/src/ts/plugin/column_config_schema.ts create mode 100644 packages/viewer-datagrid/src/ts/plugin/draw.ts create mode 100644 packages/viewer-datagrid/src/ts/plugin/restore.ts create mode 100644 packages/viewer-datagrid/src/ts/plugin/save.ts create mode 100644 packages/viewer-datagrid/src/ts/style_handlers/body.ts create mode 100644 packages/viewer-datagrid/src/ts/style_handlers/column_header.ts create mode 100644 packages/viewer-datagrid/src/ts/style_handlers/consolidated.ts create mode 100644 packages/viewer-datagrid/src/ts/style_handlers/editable.ts create mode 100644 packages/viewer-datagrid/src/ts/style_handlers/focus.ts create mode 100644 packages/viewer-datagrid/src/ts/style_handlers/group_header.ts create mode 100644 packages/viewer-datagrid/src/ts/style_handlers/table_cell/boolean.ts create mode 100644 packages/viewer-datagrid/src/ts/style_handlers/table_cell/cell_flash.ts create mode 100644 packages/viewer-datagrid/src/ts/style_handlers/table_cell/datetime.ts create mode 100644 packages/viewer-datagrid/src/ts/style_handlers/table_cell/numeric.ts create mode 100644 packages/viewer-datagrid/src/ts/style_handlers/table_cell/row_header.ts create mode 100644 packages/viewer-datagrid/src/ts/style_handlers/table_cell/string.ts create mode 100644 packages/viewer-datagrid/src/ts/style_handlers/types.ts create mode 100644 packages/viewer-datagrid/src/ts/types.ts create mode 100644 packages/viewer-datagrid/test/js/column_settings.spec.ts create mode 100644 packages/viewer-datagrid/test/js/column_style.spec.ts create mode 100644 packages/viewer-datagrid/test/js/number_string_format.spec.ts create mode 100644 packages/viewer-datagrid/test/js/scroll.spec.ts create mode 100644 packages/viewer-datagrid/test/js/superstore.spec.ts create mode 100644 packages/viewer-datagrid/test/js/tsconfig.json create mode 100644 packages/viewer-datagrid/tsconfig.json create mode 100644 packages/viewer-datagrid/types.d.ts create mode 100644 packages/workspace/build.mjs create mode 100644 packages/workspace/clean.mjs create mode 100644 packages/workspace/package.json create mode 100644 packages/workspace/src/css/dockpanel.css create mode 100644 packages/workspace/src/css/injected.css create mode 100644 packages/workspace/src/css/menu.css create mode 100644 packages/workspace/src/css/tabbar.css create mode 100644 packages/workspace/src/css/viewer.css create mode 100644 packages/workspace/src/css/widget.css create mode 100644 packages/workspace/src/css/workspace.css create mode 100644 packages/workspace/src/html/workspace.html create mode 100644 packages/workspace/src/svg/bookmark-icon.svg create mode 100644 packages/workspace/src/svg/drag-handle.svg create mode 100644 packages/workspace/src/themes/pro-dark.css create mode 100644 packages/workspace/src/themes/pro.css create mode 100644 packages/workspace/src/ts/extensions.ts create mode 100644 packages/workspace/src/ts/external.d.ts create mode 100644 packages/workspace/src/ts/external.js create mode 100644 packages/workspace/src/ts/perspective-workspace.ts create mode 100644 packages/workspace/src/ts/utils/custom_elements.ts create mode 100644 packages/workspace/src/ts/utils/observable_map.ts create mode 100644 packages/workspace/src/ts/workspace/commands.ts create mode 100644 packages/workspace/src/ts/workspace/dockpanel.ts create mode 100644 packages/workspace/src/ts/workspace/index.ts create mode 100644 packages/workspace/src/ts/workspace/menu.ts create mode 100644 packages/workspace/src/ts/workspace/tabbar.ts create mode 100644 packages/workspace/src/ts/workspace/tabbarrenderer.ts create mode 100644 packages/workspace/src/ts/workspace/widget.ts create mode 100644 packages/workspace/src/ts/workspace/workspace.ts create mode 100644 packages/workspace/test/csv/superstore.csv create mode 100644 packages/workspace/test/js/context_menu.spec.js create mode 100644 packages/workspace/test/js/delete.spec.js create mode 100644 packages/workspace/test/js/dom.spec.js create mode 100644 packages/workspace/test/js/global_filter.spec.js create mode 100644 packages/workspace/test/js/html.spec.js create mode 100644 packages/workspace/test/js/restore.spec.js create mode 100644 packages/workspace/test/js/table.spec.js create mode 100644 packages/workspace/test/js/visibility.spec.js create mode 100644 packages/workspace/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 rust-toolchain.toml create mode 100644 rust/bundle/Cargo.toml create mode 100644 rust/bundle/build.rs create mode 100644 rust/bundle/main.rs create mode 100644 rust/lint/Cargo.toml create mode 100644 rust/lint/main.rs create mode 100644 rust/metadata/Cargo.toml create mode 100644 rust/metadata/build.mjs create mode 100644 rust/metadata/main.rs create mode 100644 rust/metadata/package.json create mode 100644 rust/perspective-client/Cargo.toml create mode 100644 rust/perspective-client/build.rs create mode 100644 rust/perspective-client/package.json create mode 100644 rust/perspective-client/perspective.proto create mode 100644 rust/perspective-client/src/rust/client.rs create mode 100644 rust/perspective-client/src/rust/config/aggregates.rs create mode 100644 rust/perspective-client/src/rust/config/column_type.rs create mode 100644 rust/perspective-client/src/rust/config/expressions.rs create mode 100644 rust/perspective-client/src/rust/config/filters.rs create mode 100644 rust/perspective-client/src/rust/config/mod.rs create mode 100644 rust/perspective-client/src/rust/config/plugin.rs create mode 100644 rust/perspective-client/src/rust/config/sort.rs create mode 100644 rust/perspective-client/src/rust/config/view_config.rs create mode 100644 rust/perspective-client/src/rust/lib.rs create mode 100644 rust/perspective-client/src/rust/session.rs create mode 100644 rust/perspective-client/src/rust/table.rs create mode 100644 rust/perspective-client/src/rust/table_data.rs create mode 100644 rust/perspective-client/src/rust/table_ref.rs create mode 100644 rust/perspective-client/src/rust/utils/clone.rs create mode 100644 rust/perspective-client/src/rust/utils/logging.rs create mode 100644 rust/perspective-client/src/rust/utils/mod.rs create mode 100644 rust/perspective-client/src/rust/utils/rand_sequence.rs create mode 100644 rust/perspective-client/src/rust/utils/talc_allocator.rs create mode 100644 rust/perspective-client/src/rust/utils/tests/clone.rs create mode 100644 rust/perspective-client/src/rust/utils/tests/mod.rs create mode 100644 rust/perspective-client/src/rust/view.rs 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/generic_sql_model.rs create mode 100644 rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs create mode 100644 rust/perspective-client/src/rust/virtual_server/generic_sql_model/tests.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-js/Cargo.toml create mode 100644 rust/perspective-js/README.md create mode 100644 rust/perspective-js/build.mjs create mode 100644 rust/perspective-js/clean.mjs create mode 100644 rust/perspective-js/docs.mjs create mode 100644 rust/perspective-js/docs/index.html create mode 100644 rust/perspective-js/package.json create mode 100644 rust/perspective-js/src/rust/client.rs create mode 100644 rust/perspective-js/src/rust/generic_sql_model.rs create mode 100644 rust/perspective-js/src/rust/lib.rs create mode 100644 rust/perspective-js/src/rust/table.rs create mode 100644 rust/perspective-js/src/rust/table_data.rs create mode 100644 rust/perspective-js/src/rust/typed_array.rs create mode 100644 rust/perspective-js/src/rust/utils/browser.rs create mode 100644 rust/perspective-js/src/rust/utils/console_logger.rs create mode 100644 rust/perspective-js/src/rust/utils/errors.rs create mode 100644 rust/perspective-js/src/rust/utils/futures.rs create mode 100644 rust/perspective-js/src/rust/utils/json.rs create mode 100644 rust/perspective-js/src/rust/utils/local_poll_loop.rs create mode 100644 rust/perspective-js/src/rust/utils/mod.rs create mode 100644 rust/perspective-js/src/rust/utils/serde.rs create mode 100644 rust/perspective-js/src/rust/utils/trace_allocator.rs create mode 100644 rust/perspective-js/src/rust/view.rs create mode 100644 rust/perspective-js/src/rust/virtual_server.rs create mode 100644 rust/perspective-js/src/ts/perspective-server.worker.ts create mode 100644 rust/perspective-js/src/ts/perspective.browser.ts create mode 100644 rust/perspective-js/src/ts/perspective.cdn.ts create mode 100644 rust/perspective-js/src/ts/perspective.inline.ts create mode 100644 rust/perspective-js/src/ts/perspective.node.ts create mode 100644 rust/perspective-js/src/ts/virtual_server.ts create mode 100644 rust/perspective-js/src/ts/virtual_servers/clickhouse.ts create mode 100644 rust/perspective-js/src/ts/virtual_servers/duckdb.ts create mode 100644 rust/perspective-js/src/ts/wasm/browser.ts create mode 100644 rust/perspective-js/src/ts/wasm/decompress.ts create mode 100644 rust/perspective-js/src/ts/wasm/emscripten_api.ts create mode 100644 rust/perspective-js/src/ts/wasm/engine.ts create mode 100644 rust/perspective-js/src/ts/wasm/perspective-server.poly.ts create mode 100644 rust/perspective-js/src/ts/websocket.ts create mode 100644 rust/perspective-js/test/arrow/all_types_small.arrow create mode 100644 rust/perspective-js/test/arrow/all_types_small_multi.arrow create mode 100644 rust/perspective-js/test/arrow/bad_row_count.arrow create mode 100644 rust/perspective-js/test/arrow/bool.arrow create mode 100644 rust/perspective-js/test/arrow/chunked.arrow create mode 100644 rust/perspective-js/test/arrow/date32.arrow create mode 100644 rust/perspective-js/test/arrow/date64.arrow create mode 100644 rust/perspective-js/test/arrow/dict.arrow create mode 100644 rust/perspective-js/test/arrow/dict_update.arrow create mode 100644 rust/perspective-js/test/arrow/float32.arrow create mode 100644 rust/perspective-js/test/arrow/int_float_str.arrow create mode 100644 rust/perspective-js/test/arrow/int_float_str_file.arrow create mode 100644 rust/perspective-js/test/arrow/int_float_str_update.arrow create mode 100644 rust/perspective-js/test/arrow/lists.arrow create mode 100644 rust/perspective-js/test/arrow/number_types.arrow create mode 100644 rust/perspective-js/test/arrow/partial.arrow create mode 100644 rust/perspective-js/test/arrow/partial_missing_rows.arrow create mode 100644 rust/perspective-js/test/arrow/test.arrow create mode 100644 rust/perspective-js/test/arrow/test_null.arrow create mode 100644 rust/perspective-js/test/arrow/untitled.json create mode 100644 rust/perspective-js/test/arrow/untitled2.json create mode 100644 rust/perspective-js/test/arrow/untitled3.json create mode 100644 rust/perspective-js/test/arrow/untitled4.json create mode 100644 rust/perspective-js/test/arrow/untitled5.json create mode 100644 rust/perspective-js/test/html/service_worker.html create mode 100644 rust/perspective-js/test/html/shared_worker.html create mode 100644 rust/perspective-js/test/html/test.html create mode 100644 rust/perspective-js/test/html/worker.html create mode 100644 rust/perspective-js/test/js/aggregates.spec.js create mode 100644 rust/perspective-js/test/js/clear.spec.ts create mode 100644 rust/perspective-js/test/js/constructors.spec.js create mode 100644 rust/perspective-js/test/js/constructors/arrow.spec.ts create mode 100644 rust/perspective-js/test/js/constructors/csv.spec.ts create mode 100644 rust/perspective-js/test/js/constructors/date_timezone.spec.ts create mode 100644 rust/perspective-js/test/js/constructors/json.spec.ts create mode 100644 rust/perspective-js/test/js/delete.spec.js create mode 100644 rust/perspective-js/test/js/delta.spec.js create mode 100644 rust/perspective-js/test/js/duckdb/client.spec.js create mode 100644 rust/perspective-js/test/js/duckdb/combined.spec.js create mode 100644 rust/perspective-js/test/js/duckdb/data_types.spec.js create mode 100644 rust/perspective-js/test/js/duckdb/expressions.spec.js create mode 100644 rust/perspective-js/test/js/duckdb/filter.spec.js create mode 100644 rust/perspective-js/test/js/duckdb/group_by.spec.js create mode 100644 rust/perspective-js/test/js/duckdb/group_rollup_mode.spec.js create mode 100644 rust/perspective-js/test/js/duckdb/min_max.spec.js create mode 100644 rust/perspective-js/test/js/duckdb/setup.js create mode 100644 rust/perspective-js/test/js/duckdb/sort.spec.js create mode 100644 rust/perspective-js/test/js/duckdb/split_by.spec.js create mode 100644 rust/perspective-js/test/js/duckdb/table.spec.js create mode 100644 rust/perspective-js/test/js/duckdb/view.spec.js create mode 100644 rust/perspective-js/test/js/duckdb/viewport.spec.js create mode 100644 rust/perspective-js/test/js/expressions/common.js create mode 100644 rust/perspective-js/test/js/expressions/conversions.spec.js create mode 100644 rust/perspective-js/test/js/expressions/datetime.spec.js create mode 100644 rust/perspective-js/test/js/expressions/deltas.spec.js create mode 100644 rust/perspective-js/test/js/expressions/functionality.spec.js create mode 100644 rust/perspective-js/test/js/expressions/invariant.js create mode 100644 rust/perspective-js/test/js/expressions/multiple_views.spec.js create mode 100644 rust/perspective-js/test/js/expressions/numeric.spec.js create mode 100644 rust/perspective-js/test/js/expressions/parsing.spec.js create mode 100644 rust/perspective-js/test/js/expressions/string.spec.js create mode 100644 rust/perspective-js/test/js/expressions/string_slices.spec.js create mode 100644 rust/perspective-js/test/js/expressions/updates.spec.js create mode 100644 rust/perspective-js/test/js/expressions/vectors.spec.js create mode 100644 rust/perspective-js/test/js/filters.spec.js create mode 100644 rust/perspective-js/test/js/get_min_max.spec.js create mode 100644 rust/perspective-js/test/js/group_by.spec.js create mode 100644 rust/perspective-js/test/js/group_rollup_mode.spec.js create mode 100644 rust/perspective-js/test/js/hosted_tables.spec.js create mode 100644 rust/perspective-js/test/js/internal.spec.js create mode 100644 rust/perspective-js/test/js/joins/inner_join.spec.ts create mode 100644 rust/perspective-js/test/js/joins/inner_join_indexed.spec.ts create mode 100644 rust/perspective-js/test/js/joins/left_join.spec.ts create mode 100644 rust/perspective-js/test/js/joins/outer_join.spec.ts create mode 100644 rust/perspective-js/test/js/joins/right_on.spec.ts create mode 100644 rust/perspective-js/test/js/leaks.spec.js create mode 100644 rust/perspective-js/test/js/limit.spec.js create mode 100644 rust/perspective-js/test/js/lz4.spec.js create mode 100644 rust/perspective-js/test/js/multi_server.spec.js create mode 100644 rust/perspective-js/test/js/multiple.spec.js create mode 100644 rust/perspective-js/test/js/page_to_disk.spec.js create mode 100644 rust/perspective-js/test/js/perspective_client.ts create mode 100644 rust/perspective-js/test/js/pivot_nulls.spec.js create mode 100644 rust/perspective-js/test/js/ports.spec.js create mode 100644 rust/perspective-js/test/js/proxy_session.spec.js create mode 100644 rust/perspective-js/test/js/remote.spec.js create mode 100644 rust/perspective-js/test/js/removes.spec.js create mode 100644 rust/perspective-js/test/js/sort.spec.js create mode 100644 rust/perspective-js/test/js/sync_load.spec.js create mode 100644 rust/perspective-js/test/js/test_arrows.js create mode 100644 rust/perspective-js/test/js/to_format/_shared.js create mode 100644 rust/perspective-js/test/js/to_format/data_slice.spec.js create mode 100644 rust/perspective-js/test/js/to_format/leaves_only.spec.js create mode 100644 rust/perspective-js/test/js/to_format/to_arrow.spec.js create mode 100644 rust/perspective-js/test/js/to_format/to_column_string.spec.js create mode 100644 rust/perspective-js/test/js/to_format/to_csv.spec.ts create mode 100644 rust/perspective-js/test/js/to_format/to_format_regressions.spec.js create mode 100644 rust/perspective-js/test/js/to_format/to_format_viewport.spec.js create mode 100644 rust/perspective-js/test/js/to_format/to_json.spec.js create mode 100644 rust/perspective-js/test/js/to_format/to_ndjson.spec.js create mode 100644 rust/perspective-js/test/js/to_format/with_column_typed_array.spec.ts create mode 100644 rust/perspective-js/test/js/updates.spec.js create mode 100644 rust/perspective-js/test/js/view_config.spec.js create mode 100644 rust/perspective-js/test/js/workers.spec.js create mode 100644 rust/perspective-js/tsconfig.browser.json create mode 100644 rust/perspective-js/tsconfig.json create mode 100644 rust/perspective-js/tsconfig.node.json create mode 100644 rust/perspective-js/tsconfig.virtual_servers.json create mode 100644 rust/perspective-python/Cargo.toml create mode 100644 rust/perspective-python/README.md create mode 100644 rust/perspective-python/bench/__init__.py create mode 100644 rust/perspective-python/bench/runtime/__init__.py create mode 100644 rust/perspective-python/bench/runtime/bench.py create mode 100644 rust/perspective-python/bench/runtime/benchmark-python.html create mode 100644 rust/perspective-python/bench/runtime/benchmark_hosted.html create mode 100644 rust/perspective-python/bench/runtime/perspective_benchmark.py create mode 100644 rust/perspective-python/bench/runtime/run_perspective_benchmark.py create mode 100644 rust/perspective-python/build.mjs create mode 100644 rust/perspective-python/build.rs create mode 100644 rust/perspective-python/clean.mjs create mode 100644 rust/perspective-python/docs.mjs create mode 100644 rust/perspective-python/docs/index.html create mode 100644 rust/perspective-python/docs/lib.md create mode 100644 rust/perspective-python/package.json create mode 100644 rust/perspective-python/perspective/__init__.py create mode 100644 rust/perspective-python/perspective/extension/finos-perspective-nbextension.json create mode 100644 rust/perspective-python/perspective/handlers/__init__.py create mode 100644 rust/perspective-python/perspective/handlers/aiohttp.py create mode 100644 rust/perspective-python/perspective/handlers/starlette.py create mode 100644 rust/perspective-python/perspective/handlers/tornado.py create mode 100644 rust/perspective-python/perspective/templates/exported_widget.html.template create mode 100644 rust/perspective-python/perspective/tests/__init__.py create mode 100644 rust/perspective-python/perspective/tests/async/test_async_client.py create mode 100644 rust/perspective-python/perspective/tests/async/test_websocket_client.py create mode 100644 rust/perspective-python/perspective/tests/conftest.py create mode 100644 rust/perspective-python/perspective/tests/core/__init__.py create mode 100644 rust/perspective-python/perspective/tests/core/test_async.py create mode 100644 rust/perspective-python/perspective/tests/multi_threaded/__init__.py create mode 100644 rust/perspective-python/perspective/tests/multi_threaded/test_multi_threaded.py create mode 100644 rust/perspective-python/perspective/tests/server/__init__.py create mode 100644 rust/perspective-python/perspective/tests/server/test_server.py create mode 100644 rust/perspective-python/perspective/tests/server/test_session.py create mode 100644 rust/perspective-python/perspective/tests/table/__init__.py create mode 100644 rust/perspective-python/perspective/tests/table/arrow/date32.arrow create mode 100644 rust/perspective-python/perspective/tests/table/arrow/date64.arrow create mode 100644 rust/perspective-python/perspective/tests/table/arrow/dict.arrow create mode 100644 rust/perspective-python/perspective/tests/table/arrow/dict_update.arrow create mode 100644 rust/perspective-python/perspective/tests/table/arrow/int_float_str.arrow create mode 100644 rust/perspective-python/perspective/tests/table/arrow/int_float_str_file.arrow create mode 100644 rust/perspective-python/perspective/tests/table/arrow/int_float_str_update.arrow create mode 100644 rust/perspective-python/perspective/tests/table/object_sequence.py create mode 100644 rust/perspective-python/perspective/tests/table/test_column_paths.py create mode 100644 rust/perspective-python/perspective/tests/table/test_delete.py create mode 100644 rust/perspective-python/perspective/tests/table/test_exception.py create mode 100644 rust/perspective-python/perspective/tests/table/test_join.py create mode 100644 rust/perspective-python/perspective/tests/table/test_leaks.py create mode 100644 rust/perspective-python/perspective/tests/table/test_ports.py create mode 100644 rust/perspective-python/perspective/tests/table/test_remove.py create mode 100644 rust/perspective-python/perspective/tests/table/test_table.py create mode 100644 rust/perspective-python/perspective/tests/table/test_table_arrow.py create mode 100644 rust/perspective-python/perspective/tests/table/test_table_datetime.py create mode 100644 rust/perspective-python/perspective/tests/table/test_table_infer.py create mode 100644 rust/perspective-python/perspective/tests/table/test_table_limit.py create mode 100644 rust/perspective-python/perspective/tests/table/test_table_numpy.py create mode 100644 rust/perspective-python/perspective/tests/table/test_table_page_to_disk.py create mode 100644 rust/perspective-python/perspective/tests/table/test_table_pandas.py create mode 100644 rust/perspective-python/perspective/tests/table/test_table_polars.py create mode 100644 rust/perspective-python/perspective/tests/table/test_table_view_table.py create mode 100644 rust/perspective-python/perspective/tests/table/test_to_arrow.py create mode 100644 rust/perspective-python/perspective/tests/table/test_to_arrow_lz4.py create mode 100644 rust/perspective-python/perspective/tests/table/test_to_format.py create mode 100644 rust/perspective-python/perspective/tests/table/test_to_polars.py create mode 100644 rust/perspective-python/perspective/tests/table/test_update.py create mode 100644 rust/perspective-python/perspective/tests/table/test_update_arrow.py create mode 100644 rust/perspective-python/perspective/tests/table/test_update_pandas.py create mode 100644 rust/perspective-python/perspective/tests/table/test_view.py create mode 100644 rust/perspective-python/perspective/tests/table/test_view_expression.py create mode 100644 rust/perspective-python/perspective/tests/test_dependencies.py create mode 100644 rust/perspective-python/perspective/tests/viewer/__init__.py create mode 100644 rust/perspective-python/perspective/tests/viewer/test_viewer.py create mode 100644 rust/perspective-python/perspective/tests/virtual_servers/__init__.py create mode 100644 rust/perspective-python/perspective/tests/virtual_servers/test_coerce_types.py create mode 100644 rust/perspective-python/perspective/tests/virtual_servers/test_duckdb.py create mode 100644 rust/perspective-python/perspective/tests/virtual_servers/test_polars.py create mode 100644 rust/perspective-python/perspective/tests/widget/__init__.py create mode 100644 rust/perspective-python/perspective/tests/widget/test_widget.py create mode 100644 rust/perspective-python/perspective/tests/widget/test_widget_pandas.py create mode 100644 rust/perspective-python/perspective/virtual_servers/__init__.py create mode 100644 rust/perspective-python/perspective/virtual_servers/clickhouse.py create mode 100644 rust/perspective-python/perspective/virtual_servers/duckdb.py create mode 100644 rust/perspective-python/perspective/virtual_servers/polars.py create mode 100644 rust/perspective-python/perspective/widget/__init__.py create mode 100644 rust/perspective-python/perspective/widget/viewer/__init__.py create mode 100644 rust/perspective-python/perspective/widget/viewer/validate.py create mode 100644 rust/perspective-python/perspective/widget/viewer/viewer.py create mode 100644 rust/perspective-python/perspective/widget/viewer/viewer_traitlets.py create mode 100644 rust/perspective-python/pyodide-tests/README.md create mode 100644 rust/perspective-python/pyodide-tests/tests/__init__.py create mode 100644 rust/perspective-python/pyodide-tests/tests/conftest.py create mode 100644 rust/perspective-python/pyodide-tests/tests/test_smoke.py create mode 100644 rust/perspective-python/pyproject.toml create mode 100644 rust/perspective-python/requirements-pyodide.txt create mode 100644 rust/perspective-python/requirements.txt create mode 100644 rust/perspective-python/src/client/client_async.rs create mode 100644 rust/perspective-python/src/client/client_sync.rs create mode 100644 rust/perspective-python/src/client/mod.rs create mode 100644 rust/perspective-python/src/client/pandas.rs create mode 100644 rust/perspective-python/src/client/polars.rs create mode 100644 rust/perspective-python/src/client/proxy_session.rs create mode 100644 rust/perspective-python/src/client/pyarrow.rs create mode 100644 rust/perspective-python/src/client/table_data.rs create mode 100644 rust/perspective-python/src/client/update_data.rs create mode 100644 rust/perspective-python/src/lib.rs create mode 100644 rust/perspective-python/src/py_async.rs create mode 100644 rust/perspective-python/src/py_err.rs create mode 100644 rust/perspective-python/src/server/generic_sql_model.rs create mode 100644 rust/perspective-python/src/server/mod.rs create mode 100644 rust/perspective-python/src/server/server_async.rs create mode 100644 rust/perspective-python/src/server/server_sync.rs create mode 100644 rust/perspective-python/src/server/session_async.rs create mode 100644 rust/perspective-python/src/server/session_sync.rs create mode 100644 rust/perspective-python/src/server/virtual_server_sync.rs create mode 100644 rust/perspective-python/test.mjs create mode 100644 rust/perspective-server/Cargo.toml create mode 100644 rust/perspective-server/build.mjs create mode 100644 rust/perspective-server/build.rs create mode 100644 rust/perspective-server/clean.mjs create mode 100644 rust/perspective-server/cmake/Boost.txt.in create mode 100644 rust/perspective-server/cmake/Pybind.txt.in create mode 100644 rust/perspective-server/cmake/arrow.txt.in create mode 100644 rust/perspective-server/cmake/date.txt.in create mode 100644 rust/perspective-server/cmake/exprtk.txt.in create mode 100644 rust/perspective-server/cmake/hopscotch.txt.in create mode 100644 rust/perspective-server/cmake/modules/.clangd.in create mode 100644 rust/perspective-server/cmake/modules/FindColor.cmake create mode 100644 rust/perspective-server/cmake/modules/FindExprTk.cmake create mode 100644 rust/perspective-server/cmake/modules/FindInstallDependency.cmake create mode 100644 rust/perspective-server/cmake/modules/FindProtoc.cmake create mode 100644 rust/perspective-server/cmake/modules/FindRe2.cmake create mode 100644 rust/perspective-server/cmake/modules/LLVMToolchain.cmake create mode 100644 rust/perspective-server/cmake/modules/SetupClangd.cmake create mode 100644 rust/perspective-server/cmake/ordered-map.txt.in create mode 100644 rust/perspective-server/cmake/protobuf.txt.in create mode 100644 rust/perspective-server/cmake/rapidjson.txt.in create mode 100644 rust/perspective-server/cmake/re2.txt.in create mode 100644 rust/perspective-server/cmake/re2/CMakeLists.txt create mode 100644 rust/perspective-server/cmake/toolchains/darwin-arm64.cmake create mode 100644 rust/perspective-server/cmake/toolchains/darwin-x86_64.cmake create mode 100644 rust/perspective-server/cpp/perspective/.clangd.in create mode 100644 rust/perspective-server/cpp/perspective/CMakeLists.txt create mode 100644 rust/perspective-server/cpp/perspective/env.js create mode 100644 rust/perspective-server/cpp/perspective/patches/arrow_strptime.patch create mode 100644 rust/perspective-server/cpp/perspective/patches/fix_arrow_libtool.patch create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/aggregate.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/aggspec.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/arg_sort.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/arrow_csv.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/arrow_loader.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/arrow_writer.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/base.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/base_impl_linux.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/base_impl_osx.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/base_impl_wasm.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/base_impl_win.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/binding.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/binding_api.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/column.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/comparators.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/compat.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/compat_impl_linux.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/compat_impl_osx.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/compat_impl_wasm.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/compat_impl_win.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/computed_expression.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/computed_function.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/config.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/context_base.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/context_grouped_pkey.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/context_handle.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/context_iterators.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/context_one.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/context_two.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/context_unit.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/context_zero.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/data.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/data_slice.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/data_table.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/date.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/date_parser.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/dense_nodes.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/dense_tree.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/dense_tree_context.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/dependency.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/expression_tables.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/expression_vocab.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/extract_aggregate.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/filter.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/flat_traversal.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/get_data_extents.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/gnode.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/gnode_state.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/heap_instruments.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/init.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/join_engine.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/kernel_engine.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/main.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/mask.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/multi_sort.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/none.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/path.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/pivot.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/pool.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/port.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/process_state.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/pyutils.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/raii.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/raii_impl_linux.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/raii_impl_osx.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/raii_impl_win.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/range.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/regex.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/residency.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/rlookup.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/scalar.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/schema.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/schema_column.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/server.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/simple_bitmask.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/slice.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/sort_specification.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/sparse_tree.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/sparse_tree_node.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/step_delta.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/storage.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/storage_impl_linux.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/storage_impl_osx.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/storage_impl_wasm.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/storage_impl_win.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/sym_table.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/table.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/time.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/tracing.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/tracing_impl_linux.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/tracing_impl_osx.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/traversal.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/traversal_nodes.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/tree_context_common.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/update_task.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/utils.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/view.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/view_config.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/cpp/vocab.cpp create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/aggregate.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/aggspec.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/arg_sort.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/arrow_csv.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/arrow_loader.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/arrow_writer.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/base.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/binding.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/column.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/comparators.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/compat.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/computed_expression.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/computed_function.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/config.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/context_base.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/context_common_decls.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/context_grouped_pkey.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/context_handle.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/context_one.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/context_two.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/context_unit.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/context_zero.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/data.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/data_accessor.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/data_slice.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/data_table.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/date.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/date_parser.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/debug_helpers.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/defaults.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/dense_nodes.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/dense_tree.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/dense_tree_context.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/dependency.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/emscripten_api_utils.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/env_vars.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/exception.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/exports.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/expression_tables.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/expression_vocab.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/exprtk.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/extract_aggregate.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/filter.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/filter_utils.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/first.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/flat_traversal.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/flatten.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/fragments.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/get_data_extents.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/gnode.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/gnode_state.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/heap_instruments.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/index.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/init.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/iterator.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/join_engine.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/kernel_engine.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/last.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/mask.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/multi_sort.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/node_processor.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/node_processor_types.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/none.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/opfs.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/parallel_for.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/partition.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/path.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/pivot.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/pool.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/port.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/portable.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/process_state.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/pyutils.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/raii.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/range.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/raw_types.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/regex.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/residency.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/rlookup.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/scalar.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/schema.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/schema_column.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/server.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/simple_bitmask.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/slice.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/sort_specification.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/sparse_tree.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/sparse_tree_node.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/step_delta.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/storage.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/sym_table.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/table.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/time.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/tracing.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/tracing_impl_linux.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/tracing_impl_osx.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/tracing_impl_win.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/traversal.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/traversal_nodes.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/tree_context_common.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/tree_iterator.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/update_task.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/utils.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/val.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/view.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/view_config.h create mode 100644 rust/perspective-server/cpp/perspective/src/include/perspective/vocab.h create mode 100644 rust/perspective-server/cpp/protos/CMakeLists.txt create mode 100644 rust/perspective-server/package.json create mode 100644 rust/perspective-server/src/ffi.rs create mode 100644 rust/perspective-server/src/lib.rs create mode 100644 rust/perspective-server/src/local_client.rs create mode 100644 rust/perspective-server/src/local_session.rs create mode 100644 rust/perspective-server/src/server.rs create mode 100644 rust/perspective-viewer/Cargo.toml create mode 100644 rust/perspective-viewer/README.md create mode 100644 rust/perspective-viewer/build.mjs create mode 100644 rust/perspective-viewer/build.rs create mode 100644 rust/perspective-viewer/clean.mjs create mode 100644 rust/perspective-viewer/docs.mjs create mode 100644 rust/perspective-viewer/docs/index.html create mode 100644 rust/perspective-viewer/docs/viewer.md create mode 100644 rust/perspective-viewer/package.json create mode 100644 rust/perspective-viewer/src/css/_column_dropdown.css create mode 100644 rust/perspective-viewer/src/css/_dropdown_menu.css create mode 100644 rust/perspective-viewer/src/css/_filter_dropdown.css create mode 100644 rust/perspective-viewer/src/css/_function_dropdown.css create mode 100644 rust/perspective-viewer/src/css/_viewer_bundle.css create mode 100644 rust/perspective-viewer/src/css/aggregate-selector.css create mode 100644 rust/perspective-viewer/src/css/column-dropdown.css create mode 100644 rust/perspective-viewer/src/css/column-selector.css create mode 100644 rust/perspective-viewer/src/css/column-settings-panel.css create mode 100644 rust/perspective-viewer/src/css/column-style.css create mode 100644 rust/perspective-viewer/src/css/column-symbol-attributes.css create mode 100644 rust/perspective-viewer/src/css/config-selector.css create mode 100644 rust/perspective-viewer/src/css/containers/dropdown-menu.css create mode 100644 rust/perspective-viewer/src/css/containers/pairs-list.css create mode 100644 rust/perspective-viewer/src/css/containers/scroll-panel.css create mode 100644 rust/perspective-viewer/src/css/containers/split-panel.css create mode 100644 rust/perspective-viewer/src/css/containers/tabs.css create mode 100644 rust/perspective-viewer/src/css/dom/checkbox.css create mode 100644 rust/perspective-viewer/src/css/dom/scrollbar.css create mode 100644 rust/perspective-viewer/src/css/dom/select.css create mode 100644 rust/perspective-viewer/src/css/empty-column.css create mode 100644 rust/perspective-viewer/src/css/expression-editor.css create mode 100644 rust/perspective-viewer/src/css/filter-dropdown.css create mode 100644 rust/perspective-viewer/src/css/filter-item.css create mode 100644 rust/perspective-viewer/src/css/form/code-editor.css create mode 100644 rust/perspective-viewer/src/css/form/debug.css create mode 100644 rust/perspective-viewer/src/css/function-dropdown.css create mode 100644 rust/perspective-viewer/src/css/plugin-selector.css create mode 100644 rust/perspective-viewer/src/css/plugin-settings-panel.css create mode 100644 rust/perspective-viewer/src/css/render-warning.css create mode 100644 rust/perspective-viewer/src/css/status-bar.css create mode 100644 rust/perspective-viewer/src/css/type-icon.css create mode 100644 rust/perspective-viewer/src/css/viewer.css create mode 100644 rust/perspective-viewer/src/rust/components/column_dropdown.rs create mode 100644 rust/perspective-viewer/src/rust/components/column_selector.rs create mode 100644 rust/perspective-viewer/src/rust/components/column_selector/active_column.rs create mode 100644 rust/perspective-viewer/src/rust/components/column_selector/add_expression_button.rs create mode 100644 rust/perspective-viewer/src/rust/components/column_selector/aggregate_selector.rs create mode 100644 rust/perspective-viewer/src/rust/components/column_selector/config_selector.rs create mode 100644 rust/perspective-viewer/src/rust/components/column_selector/empty_column.rs create mode 100644 rust/perspective-viewer/src/rust/components/column_selector/expr_edit_button.rs create mode 100644 rust/perspective-viewer/src/rust/components/column_selector/filter_column.rs create mode 100644 rust/perspective-viewer/src/rust/components/column_selector/inactive_column.rs create mode 100644 rust/perspective-viewer/src/rust/components/column_selector/invalid_column.rs create mode 100644 rust/perspective-viewer/src/rust/components/column_selector/pivot_column.rs create mode 100644 rust/perspective-viewer/src/rust/components/column_selector/sort_column.rs create mode 100644 rust/perspective-viewer/src/rust/components/column_settings_sidebar.rs create mode 100644 rust/perspective-viewer/src/rust/components/column_settings_sidebar/attributes_tab.rs create mode 100644 rust/perspective-viewer/src/rust/components/column_settings_sidebar/save_settings.rs create mode 100644 rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab.rs create mode 100644 rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/agg_depth_selector.rs create mode 100644 rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs create mode 100644 rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/symbol.rs create mode 100644 rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/symbol/row_selector.rs create mode 100644 rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/symbol/symbol_pairs.rs create mode 100644 rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/symbol/symbol_pairs_item.rs create mode 100644 rust/perspective-viewer/src/rust/components/column_settings_sidebar/style_tab/symbol/symbol_selector.rs create mode 100644 rust/perspective-viewer/src/rust/components/containers/dragdrop_list.rs create mode 100644 rust/perspective-viewer/src/rust/components/containers/dropdown_menu.rs create mode 100644 rust/perspective-viewer/src/rust/components/containers/mod.rs create mode 100644 rust/perspective-viewer/src/rust/components/containers/scroll_panel.rs create mode 100644 rust/perspective-viewer/src/rust/components/containers/scroll_panel_item.rs create mode 100644 rust/perspective-viewer/src/rust/components/containers/select.rs create mode 100644 rust/perspective-viewer/src/rust/components/containers/sidebar.rs create mode 100644 rust/perspective-viewer/src/rust/components/containers/sidebar_close_button.rs create mode 100644 rust/perspective-viewer/src/rust/components/containers/split_panel.rs create mode 100644 rust/perspective-viewer/src/rust/components/containers/tab_list.rs create mode 100644 rust/perspective-viewer/src/rust/components/copy_dropdown.rs create mode 100644 rust/perspective-viewer/src/rust/components/datetime_column_style.rs create mode 100644 rust/perspective-viewer/src/rust/components/datetime_column_style/custom.rs create mode 100644 rust/perspective-viewer/src/rust/components/datetime_column_style/simple.rs create mode 100644 rust/perspective-viewer/src/rust/components/editable_header.rs create mode 100644 rust/perspective-viewer/src/rust/components/empty_row.rs create mode 100644 rust/perspective-viewer/src/rust/components/export_dropdown.rs create mode 100644 rust/perspective-viewer/src/rust/components/expression_editor.rs create mode 100644 rust/perspective-viewer/src/rust/components/filter_dropdown.rs create mode 100644 rust/perspective-viewer/src/rust/components/font_loader.rs create mode 100644 rust/perspective-viewer/src/rust/components/form/code_editor.rs create mode 100644 rust/perspective-viewer/src/rust/components/form/color_range_selector.rs create mode 100644 rust/perspective-viewer/src/rust/components/form/color_selector.rs create mode 100644 rust/perspective-viewer/src/rust/components/form/debug.rs create mode 100644 rust/perspective-viewer/src/rust/components/form/highlight.rs create mode 100644 rust/perspective-viewer/src/rust/components/form/mod.rs create mode 100644 rust/perspective-viewer/src/rust/components/form/number_field.rs create mode 100644 rust/perspective-viewer/src/rust/components/form/number_input.rs create mode 100644 rust/perspective-viewer/src/rust/components/form/number_range_field.rs create mode 100644 rust/perspective-viewer/src/rust/components/form/optional_field.rs create mode 100644 rust/perspective-viewer/src/rust/components/form/select_enum_field.rs create mode 100644 rust/perspective-viewer/src/rust/components/form/select_value_field.rs create mode 100644 rust/perspective-viewer/src/rust/components/function_dropdown.rs create mode 100644 rust/perspective-viewer/src/rust/components/main_panel.rs create mode 100644 rust/perspective-viewer/src/rust/components/mod.rs create mode 100644 rust/perspective-viewer/src/rust/components/modal.rs create mode 100644 rust/perspective-viewer/src/rust/components/number_series_style.rs create mode 100644 rust/perspective-viewer/src/rust/components/plugin_selector.rs create mode 100644 rust/perspective-viewer/src/rust/components/plugin_tab.rs create mode 100644 rust/perspective-viewer/src/rust/components/portal.rs create mode 100644 rust/perspective-viewer/src/rust/components/render_warning.rs create mode 100644 rust/perspective-viewer/src/rust/components/settings_panel.rs create mode 100644 rust/perspective-viewer/src/rust/components/status_bar.rs create mode 100644 rust/perspective-viewer/src/rust/components/status_bar_counter.rs create mode 100644 rust/perspective-viewer/src/rust/components/status_indicator.rs create mode 100644 rust/perspective-viewer/src/rust/components/string_column_style.rs create mode 100644 rust/perspective-viewer/src/rust/components/style/mod.rs create mode 100644 rust/perspective-viewer/src/rust/components/style/style_provider.rs create mode 100644 rust/perspective-viewer/src/rust/components/style_controls/mod.rs create mode 100644 rust/perspective-viewer/src/rust/components/style_controls/number_string_format.rs create mode 100644 rust/perspective-viewer/src/rust/components/style_controls/number_string_format/digits_section.rs create mode 100644 rust/perspective-viewer/src/rust/components/style_controls/number_string_format/misc_section.rs create mode 100644 rust/perspective-viewer/src/rust/components/style_controls/number_string_format/style_section.rs create mode 100644 rust/perspective-viewer/src/rust/components/style_controls/number_string_format/types.rs create mode 100644 rust/perspective-viewer/src/rust/components/type_icon.rs create mode 100644 rust/perspective-viewer/src/rust/components/viewer.rs create mode 100644 rust/perspective-viewer/src/rust/config/column_config_schema.rs create mode 100644 rust/perspective-viewer/src/rust/config/columns_config.rs create mode 100644 rust/perspective-viewer/src/rust/config/datetime_column_style.rs create mode 100644 rust/perspective-viewer/src/rust/config/datetime_column_style/color_mode.rs create mode 100644 rust/perspective-viewer/src/rust/config/datetime_column_style/custom.rs create mode 100644 rust/perspective-viewer/src/rust/config/datetime_column_style/custom_format.rs create mode 100644 rust/perspective-viewer/src/rust/config/datetime_column_style/simple.rs create mode 100644 rust/perspective-viewer/src/rust/config/datetime_column_style/simple_format.rs create mode 100644 rust/perspective-viewer/src/rust/config/export_method.rs create mode 100644 rust/perspective-viewer/src/rust/config/kvpair.rs create mode 100644 rust/perspective-viewer/src/rust/config/mod.rs create mode 100644 rust/perspective-viewer/src/rust/config/number_series_style.rs create mode 100644 rust/perspective-viewer/src/rust/config/number_string_format.rs create mode 100644 rust/perspective-viewer/src/rust/config/number_string_format/enums.rs create mode 100644 rust/perspective-viewer/src/rust/config/plugin_static_config.rs create mode 100644 rust/perspective-viewer/src/rust/config/string_column_style.rs create mode 100644 rust/perspective-viewer/src/rust/config/view_config.rs create mode 100644 rust/perspective-viewer/src/rust/config/viewer_config.rs create mode 100644 rust/perspective-viewer/src/rust/custom_elements/copy_dropdown.rs create mode 100644 rust/perspective-viewer/src/rust/custom_elements/debug_plugin.rs create mode 100644 rust/perspective-viewer/src/rust/custom_elements/export_dropdown.rs create mode 100644 rust/perspective-viewer/src/rust/custom_elements/mod.rs create mode 100644 rust/perspective-viewer/src/rust/custom_elements/modal.rs create mode 100644 rust/perspective-viewer/src/rust/custom_elements/viewer.rs create mode 100644 rust/perspective-viewer/src/rust/custom_events.rs create mode 100644 rust/perspective-viewer/src/rust/exprtk/cursor.rs create mode 100644 rust/perspective-viewer/src/rust/exprtk/mod.rs create mode 100644 rust/perspective-viewer/src/rust/exprtk/tokenize.rs create mode 100644 rust/perspective-viewer/src/rust/exprtk/tokenize/comment.rs create mode 100644 rust/perspective-viewer/src/rust/exprtk/tokenize/number.rs create mode 100644 rust/perspective-viewer/src/rust/exprtk/tokenize/string.rs create mode 100644 rust/perspective-viewer/src/rust/exprtk/tokenize/symbol.rs create mode 100644 rust/perspective-viewer/src/rust/js/clipboard.rs create mode 100644 rust/perspective-viewer/src/rust/js/clipboard_item.rs create mode 100644 rust/perspective-viewer/src/rust/js/intersection_observer.rs create mode 100644 rust/perspective-viewer/src/rust/js/mimetype.rs create mode 100644 rust/perspective-viewer/src/rust/js/mod.rs create mode 100644 rust/perspective-viewer/src/rust/js/plugin.rs create mode 100644 rust/perspective-viewer/src/rust/js/resize_observer.rs create mode 100644 rust/perspective-viewer/src/rust/lib.rs create mode 100644 rust/perspective-viewer/src/rust/presentation.rs create mode 100644 rust/perspective-viewer/src/rust/presentation/column_locator.rs create mode 100644 rust/perspective-viewer/src/rust/presentation/drag_helpers.rs create mode 100644 rust/perspective-viewer/src/rust/presentation/props.rs create mode 100644 rust/perspective-viewer/src/rust/presentation/sheets.rs create mode 100644 rust/perspective-viewer/src/rust/queries/column_locator.rs create mode 100644 rust/perspective-viewer/src/rust/queries/column_values.rs create mode 100644 rust/perspective-viewer/src/rust/queries/columns_iter_set.rs create mode 100644 rust/perspective-viewer/src/rust/queries/export_app.rs create mode 100644 rust/perspective-viewer/src/rust/queries/exports.rs create mode 100644 rust/perspective-viewer/src/rust/queries/fetch_column_stats.rs create mode 100644 rust/perspective-viewer/src/rust/queries/get_viewer_config.rs create mode 100644 rust/perspective-viewer/src/rust/queries/is_invalid_drop.rs create mode 100644 rust/perspective-viewer/src/rust/queries/mod.rs create mode 100644 rust/perspective-viewer/src/rust/queries/plugin_column_styles.rs create mode 100644 rust/perspective-viewer/src/rust/queries/validate_expression.rs create mode 100644 rust/perspective-viewer/src/rust/renderer.rs create mode 100644 rust/perspective-viewer/src/rust/renderer/activate.rs create mode 100644 rust/perspective-viewer/src/rust/renderer/limits.rs create mode 100644 rust/perspective-viewer/src/rust/renderer/plugin_store.rs create mode 100644 rust/perspective-viewer/src/rust/renderer/props.rs create mode 100644 rust/perspective-viewer/src/rust/renderer/registry.rs create mode 100644 rust/perspective-viewer/src/rust/renderer/render_timer.rs create mode 100644 rust/perspective-viewer/src/rust/root.rs create mode 100644 rust/perspective-viewer/src/rust/session.rs create mode 100644 rust/perspective-viewer/src/rust/session/column_defaults_update.rs create mode 100644 rust/perspective-viewer/src/rust/session/drag_drop_update.rs create mode 100644 rust/perspective-viewer/src/rust/session/metadata.rs create mode 100644 rust/perspective-viewer/src/rust/session/props.rs create mode 100644 rust/perspective-viewer/src/rust/session/replace_expression_update.rs create mode 100644 rust/perspective-viewer/src/rust/session/view_subscription.rs create mode 100644 rust/perspective-viewer/src/rust/tasks/copy_export.rs create mode 100644 rust/perspective-viewer/src/rust/tasks/dismiss_render_warning.rs create mode 100644 rust/perspective-viewer/src/rust/tasks/edit_expression.rs create mode 100644 rust/perspective-viewer/src/rust/tasks/eject.rs create mode 100644 rust/perspective-viewer/src/rust/tasks/intersection_observer.rs create mode 100644 rust/perspective-viewer/src/rust/tasks/mod.rs create mode 100644 rust/perspective-viewer/src/rust/tasks/reset_all.rs create mode 100644 rust/perspective-viewer/src/rust/tasks/resize_observer.rs create mode 100644 rust/perspective-viewer/src/rust/tasks/restore_and_render.rs create mode 100644 rust/perspective-viewer/src/rust/tasks/send_column_config.rs create mode 100644 rust/perspective-viewer/src/rust/tasks/send_plugin_config.rs create mode 100644 rust/perspective-viewer/src/rust/tasks/update_and_render.rs create mode 100644 rust/perspective-viewer/src/rust/tasks/update_theme.rs create mode 100644 rust/perspective-viewer/src/rust/tasks/validate_expression.rs create mode 100644 rust/perspective-viewer/src/rust/utils/browser/blob.rs create mode 100644 rust/perspective-viewer/src/rust/utils/browser/download.rs create mode 100644 rust/perspective-viewer/src/rust/utils/browser/dragdrop.rs create mode 100644 rust/perspective-viewer/src/rust/utils/browser/mod.rs create mode 100644 rust/perspective-viewer/src/rust/utils/browser/request_animation_frame.rs create mode 100644 rust/perspective-viewer/src/rust/utils/browser/selection.rs create mode 100644 rust/perspective-viewer/src/rust/utils/browser/tests/debounce.rs create mode 100644 rust/perspective-viewer/src/rust/utils/browser/tests/mod.rs create mode 100644 rust/perspective-viewer/src/rust/utils/custom_element.rs create mode 100644 rust/perspective-viewer/src/rust/utils/datetime.rs create mode 100644 rust/perspective-viewer/src/rust/utils/debounce.rs create mode 100644 rust/perspective-viewer/src/rust/utils/hooks/mod.rs create mode 100644 rust/perspective-viewer/src/rust/utils/hooks/use_async_callback.rs create mode 100644 rust/perspective-viewer/src/rust/utils/mod.rs create mode 100644 rust/perspective-viewer/src/rust/utils/modal_position.rs create mode 100644 rust/perspective-viewer/src/rust/utils/number_format.rs create mode 100644 rust/perspective-viewer/src/rust/utils/ptr_eq_rc.rs create mode 100644 rust/perspective-viewer/src/rust/utils/pubsub.rs create mode 100644 rust/perspective-viewer/src/rust/utils/tests/mod.rs create mode 100644 rust/perspective-viewer/src/rust/utils/tests/pubsub.rs create mode 100644 rust/perspective-viewer/src/rust/utils/tests/request_animation_frame.rs create mode 100644 rust/perspective-viewer/src/rust/utils/weak_scope.rs create mode 100644 rust/perspective-viewer/src/svg/align-scroll-icon.svg create mode 100644 rust/perspective-viewer/src/svg/bg-pattern.png create mode 100644 rust/perspective-viewer/src/svg/bookmark-icon.svg create mode 100644 rust/perspective-viewer/src/svg/boolean-type.svg create mode 100644 rust/perspective-viewer/src/svg/checkbox-checked-icon.svg create mode 100644 rust/perspective-viewer/src/svg/checkbox-hover.svg create mode 100644 rust/perspective-viewer/src/svg/checkbox-off.svg create mode 100644 rust/perspective-viewer/src/svg/checkbox-on.svg create mode 100644 rust/perspective-viewer/src/svg/checkbox-unchecked-icon.svg create mode 100644 rust/perspective-viewer/src/svg/close-icon.svg create mode 100644 rust/perspective-viewer/src/svg/column-settings-icon.svg create mode 100644 rust/perspective-viewer/src/svg/datagrid-select-column.svg create mode 100644 rust/perspective-viewer/src/svg/datagrid-select-region.svg create mode 100644 rust/perspective-viewer/src/svg/datagrid-select-row-tree.svg create mode 100644 rust/perspective-viewer/src/svg/datagrid-select-row.svg create mode 100644 rust/perspective-viewer/src/svg/datagrid.svg create mode 100644 rust/perspective-viewer/src/svg/date-type.svg create mode 100644 rust/perspective-viewer/src/svg/downloading.gif create mode 100644 rust/perspective-viewer/src/svg/drag-handle copy.svg create mode 100644 rust/perspective-viewer/src/svg/drag-handle.svg create mode 100644 rust/perspective-viewer/src/svg/drawer-bg-pattern-hidpi.png create mode 100644 rust/perspective-viewer/src/svg/drawer-tab-hover.svg create mode 100644 rust/perspective-viewer/src/svg/drawer-tab-invert-hover.svg create mode 100644 rust/perspective-viewer/src/svg/drawer-tab-invert.svg create mode 100644 rust/perspective-viewer/src/svg/drawer-tab.svg create mode 100644 rust/perspective-viewer/src/svg/dropdown-selector-light.svg create mode 100644 rust/perspective-viewer/src/svg/dropdown-selector.svg create mode 100644 rust/perspective-viewer/src/svg/duplicate-icon.svg create mode 100644 rust/perspective-viewer/src/svg/editable-icon.svg create mode 100644 rust/perspective-viewer/src/svg/export-icon.svg create mode 100644 rust/perspective-viewer/src/svg/expression.svg create mode 100644 rust/perspective-viewer/src/svg/free-scroll-icon.svg create mode 100644 rust/perspective-viewer/src/svg/inactive-mouseover.svg create mode 100644 rust/perspective-viewer/src/svg/mega-menu-icons-candlestick.svg create mode 100644 rust/perspective-viewer/src/svg/mega-menu-icons-datagrid.svg create mode 100644 rust/perspective-viewer/src/svg/mega-menu-icons-density.svg create mode 100644 rust/perspective-viewer/src/svg/mega-menu-icons-heatmap.svg create mode 100644 rust/perspective-viewer/src/svg/mega-menu-icons-map-density.svg create mode 100644 rust/perspective-viewer/src/svg/mega-menu-icons-map-line.svg create mode 100644 rust/perspective-viewer/src/svg/mega-menu-icons-map-scatter.svg create mode 100644 rust/perspective-viewer/src/svg/mega-menu-icons-ohlc.svg create mode 100644 rust/perspective-viewer/src/svg/mega-menu-icons-sunburst.svg create mode 100644 rust/perspective-viewer/src/svg/mega-menu-icons-treemap.svg create mode 100644 rust/perspective-viewer/src/svg/mega-menu-icons-x-bar.svg create mode 100644 rust/perspective-viewer/src/svg/mega-menu-icons-x-y-line.svg create mode 100644 rust/perspective-viewer/src/svg/mega-menu-icons-x-y-scatter.svg create mode 100644 rust/perspective-viewer/src/svg/mega-menu-icons-y-area.svg create mode 100644 rust/perspective-viewer/src/svg/mega-menu-icons-y-bar.svg create mode 100644 rust/perspective-viewer/src/svg/mega-menu-icons-y-line.svg create mode 100644 rust/perspective-viewer/src/svg/mega-menu-icons-y-scatter.svg create mode 100644 rust/perspective-viewer/src/svg/number-type.svg create mode 100644 rust/perspective-viewer/src/svg/radio-hover.svg create mode 100644 rust/perspective-viewer/src/svg/radio-off.svg create mode 100644 rust/perspective-viewer/src/svg/radio-on.svg create mode 100644 rust/perspective-viewer/src/svg/read-only-icon.svg create mode 100644 rust/perspective-viewer/src/svg/revert-icon.svg create mode 100644 rust/perspective-viewer/src/svg/sort-abs-asc-icon.svg create mode 100644 rust/perspective-viewer/src/svg/sort-abs-col-asc-icon.svg create mode 100644 rust/perspective-viewer/src/svg/sort-abs-col-desc-icon.svg create mode 100644 rust/perspective-viewer/src/svg/sort-abs-desc-icon.svg create mode 100644 rust/perspective-viewer/src/svg/sort-asc-icon.svg create mode 100644 rust/perspective-viewer/src/svg/sort-col-asc-icon.svg create mode 100644 rust/perspective-viewer/src/svg/sort-col-desc-icon.svg create mode 100644 rust/perspective-viewer/src/svg/sort-desc-icon.svg create mode 100644 rust/perspective-viewer/src/svg/sort-none-icon.svg create mode 100644 rust/perspective-viewer/src/svg/status_error.svg create mode 100644 rust/perspective-viewer/src/svg/status_ok.svg create mode 100644 rust/perspective-viewer/src/svg/string-type.svg create mode 100644 rust/perspective-viewer/src/svg/theme-icon.svg create mode 100644 rust/perspective-viewer/src/svg/updating.gif create mode 100644 rust/perspective-viewer/src/themes/botanical.css create mode 100644 rust/perspective-viewer/src/themes/defaults.css create mode 100644 rust/perspective-viewer/src/themes/dracula.css create mode 100644 rust/perspective-viewer/src/themes/gruvbox-dark.css create mode 100644 rust/perspective-viewer/src/themes/gruvbox.css create mode 100644 rust/perspective-viewer/src/themes/icons.css create mode 100644 rust/perspective-viewer/src/themes/intl.css create mode 100644 rust/perspective-viewer/src/themes/intl/de.css create mode 100644 rust/perspective-viewer/src/themes/intl/es.css create mode 100644 rust/perspective-viewer/src/themes/intl/fr.css create mode 100644 rust/perspective-viewer/src/themes/intl/ja.css create mode 100644 rust/perspective-viewer/src/themes/intl/pt.css create mode 100644 rust/perspective-viewer/src/themes/intl/zh.css create mode 100644 rust/perspective-viewer/src/themes/monokai.css create mode 100644 rust/perspective-viewer/src/themes/phosphor.css create mode 100644 rust/perspective-viewer/src/themes/pro-dark.css create mode 100644 rust/perspective-viewer/src/themes/pro.css create mode 100644 rust/perspective-viewer/src/themes/solarized-dark.css create mode 100644 rust/perspective-viewer/src/themes/solarized.css create mode 100644 rust/perspective-viewer/src/themes/themes.css create mode 100644 rust/perspective-viewer/src/themes/vaporwave.css create mode 100644 rust/perspective-viewer/src/ts/bootstrap.ts create mode 100644 rust/perspective-viewer/src/ts/column-format.ts create mode 100644 rust/perspective-viewer/src/ts/extensions.ts create mode 100644 rust/perspective-viewer/src/ts/perspective-viewer.cdn.ts create mode 100644 rust/perspective-viewer/src/ts/perspective-viewer.inline.ts create mode 100644 rust/perspective-viewer/src/ts/perspective-viewer.ts create mode 100644 rust/perspective-viewer/src/ts/perspective-viewer.worker.ts create mode 100644 rust/perspective-viewer/src/ts/plugin.ts create mode 100644 rust/perspective-viewer/test/arrow/int_float_str.arrow create mode 100644 rust/perspective-viewer/test/css/demo.css create mode 100644 rust/perspective-viewer/test/csv/superstore.csv create mode 100644 rust/perspective-viewer/test/html/blank.html create mode 100644 rust/perspective-viewer/test/html/column-selector-modes.html create mode 100644 rust/perspective-viewer/test/html/column-settings-enabled.html create mode 100644 rust/perspective-viewer/test/html/debug_plugins.js create mode 100644 rust/perspective-viewer/test/html/filters.html create mode 100644 rust/perspective-viewer/test/html/plugin-priority-order.html create mode 100644 rust/perspective-viewer/test/html/plugin-resize.html create mode 100644 rust/perspective-viewer/test/html/superstore-all.html create mode 100644 rust/perspective-viewer/test/html/superstore-debug.html create mode 100644 rust/perspective-viewer/test/html/superstore-inline.html create mode 100644 rust/perspective-viewer/test/html/superstore-single-threaded.html create mode 100644 rust/perspective-viewer/test/html/superstore.html create mode 100644 rust/perspective-viewer/test/html/superstore_lazy_viewer.html create mode 100644 rust/perspective-viewer/test/html/superstore_with_input.html create mode 100644 rust/perspective-viewer/test/js/column_settings/attributes_tab.spec.ts create mode 100644 rust/perspective-viewer/test/js/column_settings/interactions.spec.ts create mode 100644 rust/perspective-viewer/test/js/column_settings/sidebar.spec.ts create mode 100644 rust/perspective-viewer/test/js/dragdrop/cancel.spec.ts create mode 100644 rust/perspective-viewer/test/js/dragdrop/dragdrop_test_utils.ts create mode 100644 rust/perspective-viewer/test/js/dragdrop/dragover.spec.ts create mode 100644 rust/perspective-viewer/test/js/dragdrop/drop.spec.ts create mode 100644 rust/perspective-viewer/test/js/dragdrop/drop_column_settings.spec.ts create mode 100644 rust/perspective-viewer/test/js/dragdrop/drop_named_columns.spec.ts create mode 100644 rust/perspective-viewer/test/js/helpers.ts create mode 100644 rust/perspective-viewer/test/js/helpers/standard_tests.ts create mode 100644 rust/perspective-viewer/test/js/settings_panel/close.spec.ts create mode 100644 rust/perspective-viewer/test/js/settings_panel/plugins.spec.ts create mode 100644 rust/perspective-viewer/test/js/settings_panel/toggle.spec.ts create mode 100644 rust/perspective-viewer/test/js/stability/export.spec.ts create mode 100644 rust/perspective-viewer/test/js/stability/leaks.spec.ts create mode 100644 rust/perspective-viewer/test/js/superstore/inline.spec.ts create mode 100644 rust/perspective-viewer/test/js/superstore/standard.spec.ts create mode 100644 rust/perspective-viewer/test/js/tsconfig.json create mode 100644 rust/perspective-viewer/test/js/viewer_api/delete.spec.ts create mode 100644 rust/perspective-viewer/test/js/viewer_api/dom.spec.ts create mode 100644 rust/perspective-viewer/test/js/viewer_api/events.spec.ts create mode 100644 rust/perspective-viewer/test/js/viewer_api/export.spec.ts create mode 100644 rust/perspective-viewer/test/js/viewer_api/flush.spec.ts create mode 100644 rust/perspective-viewer/test/js/viewer_api/focus.spec.ts create mode 100644 rust/perspective-viewer/test/js/viewer_api/load.spec.ts create mode 100644 rust/perspective-viewer/test/js/viewer_api/reset.spec.ts create mode 100644 rust/perspective-viewer/test/js/viewer_api/save_restore.spec.ts create mode 100644 rust/perspective-viewer/test/js/viewer_api/theme.spec.ts create mode 100644 rust/perspective-viewer/test/js/viewer_api/view_lifecycle.spec.ts create mode 100644 rust/perspective-viewer/test/js/viewer_config/cancellable.spec.ts create mode 100644 rust/perspective-viewer/test/js/viewer_config/custom_elements.spec.ts create mode 100644 rust/perspective-viewer/test/js/viewer_config/expressions.spec.ts create mode 100644 rust/perspective-viewer/test/js/viewer_config/filter.spec.ts create mode 100644 rust/perspective-viewer/test/js/viewer_config/localization.spec.ts create mode 100644 rust/perspective-viewer/tools.mjs create mode 100644 rust/perspective-viewer/tsconfig.json create mode 100644 rust/perspective/Cargo.toml create mode 100644 rust/perspective/build.mjs create mode 100644 rust/perspective/package.json create mode 100644 rust/perspective/src/axum.rs create mode 100644 rust/perspective/src/lib.rs create mode 100644 rust/perspective/src/virtual_server.rs create mode 100644 rust/perspective/tests/concurrent_test.rs create mode 100644 rustfmt.toml create mode 100644 tools/bench/basic_suite.mjs create mode 100644 tools/bench/charts_cases.mjs create mode 100644 tools/bench/charts_page_harness.js create mode 100644 tools/bench/charts_suite.mjs create mode 100644 tools/bench/cross_platform_suite.mjs create mode 100644 tools/bench/package.json create mode 100755 tools/bench/perspective-bench create mode 100644 tools/bench/puppeteer_suite.mjs create mode 100644 tools/bench/python_suite.mjs create mode 100644 tools/bench/remote_suite.js create mode 100644 tools/bench/src/html/charts-bench.html create mode 100644 tools/bench/src/html/empty.html create mode 100644 tools/bench/src/html/index.html create mode 100644 tools/bench/src/js/benchmark.mjs create mode 100644 tools/bench/src/js/servers/python.mjs create mode 100644 tools/bench/src/js/superstore.mjs create mode 100644 tools/bench/src/python/server.py create mode 100644 tools/esbuild-plugin/README.md create mode 100644 tools/esbuild-plugin/amd.js create mode 100644 tools/esbuild-plugin/build.js create mode 100644 tools/esbuild-plugin/external.js create mode 100644 tools/esbuild-plugin/index.js create mode 100644 tools/esbuild-plugin/package.json create mode 100644 tools/esbuild-plugin/resolve.js create mode 100644 tools/esbuild-plugin/wasm.js create mode 100644 tools/esbuild-plugin/worker.js create mode 100644 tools/scripts/bench.mjs create mode 100644 tools/scripts/build.mjs create mode 100644 tools/scripts/clean.mjs create mode 100644 tools/scripts/docs.mjs create mode 100644 tools/scripts/fix.mjs create mode 100644 tools/scripts/install_emsdk.mjs create mode 100644 tools/scripts/install_llvm.mjs create mode 100644 tools/scripts/install_pyodide.mjs create mode 100644 tools/scripts/lint.mjs create mode 100644 tools/scripts/lint_headers.mjs create mode 100644 tools/scripts/package.json create mode 100644 tools/scripts/prepush.mjs create mode 100644 tools/scripts/publish.mjs create mode 100644 tools/scripts/pyodide.mjs create mode 100644 tools/scripts/repack_wheel.mjs create mode 100644 tools/scripts/setup.mjs create mode 100644 tools/scripts/sh_perspective.mjs create mode 100644 tools/scripts/test.mjs create mode 100644 tools/scripts/test_js.mjs create mode 100644 tools/scripts/version.mjs create mode 100644 tools/scripts/workspace.mjs create mode 100644 tools/test/assets/all_types_small_multi.arrow create mode 100644 tools/test/assets/superstore.csv create mode 100644 tools/test/load-viewer-all-types-small-multi-arrow.js create mode 100644 tools/test/load-viewer-csv-single-threaded.js create mode 100644 tools/test/load-viewer-csv.js create mode 100644 tools/test/load-viewer-superstore.js create mode 100644 tools/test/load-viewer-two-csv.js create mode 100644 tools/test/load-viewer.js create mode 100644 tools/test/load-workspace-arrow.js create mode 100644 tools/test/package.json create mode 100644 tools/test/playwright.config.ts create mode 100644 tools/test/playwright/index.html create mode 100644 tools/test/playwright/index.tsx create mode 100644 tools/test/src/html/all-types-small-multi-arrow-test.html create mode 100644 tools/test/src/html/basic-test.html create mode 100644 tools/test/src/html/superstore-test.html create mode 100644 tools/test/src/html/two-chart-test.html create mode 100644 tools/test/src/html/workspace-test.html create mode 100644 tools/test/src/js/fixture.ts create mode 100644 tools/test/src/js/global_startup.ts create mode 100644 tools/test/src/js/global_teardown.ts create mode 100644 tools/test/src/js/index.ts create mode 100644 tools/test/src/js/models/column_settings.ts create mode 100644 tools/test/src/js/models/page.ts create mode 100644 tools/test/src/js/models/plugins.ts create mode 100644 tools/test/src/js/models/plugins/datagrid.ts create mode 100644 tools/test/src/js/models/settings_panel.ts create mode 100644 tools/test/src/js/set_timezone.js create mode 100644 tools/test/src/js/simple_viewer_tests.ts create mode 100644 tools/test/src/js/snapshot-sync.ts create mode 100644 tools/test/src/js/start_test_server.ts create mode 100644 tools/test/src/js/utils.ts create mode 100644 tools/test/tsconfig.json diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..33d3f35 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,46 @@ +[alias] +bundle = "run -p perspective-bundle" + +[build] +rustflags = ["--cfg=web_sys_unstable_apis", "-Csymbol-mangling-version=v0"] +# rustflags = [ +# "--cfg=web_sys_unstable_apis", +# "--cfg=pyo3_disable_reference_pool", +# "-Csymbol-mangling-version=legacy", +# "-Zunstable-options", +# ] +target-dir = "rust/target" + +[target.wasm32-unknown-unknown] +rustflags = [ + "--cfg=getrandom_backend=\"wasm_js\"", + "--cfg=web_sys_unstable_apis", + "-Ctarget-feature=+bulk-memory,+simd128,+relaxed-simd,+reference-types", +] + +[target.i686-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static", "--cfg=web_sys_unstable_apis"] + +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static", "--cfg=web_sys_unstable_apis"] + +[future-incompat-report] +frequency = 'never' + +# TODO This is required to synth public releases on GH Actions, which doesn't +# use the root `Cargo.toml`. +[patch.crates-io] +perspective-client = { path = "rust/perspective-client" } +perspective-server = { path = "rust/perspective-server" } +perspective-js = { path = "rust/perspective-js" } +perspective-python = { path = "rust/perspective-python" } +perspective-viewer = { path = "rust/perspective-viewer" } +perspective = { path = "rust/perspective" } + +[unstable] +bindeps = true + +[term] +quiet = false +verbose = false +color = 'always' diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..c7010b9 --- /dev/null +++ b/.clang-format @@ -0,0 +1,235 @@ +--- +Language: Cpp +AccessModifierOffset: -4 +AlignAfterOpenBracket: BlockIndent +AlignArrayOfStructures: None +AlignConsecutiveAssignments: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCompound: false + PadOperators: true +AlignConsecutiveBitFields: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCompound: false + PadOperators: false +AlignConsecutiveDeclarations: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCompound: false + PadOperators: false +AlignConsecutiveMacros: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCompound: false + PadOperators: false +AlignConsecutiveShortCaseStatements: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCaseColons: false +AlignEscapedNewlines: Right +AlignOperands: DontAlign +AlignTrailingComments: + Kind: Always + OverEmptyLines: 0 +AllowAllArgumentsOnNextLine: true +AllowAllParametersOfDeclarationOnNextLine: true +AllowShortBlocksOnASingleLine: Empty +AllowShortCaseLabelsOnASingleLine: false +AllowShortEnumsOnASingleLine: true +AllowShortFunctionsOnASingleLine: All +AllowShortIfStatementsOnASingleLine: Never +AllowShortLambdasOnASingleLine: All +AllowShortLoopsOnASingleLine: false +AlwaysBreakAfterDefinitionReturnType: All +AlwaysBreakAfterReturnType: AllDefinitions +AlwaysBreakBeforeMultilineStrings: true +AlwaysBreakTemplateDeclarations: Yes +AttributeMacros: + - __capability +BinPackArguments: false +BinPackParameters: false +BitFieldColonSpacing: Both +BraceWrapping: + AfterCaseLabel: false + AfterClass: false + AfterControlStatement: Never + AfterEnum: false + AfterExternBlock: false + AfterFunction: false + AfterNamespace: false + AfterObjCDeclaration: false + AfterStruct: false + AfterUnion: false + BeforeCatch: false + BeforeElse: false + BeforeLambdaBody: false + BeforeWhile: false + IndentBraces: false + SplitEmptyFunction: true + SplitEmptyRecord: true + SplitEmptyNamespace: true +BreakAfterAttributes: Always +BreakAfterJavaFieldAnnotations: false +BreakArrays: true +BreakBeforeBinaryOperators: NonAssignment +BreakBeforeConceptDeclarations: Always +BreakBeforeBraces: Attach +BreakBeforeInlineASMColon: OnlyMultiline +BreakBeforeTernaryOperators: true +BreakConstructorInitializers: AfterColon +BreakInheritanceList: BeforeColon +BreakStringLiterals: true +ColumnLimit: 80 +CommentPragmas: "^ IWYU pragma:" +CompactNamespaces: false +ConstructorInitializerIndentWidth: 4 +ContinuationIndentWidth: 4 +Cpp11BracedListStyle: true +DerivePointerAlignment: false +DisableFormat: false +EmptyLineAfterAccessModifier: Never +EmptyLineBeforeAccessModifier: LogicalBlock +ExperimentalAutoDetectBinPacking: false +FixNamespaceComments: true +ForEachMacros: + - foreach + - Q_FOREACH + - BOOST_FOREACH +IfMacros: + - KJ_IF_MAYBE +IncludeBlocks: Preserve +IncludeCategories: + - Regex: '^"(llvm|llvm-c|clang|clang-c)/' + Priority: 2 + SortPriority: 0 + CaseSensitive: false + - Regex: '^(<|"(gtest|gmock|isl|json)/)' + Priority: 3 + SortPriority: 0 + CaseSensitive: false + - Regex: ".*" + Priority: 1 + SortPriority: 0 + CaseSensitive: false +IncludeIsMainRegex: "(Test)?$" +IncludeIsMainSourceRegex: "" +IndentAccessModifiers: false +IndentCaseBlocks: false +IndentCaseLabels: true +IndentExternBlock: AfterExternBlock +IndentGotoLabels: true +IndentPPDirectives: None +IndentRequiresClause: true +IndentWidth: 4 +IndentWrappedFunctionNames: false +InsertBraces: true +InsertNewlineAtEOF: false +InsertTrailingCommas: None +IntegerLiteralSeparator: + Binary: 0 + BinaryMinDigits: 0 + Decimal: 0 + DecimalMinDigits: 0 + Hex: 0 + HexMinDigits: 0 +JavaScriptQuotes: Leave +JavaScriptWrapImports: true +KeepEmptyLinesAtTheStartOfBlocks: true +KeepEmptyLinesAtEOF: false +LambdaBodyIndentation: Signature +LineEnding: LF +MacroBlockBegin: "" +MacroBlockEnd: "" +MaxEmptyLinesToKeep: 1 +NamespaceIndentation: Inner +ObjCBinPackProtocolList: Auto +ObjCBlockIndentWidth: 4 +ObjCBreakBeforeNestedBlockParam: true +ObjCSpaceAfterProperty: true +ObjCSpaceBeforeProtocolList: true +PackConstructorInitializers: CurrentLine +PenaltyBreakAssignment: 2 +PenaltyBreakBeforeFirstCallParameter: 19 +PenaltyBreakComment: 300 +PenaltyBreakFirstLessLess: 120 +PenaltyBreakOpenParenthesis: 0 +PenaltyBreakString: 1000 +PenaltyBreakTemplateDeclaration: 10 +PenaltyExcessCharacter: 1000000 +PenaltyIndentedWhitespace: 0 +PenaltyReturnTypeOnItsOwnLine: 60 +PointerAlignment: Left +PPIndentWidth: -1 +QualifierAlignment: Leave +ReferenceAlignment: Pointer +ReflowComments: true +RemoveBracesLLVM: false +RemoveParentheses: Leave +RemoveSemicolon: false +RequiresClausePosition: OwnLine +RequiresExpressionIndentation: OuterScope +SeparateDefinitionBlocks: Leave +ShortNamespaceLines: 1 +SortIncludes: Never +SortJavaStaticImport: Before +SortUsingDeclarations: LexicographicNumeric +SpaceAfterCStyleCast: false +SpaceAfterLogicalNot: false +SpaceAfterTemplateKeyword: true +SpaceAroundPointerQualifiers: Default +SpaceBeforeAssignmentOperators: true +SpaceBeforeCaseColon: false +SpaceBeforeCpp11BracedList: false +SpaceBeforeCtorInitializerColon: true +SpaceBeforeInheritanceColon: true +SpaceBeforeJsonColon: false +SpaceBeforeParens: ControlStatements +SpaceBeforeParensOptions: + AfterControlStatements: true + AfterForeachMacros: true + AfterFunctionDefinitionName: false + AfterFunctionDeclarationName: false + AfterIfMacros: true + AfterOverloadedOperator: false + AfterRequiresInClause: false + AfterRequiresInExpression: false + BeforeNonEmptyParentheses: false +SpaceBeforeRangeBasedForLoopColon: true +SpaceBeforeSquareBrackets: false +SpaceInEmptyBlock: false +SpacesBeforeTrailingComments: 1 +SpacesInAngles: Never +SpacesInContainerLiterals: true +SpacesInLineCommentPrefix: + Minimum: 1 + Maximum: -1 +SpacesInParens: Never +SpacesInParensOptions: + InCStyleCasts: false + InConditionalStatements: false + InEmptyParentheses: false + Other: false +SpacesInSquareBrackets: false +Standard: c++17 +StatementAttributeLikeMacros: + - Q_EMIT +StatementMacros: + - Q_UNUSED + - QT_REQUIRE_VERSION +TabWidth: 4 +UseTab: Never +VerilogBreakBetweenInstancePorts: true +WhitespaceSensitiveMacros: + - BOOST_PP_STRINGIZE + - CF_SWIFT_NAME + - NS_SWIFT_NAME + - PP_STRINGIZE + - STRINGIZE +--- + diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..099e12a --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,18 @@ +Checks: > + -*, + modernize-*, + performance-*, + readability-*, + clang-analyzer-*, + bugprone-*, + portability-*, + -modernize-use-trailing-return-type, + -readability-identifier-length, + -readability-magic-numbers, + -bugprone-easily-swappable-parameters, + -bugprone-implicit-widening-of-multiplication-result, + -readability-convert-member-functions-to-static, + -misc-const-correctness, + -readability-function-cognitive-complexity + +# WarningsAsErrors: "*" diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..0dbcadf --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,21 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/universal +{ + "name": "Ubuntu: pnpm, rust, cmake, for JS dev", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "customizations": { + "vscode": { + "extensions": [ + "rust-lang.rust-analyzer", + "ms-playwright.playwright" + ] + } + }, + + "features": { + "ghcr.io/devcontainers/features/rust:1": {}, + "ghcr.io/devcontainers-extra/features/pnpm:2": {} + }, + + "postCreateCommand": "./.devcontainer/postcreate.sh" +} diff --git a/.devcontainer/postcreate.sh b/.devcontainer/postcreate.sh new file mode 100755 index 0000000..3ffd742 --- /dev/null +++ b/.devcontainer/postcreate.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +set -e + +sudo apt-get update + +# Install cmake +test -f /usr/share/doc/kitware-archive-keyring/copyright || +wget -O - https://apt.kitware.com/keys/kitware-archive-latest.asc 2>/dev/null | gpg --dearmor - | sudo tee /usr/share/keyrings/kitware-archive-keyring.gpg >/dev/null + +echo 'deb [signed-by=/usr/share/keyrings/kitware-archive-keyring.gpg] https://apt.kitware.com/ubuntu/ jammy main' | sudo tee /etc/apt/sources.list.d/kitware.list >/dev/null +sudo apt-get update +sudo apt-get install -y kitware-archive-keyring +sudo apt-get install -y cmake + +# Install uv +curl -LsSf https://astral.sh/uv/install.sh | sh + +# Repo setup +pnpm install diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..24aac43 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +*.ipynb linguist-documentation +docs/**/* linguist-documentation +python/perspective/notebooks/* linguist-documentation +python/perspective/docs/* linguist-documentation +results/*.json linguist-documentation +**/test/**/* linguist-documentation +**/tests/**/* linguist-documentation +examples/**/* linguist-documentation +scripts/* linguist-documentation +tools/**/* linguist-documentation +cmake/** linguist-documentation +CMakeLists.txt linguist-documentation +**/*.sh linguist-documentation + +* text=auto eol=lf diff --git a/.github/ISSUE_TEMPLATE/Bug_report.md b/.github/ISSUE_TEMPLATE/Bug_report.md new file mode 100644 index 0000000..92a8530 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Bug_report.md @@ -0,0 +1,124 @@ +--- +name: Bug Report +about: If something isn't working as expected. +--- + +## Bug Report + +### Steps to Reproduce: + +Please provide a full reproduction of the issue. There are three ways we accept +repros: + +1. If the issue you are reporting is a UX/UI issue which can be recreated by + visiting a Perspective demo _hosted by the project itself_, and any dataset + required to reproduce the error can be included in the report. In this case, + please provide detailed step-by-step instructions on how to reproduce, + including any screenshots which help illustrate, as well as including any + fully-encoded test data we may need. + +2. If you are reporting a build or installation issue with the library itself, + which can be recreated from a shell. In this case, please provided detailed + code blocks describing how you tried to install, which commands were issued, + including and dependencies you needed to install and hwo you installed them. + +3. If you are reporting a _anything else_, including but not limited to: + - Build issues which require _any_ metadata files e.g. a `package.json`, + `Cargo.toml`, etc + - Bundler or packaging errors with JavaScript + - Library functions which return the wrong results or error + - CPU or memory usage performance regressions, or regressions in thread + utilization + + In this case, we require a _complete reproduction_ of the issue in the form + of a repository. Quoting this exceptional definition from + [@Rich-Harris's micro-essay on Repros](https://gist.github.com/Rich-Harris/88c5fc2ac6dc941b22e7996af05d70ff), + please follow these guidelines: + + > 1. Create a sample repo on GitHub (or wherever) + > 2. Demonstrate the problem, and nothing but the problem. If the app where + > you're experiencing the issue happens to use Gulp, I don't care, + > unless the problem involves Gulp. Remove that stuff. Whittle it down + > to the _bare minimum_ of code that reliably demonstrates the issue. + > Get rid of any dependencies that aren't _directly_ related to the + > problem. + > 3. Install all your dependencies to `package.json`. If I can't clone the + > repo and do `npm install && npm run build` (or similar – see point 4) + > to see the problem, because I need some globally installed CLI tool or + > whatever, then you've made it harder to get to the bottom of the + > issue. + > 4. Include instructions in the repo, along with a description of the + > expected and actual behaviour. Obviously the issue should include + > information about the bug as well, but it's really helpful if + > `README.md` includes that information, plus a link back to the issue. + > If there are any instructions beyond `npm install && npm run build`, + > they should go here. + + Some examples which _do not_ qualify as _complete_ and are mostly useless to + us for debugging: + - Instructions which ask us to visit a website or download an application, + even if it is _completely_ open source (and expecially if it is not) + - Instructions which just describe how to create a project, e.g. with a + specific build tool or template + - Screenshots of exceptions + - Screenshots of code + - Code snippets copied from a larger application context + +### Expected Result: + +Describe what you expected to see. If you are reporting a UX/UI error, this may +include screenshots with annotations. + +### Actual Result: + +Describe what actually happened, with special attention to the errant behavior. +Always include: + +- OS and version +- Platform/language + version + +If you are reporting a UX/UI error: + +- (if websocket) Platform/language + version of remote perspective server. +- Full exception/error message if applicable. +- Any potentially relevent JavaScript developer console error logs. +- Screenshots of the UI in an obviously broken state. (but please try to avoid + screenshots of your code, see below) + +If you are reporting a library error: + +- (if websocket) Platform/language + version of remote perspective server. +- Full exception error capture (please include the entire stack trace, including + "caused by" entries), log entries, etc. where appropriate. Please avoid + posting screenshots of code (which we may need to debug). + +If you are reporting a build or install error: + +- Full error output from running your repro, formatted as a code block (please + _do not_ include screenshots of build logs). + +### Environment: + +For JavaScript (browser): + +- `@perspective-dev/client` version +- Browser and version +- OS +- (if websocket) Language/version/OS of perspective server + +For Node.js: + +- `node` version +- OS + +For Python + +- `python` interpreter version (Only CPython). +- package manager and version (conda/pip/\*) + - Are you compiling from an sdist of wheel? +- Platform and version (Jupyter/tornado/lib/\*) +- OS + +### Additional Context: + +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/Feature_request.md b/.github/ISSUE_TEMPLATE/Feature_request.md new file mode 100644 index 0000000..7996e93 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Feature_request.md @@ -0,0 +1,22 @@ +--- +name: Feature Request +about: I have a suggestion. +--- + +## Feature Request + +### Description of Problem: + +...what _problem_ are you trying to solve that the project doesn't currently +solve? + +...please resist the temptation to describe your request in terms of a solution. +Job Story form ("When [triggering condition], I want to [motivation/goal], so I +can [outcome].") can help ensure you're expressing a problem statement. + +### Potential Solutions: + +...clearly and concisely describe what you want to happen. Add any considered +drawbacks. + +... if you've considered alternatives, clearly and concisely describe those too. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..e4aa82b --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,23 @@ + diff --git a/.github/actions/config/action.yaml b/.github/actions/config/action.yaml new file mode 100644 index 0000000..6b2b402 --- /dev/null +++ b/.github/actions/config/action.yaml @@ -0,0 +1,122 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +name: "Parse build configuration" +description: "Parses the build configuration into something easy to consume" + +outputs: + SKIP_CI: + value: ${{ steps.setuppush.outputs.SKIP_CI || steps.setuppr.outputs.SKIP_CI || steps.setupmanual.outputs.SKIP_CI }} + SKIP_CACHE: + value: ${{ steps.setuppush.outputs.SKIP_CACHE || steps.setuppr.outputs.SKIP_CACHE || steps.setupmanual.outputs.SKIP_CACHE }} + FULL_RUN: + value: ${{ steps.setuppush.outputs.FULL_RUN || steps.setuppr.outputs.FULL_RUN || steps.setupmanual.outputs.FULL_RUN }} + PUBLISH_RELEASE: + value: ${{ steps.setuppush.outputs.PUBLISH_RELEASE || steps.setuppr.outputs.PUBLISH_RELEASE || steps.setupmanual.outputs.PUBLISH_RELEASE }} + SKIP_PYTHON: + value: ${{ steps.setuppush.outputs.SKIP_PYTHON || steps.setuppr.outputs.SKIP_PYTHON || steps.setupmanual.outputs.SKIP_PYTHON }} + INCLUDE_WINDOWS: + value: ${{ steps.setuppush.outputs.INCLUDE_WINDOWS || steps.setuppr.outputs.INCLUDE_WINDOWS || steps.setupmanual.outputs.INCLUDE_WINDOWS }} + +runs: + using: "composite" + steps: + - name: Get Commit Message + shell: bash + run: echo "COMMIT_MSG=$(git log -1 --pretty=%B HEAD | tr '\n' ' ')" >> $GITHUB_ENV + if: ${{ github.event_name == 'push' }} + + - name: Get Commit Message + shell: bash + run: echo "COMMIT_MSG=$(git log -1 --pretty=%B HEAD^2 | tr '\n' ' ')" >> $GITHUB_ENV + if: ${{ github.event_name == 'pull_request' }} + + - name: Display and Setup Build Args (Push) + shell: bash + id: setuppush + run: | + echo "Commit Message: $COMMIT_MSG" + echo "Skip CI: $SKIP_CI" + echo "Skip Cache: $SKIP_CACHE" + echo "Full Run: $FULL_RUN" + echo "Publish Release: $PUBLISH_RELEASE" + echo "Skip Python: $SKIP_PYTHON" + echo "Include Windows: $INCLUDE_WINDOWS" + echo "COMMIT_MSG=$COMMIT_MSG" >> $GITHUB_OUTPUT + echo "SKIP_CI=$SKIP_CI" >> $GITHUB_OUTPUT + echo "SKIP_CACHE=$SKIP_CACHE" >> $GITHUB_OUTPUT + echo "FULL_RUN=$FULL_RUN" >> $GITHUB_OUTPUT + echo "PUBLISH_RELEASE=$PUBLISH_RELEASE" >> $GITHUB_OUTPUT + echo "SKIP_PYTHON=$SKIP_PYTHON" >> $GITHUB_OUTPUT + echo "INCLUDE_WINDOWS=$INCLUDE_WINDOWS" >> $GITHUB_OUTPUT + env: + SKIP_CI: ${{ contains(github.event.head_commit.message, '[ci-skip]') }} + SKIP_CACHE: ${{ contains(github.event.head_commit.message, '[ci-skip-cache]') }} + FULL_RUN: ${{ startsWith(github.ref_name, 'v') || contains(github.event.head_commit.message, '[ci-full]') || github.ref_name == 'master' }} + PUBLISH_RELEASE: ${{ startsWith(github.ref_name, 'v') }} + SKIP_PYTHON: ${{ contains(github.event.head_commit.message, '[ci-skip-python]') }} + INCLUDE_WINDOWS: ${{ contains(github.event.head_commit.message, '[ci-include-windows]') }} + if: ${{ github.event_name == 'push' }} + + - name: Display and Setup Build Args (PR) + shell: bash + id: setuppr + run: | + echo "Commit Message: $COMMIT_MSG" + echo "Skip CI: $SKIP_CI" + echo "Skip Cache: $SKIP_CACHE" + echo "Full Run: $FULL_RUN" + echo "Publish Release: $PUBLISH_RELEASE" + echo "Skip Python: $SKIP_PYTHON" + echo "Include Windows: $INCLUDE_WINDOWS" + echo "COMMIT_MSG=$COMMIT_MSG" >> $GITHUB_OUTPUT + echo "SKIP_CI=$SKIP_CI" >> $GITHUB_OUTPUT + echo "SKIP_CACHE=$SKIP_CACHE" >> $GITHUB_OUTPUT + echo "FULL_RUN=$FULL_RUN" >> $GITHUB_OUTPUT + echo "PUBLISH_RELEASE=$PUBLISH_RELEASE" >> $GITHUB_OUTPUT + echo "SKIP_PYTHON=$SKIP_PYTHON" >> $GITHUB_OUTPUT + echo "INCLUDE_WINDOWS=$INCLUDE_WINDOWS" >> $GITHUB_OUTPUT + env: + SKIP_CI: ${{ contains(github.event.pull_request.title, '[ci-skip]') || contains(github.event.head_commit.message, '[ci-skip]') }} + SKIP_CACHE: ${{ contains(github.event.pull_request.title, '[ci-skip-cache]') || contains(github.event.head_commit.message, '[ci-skip-cache]') }} + FULL_RUN: ${{ contains(github.event.pull_request.title, '[ci-full]') || contains(github.event.head_commit.message, '[ci-full]') }} + PUBLISH_RELEASE: ${{ startsWith(github.ref_name, 'v') }} + SKIP_PYTHON: ${{ contains(github.event.pull_request.title, '[ci-skip-python]') || contains(github.event.head_commit.message, '[ci-skip-python]') }} + INCLUDE_WINDOWS: ${{ contains(github.event.pull_request.title, '[ci-include-windows]') || contains(github.event.head_commit.message, '[ci-include-windows]') }} + if: ${{ github.event_name == 'pull_request' }} + + - name: Display and Setup Build Args (Manual) + id: setupmanual + shell: bash + run: | + echo "Commit Message: $COMMIT_MSG" + echo "Skip CI: $SKIP_CI" + echo "Skip Cache: $SKIP_CACHE" + echo "Full Run: $FULL_RUN" + echo "Publish Release: $PUBLISH_RELEASE" + echo "Skip Python: $SKIP_PYTHON" + echo "Include Windows: $INCLUDE_WINDOWS" + echo "COMMIT_MSG=$COMMIT_MSG" >> $GITHUB_OUTPUT + echo "SKIP_CI=$SKIP_CI" >> $GITHUB_OUTPUT + echo "SKIP_CACHE=$SKIP_CACHE" >> $GITHUB_OUTPUT + echo "FULL_RUN=$FULL_RUN" >> $GITHUB_OUTPUT + echo "PUBLISH_RELEASE=$PUBLISH_RELEASE" >> $GITHUB_OUTPUT + echo "SKIP_PYTHON=$SKIP_PYTHON" >> $GITHUB_OUTPUT + echo "INCLUDE_WINDOWS=$INCLUDE_WINDOWS" >> $GITHUB_OUTPUT + env: + SKIP_CI: false + SKIP_CACHE: ${{ github.event.inputs.ci-skip-cache }} + FULL_RUN: ${{ github.event.inputs.ci-full }} + PUBLISH_RELEASE: ${{ startsWith(github.ref_name, 'v') }} + SKIP_PYTHON: ${{ github.event.inputs.ci-skip-python }} + INCLUDE_WINDOWS: ${{ github.event.inputs.ci-include-windows }} + if: ${{ github.event_name == 'workflow_dispatch' }} diff --git a/.github/actions/install-deps/action.yaml b/.github/actions/install-deps/action.yaml new file mode 100644 index 0000000..d900edb --- /dev/null +++ b/.github/actions/install-deps/action.yaml @@ -0,0 +1,247 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +name: "Install Dependencies" +description: "Install and cache the project's myraid dependencies" + +inputs: + javascript: + default: "true" + description: "Install pnpm postinstall steps, playwright browsers and emsdk?" + arch: + default: "" + description: "Architecture" + manylinux: + default: "false" + description: "Deal with manylinux exceptions" + cpp: + default: "true" + description: "Install Boost and LLVM?" + rust: + default: "true" + description: "Install Rust toolchain?" + python: + default: "true" + description: "Install Python dependencies?" + playwright: + default: "false" + description: "Install browsers for playwright testing" + clean: + default: "false" + description: "Clean unused deps. This is helpful if we run out of HD but slow!" + skip_cache: + default: "false" + description: "Don't use cache from previous builds" + +runs: + using: "composite" + steps: + - name: Clean System + uses: AdityaGarg8/remove-unwanted-software@v5 + if: ${{ inputs.clean == 'true' && runner.os != 'Windows' }} + with: + remove-android: "true" + remove-dotnet: "true" + remove-haskell: "true" + remove-codeql: "true" + remove-docker-images: "true" + remove-large-packages: "true" + remove-cached-tools: "true" + + # Sticking to 3.29 because of: + # https://github.com/open-telemetry/opentelemetry-cpp/issues/2998 + - name: Setup cmake + uses: jwlawson/actions-setup-cmake@v2 + with: + cmake-version: "3.29.6" + + - name: Setup MSVC environment (Windows) + if: ${{ runner.os == 'Windows' }} + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: ${{ inputs.arch == 'aarch64' && 'amd64_arm64' || 'x64' }} + + - name: Force Ninja generator (Windows) + if: ${{ runner.os == 'Windows' }} + shell: bash + run: echo "CMAKE_GENERATOR=Ninja" >> "$GITHUB_ENV" + + - name: Install pnpm + uses: pnpm/action-setup@v3 + with: + version: 9 + + - name: Setup emsdk cache + uses: actions/cache@v4 + id: emsdk-cache + if: ${{ inputs.skip_cache == 'false' && inputs.javascript == 'true' }} + with: + path: | + ~/boost_1_82_0/ + ~/.emsdk/ + ~/.llvm/ + key: ${{ runner.os }}-emsdk-${{ hashFiles('package.json') }} + restore-keys: | + ${{ runner.os }}-emsdk- + + - name: Setup pip cache + uses: actions/cache@v4 + if: ${{ inputs.skip_cache == 'false' && inputs.python == 'true' }} + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Setup cargo cache + uses: actions/cache@v4 + if: ${{ inputs.skip_cache == 'false' && inputs.rust == 'true' }} + with: + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + restore-keys: | + ${{ runner.os }}-cargo- + + # - name: ccache + # uses: hendrikmuhs/ccache-action@v1.2 + # if: ${{ inputs.skip_cache == 'false' }} + # with: + # key: ${{ github.job }}-${{ matrix.os }} + + # https://github.com/apache/arrow/issues/38391 + - if: ${{ runner.os == 'macOS' }} + shell: bash + run: echo "MACOSX_DEPLOYMENT_TARGET=$(sw_vers -productVersion)" >> $GITHUB_ENV + + # Use python 3.12 from manylinu + - run: echo "/opt/python/cp311-cp311/bin" >> $GITHUB_PATH + shell: bash + if: ${{ runner.os == 'Linux' }} + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + if: ${{ inputs.python == 'true' && inputs.manylinux == 'false' }} + with: + python-version: ${{ matrix.python-version }} + cache: "pip" + + # - run: | + # curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py + # python get-pip.py --ignore-installed + # pip debug --verbose + # shell: bash + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: "pnpm" + cache-dependency-path: pnpm-lock.yaml + + - name: Install rust + uses: dtolnay/rust-toolchain@nightly + if: ${{ inputs.rust == 'true' && inputs.arch != 'aarch64' }} + with: + toolchain: nightly-2026-01-01 + targets: wasm32-unknown-unknown + components: rustfmt, clippy, rust-src + + - name: Install rust (aarch64 OSX) + uses: dtolnay/rust-toolchain@nightly + if: ${{ inputs.rust == 'true' && inputs.arch == 'aarch64' && runner.os == 'macOS' }} + with: + toolchain: nightly-2026-01-01 + targets: aarch64-apple-darwin + components: rust-src + + - name: Install rust (aarch64 Linux) + uses: dtolnay/rust-toolchain@nightly + if: ${{ inputs.rust == 'true' && inputs.arch == 'aarch64' && runner.os == 'Linux' }} + with: + toolchain: nightly-2026-01-01 + targets: aarch64-unknown-linux-gnu + components: rust-src + + # Did you see a CI error of the form: + # + # error: failed to install component: 'clippy-preview-aarch64-unknown-linux-gnu', + # detected conflict: 'bin/cargo-clippy' + # + # See https://github.com/rust-lang/rustup/issues/988#issuecomment-1820438467 + - name: Stupid cargo hack + shell: bash + run: cargo version + + # # TODO doesn't work. + # - name: Install LLVM 17 + # if: ${{ inputs.cpp == 'true' }} + # uses: KyleMayes/install-llvm-action@v2 + # with: + # version: "17" + # directory: "./.llvm" + # cached: true + + - name: Install JS dependencies + shell: bash + if: ${{ inputs.javascript == 'true' && inputs.playwright == 'true' }} + run: pnpm install + + - name: Install JS dependencies + shell: bash + if: ${{ inputs.javascript == 'false' || inputs.playwright == 'false'}} + run: pnpm install --ignore-scripts + + - name: Template version + shell: bash + if: ${{ !startsWith(github.ref_name, 'v') }} + run: | + git config --global --add safe.directory $GITHUB_WORKSPACE + node tools/scripts/version.mjs --nightly + + - name: Install Python dependencies + shell: bash + if: ${{ inputs.python == 'true' && inputs.manylinux == 'false' }} + run: python -m pip install -r rust/perspective-python/requirements.txt + + - name: Install Python dependencies + shell: bash + if: ${{ inputs.python == 'true' && inputs.manylinux == 'true' }} + run: /opt/python/cp311-cp311/bin/python -m pip install -r rust/perspective-python/requirements.txt + + - name: manylinux deps + shell: bash + run: | + if [ -x "$(command -v dnf)" ]; then + dnf install wget -y + fi + if: ${{ runner.os == 'Linux' && inputs.cpp == 'true' && inputs.javascript == 'false' }} + + # - name: Install CCache + # shell: bash + # run: sudo apt install -y ccache + # if: ${{ runner.os == 'Linux' }} + + # Arrow cannot install on windows without this + # https://stackoverflow.com/questions/22575662/filename-too-long-in-git-for-windows + # https://github.com/orgs/community/discussions/26952 + - run: git config --system core.longpaths true + shell: pwsh + if: ${{ runner.os == 'Windows' }} + + # - name: Free up disk space + # if: ${{ runner.os == 'Linux' }} + # run: | + # rm -rf /__t/* diff --git a/.github/actions/install-wheel/action.yaml b/.github/actions/install-wheel/action.yaml new file mode 100644 index 0000000..f1a74c9 --- /dev/null +++ b/.github/actions/install-wheel/action.yaml @@ -0,0 +1,42 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +name: "Install wheel" +description: "Installs the wheel depending on build platform, because Python" + +inputs: + inplace: + default: "true" + description: "Install in-place?" + +runs: + using: "composite" + steps: + - name: Install wheel (Linux) + shell: sh + run: python -m pip install -U --no-dependencies *.whl --target rust/perspective-python + if: ${{ inputs.inplace == 'true' && runner.os == 'Linux' }} + + - name: Install wheel (Linux) + shell: sh + run: python -m pip install -U --no-dependencies *.whl + if: ${{ inputs.inplace != 'true' && runner.os == 'Linux' }} + + - name: Install wheel (OSX) + shell: sh + run: python -m pip install -U --no-dependencies *.whl --target rust/perspective-python + if: ${{ runner.os == 'macOS' }} + + - name: Install wheel (Windows) + shell: pwsh + run: python -m pip install -U --no-dependencies (Get-ChildItem .\*.whl | Select-Object -Expand FullName) --target rust/perspective-python + if: ${{ runner.os == 'Windows' }} diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..8a25739 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,1087 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +name: Build Status + +on: + push: + branches: + - master + tags: + - v* + paths-ignore: + - AUTHORS + - CHANGELOG.md + - CONTRIBUTING.md + - LICENSE + - README.md + - binder/ + - docs/ + - examples/ + - rust/perspective-python/README.md + pull_request: + branches: + - master + workflow_dispatch: + inputs: + ci-full: + description: "Run Full CI" + required: false + type: boolean + default: false + ci-skip-cache: + description: "Omit Cache from CI run" + required: false + type: boolean + default: false + ci-skip-python: + description: "Skip Python components of CI" + required: false + type: boolean + default: false + ci-include-windows: + description: "Include Windows (Python) components of CI" + required: false + type: boolean + default: false + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + # , . . .-,--. + # ) . ,-. |- ,-. ,-. ,-| ' | \ ,-. ,-. ,-. + # / | | | | ,-| | | | | , | / | | | `-. + # `--' ' ' ' `' `-^ ' ' `-^ `-^--' `-' `-' `-' + # + lint_and_docs: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-22.04 + python-version: + - 3.11 + node-version: [22.x] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Config + id: config-step + uses: ./.github/actions/config + + - name: Initialize Build + id: init-step + uses: ./.github/actions/install-deps + with: + skip_cache: ${{ steps.config-step.outputs.SKIP_CACHE }} + + - name: Metadata Build + run: pnpm run build --ci + env: + PACKAGE: "metadata" + + - name: Lint + run: pnpm run lint --nightly + + - uses: actions/upload-artifact@v4 + with: + name: perspective-metadata + path: | + rust/perspective-server/cpp/ + rust/perspective-server/cmake/ + rust/perspective-client/src/ + rust/perspective-client/docs/ + rust/perspective-js/src/ts/ + rust/perspective-viewer/src/ts/ + rust/perspective-python/LICENSE_THIRDPARTY_cargo.yml + + # ,-,---. . . ,-_/ .---. . + # '|___/ . . . | ,-| ' | ,-. . , ,-. \___ ,-. ,-. . ,-. |- + # ,| \ | | | | | | | ,-| | / ,-| \ | | | | | | + # `-^---' `-^ ' `' `-^ | `-^ `' `-^ `---' `-' ' ' |-' `' + # /` | | + # `--' ' + build_js: + runs-on: ${{ matrix.os }} + needs: [lint_and_docs] + strategy: + fail-fast: false + matrix: + os: + - ubuntu-22.04 + python-version: + - 3.11 + node-version: [22.x] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Config + id: config-step + uses: ./.github/actions/config + + - uses: actions/download-artifact@v4 + with: + name: perspective-metadata + path: rust/ + + - name: Initialize Build + id: init-step + uses: ./.github/actions/install-deps + with: + clean: "true" + python: "false" + playwright: "true" + skip_cache: ${{ steps.config-step.outputs.SKIP_CACHE }} + + - name: WebAssembly Build + run: pnpm run build --ci + env: + PACKAGE: "server,client,viewer,viewer-datagrid,viewer-charts,workspace,react" + # PSP_USE_CCACHE: 1 + + - uses: actions/upload-artifact@v4 + with: + name: perspective-js-dist + path: | + rust/perspective-js/dist + rust/perspective-js/src + rust/perspective-server/dist + rust/perspective-viewer/dist + rust/perspective-viewer/src + packages/viewer-charts/dist + packages/viewer-datagrid/dist + packages/workspace/dist + packages/react/dist + + # ,-,---. . . .-,--. . . + # '|___/ . . . | ,-| '|__/ . . |- |-. ,-. ,-. + # ,| \ | | | | | | ,| | | | | | | | | | + # `-^---' `-^ ' `' `-^ `' `-| `' ' ' `-' ' ' + # /| + # `-' + build_python: + runs-on: ${{ matrix.os }} + needs: [lint_and_docs] + container: ${{ matrix.container }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-22.04 + - ubuntu-22.04-arm + - macos-14 + - windows-2025 + arch: + - aarch64 + - x86_64 + python-version: + - 3.11 + node-version: [22.x] + include: + - os: ubuntu-22.04 + arch: x86_64 + python-version: 3.11 + container: quay.io/pypa/manylinux_2_28_x86_64 + - os: ubuntu-22.04-arm + arch: aarch64 + python-version: 3.11 + container: quay.io/pypa/manylinux_2_28_aarch64 + is-release: + - ${{ startsWith(github.ref, 'refs/tags/v') || github.ref_name == 'master' || github.event.inputs.ci-full }} + exclude: + - os: windows-2025 + arch: aarch64 + - os: macos-14 + is-release: false + - os: macos-14 + arch: x86_64 + - os: windows-2025 + is-release: false + - os: ubuntu-22.04 + arch: aarch64 + - os: ubuntu-22.04-arm + arch: x86_64 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Config + id: config-step + uses: ./.github/actions/config + + - uses: actions/download-artifact@v4 + with: + name: perspective-metadata + path: rust/ + + - name: Initialize Build + id: init-step + uses: ./.github/actions/install-deps + with: + javascript: "false" + arch: ${{ matrix.arch }} + manylinux: ${{ matrix.container && 'true' || 'false' }} + skip_cache: ${{ steps.config-step.outputs.SKIP_CACHE }} + + - name: Run df -h + if: ${{ runner.os == 'Linux' }} + run: df -h + + - name: Python Build + run: pnpm run build + if: ${{ runner.os != 'Windows' }} + env: + PACKAGE: "python" + PSP_ARCH: ${{ matrix.arch }} + PSP_ROOT_DIR: ${{ github.workspace }} + PSP_BUILD_WHEEL: 1 + + - name: Python Build (Windows) + run: | + New-Item -ItemType Directory -Path $env:CARGO_TARGET_DIR -Force + pnpm run build + if: ${{ runner.os == 'Windows' }} + env: + CARGO_TARGET_DIR: D:\psp-rust + PSP_CPP_BUILD_DIR: D:\psp-build + PSP_ROOT_DIR: ${{ github.workspace }} + PACKAGE: "python" + PSP_ARCH: ${{ matrix.arch }} + PSP_BUILD_WHEEL: 1 + + - name: Run df -h + if: ${{ runner.os == 'Linux' }} + run: df -h + + # Windows sucks lol + - uses: actions/upload-artifact@v4 + if: ${{ runner.os == 'Windows' }} + with: + name: perspective-python-dist-${{ matrix.arch}}-${{ matrix.os }}-${{ matrix.python-version }} + path: D:\psp-rust\wheels\*.whl + + - uses: actions/upload-artifact@v4 + # if: ${{ runner.os != 'Windows' }} + with: + name: perspective-python-dist-${{ matrix.arch}}-${{ matrix.os }}-${{ matrix.python-version }} + path: rust/target/wheels/*.whl + + # ,-,---. . . . ,--,--' . + # '|___/ . . . | ,-| ,-. ,-. ,-| `- | ,-. ,-. |- + # ,| \ | | | | | | ,-| | | | | , | |-' `-. | + # `-^---' `-^ ' `' `-^ `-^ ' ' `-^ `-' `-' `-' `' + # + # .-,--. . + # `|__/ . . ,-. |- + # )| \ | | `-. | + # `' ` `-^ `-' `' + # + build_and_test_rust: + runs-on: ${{ matrix.os }} + needs: [lint_and_docs] + strategy: + fail-fast: false + matrix: + os: + - ubuntu-22.04 + - windows-2025 + arch: + - x86_64 + node-version: [22.x] + is-release: + - ${{ startsWith(github.ref, 'refs/tags/v') || github.ref_name == 'master' || github.event.inputs.ci-full }} + exclude: + - os: windows-2025 + is-release: false + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Config + id: config-step + uses: ./.github/actions/config + + - uses: actions/download-artifact@v4 + with: + name: perspective-metadata + path: rust/ + + - name: Initialize Build + id: init-step + uses: ./.github/actions/install-deps + with: + javascript: "false" + arch: ${{ matrix.arch }} + manylinux: "false" + skip_cache: ${{ steps.config-step.outputs.SKIP_CACHE }} + + - name: Rust Build + run: pnpm run build + if: ${{ !contains(matrix.os, 'windows') }} + env: + PACKAGE: "rust" + PSP_ROOT_DIR: ${{ github.workspace }} + + - name: Rust Build (Windows) + run: | + New-Item -ItemType Directory -Path $env:CARGO_TARGET_DIR -Force + pnpm run build + if: ${{ contains(matrix.os, 'windows') }} + env: + CARGO_TARGET_DIR: D:\psp-rust + PSP_ROOT_DIR: ${{ github.workspace }} + PACKAGE: "rust" + + - name: Rust Test + if: ${{ !contains(matrix.os, 'windows') }} + run: pnpm run test + env: + PACKAGE: "rust" + PSP_ROOT_DIR: ${{ github.workspace }} + + - name: Rust Test (Windows) + run: | + New-Item -ItemType Directory -Path $env:CARGO_TARGET_DIR -Force + pnpm run test + if: ${{ contains(matrix.os, 'windows') }} + env: + CARGO_TARGET_DIR: D:\psp-rust + PSP_ROOT_DIR: ${{ github.workspace }} + PACKAGE: "rust" + + - name: Package + if: ${{ !contains(matrix.os, 'windows') && steps.config-step.outputs.FULL_RUN }} + run: cargo package --no-verify --allow-dirty -p perspective -p perspective-viewer -p perspective-js -p perspective-client -p perspective-server -p perspective-python + + - uses: actions/upload-artifact@v4 + if: ${{ !contains(matrix.os, 'windows') && steps.config-step.outputs.FULL_RUN }} + with: + name: perspective-rust + path: rust/target/package/*.crate + + # ,-,---. . . .-,--. . . + # '|___/ . . . | ,-| '|__/ . . ,-. ,-| . ,-| ,-. + # ,| \ | | | | | | .| | | | | | | | | | |-' + # `-^---' `-' ' `' `-' `' `-| `-' `-' ' `-' `-' + # /| + # `-' + build_emscripten_wheel: + runs-on: ${{ matrix.os }} + needs: [lint_and_docs] + strategy: + fail-fast: false + matrix: + os: + - ubuntu-22.04 + arch: + - x86_64 + python-version: + - "3.13" + node-version: [22.x] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Config + id: config-step + uses: ./.github/actions/config + + - uses: actions/download-artifact@v4 + with: + name: perspective-metadata + path: rust/ + + - name: Initialize Build + id: init-step + uses: ./.github/actions/install-deps + with: + javascript: "false" + arch: ${{ matrix.arch }} + manylinux: "false" + skip_cache: ${{ steps.config-step.outputs.SKIP_CACHE }} + + - name: Python Build Pyodide + run: pnpm install && pnpm run build + env: + PSP_PYODIDE: 1 + PACKAGE: "python" + CI: 1 + + - name: Verify wheel is PyPI-compatible (PEP 783) + shell: bash + run: | + python -m pip install --quiet --upgrade twine "packaging>=24.2" + shopt -s nullglob + wheels=(rust/target/wheels/*.whl) + if [ ${#wheels[@]} -eq 0 ]; then + echo "::error::no emscripten wheel was produced"; exit 1 + fi + for w in "${wheels[@]}"; do + echo "checking $w" + case "$w" in + *pyemscripten_2025_0_wasm32.whl) ;; + *) echo "::error::$w is not tagged pyemscripten_2025_0_wasm32"; exit 1 ;; + esac + done + python -m twine check rust/target/wheels/*.whl + + - uses: actions/upload-artifact@v4 + with: + name: perspective-python-dist-wasm32-emscripten-${{ matrix.python-version }} + path: rust/target/wheels/*.whl + + # ~*~ Test pyodide ~*~ + test_emscripten_wheel: + runs-on: ${{ matrix.os }} + needs: [build_emscripten_wheel] + strategy: + fail-fast: false + matrix: + os: + - ubuntu-22.04 + arch: + - x86_64 + python-version: + - "3.13" + node-version: [22.x] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Config + id: config-step + uses: ./.github/actions/config + + - name: Initialize Build + uses: ./.github/actions/install-deps + with: + javascript: "false" + arch: ${{ matrix.arch }} + manylinux: "false" + skip_cache: ${{ steps.config-step.outputs.SKIP_CACHE }} + + - uses: actions/download-artifact@v4 + with: + name: perspective-python-dist-wasm32-emscripten-${{ matrix.python-version }} + path: rust/target/wheels/ + + - name: Install Python Pyodide dependencies + shell: bash + run: python -m pip install -r rust/perspective-python/requirements-pyodide.txt + + - name: Install Pyodide distribution + shell: bash + run: pnpm run install_pyodide + + - name: Install Python Playwright browsers + shell: bash + run: python -m playwright install + + - name: "Test Pyodide" + run: pnpm run test + env: + PSP_PYODIDE: 1 + PACKAGE: "python" + CI: 1 + # ,-,---. . . . ,--,--' . + # '|___/ . . . | ,-| ,-. ,-. ,-| `- | ,-. ,-. |- + # ,| \ | | | | | | ,-| | | | | , | |-' `-. | + # `-^---' `-^ ' `' `-^ `-^ ' ' `-^ `-' `-' `-' `' + # + # ,-_/ . . . + # ' | . . ,-. . . |- ,-. ,-. | ,-. |-. + # | | | | | | | | |-' | | ,-| | | + # | `-^ |-' `-| `' `-' ' `' `-^ ^-' + # /` | | /| + # `--' ' `-' + # + build_and_test_jupyterlab: + needs: [build_js, build_python] + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-22.04 + - macos-14 + - windows-2025 + arch: + - aarch64 + - x86_64 + python-version: + - 3.11 + node-version: [22.x] + is-release: + - ${{ startsWith(github.ref, 'refs/tags/v') || github.ref_name == 'master' || github.event.inputs.ci-full }} + exclude: + - os: windows-2025 + arch: aarch64 + - os: ubuntu-22.04 + arch: aarch64 + - os: macos-14 + is-release: false + - os: macos-14 + arch: x86_64 + - os: windows-2025 + is-release: false + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Config + id: config-step + uses: ./.github/actions/config + + - name: Initialize Build + id: init-step + uses: ./.github/actions/install-deps + with: + rust: "false" + playwright: "true" + cpp: "false" + skip_cache: ${{ steps.config-step.outputs.SKIP_CACHE }} + + - uses: actions/download-artifact@v4 + with: + name: perspective-js-dist + path: . + + - uses: actions/download-artifact@v4 + with: + name: perspective-metadata + path: rust/ + + - uses: actions/download-artifact@v4 + with: + name: perspective-python-dist-${{ matrix.arch }}-${{ matrix.os }}-${{ matrix.python-version }} + path: . + + - name: Build extension + run: pnpm run build + env: + PACKAGE: "jupyterlab" + + - run: node tools/scripts/repack_wheel.mjs + + - name: Python Build sdist + run: pnpm run build + env: + PACKAGE: "python" + if: ${{ runner.os == 'Linux' }} + # PSP_USE_CCACHE: 1 + # PSP_ARCH: ${{ matrix.arch }} + PSP_BUILD_SDIST: 1 + + - uses: actions/upload-artifact@v4 + with: + name: perspective-python-dist-${{ matrix.arch}}-${{ matrix.os }}-${{ matrix.python-version }} + path: "*.whl" + overwrite: true + + - uses: actions/upload-artifact@v4 + if: ${{ runner.os == 'Linux' }} + with: + name: perspective-python-sdist + path: rust/target/wheels/*.tar.gz + + - uses: ./.github/actions/install-wheel + if: ${{ runner.os == 'Linux' }} + + - name: Run Jupyter Tests + if: ${{ runner.os == 'Linux' }} + run: pnpm run test --fetch-snapshots + env: + PACKAGE: "jupyterlab" + PSP_SNAPSHOT_REPO: ${{ vars.PSP_SNAPSHOT_REPO }} + PSP_SNAPSHOT_TOKEN: ${{ secrets.PSP_SNAPSHOT_TOKEN }} + PSP_SNAPSHOT_REF: ${{ github.head_ref || github.ref_name }} + # PSP_USE_CCACHE: 1 + + - name: Run Jupyter Integration Tests + if: ${{ false }} + # if: ${{ runner.os == 'Linux' }} + run: | + jupyter lab --generate-config + pnpm run test --jupyter + env: + PACKAGE: "jupyterlab" + # PSP_USE_CCACHE: 1 + + # ,--,--' . ,-_/ .---. . + # `- | ,-. ,-. |- ' | ,-. . , ,-. \___ ,-. ,-. . ,-. |- + # , | |-' `-. | | ,-| | / ,-| \ | | | | | | + # `-' `-' `-' `' | `-^ `' `-^ `---' `-' ' ' |-' `' + # /` | | + # `--' ' + test_js: + needs: [build_js] + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-22.04 + python-version: + - 3.11 + node-version: [22.x] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Config + id: config-step + uses: ./.github/actions/config + + - name: Initialize Build + uses: ./.github/actions/install-deps + with: + python: "false" + rust: "false" + cpp: "false" + playwright: "true" + skip_cache: ${{ steps.config-step.outputs.SKIP_CACHE }} + + - uses: actions/download-artifact@v4 + with: + name: perspective-js-dist + path: . + + - name: Run Tests + id: run_tests + run: pnpm run test -- --fetch-snapshots + env: + PACKAGE: "server,client,viewer,viewer-datagrid,viewer-charts,workspace,react" + PSP_SNAPSHOT_REPO: ${{ vars.PSP_SNAPSHOT_REPO }} + PSP_SNAPSHOT_TOKEN: ${{ secrets.PSP_SNAPSHOT_TOKEN }} + PSP_SNAPSHOT_REF: ${{ github.head_ref || github.ref_name }} + # PSP_USE_CCACHE: 1 + + - name: Upload Test Failures + if: ${{ failure() && steps.run_tests.outcome == 'failure' }} + uses: actions/upload-artifact@v4 + with: + name: perspective-js-test-results + path: tools/test/dist/results + if-no-files-found: ignore + overwrite: true + + # ,--,--' . .-,--. . . + # `- | ,-. ,-. |- '|__/ . . |- |-. ,-. ,-. + # , | |-' `-. | ,| | | | | | | | | | + # `-' `-' `-' `' `' `-| `' ' ' `-' ' ' + # /| + # `-' + test_python: + needs: [build_python] + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-22.04 + - ubuntu-22.04-arm + - macos-14 + - windows-2025 + arch: + - x86_64 + - aarch64 + python-version: + - 3.11 + # - 3.11 + node-version: [22.x] + is-release: + - ${{ startsWith(github.ref, 'refs/tags/v') || github.ref_name == 'master' || github.event.inputs.ci-full }} + exclude: + - os: macos-14 + is-release: false + - os: macos-14 + is-release: false + - os: windows-2025 + is-release: false + - os: macos-14 + arch: aarch64 + - os: macos-14 + arch: x86_64 + - os: windows-2025 + arch: aarch64 + - os: ubuntu-22.04 + arch: aarch64 + - os: ubuntu-22.04-arm + arch: x86_64 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Config + id: config-step + uses: ./.github/actions/config + + - name: Initialize Build + uses: ./.github/actions/install-deps + with: + rust: "false" + cpp: "false" + skip_cache: ${{ steps.config-step.outputs.SKIP_CACHE }} + + - uses: actions/download-artifact@v4 + with: + name: perspective-python-dist-${{ matrix.arch }}-${{ matrix.os }}-${{ matrix.python-version }} + + - uses: ./.github/actions/install-wheel + + - name: Run Tests + run: pnpm run test + env: + PACKAGE: "python" + # PSP_USE_CCACHE: 1 + + test_python_sdist: + if: startsWith(github.ref, 'refs/tags/v') || github.ref_name == 'master' || github.event.inputs.ci-full + needs: [build_and_test_jupyterlab, build_and_test_rust] + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-22.04 + arch: + - x86_64 + python-version: + - 3.11 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Config + id: config-step + uses: ./.github/actions/config + + - name: Initialize Build + uses: ./.github/actions/install-deps + with: + rust: "true" + cpp: "true" + python: "true" + javascript: "false" + skip_cache: ${{ steps.config-step.outputs.SKIP_CACHE }} + + - uses: actions/download-artifact@v4 + with: + name: perspective-rust + path: crates/ + + - uses: actions/download-artifact@v4 + with: + name: perspective-python-sdist + + # The sdist depends on perspective-{client,server} crates published to + # crates.io. Patch the build to instead use .crate artifacts built + # previously in the workflow + - name: Set up path overrides for perspective .crate contents + shell: bash + run: | + (cd crates && tar xvf perspective-client-*.crate && tar xvf perspective-server-*.crate) + CLIENT_CRATE_ROOT=$(ls -d "$PWD"/crates/perspective-client-*/ | head -n 1) + SERVER_CRATE_ROOT=$(ls -d "$PWD"/crates/perspective-server-*/ | head -n 1) + + mkdir -p $HOME/.cargo + cat > $HOME/.cargo/config.toml <&1 | grep '@perspective-dev/jupyterlab' + + - name: Run import test + run: python -c 'import perspective' + + - name: Run pytests + run: pytest -v --pyargs 'perspective.tests' --timeout=300 + + # ,-,---. . . + # '|___/ ,-. ,-. ,-. |-. ,-,-. ,-. ,-. | , + # ,| \ |-' | | | | | | | | ,-| | |< + # `-^---' `-' ' ' `-' ' ' ' ' ' `-^ ' ' ` + # + # .-,--. . . + # '|__/ . . |- |-. ,-. ,-. + # ,| | | | | | | | | | + # `' `-| `' ' ' `-' ' ' + # /| + # `-' + benchmark_python: + needs: [build_python, build_js] + if: startsWith(github.ref, 'refs/tags/v') || github.ref_name == 'master' || github.event.inputs.ci-full + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04] + python-version: [3.11] + node-version: [22.x] + arch: [x86_64] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Config + id: config-step + uses: ./.github/actions/config + + - name: Initialize Build + uses: ./.github/actions/install-deps + with: + rust: "false" + cpp: "false" + skip_cache: ${{ steps.config-step.outputs.SKIP_CACHE }} + + - uses: actions/download-artifact@v4 + with: + name: perspective-js-dist + path: . + + - uses: actions/download-artifact@v4 + with: + name: perspective-python-dist-${{ matrix.arch }}-${{ matrix.os }}-${{ matrix.python-version }} + + - uses: ./.github/actions/install-wheel + with: + inplace: "false" + + - name: Benchmarks + run: pnpm run bench + env: + PACKAGE: "python" + + - uses: actions/upload-artifact@v4 + with: + name: perspective-python-benchmarks + path: tools/bench/dist/benchmark-python.arrow + + # ,-,---. . . + # '|___/ ,-. ,-. ,-. |-. ,-,-. ,-. ,-. | , + # ,| \ |-' | | | | | | | | ,-| | |< + # `-^---' `-' ' ' `-' ' ' ' ' ' `-^ ' ' ` + # + # ,-_/ .---. . + # ' | ,-. . , ,-. \___ ,-. ,-. . ,-. |- + # | ,-| | / ,-| \ | | | | | | + # | `-^ `' `-^ `---' `-' ' ' |-' `' + # /` | | + # `--' ' + benchmark_js: + needs: [build_js] + if: startsWith(github.ref, 'refs/tags/v') || github.ref_name == 'master' || github.event.inputs.ci-full + strategy: + matrix: + os: [ubuntu-22.04] + node-version: [22.x] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Config + id: config-step + uses: ./.github/actions/config + + - name: Initialize Build + uses: ./.github/actions/install-deps + with: + rust: "false" + cpp: "false" + python: "false" + javascript: "false" + skip_cache: ${{ steps.config-step.outputs.SKIP_CACHE }} + + - uses: actions/download-artifact@v4 + with: + name: perspective-js-dist + path: . + + - name: Benchmarks + run: pnpm run bench + env: + PACKAGE: "client" + + - uses: actions/upload-artifact@v4 + with: + name: perspective-js-benchmarks + path: tools/bench/dist/benchmark-js.arrow + + # .-,--. . . . + # '|__/ . . |-. | . ,-. |-. + # ,| | | | | | | `-. | | + # `' `-^ ^-' `' ' `-' ' ' + # + publish: + needs: + [ + build_and_test_jupyterlab, + test_python, + test_python_sdist, + test_js, + benchmark_js, + benchmark_python, + build_emscripten_wheel, + test_emscripten_wheel, + build_and_test_rust, + lint_and_docs, + ] + if: startsWith(github.ref, 'refs/tags/v') || github.ref_name == 'master' || github.event.inputs.ci-full + strategy: + matrix: + os: [ubuntu-22.04] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v2 + + - name: Config + id: config-step + uses: ./.github/actions/config + + - name: Initialize Build + uses: ./.github/actions/install-deps + with: + rust: "false" + cpp: "false" + python: "false" + javascript: "false" + skip_cache: ${{ steps.config-step.outputs.SKIP_CACHE }} + + - uses: actions/download-artifact@v4 + with: + name: perspective-js-dist + path: . + + - uses: actions/download-artifact@v4 + with: + name: perspective-python-dist-aarch64-macos-14-3.11 + + - uses: actions/download-artifact@v4 + with: + name: perspective-python-dist-aarch64-ubuntu-22.04-arm-3.11 + + # - uses: actions/download-artifact@v4 + # with: + # name: perspective-python-dist-x86_64-macos-14-3.9 + + - uses: actions/download-artifact@v4 + with: + name: perspective-python-dist-x86_64-windows-2025-3.11 + + - uses: actions/download-artifact@v4 + with: + name: perspective-python-dist-x86_64-ubuntu-22.04-3.11 + + - uses: actions/download-artifact@v4 + with: + name: perspective-python-dist-wasm32-emscripten-3.13 + + - uses: actions/download-artifact@v4 + with: + name: perspective-python-sdist + + - uses: actions/download-artifact@v4 + with: + name: perspective-js-benchmarks + + - uses: actions/download-artifact@v4 + with: + name: perspective-python-benchmarks + + - uses: actions/download-artifact@v4 + with: + name: perspective-rust + + - run: pnpm pack --pack-destination=../.. + working-directory: ./rust/perspective-js + + - run: pnpm pack --pack-destination=../.. + working-directory: ./rust/perspective-server + + - run: pnpm pack --pack-destination=../.. + working-directory: ./rust/perspective-viewer + + - run: pnpm pack --pack-destination=../.. + working-directory: ./packages/viewer-datagrid + + - run: pnpm pack --pack-destination=../.. + working-directory: ./packages/viewer-charts + + - run: pnpm pack --pack-destination=../.. + working-directory: ./packages/workspace + + - run: pnpm pack --pack-destination=../.. + working-directory: ./packages/react + + - run: pnpm pack --pack-destination=../.. + working-directory: ./packages/cli + + - run: pnpm pack --pack-destination=../.. + working-directory: ./packages/jupyterlab + + - name: Upload nightly artifacts + uses: actions/upload-artifact@v4 + if: ${{ steps.config-step.outputs.PUBLISH_RELEASE == 'false' }} + with: + name: nightly + path: | + *.whl + *.tar.gz + *.tgz + *.arrow + *.crate + + - name: Publish assets + uses: softprops/action-gh-release@v2 + if: ${{ steps.config-step.outputs.PUBLISH_RELEASE == 'true' }} + with: + draft: true + generate_release_notes: true + files: | + *.whl + *.tar.gz + *.tgz + *.arrow + *.crate + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b889829 --- /dev/null +++ b/.gitignore @@ -0,0 +1,71 @@ +# -*- mode: gitignore; -*- +__pycache__/ +!.vscode/extensions.json +!.vscode/perspective.code-snippets +!.vscode/settings.default.json +!.vscode/tasks.json +!website/i18n/en.json +.cache +.clangd +.DS_Store +.emsdk +.plan +.pnpm-store +.ipynb_checkpoints +.perspectiverc +.vscode/* +*.so +*~ +/.emacs.desktop +/.emacs.desktop.lock +/.idea +/auto/ +/docs/build +/elpa/ +/eshell/history +/eshell/lastdir +/examples/*/build +/jsconfig.json +/packages/*/build +/packages/sigma +/server/ +/src/include/boost +benchmark_venv +dist/ +docs/.docusaurus +docs/i18n/en.json +docs/modules.rst +docs/perspective.*.rst +docs/static/arrow/ +docs/static/blocks +docs/static/features +docs/static/guide +examples/blocks/src/nypd/nypdccrb.arrow +node_modules +packages/jupyterlab/test/config/jupyter/migrated +py_modules +python/cmake +python/cpp +rust/perspective-client/docs/expression_gen.md +rust/perspective-client/src/rust/proto.rs +rust/perspective-js/src/ts/ts-rs +rust/perspective-server/build +rust/perspective-python/LICENSE_THIRDPARTY_cargo.yml +rust/perspective-python/LICENSE.md +rust/perspective-server/docs/lib_gen.md +rust/perspective-viewer/src/ts/ts-rs +rust/perspective/src/ts/ts-rs +rust/target* +Vagrantfile +vcpkg +venv/ +rust/perspective-python/perspective_python-*.data +.pytest-cache/ +docs/static/python +docs/static/node +docs/static/browser +docs/static/viewer +docs/static/react +rust/perspective-server/build +target/ +dist-gh-pages diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..0a43593 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,3 @@ +#!/bin/sh + +pnpm run prepush diff --git a/.pnpmrc b/.pnpmrc new file mode 100644 index 0000000..537f81f --- /dev/null +++ b/.pnpmrc @@ -0,0 +1 @@ +ignore-scripts=true \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..26f996a --- /dev/null +++ b/.prettierignore @@ -0,0 +1,14 @@ +dist/ +build/ +node_modules/ +target/ +.emsdk/ +.venv*/ +boost*/ +py_modules/ +ts-rs/ +rust/perspective-python/perspective/labextension/ +rust/perspective-python/perspective.data/ +rust/perspective-python/*/data +expression_gen.md +rust/perspective-viewer/docs/exprtk.md diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..0b1b507 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,18 @@ +{ + "tabWidth": 4, + "overrides": [ + { + "files": ["*.html"], + "options": { + "printWidth": 200, + "tabWidth": 4 + } + }, + { + "files": ["*.md"], + "options": { + "proseWrap": "always" + } + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..309da72 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3810 @@ +# [v4.5.2](https://github.com/perspective-dev/perspective/releases/tag/v4.5.2) + +_9 July 2026_ ([Full changelog](https://github.com/finos/perspective/compare/v4.5.1...v4.5.2)) + +Features + +- PEP 783 (pypi emscripten wheels) [#3191](https://github.com/finos/perspective/pull/3191) +- Add `page_to_disk` option, disk-backed columns via OPFS, `mmap` or `node:fs`. [#3185](https://github.com/finos/perspective/pull/3185) + +Fixes + +- Fix `viewer-charts` context sharing and misc bugs [#3190](https://github.com/finos/perspective/pull/3190) +- Fix memory safety issues [#3188](https://github.com/finos/perspective/pull/3188) + +# [v4.5.1](https://github.com/perspective-dev/perspective/releases/tag/v4.5.1) + +_30 May 2026_ ([Full changelog](https://github.com/finos/perspective/compare/v4.5.0...v4.5.1)) + +Features + +- `<perspective-viewer>` bug fixes and UI polish [#3182](https://github.com/finos/perspective/pull/3182) +- Add `gmv` aggregate [#3180](https://github.com/finos/perspective/pull/3180) + +Fixes + +- Fix chart bugs, add deploy scripts [#3181](https://github.com/finos/perspective/pull/3181) + +# [v4.5.0](https://github.com/perspective-dev/perspective/releases/tag/v4.5.0) + +_21 May 2026_ ([Full changelog](https://github.com/finos/perspective/compare/v4.4.1...v4.5.0)) + +**Breaking** + +- New plugin API, `viewer-charts` features, replace `@perspective-dev/viewer-openlayers` [#3176](https://github.com/finos/perspective/pull/3176) +- Rename `@perspective-dev/viewer-charts`, add missing chart types [#3166](https://github.com/finos/perspective/pull/3166) + +Features + +- Add `coalesce` [#3172](https://github.com/finos/perspective/pull/3172) +- Multi-chart rendering for `@perspective-dev/charts` [#3168](https://github.com/finos/perspective/pull/3168) +- Add WebGL chart plugin with scatter, line, and treemap [#3160](https://github.com/finos/perspective/pull/3160) +- Add `View::with_typed_arrays` API [#3165](https://github.com/finos/perspective/pull/3165) + +Fixes + +- Fix `expressions` memory leak [#3177](https://github.com/finos/perspective/pull/3177) +- Fix multi-batch Arrow null validity bug [#3171](https://github.com/finos/perspective/pull/3171) +- Fix Safari rendering bug [#3162](https://github.com/finos/perspective/pull/3162) +- Fix multi-column aggregates by expressions [#3161](https://github.com/finos/perspective/pull/3161) + +Misc + +- Update security docs [#3173](https://github.com/finos/perspective/pull/3173) + +# [v4.4.1](https://github.com/perspective-dev/perspective/releases/tag/v4.4.1) + +_14 April 2026_ ([Full changelog](https://github.com/finos/perspective/compare/v4.4.0...v4.4.1)) + +Features + +- Add `LabelBar` datagrid mode [#3156](https://github.com/finos/perspective/pull/3156) + +Misc + +- Remove `docusaurus` [#3155](https://github.com/finos/perspective/pull/3155) + +# [v4.4.0](https://github.com/perspective-dev/perspective/releases/tag/v4.4.0) + +_2 April 2026_ ([Full changelog](https://github.com/finos/perspective/compare/v4.3.0...v4.4.0)) + +**Breaking** + +- Redesign theme support [#3146](https://github.com/finos/perspective/pull/3146) +- Add presize API to `<perspective-viewer>` [#3144](https://github.com/finos/perspective/pull/3144) + +Features + +- `Client::join` API [#3143](https://github.com/finos/perspective/pull/3143) +- Performance optimization for `perspective-server` [#3141](https://github.com/finos/perspective/pull/3141) +- Use Arrow IPC in Virtual Server API [#3139](https://github.com/finos/perspective/pull/3139) +- Implement `ViewGetMinMaxReq` API for Virtual Servers [#3140](https://github.com/finos/perspective/pull/3140) + +Fixes + +- SQL model fixes [#3151](https://github.com/finos/perspective/pull/3151) +- Workspace theme fixes [#3150](https://github.com/finos/perspective/pull/3150) + +Misc + +- Refactor `<perspective-viewer>` and add tests [#3138](https://github.com/finos/perspective/pull/3138) + +# [v4.3.0](https://github.com/perspective-dev/perspective/releases/tag/v4.3.0) + +_10 March 2026_ ([Full changelog](https://github.com/finos/perspective/compare/v4.2.0...v4.3.0)) + +**Breaking** + +- Add `removeConfigs` to `perspective-select` for explicit filter removal [#3133](https://github.com/finos/perspective/pull/3133) +- Add `group_rollup_mode` [#3132](https://github.com/finos/perspective/pull/3132) + +Features + +- Support for `group_rollup_mode` Virtual Server and `total` variant. [#3135](https://github.com/finos/perspective/pull/3135) +- `polars` Virtual Server [#3125](https://github.com/finos/perspective/pull/3125) +- Fix `split_by` support for rollups in generic SQL [#3127](https://github.com/finos/perspective/pull/3127) + +Fixes + +- Fix datagrid scroll position shift when sorting [#3136](https://github.com/finos/perspective/pull/3136) +- Fix view conflation test timeout [#3126](https://github.com/finos/perspective/pull/3126) + +Misc + +- Fix Python sdist tests in CI [#3134](https://github.com/finos/perspective/pull/3134) +- Add virtual server docs [#3124](https://github.com/finos/perspective/pull/3124) +- Relax React version [#3131](https://github.com/finos/perspective/pull/3131) +- ci: Revert to mainline actions-setup-cmake [#3130](https://github.com/finos/perspective/pull/3130) +- Move to Windows 2025 CI [#3123](https://github.com/finos/perspective/pull/3123) + +# [v4.2.0](https://github.com/perspective-dev/perspective/releases/tag/v4.2.0) + +_14 February 2026_ ([Full changelog](https://github.com/finos/perspective/compare/v4.1.1...v4.2.0)) + +**Breaking** + +- Upgrade toolchain [#3118](https://github.com/finos/perspective/pull/3118) + +Features + +- Clickhouse Virtual Server [#3119](https://github.com/finos/perspective/pull/3119) + +Fixes + +- Fix `null` equality bug [#3115](https://github.com/finos/perspective/pull/3115) + +Misc + +- Fix compatibility with recent crates [#3122](https://github.com/finos/perspective/pull/3122) +- Better TypeScript types for `viewer-datagrid` [#3116](https://github.com/finos/perspective/pull/3116) + +# [v4.1.1](https://github.com/perspective-dev/perspective/releases/tag/v4.1.1) + +_2 February 2026_ ([Full changelog](https://github.com/finos/perspective/compare/v4.1.0...v4.1.1)) + +Fixes + +- Fix virtual server error handling [#3114](https://github.com/finos/perspective/pull/3114) +- Fix `perspective-workspace` context submenu for tables [#3113](https://github.com/finos/perspective/pull/3113) + +# [v4.1.0](https://github.com/perspective-dev/perspective/releases/tag/v4.1.0) + +_27 January 2026_ ([Full changelog](https://github.com/finos/perspective/compare/v4.0.1...v4.1.0)) + +**Breaking** + +- Add `table` to `ViewerConfig` [#3107](https://github.com/finos/perspective/pull/3107) + +Features + +- DuckDB Virtual Server [#3062](https://github.com/finos/perspective/pull/3062) + +Fixes + +- Support `client.table(view)` in Python [#3112](https://github.com/finos/perspective/pull/3112) +- Fix Arrow decimal type conversion to float instead of integer [#3099](https://github.com/finos/perspective/pull/3099) +- Fix viewport scroll regression in datagrid [#3098](https://github.com/finos/perspective/pull/3098) + +Misc + +- `<PerspectiveWorkspace>` React component [#3109](https://github.com/finos/perspective/pull/3109) +- Fix windows Python builds [#3110](https://github.com/finos/perspective/pull/3110) +- Convert `viewer-datagrid` to TypeScript [#3108](https://github.com/finos/perspective/pull/3108) + +# [v4.0.1](https://github.com/perspective-dev/perspective/releases/tag/v4.0.1) + +_15 December 2025_ ([Full changelog](https://github.com/finos/perspective/compare/v4.0.0...v4.0.1)) + +Features + +- Build arm64/aarch64 Linux wheels [#3092](https://github.com/finos/perspective/pull/3092) + +Fixes + +- Fix "Export to HTML" feature in `@perspective-dev/viewer` component [#3095](https://github.com/finos/perspective/pull/3095) +- Support a wider array of integers [#3084](https://github.com/finos/perspective/pull/3084) +- Prevent homebrew resolution [#3083](https://github.com/finos/perspective/pull/3083) +- Fix `datetime` filters against numeric parameters [#3081](https://github.com/finos/perspective/pull/3081) +- Fix workspace special casing issue [#3066](https://github.com/finos/perspective/pull/3066) + +Misc + +- OpenJS onboarding pt.2 [#3087](https://github.com/finos/perspective/pull/3087) +- Publish `@perspective-dev/server` package in CI [#3078](https://github.com/finos/perspective/pull/3078) + +# [v4.0.0](https://github.com/perspective-dev/perspective/releases/tag/v4.0.0) + +_28 October 2025_ ([Full changelog](https://github.com/finos/perspective/compare/v3.8.0...v4.0.0)) + +**Breaking** + +- Move to OpenJS Foundation [#3073](https://github.com/finos/perspective/pull/3073) + +Fixes + +- Fix `View::expand`, `View::collapse` unlocked server section [#3074](https://github.com/finos/perspective/pull/3074) +- Fix concurrent update/view creation segfault [#3068](https://github.com/finos/perspective/pull/3068) +- Fix plugins which inherit from `perspective-viewer-datagrid` [#3064](https://github.com/finos/perspective/pull/3064) +- Fix jupyterlab renderers [#3059](https://github.com/finos/perspective/pull/3059) +- Fix `aggregates` field in `View::get_config` [#3053](https://github.com/finos/perspective/pull/3053) + +Misc + +- Correct images to correspond to architecture explanations [#3067](https://github.com/finos/perspective/pull/3067) +- Disable `pnpm` post install scripts and fix `pnpm audit` dependencies [#3054](https://github.com/finos/perspective/pull/3054) + +# [v3.8.0](https://github.com/perspective-dev/perspective/releases/tag/v3.8.0) + +_2 September 2025_ ([Full changelog](https://github.com/finos/perspective/compare/v3.7.4...v3.8.0)) + +**Breaking** + +- Better `features` support [#3049](https://github.com/finos/perspective/pull/3049) +- Update column paths when the colun set changes in `perspective-viewer-datagrid` [#3047](https://github.com/finos/perspective/pull/3047) + +Fixes + +- Fix concurrent `HTMLPerspectiveViewerElement::load` error [#3045](https://github.com/finos/perspective/pull/3045) +- Fix `null` handling for cast ExprTK functions [#3040](https://github.com/finos/perspective/pull/3040) + +Misc + +- Fix Workspace.removeTable() [#3048](https://github.com/finos/perspective/pull/3048) +- Move C++ source to `perspective-server` crate [#3038](https://github.com/finos/perspective/pull/3038) +- Remove blog links [#3043](https://github.com/finos/perspective/pull/3043) +- Add API docs for `perspective-react` [#3037](https://github.com/finos/perspective/pull/3037) + +# [v3.7.4](https://github.com/perspective-dev/perspective/releases/tag/v3.7.4) + +_20 July 2025_ ([Full changelog](https://github.com/finos/perspective/compare/v3.7.3...v3.7.4)) + +Features + +- Upgrade `Client::system_info` [#3036](https://github.com/finos/perspective/pull/3036) + +# [v3.7.3](https://github.com/perspective-dev/perspective/releases/tag/v3.7.3) + +_13 July 2025_ ([Full changelog](https://github.com/finos/perspective/compare/v3.7.2...v3.7.3)) + +Features + +- Expose `null` cell style for `perspective-viewer-datagrid` [#3032](https://github.com/finos/perspective/pull/3032) + +Fixes + +- Fix thread safety of multiple `Server` instances in the same process [#3034](https://github.com/finos/perspective/pull/3034) + +# [v3.7.2](https://github.com/perspective-dev/perspective/releases/tag/v3.7.2) + +_8 July 2025_ ([Full changelog](https://github.com/finos/perspective/compare/v3.7.1...v3.7.2)) + +Fixes + +- Fix datagrid editing and add tests [#3030](https://github.com/finos/perspective/pull/3030) + +Misc + +- Refactor docs [#3029](https://github.com/finos/perspective/pull/3029) + +# [v3.7.1](https://github.com/perspective-dev/perspective/releases/tag/v3.7.1) + +_2 July 2025_ ([Full changelog](https://github.com/finos/perspective/compare/v3.7.0...v3.7.1)) + +Fixes + +- Fix global filter regression [#3025](https://github.com/finos/perspective/pull/3025) + +Misc + +- Fix dep version conflict with `rand` [#3024](https://github.com/finos/perspective/pull/3024) + +# [v3.7.0](https://github.com/perspective-dev/perspective/releases/tag/v3.7.0) + +_30 June 2025_ ([Full changelog](https://github.com/finos/perspective/compare/v3.6.1...v3.7.0)) + +**Breaking** + +- New error UX [#3003](https://github.com/finos/perspective/pull/3003) +- New conflation model [#2991](https://github.com/finos/perspective/pull/2991) +- `@finos/perspective-react` [#2893](https://github.com/finos/perspective/pull/2893) + +Features + +- Fix datagrid `abs` sorting on header click [#3014](https://github.com/finos/perspective/pull/3014) +- Add some native HTML attributes to `perspective-react` [#3010](https://github.com/finos/perspective/pull/3010) +- Add `max by` and `min by` aggregates [#2998](https://github.com/finos/perspective/pull/2998) + +Fixes + +- Fix UI bugs from large dataset example [#3022](https://github.com/finos/perspective/pull/3022) +- Fix `max_by`, `min_by`, `max`, `min` aggregates with empty filter sets [#3016](https://github.com/finos/perspective/pull/3016) +- Fix `to_arrow` thread safety [#3006](https://github.com/finos/perspective/pull/3006) +- Fix `perspective_viewer::restore` calls blurring [#3005](https://github.com/finos/perspective/pull/3005) +- Fix `ProxySession` message routing bug with Jupyter widget [#2989](https://github.com/finos/perspective/pull/2989) + +Misc + +- Fix typo in docs for replace_all [#3021](https://github.com/finos/perspective/pull/3021) +- Fix perspective-viewer-datagrid draw error when group_by+split_by+filter are empty [#3015](https://github.com/finos/perspective/pull/3015) +- Fix examples, docs and imports [#3013](https://github.com/finos/perspective/pull/3013) +- Fix Windows CI [#3004](https://github.com/finos/perspective/pull/3004) +- Add `aggregate_depth` datagrid style config [#3002](https://github.com/finos/perspective/pull/3002) +- `<perspective-workspace>` toolbar fix [#3000](https://github.com/finos/perspective/pull/3000) +- Nightly versions [#2990](https://github.com/finos/perspective/pull/2990) +- Nightly artifacts [#2987](https://github.com/finos/perspective/pull/2987) + +# [v3.6.1](https://github.com/perspective-dev/perspective/releases/tag/v3.6.1) + +_30 April 2025_ ([Full changelog](https://github.com/finos/perspective/compare/v3.6.0...v3.6.1)) + +Fixes + +- Fix line chart alt Y axis on zoom [#2986](https://github.com/finos/perspective/pull/2986) + +# [v3.6.0](https://github.com/perspective-dev/perspective/releases/tag/v3.6.0) + +_24 April 2025_ ([Full changelog](https://github.com/finos/perspective/compare/v3.5.1...v3.6.0)) + +**Breaking** + +- Fix `limit` overflow bug [#2983](https://github.com/finos/perspective/pull/2983) + +# [v3.5.1](https://github.com/perspective-dev/perspective/releases/tag/v3.5.1) + +_11 April 2025_ ([Full changelog](https://github.com/finos/perspective/compare/v3.5.0...v3.5.1)) + +Features + +- AsyncTable support in widget [#2974](https://github.com/finos/perspective/pull/2974) + +Misc + +- Add vscode settings to enable prettier [#2959](https://github.com/finos/perspective/pull/2959) + +# [v3.5.0](https://github.com/perspective-dev/perspective/releases/tag/v3.5.0) + +_10 April 2025_ ([Full changelog](https://github.com/finos/perspective/compare/v3.4.3...v3.5.0)) + +Features + +- Add API for keyboard copy from `perspective-viewer-datagrid` [#2975](https://github.com/finos/perspective/pull/2975) +- Update `limit` behavior to use incrementing `index` (fix `last_by_index` aggregate) [#2964](https://github.com/finos/perspective/pull/2964) + +Fixes + +- Fix support for browsers lacking the `SharedWorker` API [#2978](https://github.com/finos/perspective/pull/2978) +- Fix chart bug which rounds subsecond ticks [#2971](https://github.com/finos/perspective/pull/2971) +- Fix export-to-html in `perspective-viewer` [#2960](https://github.com/finos/perspective/pull/2960) + +Misc + +- Fix parallel_for and cleanup commented out code. [#2972](https://github.com/finos/perspective/pull/2972) + +# [v3.4.3](https://github.com/perspective-dev/perspective/releases/tag/v3.4.3) + +_20 March 2025_ ([Full changelog](https://github.com/finos/perspective/compare/v3.4.2...v3.4.3)) + +Fixes + +- Fix date filters in perspective-{select,click} events [#2957](https://github.com/finos/perspective/pull/2957) +- Invalidate viewer session before loading table [#2958](https://github.com/finos/perspective/pull/2958) + +# [v3.4.2](https://github.com/perspective-dev/perspective/releases/tag/v3.4.2) + +_14 March 2025_ ([Full changelog](https://github.com/finos/perspective/compare/v3.4.1...v3.4.2)) + +Fixes + +- Add timeouts to pytest, fix jupyterlab error [#2952](https://github.com/finos/perspective/pull/2952) + +# [v3.4.1](https://github.com/perspective-dev/perspective/releases/tag/v3.4.1) + +_12 March 2025_ ([Full changelog](https://github.com/finos/perspective/compare/v3.4.0...v3.4.1)) + +Features + +- Add `Client::on_hosted_tables_update` [#2948](https://github.com/finos/perspective/pull/2948) + +Fixes + +- Fix segfault in Python webservers [#2943](https://github.com/finos/perspective/pull/2943) + +# [v3.4.0](https://github.com/perspective-dev/perspective/releases/tag/v3.4.0) + +_6 March 2025_ ([Full changelog](https://github.com/finos/perspective/compare/v3.3.4...v3.4.0)) + +Features + +- Add connection error/retry API and UI support [#2902](https://github.com/finos/perspective/pull/2902) +- Add dedicated `Worker`, `SharedWorker` and `ServiceWorker` support [#2936](https://github.com/finos/perspective/pull/2936) +- Python AsyncClient and JS ProxySession [#2929](https://github.com/finos/perspective/pull/2929) +- Quartiles [#2930](https://github.com/finos/perspective/pull/2930) +- Fix `View::to_polars` and add `LargeUTF8` Arrow dictionary support [#2924](https://github.com/finos/perspective/pull/2924) + +Fixes + +- Show appropriate date when date is part of grouped data on chart. [#2857](https://github.com/finos/perspective/pull/2857) + +Misc + +- Shrink `d3fc` bundle [#2934](https://github.com/finos/perspective/pull/2934) + +# [v3.3.4](https://github.com/perspective-dev/perspective/releases/tag/v3.3.4) + +_9 February 2025_ ([Full changelog](https://github.com/finos/perspective/compare/v3.3.3...v3.3.4)) + +Fixes + +- Fix datagrid scroll drift on resize [#2919](https://github.com/finos/perspective/pull/2919) + +# [v3.3.3](https://github.com/perspective-dev/perspective/releases/tag/v3.3.3) + +_4 February 2025_ ([Full changelog](https://github.com/finos/perspective/compare/v3.3.2...v3.3.3)) + +Misc + +- Heap Profiling Tooling [#2913](https://github.com/finos/perspective/pull/2913) +- Fix API docs [#2910](https://github.com/finos/perspective/pull/2910) +- Support for pyodide 0.27.1, including pyarrow + pandas [#2901](https://github.com/finos/perspective/pull/2901) + +# [v3.3.2](https://github.com/perspective-dev/perspective/releases/tag/v3.3.2) + +_1 February 2025_ ([Full changelog](https://github.com/finos/perspective/compare/v3.3.1...v3.3.2)) + +Features + +- Fix overallocations [#2909](https://github.com/finos/perspective/pull/2909) + +# [v3.3.1](https://github.com/perspective-dev/perspective/releases/tag/v3.3.1) + +_26 January 2025_ ([Full changelog](https://github.com/finos/perspective/compare/v3.3.0...v3.3.1)) + +Fixes + +- Fix `date` formatting for `perspective-viewer-datagrid` plugin [#2904](https://github.com/finos/perspective/pull/2904) + +# [v3.3.0](https://github.com/perspective-dev/perspective/releases/tag/v3.3.0) + +_14 January 2025_ ([Full changelog](https://github.com/finos/perspective/compare/v3.2.1...v3.3.0)) + +**Breaking** + +- Unbundled JavaScript builds [#2890](https://github.com/finos/perspective/pull/2890) + +Misc + +- Add build support for `Memory64` [#2888](https://github.com/finos/perspective/pull/2888) + +# [v3.2.1](https://github.com/perspective-dev/perspective/releases/tag/v3.2.1) + +_23 December 2024_ ([Full changelog](https://github.com/finos/perspective/compare/v3.2.0...v3.2.1)) + +Features + +- Better `datetime`/`date` string parsing performance [#2885](https://github.com/finos/perspective/pull/2885) +- Add `min` and `max` aggregates for `date` and `datetime` columns [#2884](https://github.com/finos/perspective/pull/2884) + +Fixes + +- Fix `ViewConfig` serialization for internal arrow types [#2883](https://github.com/finos/perspective/pull/2883) +- Fix memory corruption on filter text overflow [#2882](https://github.com/finos/perspective/pull/2882) + +Misc + +- Fix examples and cleanup CSS [#2886](https://github.com/finos/perspective/pull/2886) + +# [v3.2.0](https://github.com/perspective-dev/perspective/releases/tag/v3.2.0) + +_9 December 2024_ ([Full changelog](https://github.com/finos/perspective/compare/v3.1.8...v3.2.0)) + +Features + +- Support ndjson [#2873](https://github.com/finos/perspective/pull/2873) + +Fixes + +- Fix duplicate columns [#2872](https://github.com/finos/perspective/pull/2872) + +# [v3.1.8](https://github.com/perspective-dev/perspective/releases/tag/v3.1.8) + +_2 December 2024_ ([Full changelog](https://github.com/finos/perspective/compare/v3.1.7...v3.1.8)) + +Fixes + +- fix treemap rendering issue in firefox [#2813](https://github.com/finos/perspective/pull/2813) +- Allow for passthrough of event loop in psp webserver handlers [#2829](https://github.com/finos/perspective/pull/2829) +- Fix workspace dark theme, theme render bug [#2864](https://github.com/finos/perspective/pull/2864) + +# [v3.1.7](https://github.com/perspective-dev/perspective/releases/tag/v3.1.7) + +_30 November 2024_ ([Full changelog](https://github.com/finos/perspective/compare/v3.1.6...v3.1.7)) + +Fixes + +- Remove `workspace-layout-update` event debounce [#2862](https://github.com/finos/perspective/pull/2862) + +Misc + +- Add some information on JSON input formats [#2856](https://github.com/finos/perspective/pull/2856) + +# [v3.1.6](https://github.com/perspective-dev/perspective/releases/tag/v3.1.6) + +_18 November 2024_ ([Full changelog](https://github.com/finos/perspective/compare/v3.1.5...v3.1.6)) + +Features + +- Support input/output to/from polars via python (not native rust) [#2849](https://github.com/finos/perspective/pull/2849) + +Fixes + +- Fix empty Arrow `Table` constructor [#2854](https://github.com/finos/perspective/pull/2854) + +Misc + +- Check pyarrow import and make clear(er) error message [#2848](https://github.com/finos/perspective/pull/2848) + +# [v3.1.5](https://github.com/perspective-dev/perspective/releases/tag/v3.1.5) + +_15 November 2024_ ([Full changelog](https://github.com/finos/perspective/compare/v3.1.4...v3.1.5)) + +Features + +- Relax `Table` schema constructor [#2850](https://github.com/finos/perspective/pull/2850) + +Fixes + +- Fix `perspective-workspace` restore call on errored table [#2851](https://github.com/finos/perspective/pull/2851) +- Fix `integer` cast expression function [#2842](https://github.com/finos/perspective/pull/2842) +- Import importlib metadata submodule [#2841](https://github.com/finos/perspective/pull/2841) + +Misc + +- Update ExprTk [#2825](https://github.com/finos/perspective/pull/2825) +- sdist licensing follow ups [#2843](https://github.com/finos/perspective/pull/2843) + +# [v3.1.4](https://github.com/perspective-dev/perspective/releases/tag/v3.1.4) + +_7 November 2024_ ([Full changelog](https://github.com/finos/perspective/compare/v3.1.3...v3.1.4)) + +Features + +- Add back Table and View exports, make constructors throw like pyarrow.Table [#2831](https://github.com/finos/perspective/pull/2831) + +Fixes + +- Fixed workspace persistence structure symmetry [#2833](https://github.com/finos/perspective/pull/2833) +- Fix concurrent update segfault [#2835](https://github.com/finos/perspective/pull/2835) +- Fix `plugin` switch [#2828](https://github.com/finos/perspective/pull/2828) + +Misc + +- Fix failed color range update [#2823](https://github.com/finos/perspective/pull/2823) +- CI sdist fixes, port conda patch, fix ODR violation [#2826](https://github.com/finos/perspective/pull/2826) + +# [v3.1.3](https://github.com/perspective-dev/perspective/releases/tag/v3.1.3) + +_31 October 2024_ ([Full changelog](https://github.com/finos/perspective/compare/v3.1.2...v3.1.3)) + +Fixes + +- Fix `perspective-python` sdist and add sdist tests to CI [#2817](https://github.com/finos/perspective/pull/2817) +- Fix workspace resize and related bugs [#2808](https://github.com/finos/perspective/pull/2808) +- fix aiohttp disconnect error on websockets [#2806](https://github.com/finos/perspective/pull/2806) +- Missing tooltip data [#2753](https://github.com/finos/perspective/pull/2753) +- Escape esbuild plugin's symbol replacement. [#2807](https://github.com/finos/perspective/pull/2807) +- Add aarch64 Mac runners to test_python, restore PSP_ARCH dev behavior [#2802](https://github.com/finos/perspective/pull/2802) + +# [v3.1.2](https://github.com/perspective-dev/perspective/releases/tag/v3.1.2) + +_22 October 2024_ ([Full changelog](https://github.com/finos/perspective/compare/v3.1.1...v3.1.2)) + +Fixes + +- Fix docs, inline builds, export, and workspace CSS [#2800](https://github.com/finos/perspective/pull/2800) +- arrow: Fix cross-compiling errors on macos [#2798](https://github.com/finos/perspective/pull/2798) + +# [v3.1.1](https://github.com/perspective-dev/perspective/releases/tag/v3.1.1) + +_18 October 2024_ ([Full changelog](https://github.com/finos/perspective/compare/v3.1.0...v3.1.1)) + +Fixes + +- <TAB> key press inserts '\t' character in code editor as indentation. [#2779](https://github.com/finos/perspective/pull/2779) +- Column type icon matches the datatype type of the field in column selectors (where, group by, sortby, filter) [#2780](https://github.com/finos/perspective/pull/2780) +- Fix filter values for non-interned strings [#2784](https://github.com/finos/perspective/pull/2784) + +Misc + +- Port Conda patches, use vendored boost [#2793](https://github.com/finos/perspective/pull/2793) +- Update documentation for `3.x` [#2778](https://github.com/finos/perspective/pull/2778) + +# [v3.1.0](https://github.com/perspective-dev/perspective/releases/tag/v3.1.0) + +_23 September 2024_ ([Full changelog](https://github.com/finos/perspective/compare/v3.0.3...v3.1.0)) + +**Breaking** + +- Python API changes from community [#2754](https://github.com/finos/perspective/pull/2754) + +Features + +- Allow `bytes` and `string` for JSON/CSV input data with `format` option [#2763](https://github.com/finos/perspective/pull/2763) + +Fixes + +- Fix cancellable methods in `perspective-viewer` [#2755](https://github.com/finos/perspective/pull/2755) + +Misc + +- Update `README.md`, examples and build dependencies [#2762](https://github.com/finos/perspective/pull/2762) +- Add Windows build for `perspective-rs` to CI [#2759](https://github.com/finos/perspective/pull/2759) +- Add Python benchmark suite for `3.x` [#2761](https://github.com/finos/perspective/pull/2761) +- Upgrade Apache Arrow C++ to 17.0.0 [#2749](https://github.com/finos/perspective/pull/2749) +- Emscripten wheel fixes [#2739](https://github.com/finos/perspective/pull/2739) + +# [v3.0.3](https://github.com/perspective-dev/perspective/releases/tag/v3.0.3) + +_3 September 2024_ ([Full changelog](https://github.com/finos/perspective/compare/v3.0.2...v3.0.3)) + +Fixes + +- Fix `perspective` crate, link `perspective-server` as static lib [#2734](https://github.com/finos/perspective/pull/2734) + +Misc + +- Support packaging in CI [#2735](https://github.com/finos/perspective/pull/2735) + +# [v3.0.2](https://github.com/perspective-dev/perspective/releases/tag/v3.0.2) + +_30 August 2024_ ([Full changelog](https://github.com/finos/perspective/compare/v3.0.1...v3.0.2)) + +Fixes + +- Fix link error in emscripten wheel [#2726](https://github.com/finos/perspective/pull/2726) +- Fix `PerspectiveTornadoHandler` misnamed `close()` [#2720](https://github.com/finos/perspective/pull/2720) +- fix: Doctype in html export [#2690](https://github.com/finos/perspective/pull/2690) + +# [v3.0.1](https://github.com/perspective-dev/perspective/releases/tag/v3.0.1) + +_25 August 2024_ ([Full changelog](https://github.com/finos/perspective/compare/v3.0.0...v3.0.1)) + +Features + +- Stringify Arrow lists [#2700](https://github.com/finos/perspective/pull/2700) +- Zstd decompression for arrows [#2717](https://github.com/finos/perspective/pull/2717) +- Better Timestamp Parsing [#2713](https://github.com/finos/perspective/pull/2713) + +Misc + +- Fix docs site serialized loading, docs.rs gen [#2718](https://github.com/finos/perspective/pull/2718) + +# [v3.0.0](https://github.com/perspective-dev/perspective/releases/tag/v3.0.0) + +_23 August 2024_ ([Full changelog](https://github.com/finos/perspective/compare/v2.10.1...v3.0.0)) + +**Breaking** + +- Perspective Virtual API (Python) [#2625](https://github.com/finos/perspective/pull/2625) +- Perspective Virtual API (JavaScript) [#2615](https://github.com/finos/perspective/pull/2615) + +Features + +- Add Pyodide support and split Rust/C++ builds for Python wheel [#2707](https://github.com/finos/perspective/pull/2707) +- Draw 0 axis prominently [#2698](https://github.com/finos/perspective/pull/2698) +- Calculate `group_by` in parallel [#2701](https://github.com/finos/perspective/pull/2701) +- `PerspectiveWidget` support for new Python API [#2658](https://github.com/finos/perspective/pull/2658) +- Use ABI3 for `perspective-python` shared library [#2661](https://github.com/finos/perspective/pull/2661) +- Add region, column and row selection modes to Perspective Datagrid [#2618](https://github.com/finos/perspective/pull/2618) + +Fixes + +- Fix missing icons [#2712](https://github.com/finos/perspective/pull/2712) +- Fix Windows JupyterLab packaging [#2705](https://github.com/finos/perspective/pull/2705) +- Fix Python `sdist`, update docs, add `lint` to CI [#2702](https://github.com/finos/perspective/pull/2702) + +Misc + +- Update `docusaurus` website and examples [#2708](https://github.com/finos/perspective/pull/2708) +- Update docs [#2706](https://github.com/finos/perspective/pull/2706) +- New status indicator [#2692](https://github.com/finos/perspective/pull/2692) +- Docs and examples update for new API [#2659](https://github.com/finos/perspective/pull/2659) + +# [v2.10.1](https://github.com/perspective-dev/perspective/releases/tag/v2.10.1) + +_23 May 2024_ ([Full changelog](https://github.com/finos/perspective/compare/v2.10.0...v2.10.1)) + +Fixes + +- Fix `delete()` Python async call not resolving [#2610](https://github.com/finos/perspective/pull/2610) +- Fix intermittent `to_arrow()` error in Python [#2608](https://github.com/finos/perspective/pull/2608) +- Fix compatibility with other MSVC versions by explicitly converting to string [#2570](https://github.com/finos/perspective/pull/2570) + +Misc + +- Signed-off-by: Andrew Stein <steinlink@gmail.com> [#2623](https://github.com/finos/perspective/pull/2623) +- Update contributing documentation for DCO [#2595](https://github.com/finos/perspective/pull/2595) +- Update to pyodide 0.25.1 for pyodide/pyodide#4655 [#2596](https://github.com/finos/perspective/pull/2596) + +# [v2.10.0](https://github.com/perspective-dev/perspective/releases/tag/v2.10.0) + +_25 March 2024_ ([Full changelog](https://github.com/finos/perspective/compare/v2.9.0...v2.10.0)) + +Features + +- Debug component [#2574](https://github.com/finos/perspective/pull/2574) +- Expression editor & filter UX updates [#2572](https://github.com/finos/perspective/pull/2572) + +# [v2.9.0](https://github.com/perspective-dev/perspective/releases/tag/v2.9.0) + +_12 March 2024_ ([Full changelog](https://github.com/finos/perspective/compare/v2.8.1...v2.9.0)) + +**Breaking** + +- Formatting for `float` and `integer` columns via `Intl.NumberFormat` [#2563](https://github.com/finos/perspective/pull/2563) + +Features + +- Localization Support [#2565](https://github.com/finos/perspective/pull/2565) + +Fixes + +- Fix get_hosted_table_names in Python client [#2551](https://github.com/finos/perspective/pull/2551) +- Fix Candlestick & OHLC charts [#2562](https://github.com/finos/perspective/pull/2562) + +Misc + +- Update Pyodide to version 0.25.0 [#2547](https://github.com/finos/perspective/pull/2547) + +# [v2.8.1](https://github.com/perspective-dev/perspective/releases/tag/v2.8.1) + +_26 February 2024_ ([Full changelog](https://github.com/finos/perspective/compare/v2.8.0...v2.8.1)) + +Fixes + +- Fix `split_by` with `date`/`datetime` columns [#2545](https://github.com/finos/perspective/pull/2545) + +Misc + +- Update to LLVM 17 & fix Python/Windows build [#2546](https://github.com/finos/perspective/pull/2546) +- Add `clang-format`, `clang-tidy` and `clangd` support for C++ development [#2541](https://github.com/finos/perspective/pull/2541) +- Add Docs Tests [#2502](https://github.com/finos/perspective/pull/2502) +- Add workspaceFolder to cargo_target_dir [#2537](https://github.com/finos/perspective/pull/2537) + +# [v2.8.0](https://github.com/perspective-dev/perspective/releases/tag/v2.8.0) + +_16 February 2024_ ([Full changelog](https://github.com/finos/perspective/compare/v2.7.1...v2.8.0)) + +Features + +- New color selector component [#2536](https://github.com/finos/perspective/pull/2536) +- Render table in the shadow DOM for `@finos/perspective-viewer-datagrid` [#2482](https://github.com/finos/perspective/pull/2482) +- Add column edit highlight to `perspective-viewer-datagrid` [#2486](https://github.com/finos/perspective/pull/2486) + +Fixes + +- Bugfix - De-aggregating doesn't change column style type [#2535](https://github.com/finos/perspective/pull/2535) +- A few CSS fixes for regular-table in shadow DOM [#2526](https://github.com/finos/perspective/pull/2526) +- Settings panel width is inconsistent for columns moved to `Config` [#2527](https://github.com/finos/perspective/pull/2527) +- Fix datagrid scroll performance regression via `regular-table` upgrade [#2498](https://github.com/finos/perspective/pull/2498) +- Fix output scrolling in Jupyter when `perspective-python` is installed [#2495](https://github.com/finos/perspective/pull/2495) +- Fix datagrid cell pulse styling [#2489](https://github.com/finos/perspective/pull/2489) +- Update ipynb examples with new expression syntax [#2463](https://github.com/finos/perspective/pull/2463) +- Fix Jupyterlab Widget Save/Restore Symmetry [#2465](https://github.com/finos/perspective/pull/2465) + +Misc + +- Add `yew-fmt` [#2533](https://github.com/finos/perspective/pull/2533) +- Expression Names in Column Settings Header + Refactors [#2459](https://github.com/finos/perspective/pull/2459) +- Update `http-server` examples dependency [#2505](https://github.com/finos/perspective/pull/2505) +- Use a boost mirror [#2484](https://github.com/finos/perspective/pull/2484) +- Refactor How Column Style Components Are Selected [#2443](https://github.com/finos/perspective/pull/2443) +- Remove docs build from CI [#2483](https://github.com/finos/perspective/pull/2483) +- Add starting support for Typescript in perspective-viewer-d3fc package [#2432](https://github.com/finos/perspective/pull/2432) +- Fix cargo features [#2481](https://github.com/finos/perspective/pull/2481) +- Fix docs site [#2462](https://github.com/finos/perspective/pull/2462) + +# [v2.7.1](https://github.com/perspective-dev/perspective/releases/tag/v2.7.1) + +_29 November 2023_ ([Full changelog](https://github.com/finos/perspective/compare/v2.7.0...v2.7.1)) + +Features + +- Drag/drop UX for invalid positions [#2456](https://github.com/finos/perspective/pull/2456) + +Fixes + +- Fix `perspective-click` event for `"Treemap"` plugin [#2452](https://github.com/finos/perspective/pull/2452) +- Fix 2.7.0 Migration [#2444](https://github.com/finos/perspective/pull/2444) +- Better CSS build [#2451](https://github.com/finos/perspective/pull/2451) + +Misc + +- Fix Blocks build script execution [#2454](https://github.com/finos/perspective/pull/2454) + +# [v2.7.0](https://github.com/perspective-dev/perspective/releases/tag/v2.7.0) + +_20 November 2023_ ([Full changelog](https://github.com/finos/perspective/compare/v2.6.1...v2.7.0)) + +**Breaking** + +- X/Y Scatter Symbol Serialization as Dictionary [#2429](https://github.com/finos/perspective/pull/2429) +- Expressions API Updates [#2399](https://github.com/finos/perspective/pull/2399) + +Features + +- API Versioning + Migration [#2430](https://github.com/finos/perspective/pull/2430) +- `PerspectiveWidget` HTML export support in Jupyter [#2418](https://github.com/finos/perspective/pull/2418) +- Self-extracting WebAssembly binaries [#2428](https://github.com/finos/perspective/pull/2428) + +Fixes + +- Update `expressions` format default [#2438](https://github.com/finos/perspective/pull/2438) +- Serialize Expressions to Objects [#2436](https://github.com/finos/perspective/pull/2436) +- Column Settings Panel UI Tweaks [#2421](https://github.com/finos/perspective/pull/2421) +- Add support for StringDtype (fixes #1237) [#2319](https://github.com/finos/perspective/pull/2319) + +Misc + +- Update examples [#2433](https://github.com/finos/perspective/pull/2433) +- Fix expression alias UX/API [#2431](https://github.com/finos/perspective/pull/2431) +- Add CI requirements update script [#2422](https://github.com/finos/perspective/pull/2422) +- Upgrade `pyodide` compatibility to v0.24.1 [#2402](https://github.com/finos/perspective/pull/2402) + +# [v2.6.1](https://github.com/perspective-dev/perspective/releases/tag/v2.6.1) + +_1 November 2023_ ([Full changelog](https://github.com/finos/perspective/compare/v2.6.0...v2.6.1)) + +Features + +- Add `setAutoPause()` method [#2411](https://github.com/finos/perspective/pull/2411) + +Fixes + +- Fix scroll panel height calculation bug [#2404](https://github.com/finos/perspective/pull/2404) +- Fix `in` and `not in` filter UI bug [#2403](https://github.com/finos/perspective/pull/2403) + +# [v2.6.0](https://github.com/perspective-dev/perspective/releases/tag/v2.6.0) + +_20 October 2023_ ([Full changelog](https://github.com/finos/perspective/compare/v2.5.2...v2.6.0)) + +Features + +- X/Y scatter multi-chart [#2395](https://github.com/finos/perspective/pull/2395) +- Symbols column in `X/Y Scatter` plot [#2394](https://github.com/finos/perspective/pull/2394) +- New themes `Gruvbox` and `Dracula` + UI performance improvements [#2393](https://github.com/finos/perspective/pull/2393) +- Add `index()`, `col()`, and `vlookup()` to ExprTk [#2374](https://github.com/finos/perspective/pull/2374) +- Datagrid rows restyle [#2383](https://github.com/finos/perspective/pull/2383) + +Fixes + +- Column selector style updates [#2385](https://github.com/finos/perspective/pull/2385) +- fix gradient values [#2384](https://github.com/finos/perspective/pull/2384) + +# [v2.5.2](https://github.com/perspective-dev/perspective/releases/tag/v2.5.2) + +_8 October 2023_ ([Full changelog](https://github.com/finos/perspective/compare/v2.5.1...v2.5.2)) + +Features + +- Move styles to sidebar [#2366](https://github.com/finos/perspective/pull/2366) + +Fixes + +- fix some viewer bugs [#2371](https://github.com/finos/perspective/pull/2371) + +Misc + +- Fix tests [#2380](https://github.com/finos/perspective/pull/2380) +- Localize NYPD, Magic and Olympics examples [#2375](https://github.com/finos/perspective/pull/2375) + +# [v2.5.1](https://github.com/perspective-dev/perspective/releases/tag/v2.5.1) + +_31 August 2023_ ([Full changelog](https://github.com/finos/perspective/compare/v2.5.0...v2.5.1)) + +Features + +- Datagrid column header configuration UX update [#2351](https://github.com/finos/perspective/pull/2351) + +Fixes + +- Fix filter number validation with trailing zeroes [#2353](https://github.com/finos/perspective/pull/2353) +- Fix Jupyter plugin resize behavior [#2352](https://github.com/finos/perspective/pull/2352) + +Misc + +- Update webpack dev server configuration options, fixes for perspective 2.0+ [#2350](https://github.com/finos/perspective/pull/2350) +- Fix binder installation to not manage dependencies seperately [#2342](https://github.com/finos/perspective/pull/2342) + +# [v2.5.0](https://github.com/perspective-dev/perspective/releases/tag/v2.5.0) + +_14 August 2023_ ([Full changelog](https://github.com/finos/perspective/compare/v2.4.0...v2.5.0)) + +**Breaking** + +- LZ4 compression for Apache Arrow [#2339](https://github.com/finos/perspective/pull/2339) + +Misc + +- prune win 2019 from ci, switch over to windows-2022 only [#2333](https://github.com/finos/perspective/pull/2333) + +# [v2.4.0](https://github.com/perspective-dev/perspective/releases/tag/v2.4.0) + +_9 August 2023_ ([Full changelog](https://github.com/finos/perspective/compare/v2.3.2...v2.4.0)) + +**Breaking** + +- Add `to_columns_string()` C++ JSON API [#2315](https://github.com/finos/perspective/pull/2315) + +Fixes + +- Fix TypeScript 5 `moduleResolution: "bundler"` support [#2289](https://github.com/finos/perspective/pull/2289) +- Fix `update()` with `expressions` overcalc [#2328](https://github.com/finos/perspective/pull/2328) +- Fix over-calculation in when loading data in `perspective-viewer` [#2323](https://github.com/finos/perspective/pull/2323) + +Misc + +- Pin Python dependency versions [#2327](https://github.com/finos/perspective/pull/2327) +- Fix build.rs escaping OUT_DIR [#2313](https://github.com/finos/perspective/pull/2313) + +# [v2.3.2](https://github.com/perspective-dev/perspective/releases/tag/v2.3.2) + +_20 July 2023_ ([Full changelog](https://github.com/finos/perspective/compare/v2.3.1...v2.3.2)) + +Fixes + +- Fix expression column aggregate calculation with `group_by` after `remove()` [#2311](https://github.com/finos/perspective/pull/2311) +- Fix empty column name validation in ExprTk editor [#2302](https://github.com/finos/perspective/pull/2302) +- Fix XY-Scatter Plots Colors - Rebased [#2303](https://github.com/finos/perspective/pull/2303) + +Misc + +- Publish benchmarks [#2310](https://github.com/finos/perspective/pull/2310) +- Add GitHub Releases [#2294](https://github.com/finos/perspective/pull/2294) + +# [v2.3.1](https://github.com/perspective-dev/perspective/releases/tag/v2.3.1) + +_4 July 2023_ ([Full changelog](https://github.com/finos/perspective/compare/v2.3.0...v2.3.1)) + +Features + +- Add `custom` format mode to `datetime` columns in Datagrid [#2286](https://github.com/finos/perspective/pull/2286) +- Add `get_hosted_table_names` to js and python server apis. [#2281](https://github.com/finos/perspective/pull/2281) + +Fixes + +- Fix racing tests. [#2290](https://github.com/finos/perspective/pull/2290) +- Fix python segfault in multi-threaded execution [#2283](https://github.com/finos/perspective/pull/2283) +- Fix OSX arm64 wheels [#2272](https://github.com/finos/perspective/pull/2272) +- Write custom parser for us locale time string format [#2262](https://github.com/finos/perspective/pull/2262) + +Misc + +- Fix build regression in python dependency [#2285](https://github.com/finos/perspective/pull/2285) +- Fix bug preventing blocks example server from starting. [#2282](https://github.com/finos/perspective/pull/2282) +- Fix bad CSS headers and update formatted CSS test snapshots [#2280](https://github.com/finos/perspective/pull/2280) +- Remove accidental test.only, ensure it never happens again [#2276](https://github.com/finos/perspective/pull/2276) +- Add API to give number of views into table [#2267](https://github.com/finos/perspective/pull/2267) + +# [v2.3.0](https://github.com/perspective-dev/perspective/releases/tag/v2.3.0) + +_20 June 2023_ ([Full changelog](https://github.com/finos/perspective/compare/v2.2.1...v2.3.0)) + +**Breaking** + +- Thread-safe python readers [#2261](https://github.com/finos/perspective/pull/2261) + +Fixes + +- Fix workspace `save()` w/settings [#2257](https://github.com/finos/perspective/pull/2257) + +Misc + +- Validate installed emsdk version [#2256](https://github.com/finos/perspective/pull/2256) +- Allow `pandas~=2.0.0` to be installed [#2244](https://github.com/finos/perspective/pull/2244) + +# [v2.2.1](https://github.com/perspective-dev/perspective/releases/tag/v2.2.1) + +_4 June 2023_ ([Full changelog](https://github.com/finos/perspective/compare/v2.2.0...v2.2.1)) + +Features + +- Pyodide wheel [#2241](https://github.com/finos/perspective/pull/2241) + +Fixes + +- Fix CSS positioning of expression panel in workspace [#2243](https://github.com/finos/perspective/pull/2243) + +# [v2.2.0](https://github.com/perspective-dev/perspective/releases/tag/v2.2.0) + +_31 May 2023_ ([Full changelog](https://github.com/finos/perspective/compare/v2.1.4...v2.2.0)) + +Features + +- Moving Expression Editor modal to a sidebar. [#2239](https://github.com/finos/perspective/pull/2239) + +Fixes + +- Disallow non-1 multiplicity for bucketing weeks and days [#2238](https://github.com/finos/perspective/pull/2238) + +Misc + +- Developer QoL fixes [#2240](https://github.com/finos/perspective/pull/2240) +- Add Python 3.11 support [#2234](https://github.com/finos/perspective/pull/2234) +- Add skip test: _update_with_missing_or_null_values [#2230](https://github.com/finos/perspective/pull/2230) + +# [v2.1.4](https://github.com/perspective-dev/perspective/releases/tag/v2.1.4) + +_26 May 2023_ ([Full changelog](https://github.com/finos/perspective/compare/v2.1.3...v2.1.4)) + +Fixes + +- Fix median miscalculation for even-sized item list [#2224](https://github.com/finos/perspective/pull/2224) +- Fix a bug when calling `load()` with a rejected promise [#2236](https://github.com/finos/perspective/pull/2236) + +# [v2.1.3](https://github.com/perspective-dev/perspective/releases/tag/v2.1.3) + +_17 May 2023_ ([Full changelog](https://github.com/finos/perspective/compare/v2.1.2...v2.1.3)) + +Misc + +- Add 3.7 wheel [#2223](https://github.com/finos/perspective/pull/2223) + +# [v2.1.2](https://github.com/perspective-dev/perspective/releases/tag/v2.1.2) + +_16 May 2023_ ([Full changelog](https://github.com/finos/perspective/compare/v2.1.1...v2.1.2)) + +Misc + +- Public `perspective-viewer` components [#2222](https://github.com/finos/perspective/pull/2222) + +# [v2.1.1](https://github.com/perspective-dev/perspective/releases/tag/v2.1.1) + +_7 May 2023_ ([Full changelog](https://github.com/finos/perspective/compare/v2.1.0...v2.1.1)) + +Fixes + +- Workspace bug fixes [#2218](https://github.com/finos/perspective/pull/2218) +- Fix windows python builds [#2217](https://github.com/finos/perspective/pull/2217) +- Fix sum aggregate for integer type expression columns [#2216](https://github.com/finos/perspective/pull/2216) + +# [v2.1.0](https://github.com/perspective-dev/perspective/releases/tag/v2.1.0) + +_6 May 2023_ ([Full changelog](https://github.com/finos/perspective/compare/v2.0.1...v2.1.0)) + +**Breaking** + +- Remove `umd` builds from all packages [#2211](https://github.com/finos/perspective/pull/2211) + +Features + +- Update `perspective-workspace` design [#2214](https://github.com/finos/perspective/pull/2214) +- Add scalar factors to Exprtk `bucket()` [#2210](https://github.com/finos/perspective/pull/2210) + +Fixes + +- Coerce to float64 when user tries to pass in float32 as float64 - fixes #1717 [#2189](https://github.com/finos/perspective/pull/2189) +- Handle zero values in line plots [#2191](https://github.com/finos/perspective/pull/2191) +- pass through websocket connection arguments in python websocket clients, normalize max message size and ensure set for aiohttp [#2187](https://github.com/finos/perspective/pull/2187) + +Misc + +- rename context menu names for perspective renderers in jupyterlab [#2209](https://github.com/finos/perspective/pull/2209) +- New `playwright` test suite [#2201](https://github.com/finos/perspective/pull/2201) + +# [v2.0.1](https://github.com/perspective-dev/perspective/releases/tag/v2.0.1) + +_10 April 2023_ ([Full changelog](https://github.com/finos/perspective/compare/v2.0.0...v2.0.1)) + +Fixes + +- Fix drag/drop behavior for duplicate group/split columns [#2177](https://github.com/finos/perspective/pull/2177) +- Fix expression editor bug with escaped characters in strings [#2178](https://github.com/finos/perspective/pull/2178) + +Misc + +- Better logging and build for rust/wasm [#2179](https://github.com/finos/perspective/pull/2179) +- Reorder examples on `README.md` [#2176](https://github.com/finos/perspective/pull/2176) +- link for dark mode logo in readme [#2166](https://github.com/finos/perspective/pull/2166) + +# [v2.0.0](https://github.com/perspective-dev/perspective/releases/tag/v2.0.0) + +_4 April 2023_ ([Full changelog](https://github.com/finos/perspective/compare/v1.9.4...v2.0.0)) + +**Breaking** + +- Distribute prebuilt JupyterLab extension / Notebook Classic extension (2023 edition) [#2136](https://github.com/finos/perspective/pull/2136) +- UI Redesign [#2110](https://github.com/finos/perspective/pull/2110) + +Features + +- Auto-complete text field column selectors [#2156](https://github.com/finos/perspective/pull/2156) + +Fixes + +- Gracefully handle websocket errors on python side [#2157](https://github.com/finos/perspective/pull/2157) +- Fix ping loop state, make node server `close` method async [#2151](https://github.com/finos/perspective/pull/2151) +- Fix autocomplete behavior for `in` and `not in` filter ops [#2142](https://github.com/finos/perspective/pull/2142) +- fixed filter keyword [#2124](https://github.com/finos/perspective/pull/2124) + +Misc + +- Run yarn cache path commands in bash shell [#2163](https://github.com/finos/perspective/pull/2163) +- Fixes for docs site [#2162](https://github.com/finos/perspective/pull/2162) +- A11y front page labels [#2161](https://github.com/finos/perspective/pull/2161) +- Fixed deprecated features in GitHub actions [#2141](https://github.com/finos/perspective/pull/2141) +- Specify that Yarn v1 must be used in development docs. [#2143](https://github.com/finos/perspective/pull/2143) +- Miscellaneous cleanup from big Jupyter prebuilt overhaul, python 3.7 deprecation, and various other accumulated but unfixed issues [#2144](https://github.com/finos/perspective/pull/2144) +- Implement `title` field in `PerspectiveWidget` [#2123](https://github.com/finos/perspective/pull/2123) +- New demo `market` [#2128](https://github.com/finos/perspective/pull/2128) +- Fix readme logo link [#2129](https://github.com/finos/perspective/pull/2129) +- Update docs/branding [#2122](https://github.com/finos/perspective/pull/2122) +- vendor all C++ dependencies at build time, fixes #2075, closes #2076, [#2116](https://github.com/finos/perspective/pull/2116) +- fix icon filenames for windows [#2118](https://github.com/finos/perspective/pull/2118) +- New icons [#2111](https://github.com/finos/perspective/pull/2111) + +# [v1.9.4](https://github.com/perspective-dev/perspective/releases/tag/v1.9.4) + +_22 February 2023_ ([Full changelog](https://github.com/finos/perspective/compare/v1.9.3...v1.9.4)) + +Features + +- Update ExprTk package to 0.0.2 tag [#2103](https://github.com/finos/perspective/pull/2103) + +Fixes + +- Invert heatmap y-axis [#2113](https://github.com/finos/perspective/pull/2113) +- Fix legend on X/Y line chart [#2107](https://github.com/finos/perspective/pull/2107) +- Update dependencies to be compatible with jupyterlab 3.6.1 [#2101](https://github.com/finos/perspective/pull/2101) + +Misc + +- Update contributing guidelines [#2077](https://github.com/finos/perspective/pull/2077) +- Updated README.md [#2098](https://github.com/finos/perspective/pull/2098) +- Update charting tests to cover more HTML [#2092](https://github.com/finos/perspective/pull/2092) + +# [v1.9.3](https://github.com/perspective-dev/perspective/releases/tag/v1.9.3) + +_19 January 2023_ ([Full changelog](https://github.com/finos/perspective/compare/v1.9.2...v1.9.3)) + +Features + +- Add `@finos/perspective-viewer-openlayers` plugin to CLI package [#2087](https://github.com/finos/perspective/pull/2087) + +Misc + +- Fix script errors in publish and build commands [#2088](https://github.com/finos/perspective/pull/2088) + +# [v1.9.2](https://github.com/perspective-dev/perspective/releases/tag/v1.9.2) + +_18 January 2023_ ([Full changelog](https://github.com/finos/perspective/compare/v1.9.1...v1.9.2)) + +Fixes + +- Fix regression in `perspective-cli` [#2086](https://github.com/finos/perspective/pull/2086) + +Misc + +- Build cleanup [#2078](https://github.com/finos/perspective/pull/2078) + +# [v1.9.1](https://github.com/perspective-dev/perspective/releases/tag/v1.9.1) + +_10 January 2023_ ([Full changelog](https://github.com/finos/perspective/compare/v1.9.0...v1.9.1)) + +Features + +- Add macOS arm wheels to build matrix [#2072](https://github.com/finos/perspective/pull/2072) + +Fixes + +- Fix `perspective-cli` and add test [#2079](https://github.com/finos/perspective/pull/2079) + +Misc + +- Add default .vscode project [#2074](https://github.com/finos/perspective/pull/2074) +- Actually copy readme this time, fixes incomplete #1991 [#2071](https://github.com/finos/perspective/pull/2071) +- Fix test regression in chrome [#2070](https://github.com/finos/perspective/pull/2070) + +# [v1.9.0](https://github.com/perspective-dev/perspective/releases/tag/v1.9.0) + +_29 December 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.8.1...v1.9.0)) + +**Breaking** + +- Add Python 3.10 support, deprecate 3.7 [#2065](https://github.com/finos/perspective/pull/2065) + +Fixes + +- Improved point label position [#2067](https://github.com/finos/perspective/pull/2067) + +Misc + +- Fix CSS compilation for `@finos/perspective-jupyterlab` [#2069](https://github.com/finos/perspective/pull/2069) +- Fix CSS compilation to not inline http resources [#2068](https://github.com/finos/perspective/pull/2068) +- Cleanup examples [#2057](https://github.com/finos/perspective/pull/2057) +- Move examples to `gh-pages` site [#2054](https://github.com/finos/perspective/pull/2054) +- Fix copypasta in code of conduct, fix capitalization in readme [#2053](https://github.com/finos/perspective/pull/2053) +- Update README gallery [#2044](https://github.com/finos/perspective/pull/2044) + +# [v1.8.1](https://github.com/perspective-dev/perspective/releases/tag/v1.8.1) + +_10 December 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.8.0...v1.8.1)) + +Features + +- Vec3 functions for ExprTK and new example "Ray Casting" [#2042](https://github.com/finos/perspective/pull/2042) + +Fixes + +- Fix parsing of bug related to d3fc splitting selection data (redux of #2039) [#2043](https://github.com/finos/perspective/pull/2043) +- Fix CSS and Scatter Chart positioning bugs [#2035](https://github.com/finos/perspective/pull/2035) + +# [v1.8.0](https://github.com/perspective-dev/perspective/releases/tag/v1.8.0) + +_5 December 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.7.2...v1.8.0)) + +Features + +- Add labels to Scatter plot points. [#2033](https://github.com/finos/perspective/pull/2033) + +Fixes + +- Resize labels to avoid overlap [#2034](https://github.com/finos/perspective/pull/2034) +- Fix Default Plugin Order [#2028](https://github.com/finos/perspective/pull/2028) + +Misc + +- Update docusaurus footer with correct link [#2015](https://github.com/finos/perspective/pull/2015) +- remove reference to gitter from contributing docs [#2025](https://github.com/finos/perspective/pull/2025) +- Fix cargo src rules, publish to crates.io [#2019](https://github.com/finos/perspective/pull/2019) + +# [v1.7.2](https://github.com/perspective-dev/perspective/releases/tag/v1.7.2) + +_25 November 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.7.1...v1.7.2)) + +Misc + +- Upgrade to Yew 0.20.0 [#2018](https://github.com/finos/perspective/pull/2018) +- Cleanup items [#2012](https://github.com/finos/perspective/pull/2012) +- Re-add secondary dependency as primary dependency to fix python tests [#2013](https://github.com/finos/perspective/pull/2013) +- accidentally copying license as readme when disting python [#1991](https://github.com/finos/perspective/pull/1991) + +# [v1.7.1](https://github.com/perspective-dev/perspective/releases/tag/v1.7.1) + +_7 October 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.7.0...v1.7.1)) + +Misc + +- Move default plugin to WASM [#1986](https://github.com/finos/perspective/pull/1986) +- Bump dependencies when installing and building python, drop support for `manylinux2010` [#1984](https://github.com/finos/perspective/pull/1984) + +# [v1.7.0](https://github.com/perspective-dev/perspective/releases/tag/v1.7.0) + +_1 October 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.6.5...v1.7.0)) + +Features + +- Replace `monaco-editor` [#1971](https://github.com/finos/perspective/pull/1971) + +Fixes + +- Expression Editor autocomplete bug fixes [#1982](https://github.com/finos/perspective/pull/1982) +- Attempt to instance worker before giving up [#1980](https://github.com/finos/perspective/pull/1980) + +Misc + +- increment black to latest version [#1977](https://github.com/finos/perspective/pull/1977) +- Better rust api [#1960](https://github.com/finos/perspective/pull/1960) + +# [v1.6.5](https://github.com/perspective-dev/perspective/releases/tag/v1.6.5) + +_26 August 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.6.4...v1.6.5)) + +Fixes + +- Fix column style menus for string & date(time) [#1950](https://github.com/finos/perspective/pull/1950) + +# [v1.6.4](https://github.com/perspective-dev/perspective/releases/tag/v1.6.4) + +_22 August 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.6.3...v1.6.4)) + +Fixes + +- Add `not in` filter to valid string type filters in `<perspective-viewer>` [#1945](https://github.com/finos/perspective/pull/1945) +- Upper bound for `ipywidgets` [#1944](https://github.com/finos/perspective/pull/1944) +- Fix Sunburst chart infinite loop bug [#1939](https://github.com/finos/perspective/pull/1939) + +Misc + +- Per-component CSS model [#1943](https://github.com/finos/perspective/pull/1943) + +# [v1.6.3](https://github.com/perspective-dev/perspective/releases/tag/v1.6.3) + +_10 August 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.6.2...v1.6.3)) + +Features + +- Add `date` and `datetime` column config [#1932](https://github.com/finos/perspective/pull/1932) + +Fixes + +- Don't calculate unused `expressions` [#1933](https://github.com/finos/perspective/pull/1933) + +Misc + +- Refactor `@finos/perspective-viewer-datagrid` [#1927](https://github.com/finos/perspective/pull/1927) +- Fix python `_requires_python` publish script on windows [#1929](https://github.com/finos/perspective/pull/1929) + +# [v1.6.2](https://github.com/perspective-dev/perspective/releases/tag/v1.6.2) + +_2 August 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.6.1...v1.6.2)) + +Fixes + +- Fix Python bug in `async` mode [#1926](https://github.com/finos/perspective/pull/1926) +- Fix app export [#1921](https://github.com/finos/perspective/pull/1921) + +Misc + +- Cleanup up python requirements installation in CI, remove some dangling azure references, refresh python README [#1917](https://github.com/finos/perspective/pull/1917) + +# [v1.6.1](https://github.com/perspective-dev/perspective/releases/tag/v1.6.1) + +_27 July 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.6.0...v1.6.1)) + +Features + +- Add zoom persistence to OpenLayers plugin [#1920](https://github.com/finos/perspective/pull/1920) +- Publish `@finos/perspective-esbuild-plugin` [#1918](https://github.com/finos/perspective/pull/1918) + +Fixes + +- Fix "inline mode" build [#1919](https://github.com/finos/perspective/pull/1919) + +Misc + +- Make `perspective-viewer` crate importable for external wasm projects [#1916](https://github.com/finos/perspective/pull/1916) +- Fix publish issues [#1915](https://github.com/finos/perspective/pull/1915) + +# [v1.6.0](https://github.com/perspective-dev/perspective/releases/tag/v1.6.0) + +_19 July 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.5.1...v1.6.0)) + +Features + +- Add `@finos/perspective-viewer-openlayers` plugin [#1882](https://github.com/finos/perspective/pull/1882) +- Add categories to plugin selector [#1908](https://github.com/finos/perspective/pull/1908) + +Fixes + +- Add migrate support for datagrid `plugin_config` API [#1914](https://github.com/finos/perspective/pull/1914) +- Fix webpack plugin compat with emsdk single-link build [#1904](https://github.com/finos/perspective/pull/1904) + +Misc + +- New GitHub Pages site [#1912](https://github.com/finos/perspective/pull/1912) +- Fix Python sdist CI build [#1907](https://github.com/finos/perspective/pull/1907) +- Fix python CI builds for all linux variants [#1905](https://github.com/finos/perspective/pull/1905) +- Expression Docs [#1903](https://github.com/finos/perspective/pull/1903) + +# [v1.5.1](https://github.com/perspective-dev/perspective/releases/tag/v1.5.1) + +_8 July 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.5.0...v1.5.1)) + +Features + +- Add `pulse` numeric column background mode [#1895](https://github.com/finos/perspective/pull/1895) +- Split foreground and background numeric column color modes [#1889](https://github.com/finos/perspective/pull/1889) + +Fixes + +- Fix expand/collapse on first row of ctx2 [#1887](https://github.com/finos/perspective/pull/1887) +- Fix gradient bounds calc [#1890](https://github.com/finos/perspective/pull/1890) +- Expand/collapse on entire group cell, rather than just button [#1888](https://github.com/finos/perspective/pull/1888) + +Misc + +- Remove custom docker images [#1893](https://github.com/finos/perspective/pull/1893) +- Fix jlab tests to use local packages [#1891](https://github.com/finos/perspective/pull/1891) +- GHA default (remove azure) [#1854](https://github.com/finos/perspective/pull/1854) + +# [v1.5.0](https://github.com/perspective-dev/perspective/releases/tag/v1.5.0) + +_4 July 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.4.1...v1.5.0)) + +**Breaking** + +- Remove Python 3.6 support [#1867](https://github.com/finos/perspective/pull/1867) + +Fixes + +- Fix small memory leak and validate package.json with CI cache [#1881](https://github.com/finos/perspective/pull/1881) + +Misc + +- Pin `tornado` to 6.1 in CI [#1886](https://github.com/finos/perspective/pull/1886) +- Changed default color label style for charts with multiple secondary axii [#1883](https://github.com/finos/perspective/pull/1883) +- Update `emsdk` to `3.1.14` [#1866](https://github.com/finos/perspective/pull/1866) + +# [v1.4.1](https://github.com/perspective-dev/perspective/releases/tag/v1.4.1) + +_22 June 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.4.0...v1.4.1)) + +Features + +- Add python webserver handlers and clients for starlette (fastapi) and aiohttp [#1828](https://github.com/finos/perspective/pull/1828) + +Fixes + +- Fix `set_threadpool_size(n)` for `n == 1` [#1852](https://github.com/finos/perspective/pull/1852) +- Reimplement `editable` for PerspectiveWidget in JupyterLab to take into account the latest changes [#1850](https://github.com/finos/perspective/pull/1850) +- Fix `@finos/perspective-viewer-d3fc` chart-specific exports [#1847](https://github.com/finos/perspective/pull/1847) + +Misc + +- Remove unused watch commands from viewer-d3fc and viewer-datagrid, remove dependabot, dont trigger full build on merge to master [#1859](https://github.com/finos/perspective/pull/1859) +- Simplify and uplift binder to latest supported python, perspective wheel, and dependency set [#1855](https://github.com/finos/perspective/pull/1855) +- Move CI/CD from Azure Pipelines to GitHub Actions [#1808](https://github.com/finos/perspective/pull/1808) +- Make `PerspectiveViewerElement` rust type usable from non-wasm-abi [#1846](https://github.com/finos/perspective/pull/1846) +- Include column name in error log for arrow filling [#1841](https://github.com/finos/perspective/pull/1841) +- ExprTK/Perspective Documentation [#1845](https://github.com/finos/perspective/pull/1845) + +# [v1.4.0](https://github.com/perspective-dev/perspective/releases/tag/v1.4.0) + +_6 June 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.3.13...v1.4.0)) + +**Breaking** + +- Fix `exports` field for Node.js 16 compatibility [#1838](https://github.com/finos/perspective/pull/1838) + +Fixes + +- Fix scrolling in Chrome 102 [#1837](https://github.com/finos/perspective/pull/1837) + +Misc + +- fix bug in setup.js where extra set of quotes prevents initial build [#1829](https://github.com/finos/perspective/pull/1829) +- fix cmake build error when install under virtualenv [#1779](https://github.com/finos/perspective/pull/1779) +- Fix jupyterlab test timeouts [#1823](https://github.com/finos/perspective/pull/1823) +- prune 2 more python2 references [#1811](https://github.com/finos/perspective/pull/1811) +- remove duplicate steps [#1815](https://github.com/finos/perspective/pull/1815) +- bump arrow version to 8.0.0 [#1816](https://github.com/finos/perspective/pull/1816) + +# [v1.3.13](https://github.com/perspective-dev/perspective/releases/tag/v1.3.13) + +_13 May 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.3.12...v1.3.13)) + +Fixes + +- Fix export-to-html [#1806](https://github.com/finos/perspective/pull/1806) +- Make toggle-menu more responsive under load [#1804](https://github.com/finos/perspective/pull/1804) + +Misc + +- Fix jest timeouts for jupyterlab [#1805](https://github.com/finos/perspective/pull/1805) + +# [v1.3.12](https://github.com/perspective-dev/perspective/releases/tag/v1.3.12) + +_9 May 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.3.11...v1.3.12)) + +Features + +- Support autocomplete for IN filter type [#1798](https://github.com/finos/perspective/pull/1798) + +Fixes + +- Fix `restore()` error [#1803](https://github.com/finos/perspective/pull/1803) + +# [v1.3.11](https://github.com/perspective-dev/perspective/releases/tag/v1.3.11) + +_1 May 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.3.10...v1.3.11)) + +Features + +- Add support for LargeUtf8 arrow column type [#1796](https://github.com/finos/perspective/pull/1796) + +Fixes + +- Fix datagrid error when `resetThemes()` is called while hidden [#1795](https://github.com/finos/perspective/pull/1795) +- Allow attempt to create workers from file protocol for e.g. electron [#1794](https://github.com/finos/perspective/pull/1794) +- Fix `sum` aggregate to force re-agg for expression columns [#1793](https://github.com/finos/perspective/pull/1793) + +# [v1.3.10](https://github.com/perspective-dev/perspective/releases/tag/v1.3.10) + +_26 April 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.3.9...v1.3.10)) + +Fixes + +- Remove 'treemaps' persistent token for d3fc plugin [#1789](https://github.com/finos/perspective/pull/1789) + +# [v1.3.9](https://github.com/perspective-dev/perspective/releases/tag/v1.3.9) + +_25 April 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.3.8...v1.3.9)) + +Fixes + +- Fix `perspective-workspace` detach error [#1788](https://github.com/finos/perspective/pull/1788) +- Fix spurious logging [#1785](https://github.com/finos/perspective/pull/1785) + +# [v1.3.8](https://github.com/perspective-dev/perspective/releases/tag/v1.3.8) + +_18 April 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.3.7...v1.3.8)) + +Fixes + +- Fix `perspective-workspace` webpack compat [#1784](https://github.com/finos/perspective/pull/1784) + +# [v1.3.7](https://github.com/perspective-dev/perspective/releases/tag/v1.3.7) + +_18 April 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.3.6...v1.3.7)) + +Features + +- `perspective-workspace` Context Menu Update [#1783](https://github.com/finos/perspective/pull/1783) + +Misc + +- Upgrade `yew` [#1776](https://github.com/finos/perspective/pull/1776) +- Add Github dark mode logo to `README.md` [#1782](https://github.com/finos/perspective/pull/1782) +- Make toolbar icons configurable [#1767](https://github.com/finos/perspective/pull/1767) + +# [v1.3.6](https://github.com/perspective-dev/perspective/releases/tag/v1.3.6) + +_17 March 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.3.5...v1.3.6)) + +Features + +- Add extra empty column indicator when columns can be appended [#1770](https://github.com/finos/perspective/pull/1770) + +Fixes + +- Fix tree column override width persistence in `perspective-viewer-datagrid` [#1772](https://github.com/finos/perspective/pull/1772) + +Misc + +- Fix `--debug` flag for real this time [#1769](https://github.com/finos/perspective/pull/1769) +- Fix `debug` build script [#1766](https://github.com/finos/perspective/pull/1766) + +# [v1.3.5](https://github.com/perspective-dev/perspective/releases/tag/v1.3.5) + +_14 March 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.3.4...v1.3.5)) + +Fixes + +- Fix JLab `settings` flicker [#1762](https://github.com/finos/perspective/pull/1762) +- Export `perspective-viewer-datagrid` types [#1759](https://github.com/finos/perspective/pull/1759) + +Misc + +- Allow more icons to be overridden [#1763](https://github.com/finos/perspective/pull/1763) +- Rust lint+nightly [#1760](https://github.com/finos/perspective/pull/1760) +- Fix flatbuffers [#1757](https://github.com/finos/perspective/pull/1757) + +# [v1.3.4](https://github.com/perspective-dev/perspective/releases/tag/v1.3.4) + +_10 March 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.3.3...v1.3.4)) + +Features + +- Copy menu [#1744](https://github.com/finos/perspective/pull/1744) +- New aggregates `last minus first` and `high minus low` [#1742](https://github.com/finos/perspective/pull/1742) +- Export `.png`, `.html`, `.arrow` [#1740](https://github.com/finos/perspective/pull/1740) + +Fixes + +- D3FC Chart style and bug fixes [#1752](https://github.com/finos/perspective/pull/1752) +- export `perspective-viewer-datagrid` plugin customElement and it's typings [#1746](https://github.com/finos/perspective/pull/1746) +- Fire `perspective-config-update` event on D3FC legend and axis events [#1748](https://github.com/finos/perspective/pull/1748) +- Add `main` field to package json [#1745](https://github.com/finos/perspective/pull/1745) + +Misc + +- Disable OSX Python CI [#1754](https://github.com/finos/perspective/pull/1754) +- Disable Windows builds [#1741](https://github.com/finos/perspective/pull/1741) + +# [v1.3.3](https://github.com/perspective-dev/perspective/releases/tag/v1.3.3) + +_4 March 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.3.2...v1.3.3)) + +Features + +- Allow `@finos/perspective` to run in single-threaded mode [#1737](https://github.com/finos/perspective/pull/1737) + +Fixes + +- Fix bugs reported from docs site [#1733](https://github.com/finos/perspective/pull/1733) + +Misc + +- Update dev docs [#1736](https://github.com/finos/perspective/pull/1736) + +# [v1.3.2](https://github.com/perspective-dev/perspective/releases/tag/v1.3.2) + +_2 March 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.3.1...v1.3.2)) + +Features + +- Vertical resize for expression editor [#1725](https://github.com/finos/perspective/pull/1725) + +Fixes + +- UI small fixes [#1731](https://github.com/finos/perspective/pull/1731) +- fix: fire config-update evnt on perspective-viewer [#1728](https://github.com/finos/perspective/pull/1728) +- Fix filtering on string columns containing `null` [#1726](https://github.com/finos/perspective/pull/1726) + +Misc + +- Add tests [#1730](https://github.com/finos/perspective/pull/1730) + +# [v1.3.1](https://github.com/perspective-dev/perspective/releases/tag/v1.3.1) + +_25 February 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.3.0...v1.3.1)) + +Fixes + +- Fix python memory leak in `table()` [#1724](https://github.com/finos/perspective/pull/1724) + +# [v1.3.0](https://github.com/perspective-dev/perspective/releases/tag/v1.3.0) + +_22 February 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.2.0...v1.3.0)) + +**Breaking** + +- Add scroll_lock optional free scroll mode for `@finos/perspective-viewer-datagrid` [#1721](https://github.com/finos/perspective/pull/1721) + +Fixes + +- Virtual column selector [#1722](https://github.com/finos/perspective/pull/1722) + +Misc + +- remove defunct IEX Cloud example - replace with Polygon.io stock example [#1714](https://github.com/finos/perspective/pull/1714) +- Add column-style-checkbox css variables [#1715](https://github.com/finos/perspective/pull/1715) +- Drag/drop tests [#1716](https://github.com/finos/perspective/pull/1716) + +# [v1.2.0](https://github.com/perspective-dev/perspective/releases/tag/v1.2.0) + +_1 February 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.1.1...v1.2.0)) + +**Breaking** + +- Replace `row_pivots` with `group_by`, `column_pivots` with `split_by`, add `convert()` migration utility [#1709](https://github.com/finos/perspective/pull/1709) +- Add `settings` and `theme` to `perspective-config-update` DOM event and `PerspectiveWidget` python class [#1712](https://github.com/finos/perspective/pull/1712) + +Features + +- User-selectable Theme API [#1711](https://github.com/finos/perspective/pull/1711) + +Misc + +- Update docs for new theme API [#1713](https://github.com/finos/perspective/pull/1713) + +# [v1.1.1](https://github.com/perspective-dev/perspective/releases/tag/v1.1.1) + +_18 January 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.1.0...v1.1.1)) + +Fixes + +- Fix bad filter when `.update()` contains missing string column value [#1708](https://github.com/finos/perspective/pull/1708) +- Fix async mode segfault on `.load()` with indexed Arrow [#1707](https://github.com/finos/perspective/pull/1707) +- Fix `perspective-viewer` filter dropdown for values with double quotes [#1696](https://github.com/finos/perspective/pull/1696) + +Misc + +- Pin `flatc` to 1.12.1 [#1705](https://github.com/finos/perspective/pull/1705) + +# [v1.1.0](https://github.com/perspective-dev/perspective/releases/tag/v1.1.0) + +_5 January 2022_ ([Full changelog](https://github.com/finos/perspective/compare/v1.0.8...v1.1.0)) + +**Breaking** + +- CSV output rewrite (Arrow/C++) [#1692](https://github.com/finos/perspective/pull/1692) + +Features + +- Column style menu for `string` type columns [#1691](https://github.com/finos/perspective/pull/1691) + +Fixes + +- Fix auto-reset when `HTMLPerspectiveViewerElement.load()` called twice [#1695](https://github.com/finos/perspective/pull/1695) + +Misc + +- purge six dependency [#1689](https://github.com/finos/perspective/pull/1689) +- Reduce CI: Turn off branch builds, only build on PRs to master [#1688](https://github.com/finos/perspective/pull/1688) + +# [v1.0.8](https://github.com/perspective-dev/perspective/releases/tag/v1.0.8) + +_23 December 2021_ ([Full changelog](https://github.com/finos/perspective/compare/v1.0.7...v1.0.8)) + +Features + +- 80% reduction in `.wasm` asset size [#1683](https://github.com/finos/perspective/pull/1683) +- Use `puppeteer` for app from CLI [#1674](https://github.com/finos/perspective/pull/1674) + +Fixes + +- `date` and `datetime` filter bug fixes and support for non-Chrome browsers [#1685](https://github.com/finos/perspective/pull/1685) +- Fix Treemap zoom when a group level is `null` [#1676](https://github.com/finos/perspective/pull/1676) + +Misc + +- fix wheel build, make extension first to ensure binaries are inlined, then assemble wheel [#1679](https://github.com/finos/perspective/pull/1679) +- Upgrade Arrow to `6.0.2` [#1680](https://github.com/finos/perspective/pull/1680) +- Benchmarks update [#1681](https://github.com/finos/perspective/pull/1681) + +# [v1.0.7](https://github.com/perspective-dev/perspective/releases/tag/v1.0.7) + +_13 December 2021_ ([Full changelog](https://github.com/finos/perspective/compare/v1.0.6...v1.0.7)) + +Features + +- Preserve `expressions` on reset (shift+ to force) [#1673](https://github.com/finos/perspective/pull/1673) + +Fixes + +- Fix `perspective-workspace` errors [#1675](https://github.com/finos/perspective/pull/1675) + +Misc + +- Build windows wheel every time so its tested [#1672](https://github.com/finos/perspective/pull/1672) + +# [v1.0.6](https://github.com/perspective-dev/perspective/releases/tag/v1.0.6) + +_11 December 2021_ ([Full changelog](https://github.com/finos/perspective/compare/v1.0.5...v1.0.6)) + +Features + +- Fix `perspective-workspace` and add `monaco-editor-webpack-plugin` compatibility to `perspective-webpack-plugin` [#1662](https://github.com/finos/perspective/pull/1662) +- D3FC fast `Treemap`, fix for non-POSIX time axis, better resize [#1660](https://github.com/finos/perspective/pull/1660) +- Resizable expression editor [#1643](https://github.com/finos/perspective/pull/1643) + +Fixes + +- Fix `position: sticky` Chrome render bug in `perspective-viewer-datagrid` [#1661](https://github.com/finos/perspective/pull/1661) +- Implement `getView()` [#1657](https://github.com/finos/perspective/pull/1657) + +Misc + +- add missing definition for re2 on os x to reenable conda builds [#1654](https://github.com/finos/perspective/pull/1654) + +# [v1.0.5](https://github.com/perspective-dev/perspective/releases/tag/v1.0.5) + +_6 December 2021_ ([Full changelog](https://github.com/finos/perspective/compare/v1.0.4...v1.0.5)) + +Fixes + +- D3FC Treemap bug fixes [#1652](https://github.com/finos/perspective/pull/1652) +- Fix `datetime`/`date`/`integer`/`float` filter occasionally resetting while typing [#1653](https://github.com/finos/perspective/pull/1653) + +# [v1.0.4](https://github.com/perspective-dev/perspective/releases/tag/v1.0.4) + +_5 December 2021_ ([Full changelog](https://github.com/finos/perspective/compare/v1.0.3...v1.0.4)) + +Features + +- Parallelized `.wasm` download and deferred font loading [#1647](https://github.com/finos/perspective/pull/1647) +- Add auto-resizing to `<perspective-viewer>` [#1633](https://github.com/finos/perspective/pull/1633) + +Fixes + +- Remove wasm-pack artifacts which interfere with perspective packaging [#1651](https://github.com/finos/perspective/pull/1651) +- Fix column-selector collapse (inverted) [#1650](https://github.com/finos/perspective/pull/1650) +- Emit `.d.ts` declaration files for `@finos/perspective-viewer` [#1649](https://github.com/finos/perspective/pull/1649) + +Misc + +- Overload re2 cmakelists to fix cmake/threads detection issue on conda-forge mac builds, and pin C++ dependency versions [#1634](https://github.com/finos/perspective/pull/1634) +- [Formatting] Fix mixed tabs/spaces in C++ CMakeLists.txt [#1630](https://github.com/finos/perspective/pull/1630) +- [Formatting] force line-feed line endings for all text files for easier windows development [#1635](https://github.com/finos/perspective/pull/1635) +- Update CMakeLists.txt for more thread help on mac for conda [#1631](https://github.com/finos/perspective/pull/1631) +- Fix Azure windows wheel step [#1629](https://github.com/finos/perspective/pull/1629) + +# [v1.0.3](https://github.com/perspective-dev/perspective/releases/tag/v1.0.3) + +_23 November 2021_ ([Full changelog](https://github.com/finos/perspective/compare/v1.0.2...v1.0.3)) + +Fixes + +- Fix partial-updates on tables with `limit` set for javascript [#1624](https://github.com/finos/perspective/pull/1624) +- Fix partial-updates on tables with `limit` set for python [#1623](https://github.com/finos/perspective/pull/1623) +- `PerspectiveWidget()` dialog theme fix [#1621](https://github.com/finos/perspective/pull/1621) +- Add `wait_for_table` flag to `getTable()` [#1619](https://github.com/finos/perspective/pull/1619) +- Fix `worker.js` cross-origin workaround for CDN [#1618](https://github.com/finos/perspective/pull/1618) + +Misc + +- bump windows build for vc 14.2 / vs 16 2019, add macos 11 build for py3.9 [#1626](https://github.com/finos/perspective/pull/1626) +- add threads explicit on mac [#1625](https://github.com/finos/perspective/pull/1625) +- Fix publish through practice [#1620](https://github.com/finos/perspective/pull/1620) + +# [v1.0.2](https://github.com/perspective-dev/perspective/releases/tag/v1.0.2) + +_20 November 2021_ ([Full changelog](https://github.com/finos/perspective/compare/v1.0.1...v1.0.2)) + +Features + +- More regex functions - find, substring, replace [#1598](https://github.com/finos/perspective/pull/1598) +- Add Regex functions using Exprtk and RE2 [#1596](https://github.com/finos/perspective/pull/1596) +- Persist edited expressions and add 'Reset' button [#1594](https://github.com/finos/perspective/pull/1594) + +Fixes + +- Fix D3 alt-axis regression [#1617](https://github.com/finos/perspective/pull/1617) + +Misc + +- New example [#1615](https://github.com/finos/perspective/pull/1615) +- Publish script for Python [#1613](https://github.com/finos/perspective/pull/1613) +- Port to `esbuild` [#1611](https://github.com/finos/perspective/pull/1611) +- Upgrade `d3fc` to `15.2.4` and switch "Heatmap" to use canvas renderer [#1599](https://github.com/finos/perspective/pull/1599) +- Add ExprTK example fractal [#1595](https://github.com/finos/perspective/pull/1595) + +# [v1.0.1](https://github.com/perspective-dev/perspective/releases/tag/v1.0.1) + +_30 October 2021_ ([Full changelog](https://github.com/finos/perspective/compare/v1.0.0...v1.0.1)) + +Features + +- Universal binaries for OSX [#1590](https://github.com/finos/perspective/pull/1590) +- Drag/drop highlight and in-place column updates [#1586](https://github.com/finos/perspective/pull/1586) + +Fixes + +- Fix JupyterLab widget `filter` traitlet [#1593](https://github.com/finos/perspective/pull/1593) +- Fix corrupt string results in `expressions` [#1589](https://github.com/finos/perspective/pull/1589) + +Misc + +- Remove TBB [#1591](https://github.com/finos/perspective/pull/1591) +- swap out JS style versioning in python script [#1563](https://github.com/finos/perspective/pull/1563) +- Align `@lumino` versions [#1576](https://github.com/finos/perspective/pull/1576) +- Bar Rendering bug null sends fix [#1569](https://github.com/finos/perspective/pull/1569) +- remove deprecated webpack-specific tilde prefix convention [#1582](https://github.com/finos/perspective/pull/1582) +- Remove python 2 from build and CI scripts [#1583](https://github.com/finos/perspective/pull/1583) +- fixes #1506 [#1581](https://github.com/finos/perspective/pull/1581) +- Upgrade Apache Arrow to 5.0.0 [#1545](https://github.com/finos/perspective/pull/1545) +- Update python classifiers [#1579](https://github.com/finos/perspective/pull/1579) + +# [v1.0.0](https://github.com/perspective-dev/perspective/releases/tag/v1.0.0) + +_12 October 2021_ ([Full changelog](https://github.com/finos/perspective/compare/v1.0.0-rc.2...v1.0.0)) + +Fixes + +- Fix #1566, remove deprecated flags from WASM debug build [#1567](https://github.com/finos/perspective/pull/1567) +- Fix #1562, fix regressions in PerspectiveWidget [#1565](https://github.com/finos/perspective/pull/1565) + +Misc + +- `docs` for updated `perspective-viewer` [#1574](https://github.com/finos/perspective/pull/1574) + +# [v1.0.0-rc.2](https://github.com/perspective-dev/perspective/releases/tag/v1.0.0-rc.2) + +_29 September 2021_ ([Full changelog](https://github.com/finos/perspective/compare/v1.0.0-rc.1...v1.0.0-rc.2)) + + +# [v1.0.0-rc.1](https://github.com/perspective-dev/perspective/releases/tag/v1.0.0-rc.1) + +_29 September 2021_ ([Full changelog](https://github.com/finos/perspective/compare/v1.0.0-rc.0...v1.0.0-rc.1)) + +Fixes + +- Fix python wheel CI [#1552](https://github.com/finos/perspective/pull/1552) + +Misc + +- Make `/node_modules` external to TS [#1557](https://github.com/finos/perspective/pull/1557) + +# [v1.0.0-rc.0](https://github.com/perspective-dev/perspective/releases/tag/v1.0.0-rc.0) + +_28 September 2021_ ([Full changelog](https://github.com/finos/perspective/compare/v0.10.3...v1.0.0-rc.0)) + +**Breaking** + +- New `<perspective-viewer>` [#1488](https://github.com/finos/perspective/pull/1488) + +Features + +- Boolean datagrid columns [#1553](https://github.com/finos/perspective/pull/1553) +- Return booleans from expression comparisons, allow for vectors to be defined in expressions [#1548](https://github.com/finos/perspective/pull/1548) +- Persistent column widths [#1549](https://github.com/finos/perspective/pull/1549) +- Boolean column filter controls for `<perspective-viewer>` [#1547](https://github.com/finos/perspective/pull/1547) +- Fix M1 (Apple Silicon) build for `perspective-python` and improve developer docs [#1525](https://github.com/finos/perspective/pull/1525) + +Fixes + +- Fix examples [#1556](https://github.com/finos/perspective/pull/1556) +- Fix expression column button toolbar styling [#1555](https://github.com/finos/perspective/pull/1555) +- Fix hidden sort aggregate as `unique` only when sorted on the same axis [#1554](https://github.com/finos/perspective/pull/1554) +- Fixes `bucket()` computed function validation [#1551](https://github.com/finos/perspective/pull/1551) +- Fix 'weighted mean' aggregate support in <perspective-viewer> [#1543](https://github.com/finos/perspective/pull/1543) +- Fix column section collapse with expressions [#1542](https://github.com/finos/perspective/pull/1542) +- Fix `is (not) null`, `date`, `datetime` filters [#1541](https://github.com/finos/perspective/pull/1541) +- Fix workspace filter events [#1540](https://github.com/finos/perspective/pull/1540) +- Fix `docs` site and NPM artifact for `<perspective-viewer>` update [#1533](https://github.com/finos/perspective/pull/1533) +- Fix drag/drop exclusive cases [#1532](https://github.com/finos/perspective/pull/1532) +- Re-add `getEditPort()` and `restyleElement()` methods [#1531](https://github.com/finos/perspective/pull/1531) +- Use TypeScript for `@finos/perspective-viewer` [#1530](https://github.com/finos/perspective/pull/1530) +- Fix `settings` key to trigger redraw + container redraw [#1529](https://github.com/finos/perspective/pull/1529) +- Fix #1505, #998, #1225 - results after remove are correct [#1528](https://github.com/finos/perspective/pull/1528) +- Fix D3FC chart resize via `preserveAspectRatio` [#1526](https://github.com/finos/perspective/pull/1526) + +Misc + +- add some light sdist tests and upload sdist in CI [#1433](https://github.com/finos/perspective/pull/1433) +- Upgrade emscripten to 2.0.29 [#1539](https://github.com/finos/perspective/pull/1539) +- Add docs for `<perspective-viewer-plugin>` [#1538](https://github.com/finos/perspective/pull/1538) +- Lint upgrade and remove TypeScript for `@finos/perspective-jupyterlab` [#1537](https://github.com/finos/perspective/pull/1537) + +# [v0.10.3](https://github.com/perspective-dev/perspective/releases/tag/v0.10.3) + +_31 August 2021_ ([Full changelog](https://github.com/finos/perspective/compare/v0.10.2...v0.10.3)) + +Fixes + +- Refactor `to_arrow`, fix row deltas for pivoted views [#1519](https://github.com/finos/perspective/pull/1519) +- Fix count aggregate when last aggregate and partial updates are applied [#1518](https://github.com/finos/perspective/pull/1518) + +# [v0.10.2](https://github.com/perspective-dev/perspective/releases/tag/v0.10.2) + +_30 August 2021_ ([Full changelog](https://github.com/finos/perspective/compare/v0.10.1...v0.10.2)) + +Fixes + +- Fix support for array-like filter terms [#1524](https://github.com/finos/perspective/pull/1524) +- Add new aggregates to ViewConfig enum [#1516](https://github.com/finos/perspective/pull/1516) + +# [v0.10.1](https://github.com/perspective-dev/perspective/releases/tag/v0.10.1) + +_12 August 2021_ ([Full changelog](https://github.com/finos/perspective/compare/v0.10.0...v0.10.1)) + +Features + +- Upgrade `regular-table` [#1475](https://github.com/finos/perspective/pull/1475) +- Add standard deviation and variance aggregates [#1476](https://github.com/finos/perspective/pull/1476) + +Fixes + +- Remove filter limit [#1514](https://github.com/finos/perspective/pull/1514) +- Add required dependencies for webpack plugin [#1480](https://github.com/finos/perspective/pull/1480) +- Fix throttle attribute [#1479](https://github.com/finos/perspective/pull/1479) + +Misc + +- Preload fonts [#1481](https://github.com/finos/perspective/pull/1481) +- Refactoring [#1471](https://github.com/finos/perspective/pull/1471) + +# [v0.10.0](https://github.com/perspective-dev/perspective/releases/tag/v0.10.0) + +_8 July 2021_ ([Full changelog](https://github.com/finos/perspective/compare/v0.9.0...v0.10.0)) + +Features + +- Inlined build for perspective-jupyterlab, improve PerspectiveWidget [#1466](https://github.com/finos/perspective/pull/1466) +- Spark bar [#1459](https://github.com/finos/perspective/pull/1459) +- New plugin api [#1457](https://github.com/finos/perspective/pull/1457) +- Read CSV strings in perspective-python [#1447](https://github.com/finos/perspective/pull/1447) + +Fixes + +- Fix Binder by updating Jupyterlab to 3.0.14 from 3.0.9 [#1469](https://github.com/finos/perspective/pull/1469) +- Misc. plugin bug fixes [#1465](https://github.com/finos/perspective/pull/1465) +- Fix memory errors when streaming updates with expression columns [#1464](https://github.com/finos/perspective/pull/1464) +- Fixes #1340 - removes dependency on removed ipywidgets API [#1455](https://github.com/finos/perspective/pull/1455) + +Misc + +- New website [#1470](https://github.com/finos/perspective/pull/1470) +- Add Jupyterlab tests to CI [#1460](https://github.com/finos/perspective/pull/1460) +- Build Windows wheel, limit wheel builds to scheduled and tagged builds [#1453](https://github.com/finos/perspective/pull/1453) + +# [v0.9.0](https://github.com/perspective-dev/perspective/releases/tag/v0.9.0) + +_16 June 2021_ ([Full changelog](https://github.com/finos/perspective/compare/v0.8.3...v0.9.0)) + +**Breaking** + +- Add Expressions Engine using ExprTk [#1354](https://github.com/finos/perspective/pull/1354) + +Features + +- Error reporting for `monaco` [#1444](https://github.com/finos/perspective/pull/1444) +- Column name completion for `monaco` [#1443](https://github.com/finos/perspective/pull/1443) +- Output more metadata on expression errors, fix #1440 [#1441](https://github.com/finos/perspective/pull/1441) +- Add integer, float, string, date, and datetime conversion functions [#1437](https://github.com/finos/perspective/pull/1437) +- Add theme support to monaco [#1439](https://github.com/finos/perspective/pull/1439) +- Isolate expressions per-context and ensure memory stability [#1431](https://github.com/finos/perspective/pull/1431) +- Expression Editor UI via `monaco-editor` [#1426](https://github.com/finos/perspective/pull/1426) + +Fixes + +- Expression editor bug fixes [#1450](https://github.com/finos/perspective/pull/1450) +- Parse aggregates in column order [#1432](https://github.com/finos/perspective/pull/1432) + +Misc + +- Update Jupyter Notebook Examples [#1446](https://github.com/finos/perspective/pull/1446) +- Optional lazy-load `monaco-editor` [#1435](https://github.com/finos/perspective/pull/1435) +- Fix benchmarks and remove versions pre 0.5.0 from benchmarking [#1436](https://github.com/finos/perspective/pull/1436) +- Expose `join` aggregate [#1434](https://github.com/finos/perspective/pull/1434) +- organize azure pipelines file [#1381](https://github.com/finos/perspective/pull/1381) + +# [v0.8.3](https://github.com/perspective-dev/perspective/releases/tag/v0.8.3) + +_12 May 2021_ ([Full changelog](https://github.com/finos/perspective/compare/v0.8.2...v0.8.3)) + +Misc + +- Double-render fix [#1420](https://github.com/finos/perspective/pull/1420) + +# [v0.8.2](https://github.com/perspective-dev/perspective/releases/tag/v0.8.2) + +_11 May 2021_ ([Full changelog](https://github.com/finos/perspective/compare/v0.8.1...v0.8.2)) + +Fixes + +- Fix CLI `async` regression [#1419](https://github.com/finos/perspective/pull/1419) +- Fix color gradient charts containing 0 [#1418](https://github.com/finos/perspective/pull/1418) +- Fix styling bugs from CSS minification [#1417](https://github.com/finos/perspective/pull/1417) + +# [v0.8.1](https://github.com/perspective-dev/perspective/releases/tag/v0.8.1) + +_10 May 2021_ ([Full changelog](https://github.com/finos/perspective/compare/v0.8.0...v0.8.1)) + +Features + +- More Material style updates [#1416](https://github.com/finos/perspective/pull/1416) +- Color-by-string for Treemap/Sunburst [#1415](https://github.com/finos/perspective/pull/1415) + +Fixes + +- Responsive column style menu [#1414](https://github.com/finos/perspective/pull/1414) +- Fix memory leak(s), leak tests, `memory_usage()` wasm heap API [#1412](https://github.com/finos/perspective/pull/1412) + +Misc + +- Fix `react` and `remote-workspace` examples [#1411](https://github.com/finos/perspective/pull/1411) +- Getting pybind version number always fails [#1413](https://github.com/finos/perspective/pull/1413) +- Install Boost from JFrog, fix outdated docs from #1409 [#1410](https://github.com/finos/perspective/pull/1410) +- Upgrade `puppeteer` to `9.0.0` [#1408](https://github.com/finos/perspective/pull/1408) + +# [v0.8.0](https://github.com/perspective-dev/perspective/releases/tag/v0.8.0) + +_27 April 2021_ ([Full changelog](https://github.com/finos/perspective/compare/v0.7.0...v0.8.0)) + +**Breaking** + +- Add `get_min_max()` to Perspective API [#1395](https://github.com/finos/perspective/pull/1395) + +Features + +- Enable editing to mime renderer in JupyterLab [#1353](https://github.com/finos/perspective/pull/1353) +- Datagrid Styleable Column [#1386](https://github.com/finos/perspective/pull/1386) + +Fixes + +- Fix `last` aggregate to preserve status [#1390](https://github.com/finos/perspective/pull/1390) + +Misc + +- Updated `gh-pages` site and `README.md` [#1399](https://github.com/finos/perspective/pull/1399) +- Disable column style menu for non-numeric columns [#1391](https://github.com/finos/perspective/pull/1391) + +# [v0.7.0](https://github.com/perspective-dev/perspective/releases/tag/v0.7.0) + +_20 April 2021_ ([Full changelog](https://github.com/finos/perspective/compare/v0.6.2...v0.7.0)) + +**Breaking** + +- Remove webcomponentsjs [#1388](https://github.com/finos/perspective/pull/1388) +- Async Table and View Constructor [#1289](https://github.com/finos/perspective/pull/1289) + +Features + +- Material Theme 2.0 [#1380](https://github.com/finos/perspective/pull/1380) +- Add `call_loop` and `get_table_names` [#1375](https://github.com/finos/perspective/pull/1375) +- Deprecate py27 (linux), add py39 (osx) [#1336](https://github.com/finos/perspective/pull/1336) +- Status Bar Component [#1314](https://github.com/finos/perspective/pull/1314) + +Misc + +- Fix cross-origin webpack defaults [#1387](https://github.com/finos/perspective/pull/1387) +- Backwards compatibility for table() and view() [#1384](https://github.com/finos/perspective/pull/1384) +- Adds `yarn repl` to launch a shell inside our docker images [#1382](https://github.com/finos/perspective/pull/1382) +- Update regular-table to 0.3.1 [#1379](https://github.com/finos/perspective/pull/1379) +- Fix `@finos/perspective-jupyterlab` to work with WebAssembly/Webpack5 [#1377](https://github.com/finos/perspective/pull/1377) +- install boost via choco [#1351](https://github.com/finos/perspective/pull/1351) +- Fix timezone tests to take DST into account [#1349](https://github.com/finos/perspective/pull/1349) +- Fix D3FC label font bug [#1343](https://github.com/finos/perspective/pull/1343) +- Add nightly complete builds [#1338](https://github.com/finos/perspective/pull/1338) +- fix binder [#1341](https://github.com/finos/perspective/pull/1341) +- Replace `emsdk-npm` with simple script [#1342](https://github.com/finos/perspective/pull/1342) +- Fix #1324: use ~major.minor.patch in PerspectiveWidget versioning [#1331](https://github.com/finos/perspective/pull/1331) +- Add `SplitPanel` and port to Yew [#1326](https://github.com/finos/perspective/pull/1326) + +# [v0.6.2](https://github.com/perspective-dev/perspective/releases/tag/v0.6.2) + +_11 February 2021_ ([Full changelog](https://github.com/finos/perspective/compare/v0.6.1...v0.6.2)) + +Fixes + +- Fix `@finos/perspective-jupyterlab` compat with webpack5 [#1323](https://github.com/finos/perspective/pull/1323) + +# [v0.6.1](https://github.com/perspective-dev/perspective/releases/tag/v0.6.1) + +_11 February 2021_ ([Full changelog](https://github.com/finos/perspective/compare/v0.6.0...v0.6.1)) + +Features + +- Date/datetime filter autocompletion, new timezone test suite for JS [#1282](https://github.com/finos/perspective/pull/1282) + +Fixes + +- Fix missing return in ctx0::notify [#1320](https://github.com/finos/perspective/pull/1320) +- Fix for layout jitter in the Column Selector [#1318](https://github.com/finos/perspective/pull/1318) +- Remove webpack as a peerDependency for the webpack plugin [#1311](https://github.com/finos/perspective/pull/1311) +- Fix partial updates in Python using dicts [#1298](https://github.com/finos/perspective/pull/1298) +- Fix for sorting via `avg` aggregated column. [#1286](https://github.com/finos/perspective/pull/1286) +- Fixes style issue with <select> dark theme on Windows [#1287](https://github.com/finos/perspective/pull/1287) + +Misc + +- Export inline `@finos/perspective` by default, and simplify some package `dist` with rollup [#1322](https://github.com/finos/perspective/pull/1322) +- Update regular-table to 0.2.1 [#1321](https://github.com/finos/perspective/pull/1321) +- Docs site updates [#1319](https://github.com/finos/perspective/pull/1319) +- Removed dead code from C++ src [#1293](https://github.com/finos/perspective/pull/1293) +- Add `@finos/perspective-cpp` module [#1292](https://github.com/finos/perspective/pull/1292) +- Add `jlab_link` script to link local changes into development Jupyterlab [#1309](https://github.com/finos/perspective/pull/1309) +- JupyterLab update to ^3.0.0, and fix "Open With.." regression [#1294](https://github.com/finos/perspective/pull/1294) +- Statically link `perspective-python` and `libarrow` [#1290](https://github.com/finos/perspective/pull/1290) +- Upgrade Emscripten to 2.0.6 and remove emsdk docker image [#1232](https://github.com/finos/perspective/pull/1232) +- Fix #1244: make test_wheels script use python -m pip [#1288](https://github.com/finos/perspective/pull/1288) + +# [v0.6.0](https://github.com/perspective-dev/perspective/releases/tag/v0.6.0) + +_8 January 2021_ ([Full changelog](https://github.com/finos/perspective/compare/v0.5.6...v0.6.0)) + +**Breaking** + +- Restrict `viewer.load()` to only accept `Table()` [#1231](https://github.com/finos/perspective/pull/1231) +- Remove host_view and open_view from public API [#1240](https://github.com/finos/perspective/pull/1240) + +Features + +- Validate view config in engine, fix computed column state bugs [#1272](https://github.com/finos/perspective/pull/1272) +- Reimplement pandas deconstruction [#1238](https://github.com/finos/perspective/pull/1238) +- Fix #1213 - improve render warning behavior [#1249](https://github.com/finos/perspective/pull/1249) +- Add unit context for 0-sided views [#1235](https://github.com/finos/perspective/pull/1235) +- Add Python client-based stresstest [#1223](https://github.com/finos/perspective/pull/1223) + +Fixes + +- Fix Date/Time formatting in `perspective-viewer` CSV output [#1281](https://github.com/finos/perspective/pull/1281) +- Fix build for pyarrow 2.0.0 [#1260](https://github.com/finos/perspective/pull/1260) +- Fix #1241 - date and datetime values in client mode are parsed properly [#1248](https://github.com/finos/perspective/pull/1248) +- Fix distributed editing in PerspectiveWidget [#1236](https://github.com/finos/perspective/pull/1236) + +Misc + +- New Documentation & Project Site [#1275](https://github.com/finos/perspective/pull/1275) +- free() pointer to arrow binary in Python [#1273](https://github.com/finos/perspective/pull/1273) +- Cleanup docs and examples to viewer.load(table) [#1271](https://github.com/finos/perspective/pull/1271) +- Fix: version conflict that makes `npm install` failed [#1266](https://github.com/finos/perspective/pull/1266) +- Fix flapping tests for render warnings [#1264](https://github.com/finos/perspective/pull/1264) +- Documentation: Point to current jupyter examples, and remove out-of-date/broken example [#1257](https://github.com/finos/perspective/pull/1257) +- Add perspective-click event to grid [#1250](https://github.com/finos/perspective/pull/1250) +- Adding abs sum functionality [#1247](https://github.com/finos/perspective/pull/1247) +- Update Azure Windows boost to 1.72 [#1253](https://github.com/finos/perspective/pull/1253) +- Fix example notebooks [#1239](https://github.com/finos/perspective/pull/1239) +- tweak py wheel build on linux [#1234](https://github.com/finos/perspective/pull/1234) +- Add `get_index()` and `get_limit()` to the public API, internal code cleanup [#1230](https://github.com/finos/perspective/pull/1230) +- Refactor C++, misc. C++ fixes [#1233](https://github.com/finos/perspective/pull/1233) +- Explicitly import `tornado.locks` [#1227](https://github.com/finos/perspective/pull/1227) + +# [v0.5.6](https://github.com/perspective-dev/perspective/releases/tag/v0.5.6) + +_15 October 2020_ ([Full changelog](https://github.com/finos/perspective/compare/v0.5.5...v0.5.6)) + +Features + +- adding binder link [#1217](https://github.com/finos/perspective/pull/1217) +- Send large arrow binaries as chunks, add client-level Websocket heartbeat [#1209](https://github.com/finos/perspective/pull/1209) +- Add `perspective-plugin-update` event for d3fc [#1212](https://github.com/finos/perspective/pull/1212) +- Upgrade WebAssembly build to Arrow 1.0.1 [#1207](https://github.com/finos/perspective/pull/1207) + +Fixes + +- Fix infinite loop bug in 2-sided context [#1219](https://github.com/finos/perspective/pull/1219) +- Fix `computed-column` and `table()` constructor Javascript bugs [#1214](https://github.com/finos/perspective/pull/1214) + +Misc + +- Separate 'release' and 'debug' builds [#1221](https://github.com/finos/perspective/pull/1221) +- C++ Datetime Parsing [#1220](https://github.com/finos/perspective/pull/1220) +- fix broken link to apache arrow [#1211](https://github.com/finos/perspective/pull/1211) +- Improve Python Examples [#1197](https://github.com/finos/perspective/pull/1197) +- Fix typo, update arrow version info. [#1204](https://github.com/finos/perspective/pull/1204) + +# [v0.5.5](https://github.com/perspective-dev/perspective/releases/tag/v0.5.5) + +_21 September 2020_ ([Full changelog](https://github.com/finos/perspective/compare/v0.5.3...v0.5.5)) + +Features + +- Release GIL for `Table` methods [#1202](https://github.com/finos/perspective/pull/1202) +- GIL release for perspective-python Async mode [#1198](https://github.com/finos/perspective/pull/1198) +- Add Python client implementation [#1199](https://github.com/finos/perspective/pull/1199) +- Server mode for Jupyterlab `PerspectiveWidget` [#1195](https://github.com/finos/perspective/pull/1195) + +Fixes + +- Upgrade to regular-table 0.1.5 and fix custom-style example [#1203](https://github.com/finos/perspective/pull/1203) +- `perspective-python` Client fixes [#1200](https://github.com/finos/perspective/pull/1200) +- Remove linking against python [#1194](https://github.com/finos/perspective/pull/1194) + +Misc + +- add some build troubleshooting docs [#1193](https://github.com/finos/perspective/pull/1193) +- Bl.ocks [#1191](https://github.com/finos/perspective/pull/1191) +- Use `black` Python format [#1188](https://github.com/finos/perspective/pull/1188) +- Move Python examples to `/examples/` [#1178](https://github.com/finos/perspective/pull/1178) + +# [v0.5.3](https://github.com/perspective-dev/perspective/releases/tag/v0.5.3) + +_7 September 2020_ ([Full changelog](https://github.com/finos/perspective/compare/v0.5.2...v0.5.3)) + +Features + +- Add websocket stresstests for Tornado server [#1177](https://github.com/finos/perspective/pull/1177) +- `perspective-viewer-datagrid` support for `editable` [#1176](https://github.com/finos/perspective/pull/1176) +- Remove deprecated plugins `@finos/perspective-viewer-hypergrid` and `@finos/perspective-viewer-highcharts` [#1174](https://github.com/finos/perspective/pull/1174) +- Updated `@finos/perspective-viewer-datagrid` plugin to `regular-table` 0.0.9 [#1169](https://github.com/finos/perspective/pull/1169) +- Add `d3fc` X/Y Line Chart [#1172](https://github.com/finos/perspective/pull/1172) +- fix for virtualenvs/envs with multiple py3 installed, allow for up to arrow 0.17 [#1163](https://github.com/finos/perspective/pull/1163) +- Fixes #1159 - deltas are now generated on first update for 0-sided contexts [#1164](https://github.com/finos/perspective/pull/1164) +- Submit tornado write_message to IOLoop, add websocket tests in Python [#1156](https://github.com/finos/perspective/pull/1156) +- Adds PR templates for bugfix, documentation, and feature [#1155](https://github.com/finos/perspective/pull/1155) +- Adds `set_threadpool_size()` [#1147](https://github.com/finos/perspective/pull/1147) +- Generate/test Manylinux wheels, clean up wheel/build process [#1105](https://github.com/finos/perspective/pull/1105) + +Fixes + +- Datagrid overdraw fix [#1187](https://github.com/finos/perspective/pull/1187) +- D3FC render fixes [#1186](https://github.com/finos/perspective/pull/1186) +- Fix sticky dragdrop on `perspective-viewer` [#1185](https://github.com/finos/perspective/pull/1185) +- `promo` example project update and computed function fix [#1184](https://github.com/finos/perspective/pull/1184) +- Fix `perspective-viewer-datagrid` sorting [#1181](https://github.com/finos/perspective/pull/1181) +- `perspective-workspace` CSS fixes [#1183](https://github.com/finos/perspective/pull/1183) +- Fix segfault and errors on 2-sided sorted views when data window is invalid [#1153](https://github.com/finos/perspective/pull/1153) +- fix(perspective-viewer-d3fc): show formatted dates on treemap and sunburst (1144) [#1165](https://github.com/finos/perspective/pull/1165) +- Fix #1129 and #1130, remove Highcharts and Hypergrid from PerspectiveWidget [#1142](https://github.com/finos/perspective/pull/1142) +- Fix issue where contexts were being notified before gnode state was updated [#1136](https://github.com/finos/perspective/pull/1136) +- Enable updates from arrows that have more/less columns than the Table schema [#1140](https://github.com/finos/perspective/pull/1140) +- Fix path resolution for cross-origin assets [#1141](https://github.com/finos/perspective/pull/1141) + +Misc + +- Updated development.md to show correct Emscripten version. [#1138](https://github.com/finos/perspective/pull/1138) + +# [v0.5.2](https://github.com/perspective-dev/perspective/releases/tag/v0.5.2) + +_28 July 2020_ ([Full changelog](https://github.com/finos/perspective/compare/v0.5.1...v0.5.2)) + +Features + +- slight UX change, dont end with stack trace as it looks like it raise… [#1110](https://github.com/finos/perspective/pull/1110) + +Fixes + +- Properly display computed axis on charts, use local time for all datetime functions [#1116](https://github.com/finos/perspective/pull/1116) + +Misc + +- `perspective-viewer-highcharts-lite` [#1135](https://github.com/finos/perspective/pull/1135) +- Explicitly floor start row/col and ceil end row/col [#1112](https://github.com/finos/perspective/pull/1112) +- Fix transferable list in worker.postMessage [#1119](https://github.com/finos/perspective/pull/1119) +- Fixed Typos in Perspective API Docs [#1096](https://github.com/finos/perspective/pull/1096) +- Add client/server editing example, fix hypergrid to allow editing in workspace [#1109](https://github.com/finos/perspective/pull/1109) + +# [v0.5.1](https://github.com/perspective-dev/perspective/releases/tag/v0.5.1) + +_25 June 2020_ ([Full changelog](https://github.com/finos/perspective/compare/v0.5.0...v0.5.1)) + +Features + +- Computed expressions respect left-to-right associativity and operator precedence [#1090](https://github.com/finos/perspective/pull/1090) +- Enable Manylinux wheel builds [#1057](https://github.com/finos/perspective/pull/1057) +- Fix for CRLF issue [#1078](https://github.com/finos/perspective/pull/1078) +- Use `regular-table` [#1060](https://github.com/finos/perspective/pull/1060) +- add perspective-update-complete event example to js guide [#1027](https://github.com/finos/perspective/pull/1027) + +Fixes + +- Fix Windows build on Azure [#1095](https://github.com/finos/perspective/pull/1095) +- Use local time for column/row headers and computed functions [#1074](https://github.com/finos/perspective/pull/1074) +- Fix regression in regex for Firefox [#1065](https://github.com/finos/perspective/pull/1065) + +Misc + +- Unpin arrow version [#1104](https://github.com/finos/perspective/pull/1104) +- Cleans up CMakeLists & Python build scripts, fixes datetime string rendering [#1091](https://github.com/finos/perspective/pull/1091) +- Refactor manager internal API, speed up string filters in UI, add manager API tests [#1077](https://github.com/finos/perspective/pull/1077) +- Spelling and grammar corrections [#1094](https://github.com/finos/perspective/pull/1094) +- Spell-Fix [#1093](https://github.com/finos/perspective/pull/1093) +- Spelling fixes [#1083](https://github.com/finos/perspective/pull/1083) +- Fix #1068 [#1103](https://github.com/finos/perspective/pull/1103) +- Bump websocket-extensions from 0.1.3 to 0.1.4 [#1080](https://github.com/finos/perspective/pull/1080) +- Run ESLint on documentation + minor documentation improvements [#1069](https://github.com/finos/perspective/pull/1069) +- Remap lab extension command in makefile [#1046](https://github.com/finos/perspective/pull/1046) +- Fixed typo on documentation page [#1062](https://github.com/finos/perspective/pull/1062) + +# [v0.5.0](https://github.com/perspective-dev/perspective/releases/tag/v0.5.0) + +_24 May 2020_ ([Full changelog](https://github.com/finos/perspective/compare/v0.4.8...v0.5.0)) + +Features + +- Autocomplete [#1052](https://github.com/finos/perspective/pull/1052) +- Implement Client/Server Editing [#1043](https://github.com/finos/perspective/pull/1043) +- Reference python objects directly in perspective tables [#975](https://github.com/finos/perspective/pull/975) +- Expression-based Computed Columns [#983](https://github.com/finos/perspective/pull/983) + +Fixes + +- Fix flapping `perspective-workspace` tests [#1022](https://github.com/finos/perspective/pull/1022) + +Misc + +- Styleable scroll-area for `@finos/perspective-viewer-datagrid` [#1058](https://github.com/finos/perspective/pull/1058) +- Restore firefox support by removing captrue group from regex pattern [#1048](https://github.com/finos/perspective/pull/1048) +- Remove duplicate `psp_okey` column from arrow updates [#1044](https://github.com/finos/perspective/pull/1044) +- Adds jsdelivr package.json metadata [#1040](https://github.com/finos/perspective/pull/1040) +- Add abs sum as an aggregator [#1031](https://github.com/finos/perspective/pull/1031) +- Add plugin save and restore api to `perspective-viewer-datagrid` [#1029](https://github.com/finos/perspective/pull/1029) +- Fix minor bugs in Perspective datetime + NaN handling [#1028](https://github.com/finos/perspective/pull/1028) +- Upgrade `papaparse` [#1033](https://github.com/finos/perspective/pull/1033) +- Update to emsdk 1.39.12 and remove 2gb MAXIMUM_MEMORY [#1015](https://github.com/finos/perspective/pull/1015) + +# [v0.4.8](https://github.com/perspective-dev/perspective/releases/tag/v0.4.8) + +_21 April 2020_ ([Full changelog](https://github.com/finos/perspective/compare/v0.4.7...v0.4.8)) + +Misc + +- Datagrid virtual scroll fixes and `-webkit-scrollbar` CSS [#1018](https://github.com/finos/perspective/pull/1018) +- When updating tables typed as `int` with `float64` numpy arrays, copy instead of filling iteratively [#1012](https://github.com/finos/perspective/pull/1012) +- Update Python benchmarks to include results for `update` calls [#1011](https://github.com/finos/perspective/pull/1011) +- Bump minimist from 1.2.0 to 1.2.3 [#1001](https://github.com/finos/perspective/pull/1001) +- Adds -Wall and fixes C++ build warnings [#1017](https://github.com/finos/perspective/pull/1017) +- Add Manylinux Python 3.7 wheel build + tests, disable Azure Windows Python build [#1010](https://github.com/finos/perspective/pull/1010) + +# [v0.4.7](https://github.com/perspective-dev/perspective/releases/tag/v0.4.7) + +_6 April 2020_ ([Full changelog](https://github.com/finos/perspective/compare/v0.4.6...v0.4.7)) + +Features + +- `@finos/perspective-viewer-datagrid` Tree Formatting [#993](https://github.com/finos/perspective/pull/993) + +Fixes + +- Fix column resizing when column pivots are present, and update docs and examples. [#1002](https://github.com/finos/perspective/pull/1002) +- Fix incorrect sort order when hidden, pivoted columns are sorted [#1000](https://github.com/finos/perspective/pull/1000) +- `@finos/perspective-viewer-datagrid` Bug Fixes [#997](https://github.com/finos/perspective/pull/997) +- tweak accessor to accept numpy dict [#985](https://github.com/finos/perspective/pull/985) +- perspective-workspace fixes [#990](https://github.com/finos/perspective/pull/990) +- remove cpp test check from setup.py (no more cpp tests) [#982](https://github.com/finos/perspective/pull/982) + +Misc + +- Add `lock` to PerspectiveManager [#999](https://github.com/finos/perspective/pull/999) +- Look for PyArrow relative to Perspective in @rpath [#995](https://github.com/finos/perspective/pull/995) +- Purge `@finos/perspective-viewer-hypergrid` [#991](https://github.com/finos/perspective/pull/991) +- Documentation: update PerspectiveTornadoServer to PerspectiveTornadoHandler [#987](https://github.com/finos/perspective/pull/987) + +# [v0.4.6](https://github.com/perspective-dev/perspective/releases/tag/v0.4.6) + +_17 March 2020_ ([Full changelog](https://github.com/finos/perspective/compare/v0.4.5...v0.4.6)) + +Features + +- Datagrid row selection and column resize [#979](https://github.com/finos/perspective/pull/979) +- Remove duplicate brew instructions [#973](https://github.com/finos/perspective/pull/973) +- Add `linked` filter mode to `perspective-workspace` [#969](https://github.com/finos/perspective/pull/969) +- Datagrid header click-to-sort and assorted improvements [#967](https://github.com/finos/perspective/pull/967) +- New plugin `@finos/perspective-viewer-datagrid` [#954](https://github.com/finos/perspective/pull/954) +- Add ==, !=, >, <, and string equality computed columns [#957](https://github.com/finos/perspective/pull/957) + +Fixes + +- Update azure-pipelines.yml for Azure Pipelines [#977](https://github.com/finos/perspective/pull/977) +- PerspectiveManager no longer treats `str` as `bytes` in Python 2 [#965](https://github.com/finos/perspective/pull/965) +- Fully clear queued updates before adding a new computed column [#961](https://github.com/finos/perspective/pull/961) + +Misc + +- Update typings to include nodejs only components [#980](https://github.com/finos/perspective/pull/980) +- update dependencies to jupyterlab 2.0, phosphor -> lumino [#970](https://github.com/finos/perspective/pull/970) +- Refactor out `WebsSocketManager` from `WebSockerServer` [#963](https://github.com/finos/perspective/pull/963) + +# [v0.4.5](https://github.com/perspective-dev/perspective/releases/tag/v0.4.5) + +_28 February 2020_ ([Full changelog](https://github.com/finos/perspective/compare/v0.4.4...v0.4.5)) + +Fixes + +- Emit source maps for WebWorker. [#956](https://github.com/finos/perspective/pull/956) +- Bugfix sdist [#953](https://github.com/finos/perspective/pull/953) + +Misc + +- Refactor `gnode` and `gnode_state`, remove C++ test suite [#939](https://github.com/finos/perspective/pull/939) + +# [v0.4.4](https://github.com/perspective-dev/perspective/releases/tag/v0.4.4) + +_26 February 2020_ ([Full changelog](https://github.com/finos/perspective/compare/v0.4.3...v0.4.4)) + +Features + +- Remove some stale code [#927](https://github.com/finos/perspective/pull/927) +- Fix for #921: unifies versioning between JS and Python libraries [#923](https://github.com/finos/perspective/pull/923) + +Fixes + +- Fixes off-by-one error in `end_col` [#948](https://github.com/finos/perspective/pull/948) +- Fix tables `delete` bug in `perspective-workspace` [#946](https://github.com/finos/perspective/pull/946) +- Fix #945: Improve Python install docs [#947](https://github.com/finos/perspective/pull/947) +- Deal with np.int_ on Windows, handle missing __INDEX__ [#943](https://github.com/finos/perspective/pull/943) +- Fix widget title in `perspective-workspace` [#934](https://github.com/finos/perspective/pull/934) +- Fix `perspective-workspace` initialize bug [#924](https://github.com/finos/perspective/pull/924) +- Fix `perspective-workspace` non-unique generated slotid bug [#925](https://github.com/finos/perspective/pull/925) + +Misc + +- Activate Perspective in FINOS Foundation [#949](https://github.com/finos/perspective/pull/949) +- Package/dist license in python package [#930](https://github.com/finos/perspective/pull/930) +- Adds test coverage reporting for`@finos/perspective` [#920](https://github.com/finos/perspective/pull/920) + +# [v0.4.3](https://github.com/perspective-dev/perspective/releases/tag/v0.4.3) + +_12 February 2020_ ([Full changelog](https://github.com/finos/perspective/compare/v0.4.2...v0.4.3)) + +Features + +- adding sdist check so we don't deploy broken sdists accidentally [#918](https://github.com/finos/perspective/pull/918) + +Fixes + +- Event regression fix [#922](https://github.com/finos/perspective/pull/922) +- Remove `@finos/perspective-phosphor` and fix #825 regression. [#919](https://github.com/finos/perspective/pull/919) + +Misc + +- Added Azure compatible reporting for tests [#916](https://github.com/finos/perspective/pull/916) +- Fix bug where `perspective-viewer` doesn't resize [#911](https://github.com/finos/perspective/pull/911) +- Set up CI with Azure Pipelines [#915](https://github.com/finos/perspective/pull/915) + +# [v0.4.2](https://github.com/perspective-dev/perspective/releases/tag/v0.4.2) + +_10 February 2020_ ([Full changelog](https://github.com/finos/perspective/compare/v0.4.1...v0.4.2)) + +Features + +- Custom Element API for `<perspective-workspace>` [#901](https://github.com/finos/perspective/pull/901) +- `perspective-viewer-hypergrid` Tree column toggle buttons. [#914](https://github.com/finos/perspective/pull/914) +- Remove `perspective.node` Python module. [#912](https://github.com/finos/perspective/pull/912) +- Add 'workspace-layout-update' event and css class selector names cleanup [#902](https://github.com/finos/perspective/pull/902) +- updates and fixes for windows build [#884](https://github.com/finos/perspective/pull/884) +- Make `perspective-viewer-hypergrid` selection state save/restore compatible [#903](https://github.com/finos/perspective/pull/903) +- implement save/restore on viewer configuration [#896](https://github.com/finos/perspective/pull/896) +- Make `@finos/perspective-workspace` widget title editable by doubleclick [#891](https://github.com/finos/perspective/pull/891) +- Add 'perspective-select' event to `@finos/perspective-viewer-hypergrid` [#894](https://github.com/finos/perspective/pull/894) +- Allow missing columns [#881](https://github.com/finos/perspective/pull/881) +- Style fixes for `perspective-workspace` [#890](https://github.com/finos/perspective/pull/890) +- Fix selection styling on `@finos/perspective-viewer-hypergrid` [#889](https://github.com/finos/perspective/pull/889) +- Fixed React types. [#886](https://github.com/finos/perspective/pull/886) +- Add new package `@finos/perspective-workspace` [#874](https://github.com/finos/perspective/pull/874) + +Fixes + +- Fix column ordering in Python, null handling for computed columns [#907](https://github.com/finos/perspective/pull/907) +- Fix #898 - week bucket overflows [#899](https://github.com/finos/perspective/pull/899) + +Misc + +- Remove yarn dependency duplication. [#908](https://github.com/finos/perspective/pull/908) +- Bump core-js to v3.6.4 and Babel to 7.8.4 [#906](https://github.com/finos/perspective/pull/906) +- `<perspective-viewer>` UI cleanup [#905](https://github.com/finos/perspective/pull/905) +- Implement computed column functions in C++ [#892](https://github.com/finos/perspective/pull/892) +- Better test screenshot archiving. [#895](https://github.com/finos/perspective/pull/895) +- Update Python README [#893](https://github.com/finos/perspective/pull/893) +- Improve Perspective Documentation [#873](https://github.com/finos/perspective/pull/873) + +# [v0.4.1](https://github.com/perspective-dev/perspective/releases/tag/v0.4.1) + +_27 January 2020_ ([Full changelog](https://github.com/finos/perspective/compare/v0.4.0...v0.4.1)) + +Features + +- New computed functions `pow`, `invert`, `sqrt`, `abs` [#871](https://github.com/finos/perspective/pull/871) +- Optional `@finos/perspective-webpack-plugin` [#870](https://github.com/finos/perspective/pull/870) + +Fixes + +- Fix `on_update` callbacks in Python [#880](https://github.com/finos/perspective/pull/880) +- Time zone awareness in perspective-python [#867](https://github.com/finos/perspective/pull/867) +- Improve `@finos/perspective-viewer` typings [#872](https://github.com/finos/perspective/pull/872) +- Allow plugins to be importable before '`perspective-viewer` [#868](https://github.com/finos/perspective/pull/868) +- Fix scrolling for pivoted hypergrid [#866](https://github.com/finos/perspective/pull/866) + +Misc + +- Extend `perspective-viewer` typings and added a react-typescript example [#877](https://github.com/finos/perspective/pull/877) +- Hiring! [#876](https://github.com/finos/perspective/pull/876) +- Added simple webpack example and renamed existing webpack example [#869](https://github.com/finos/perspective/pull/869) +- Python versioning fix [#863](https://github.com/finos/perspective/pull/863) + +# [v0.4.0](https://github.com/perspective-dev/perspective/releases/tag/v0.4.0) + +_7 January 2020_ ([Full changelog](https://github.com/finos/perspective/compare/v0.4.0-rc.6...v0.4.0)) + +Features + +- Implement `to_arrow` in C++ for JS/Python [#850](https://github.com/finos/perspective/pull/850) +- Exp bin functions [#851](https://github.com/finos/perspective/pull/851) + +Fixes + +- Fix `perspective-viewer` to allow loading a table before it's attache… [#854](https://github.com/finos/perspective/pull/854) +- Fix `perspective-jupyterlab` theme [#853](https://github.com/finos/perspective/pull/853) + +Misc + +- Improved error messages from C++ [#862](https://github.com/finos/perspective/pull/862) +- Add benchmark suite for Python, Refactor module loading for environments where C++ cannot be built [#859](https://github.com/finos/perspective/pull/859) +- add pypi badge [#855](https://github.com/finos/perspective/pull/855) +- Workspace fixes [#858](https://github.com/finos/perspective/pull/858) +- Update versioning script for Python [#852](https://github.com/finos/perspective/pull/852) + +# [v0.4.0-rc.6](https://github.com/perspective-dev/perspective/releases/tag/v0.4.0-rc.6) + +_18 December 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.4.0-rc.5...v0.4.0-rc.6)) + + +# [v0.4.0-rc.5](https://github.com/perspective-dev/perspective/releases/tag/v0.4.0-rc.5) + +_18 December 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.4.0-rc.4...v0.4.0-rc.5)) + +Features + +- Add `selectable` attribute to `perspective-viewer` [#842](https://github.com/finos/perspective/pull/842) +- `weighted mean` aggregate type [#846](https://github.com/finos/perspective/pull/846) +- Theme `material-dense` [#845](https://github.com/finos/perspective/pull/845) +- CSV/JSON renderer in JupyterLab [#832](https://github.com/finos/perspective/pull/832) +- Read date32, date64, decimal128 from Arrow datasets [#829](https://github.com/finos/perspective/pull/829) +- Add `delete()` to widget, cache client updates before render, refactor module structure [#823](https://github.com/finos/perspective/pull/823) + +Fixes + +- Fixed resize behavior [#848](https://github.com/finos/perspective/pull/848) +- Node.js `table` unpin [#844](https://github.com/finos/perspective/pull/844) +- Asynchronously process updates when running in Tornado [#838](https://github.com/finos/perspective/pull/838) +- Throttle fix [#835](https://github.com/finos/perspective/pull/835) +- Preserve user columns and pivots in widget [#833](https://github.com/finos/perspective/pull/833) +- Fix `PerspectiveWorkspace` when tabbed views are moved to master [#831](https://github.com/finos/perspective/pull/831) +- Widget fixes for resizing, TS typings, boolean columns [#826](https://github.com/finos/perspective/pull/826) +- Properly remove `on_delete` and `on_update` callbacks that fail. [#822](https://github.com/finos/perspective/pull/822) +- Default to int64 in Python3, add `long` and `unicode` to schema and type inference [#821](https://github.com/finos/perspective/pull/821) +- Fix misordered columns in update [#818](https://github.com/finos/perspective/pull/818) + +Misc + +- Refactor `perspective-viewer` themes to include css classes [#849](https://github.com/finos/perspective/pull/849) +- Python build overhaul [#839](https://github.com/finos/perspective/pull/839) +- add editable example to readme [#847](https://github.com/finos/perspective/pull/847) +- Async resize [#840](https://github.com/finos/perspective/pull/840) +- Improvements to Arrow updates and indexed columns [#837](https://github.com/finos/perspective/pull/837) +- Remove `ci_python` and refactor scripts. [#836](https://github.com/finos/perspective/pull/836) +- Set Enums as values for Widget/Viewer, refactor test folder structure [#834](https://github.com/finos/perspective/pull/834) +- Stricter linting for comments [#828](https://github.com/finos/perspective/pull/828) +- Add PerspectiveWorkspace olympics example to README [#820](https://github.com/finos/perspective/pull/820) +- Update Perspective website with Python API and user guide [#819](https://github.com/finos/perspective/pull/819) + +# [v0.4.0-rc.4](https://github.com/perspective-dev/perspective/releases/tag/v0.4.0-rc.4) + +_14 November 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.4.0-rc.3...v0.4.0-rc.4)) + +Features + +- Client mode supports dataframes, np.ndarray, structured and recarray [#813](https://github.com/finos/perspective/pull/813) +- Allow client mode widget to be constructed with schema [#807](https://github.com/finos/perspective/pull/807) + +Fixes + +- Fixed 2-sided sorting on column-only pivots [#815](https://github.com/finos/perspective/pull/815) +- Python build fixes [#809](https://github.com/finos/perspective/pull/809) +- Fixed Python Arrow loading bug [#806](https://github.com/finos/perspective/pull/806) +- Two sided sort fixes [#805](https://github.com/finos/perspective/pull/805) + +Misc + +- Add puppeteer tests for `perspective-phosphor` [#812](https://github.com/finos/perspective/pull/812) +- Hypergrid no longer depends on `Object.keys` order of dataset [#811](https://github.com/finos/perspective/pull/811) +- allow for right master [#799](https://github.com/finos/perspective/pull/799) +- Tweak date/datetime inference, remove dependency on non-core Numpy/Pandas API [#802](https://github.com/finos/perspective/pull/802) +- add websocket export in type definition [#800](https://github.com/finos/perspective/pull/800) +- Add umd build and updated tests for `perspective-phosphor` [#798](https://github.com/finos/perspective/pull/798) + +# [v0.4.0-rc.3](https://github.com/perspective-dev/perspective/releases/tag/v0.4.0-rc.3) + +_6 November 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.4.0-rc.2...v0.4.0-rc.3)) + +Features + +- Dataframe/Numpy Array Loader [#791](https://github.com/finos/perspective/pull/791) + +Misc + +- Fixed travis build error & hypergrid console error [#795](https://github.com/finos/perspective/pull/795) +- Added `promo` example project [#794](https://github.com/finos/perspective/pull/794) +- Add `update` for widget in client mode, fix `on_update` in Python 2 [#793](https://github.com/finos/perspective/pull/793) +- Fixed aggregates to apply to hidden sorted columns [#792](https://github.com/finos/perspective/pull/792) +- Fixed `client` flag to default to True on Windows [#788](https://github.com/finos/perspective/pull/788) +- New logo & demo gif [#790](https://github.com/finos/perspective/pull/790) + +# [v0.4.0-rc.2](https://github.com/perspective-dev/perspective/releases/tag/v0.4.0-rc.2) + +_31 October 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.4.0-rc.1...v0.4.0-rc.2)) + +Fixes + +- Python 2/GCC <5 compatibility [#784](https://github.com/finos/perspective/pull/784) + +Misc + +- Hypergrid paintloop removal [#785](https://github.com/finos/perspective/pull/785) + +# [v0.4.0-rc.1](https://github.com/perspective-dev/perspective/releases/tag/v0.4.0-rc.1) + +_24 October 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.3.9...v0.4.0-rc.1)) + +Features + +- `PerspectiveDockPanel` and `PerspectiveWorkspace` components [#782](https://github.com/finos/perspective/pull/782) +- Computed UX [#765](https://github.com/finos/perspective/pull/765) +- Added `Table()` support for numpy recarray [#771](https://github.com/finos/perspective/pull/771) +- Add `to_csv` to Python API [#759](https://github.com/finos/perspective/pull/759) +- Apache Arrow native reads for JS+Python [#755](https://github.com/finos/perspective/pull/755) +- VΛPORWΛVΞ [#739](https://github.com/finos/perspective/pull/739) +- Readable dates [#695](https://github.com/finos/perspective/pull/695) + +Fixes + +- Mobile fixes [#781](https://github.com/finos/perspective/pull/781) +- Arrow fixes [#772](https://github.com/finos/perspective/pull/772) +- Fixed ios compatibility [#770](https://github.com/finos/perspective/pull/770) +- Hypergrid renderer fixes [#764](https://github.com/finos/perspective/pull/764) +- Fixed `yarn clean` script [#753](https://github.com/finos/perspective/pull/753) +- Fixed grid performance when pivoted [#752](https://github.com/finos/perspective/pull/752) +- Fix update bugs [#749](https://github.com/finos/perspective/pull/749) +- Fix empty list in phosphor plugin [#745](https://github.com/finos/perspective/pull/745) +- Read numpy nans/datetimes [#741](https://github.com/finos/perspective/pull/741) + +Misc + +- Python sdist [#763](https://github.com/finos/perspective/pull/763) +- Fixes jupyterlab plugin regressions [#779](https://github.com/finos/perspective/pull/779) +- Link `lerna version` to `bumpversion` [#780](https://github.com/finos/perspective/pull/780) +- Adjust setup.py for MacOS wheel dist [#778](https://github.com/finos/perspective/pull/778) +- Add exception handling, clean up PSP_COMPLAIN_AND_ABORT [#777](https://github.com/finos/perspective/pull/777) +- Multiple views on JLab plugin [#776](https://github.com/finos/perspective/pull/776) +- Update declaration [#773](https://github.com/finos/perspective/pull/773) +- Upgrade Arrow to 0.15.0, link python arrow from prebuilt library [#768](https://github.com/finos/perspective/pull/768) +- Add tornado handler for perspective-python [#766](https://github.com/finos/perspective/pull/766) +- Add core-js@2 dependency that is required by transpiled outputs of Babel [#758](https://github.com/finos/perspective/pull/758) +- Rewrite Perspective Jupyterlab and Phosphor API [#756](https://github.com/finos/perspective/pull/756) +- ES6 Client API [#751](https://github.com/finos/perspective/pull/751) +- add compute() to dynamically show/hide sidebar computation panel [#748](https://github.com/finos/perspective/pull/748) +- Allow users register multiple on_delete callbacks to view and table [#747](https://github.com/finos/perspective/pull/747) +- Some cleanup items [#744](https://github.com/finos/perspective/pull/744) +- Add PerspectiveManager remote API for Python, Tornado server example [#743](https://github.com/finos/perspective/pull/743) +- Computed column partial update [#740](https://github.com/finos/perspective/pull/740) +- Test suite failure feedback [#735](https://github.com/finos/perspective/pull/735) +- Added .gitattributes [#734](https://github.com/finos/perspective/pull/734) +- Enable update with __INDEX__ on explicit index column [#733](https://github.com/finos/perspective/pull/733) +- fixes view->plugin rename that didnt make it from original repo [#731](https://github.com/finos/perspective/pull/731) +- Python improvements + refactor [#730](https://github.com/finos/perspective/pull/730) +- Remove incorrect does not equal filter [#732](https://github.com/finos/perspective/pull/732) +- convert to monolithic python install [#728](https://github.com/finos/perspective/pull/728) +- Allow partial updates on computed column source columns [#729](https://github.com/finos/perspective/pull/729) +- Python [#723](https://github.com/finos/perspective/pull/723) + +# [v0.3.9](https://github.com/perspective-dev/perspective/releases/tag/v0.3.9) + +_16 September 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.3.8...v0.3.9)) + +Features + +- Editing for `@finos/perspective-viewer-hypergrid` [#708](https://github.com/finos/perspective/pull/708) +- Added `index` API to `to_format` methods [#693](https://github.com/finos/perspective/pull/693) + +Fixes + +- Copy-paste error in last PR [#710](https://github.com/finos/perspective/pull/710) + +Misc + +- Fixed schema miscalculation with computed columns and implicit index [#727](https://github.com/finos/perspective/pull/727) +- Type-on-edit [#715](https://github.com/finos/perspective/pull/715) +- Misc cleanup and fixes [#726](https://github.com/finos/perspective/pull/726) +- `yarn setup` [#725](https://github.com/finos/perspective/pull/725) +- Remove regeneratorRuntime entirely [#718](https://github.com/finos/perspective/pull/718) +- Bump lodash.merge from 4.6.1 to 4.6.2 [#717](https://github.com/finos/perspective/pull/717) +- `perspective-test` Module [#711](https://github.com/finos/perspective/pull/711) +- Refactor C++ [#707](https://github.com/finos/perspective/pull/707) +- documentation/style fix [#705](https://github.com/finos/perspective/pull/705) +- Test implicit index & remove extraneous example file [#706](https://github.com/finos/perspective/pull/706) +- `perspective-viewer` cleanup [#703](https://github.com/finos/perspective/pull/703) +- Allow for perspective in ipywidgets layouts [#702](https://github.com/finos/perspective/pull/702) +- Limit charts [#700](https://github.com/finos/perspective/pull/700) +- `leaves_only` flag for perspective [#699](https://github.com/finos/perspective/pull/699) +- `index` column support [#698](https://github.com/finos/perspective/pull/698) +- Bump mixin-deep from 1.3.1 to 1.3.2 [#697](https://github.com/finos/perspective/pull/697) +- Fixed README.md [#696](https://github.com/finos/perspective/pull/696) +- Calculate row offset in C++, refactor Table, remove implicit primary key mode [#692](https://github.com/finos/perspective/pull/692) +- Fixed chunked arrow test to actually be chunked [#691](https://github.com/finos/perspective/pull/691) + +# [v0.3.8](https://github.com/perspective-dev/perspective/releases/tag/v0.3.8) + +_26 August 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.3.7...v0.3.8)) + +Misc + +- Emsdk upstream [#690](https://github.com/finos/perspective/pull/690) +- 0-sided Performance Fix [#689](https://github.com/finos/perspective/pull/689) + +# [v0.3.7](https://github.com/perspective-dev/perspective/releases/tag/v0.3.7) + +_20 August 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.3.6...v0.3.7)) + +Misc + +- Added column-width to style API [#688](https://github.com/finos/perspective/pull/688) +- Added 'dataset' configurable data example [#687](https://github.com/finos/perspective/pull/687) +- `perspective-click` fix [#686](https://github.com/finos/perspective/pull/686) +- Fix performance regression [#685](https://github.com/finos/perspective/pull/685) +- Fix scatter row_pivots_values exception on click [#684](https://github.com/finos/perspective/pull/684) +- Pinned D3FC to 14.0.40 [#683](https://github.com/finos/perspective/pull/683) +- Style API [#682](https://github.com/finos/perspective/pull/682) +- Remove nan filtering, add null filtering [#676](https://github.com/finos/perspective/pull/676) +- Add view config class in C++ [#672](https://github.com/finos/perspective/pull/672) +- Fix #616: perspective-jupyterlab versioning [#675](https://github.com/finos/perspective/pull/675) +- Fix jlab again [#673](https://github.com/finos/perspective/pull/673) +- Update phosphor component to use `plugin` instead of view [#667](https://github.com/finos/perspective/pull/667) +- Cleaner API through the Table abstraction, prepare for Python API [#642](https://github.com/finos/perspective/pull/642) +- Fixes user defined aggregates for computed columns [#666](https://github.com/finos/perspective/pull/666) +- Fix exception for filter labels [#669](https://github.com/finos/perspective/pull/669) +- Allow arrow tables to be created from a schema [#663](https://github.com/finos/perspective/pull/663) +- Updated example gists to use latest API [#661](https://github.com/finos/perspective/pull/661) + +# [v0.3.6](https://github.com/perspective-dev/perspective/releases/tag/v0.3.6) + +_15 July 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.3.5...v0.3.6)) + +Misc + +- Fixed webpack plugin resolution of require() [#660](https://github.com/finos/perspective/pull/660) + +# [v0.3.5](https://github.com/perspective-dev/perspective/releases/tag/v0.3.5) + +_15 July 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.3.4...v0.3.5)) + +Misc + +- Npm gh fix [#659](https://github.com/finos/perspective/pull/659) + +# [v0.3.4](https://github.com/perspective-dev/perspective/releases/tag/v0.3.4) + +_12 July 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.3.3...v0.3.4)) + + +# [v0.3.3](https://github.com/perspective-dev/perspective/releases/tag/v0.3.3) + +_12 July 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.3.2...v0.3.3)) + + +# [v0.3.2](https://github.com/perspective-dev/perspective/releases/tag/v0.3.2) + +_12 July 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.3.1...v0.3.2)) + +Misc + +- Fixed github reported vulnerability in lodash.template [#654](https://github.com/finos/perspective/pull/654) +- Panel resize fixes [#652](https://github.com/finos/perspective/pull/652) +- Made side-panel resizable [#650](https://github.com/finos/perspective/pull/650) +- Fixed null pivotting issue [#651](https://github.com/finos/perspective/pull/651) +- Remove column pivot __ROW_PATH__ filter [#649](https://github.com/finos/perspective/pull/649) +- Add hover styles to copy, download, reset [#643](https://github.com/finos/perspective/pull/643) +- Perspective config [#644](https://github.com/finos/perspective/pull/644) +- Moved loader dependencies out of webpack plugin [#641](https://github.com/finos/perspective/pull/641) +- Fix debug builds [#637](https://github.com/finos/perspective/pull/637) +- Refactor [#639](https://github.com/finos/perspective/pull/639) +- Fixed webpack config error in webpack example [#640](https://github.com/finos/perspective/pull/640) +- Don't use transferable for WASM payload [#635](https://github.com/finos/perspective/pull/635) +- Fixed async loading of node.js module [#634](https://github.com/finos/perspective/pull/634) + +# [v0.3.1](https://github.com/perspective-dev/perspective/releases/tag/v0.3.1) + +_25 June 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.3.0...v0.3.1)) + +Fixes + +- Phosphor and JupyterLab bug and regression fixes [#625](https://github.com/finos/perspective/pull/625) + +Misc + +- Fixed safari hypergrid rendering [#632](https://github.com/finos/perspective/pull/632) +- Update Babel `preset-env` [#631](https://github.com/finos/perspective/pull/631) +- Use MODULARIZE emscripten option instead of a custom one [#630](https://github.com/finos/perspective/pull/630) +- Remove support for ASM.JS, Internet Explorer and other non-WASM browsers [#629](https://github.com/finos/perspective/pull/629) +- Memory usage fixes [#628](https://github.com/finos/perspective/pull/628) + +# [v0.3.0](https://github.com/perspective-dev/perspective/releases/tag/v0.3.0) + +_10 June 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.3.0-rc.4...v0.3.0)) + +Features + +- Allow transfer as columns [#607](https://github.com/finos/perspective/pull/607) + +Misc + +- Webpack perf [#615](https://github.com/finos/perspective/pull/615) +- Updated website [#614](https://github.com/finos/perspective/pull/614) +- Make "view" attribute of `<perspective-viewer>` backwards compatible [#611](https://github.com/finos/perspective/pull/611) +- Made drag/drop zones more generous [#610](https://github.com/finos/perspective/pull/610) +- Fixed split-axis y_line charts in D3FC [#609](https://github.com/finos/perspective/pull/609) +- Restore highchairs on phosphor/jlab until d3 is complete [#608](https://github.com/finos/perspective/pull/608) +- Sync refactor [#606](https://github.com/finos/perspective/pull/606) + +# [v0.3.0-rc.4](https://github.com/perspective-dev/perspective/releases/tag/v0.3.0-rc.4) + +_30 May 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.3.0-rc.3...v0.3.0-rc.4)) + +Misc + +- Replace "distinct count" default aggregate with "count" [#604](https://github.com/finos/perspective/pull/604) +- Replace atomic [#603](https://github.com/finos/perspective/pull/603) + +# [v0.3.0-rc.3](https://github.com/perspective-dev/perspective/releases/tag/v0.3.0-rc.3) + +_28 May 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.3.0-rc.2...v0.3.0-rc.3)) + +Features + +- A few quality-of-life improvements for perspective-phosphor [#591](https://github.com/finos/perspective/pull/591) + +Misc + +- Clean up API names [#600](https://github.com/finos/perspective/pull/600) +- D3FC Default Plugin [#599](https://github.com/finos/perspective/pull/599) +- simplify python setup.py, add psp_test to python tests [#596](https://github.com/finos/perspective/pull/596) +- Fixed D3FC heatmap color for Material dark theme. [#595](https://github.com/finos/perspective/pull/595) +- Row delta fixes [#594](https://github.com/finos/perspective/pull/594) +- Row deltas return arrow-serialized data [#589](https://github.com/finos/perspective/pull/589) +- Fix for webpack 4 themes generation error on Windows [#590](https://github.com/finos/perspective/pull/590) + +# [v0.3.0-rc.2](https://github.com/perspective-dev/perspective/releases/tag/v0.3.0-rc.2) + +_22 May 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.3.0-rc.1...v0.3.0-rc.2)) + +Features + +- Fixed tooltip value when split_values and multiple main values [#583](https://github.com/finos/perspective/pull/583) +- add restyle to type definitions, fix dark mode in jlab, fix CSS issue [#578](https://github.com/finos/perspective/pull/578) +- Feature/dual y axis POC [#564](https://github.com/finos/perspective/pull/564) +- JupyterLab enhancements [#570](https://github.com/finos/perspective/pull/570) +- D3 Treemaps [#563](https://github.com/finos/perspective/pull/563) + +Misc + +- New benchmark suite [#588](https://github.com/finos/perspective/pull/588) +- Port to Webpack 4 [#587](https://github.com/finos/perspective/pull/587) +- Update CONTRIBUTING.md [#584](https://github.com/finos/perspective/pull/584) +- Row delta supports addition, delete, non-contiguous updates [#582](https://github.com/finos/perspective/pull/582) +- Undo condensed column layout when width < 600px [#580](https://github.com/finos/perspective/pull/580) +- Fixed grid styling [#576](https://github.com/finos/perspective/pull/576) +- UX fixes [#575](https://github.com/finos/perspective/pull/575) +- Performance overhaul for `table.update()` [#574](https://github.com/finos/perspective/pull/574) +- Feature/treemap enhancements [#572](https://github.com/finos/perspective/pull/572) +- Fix the format of long numbers [#573](https://github.com/finos/perspective/pull/573) +- Refactor Javascript API +add documentation [#571](https://github.com/finos/perspective/pull/571) +- Update results.json to reflect Scatter name change [#190](https://github.com/finos/perspective/pull/190) +- Overwrite test results [#189](https://github.com/finos/perspective/pull/189) +- Update results.json to reflect Scatter name change [#188](https://github.com/finos/perspective/pull/188) +- Correctly read and generate boolean values for Arrow format [#561](https://github.com/finos/perspective/pull/561) +- Rename charts [#187](https://github.com/finos/perspective/pull/187) +- correct testData file reference [#186](https://github.com/finos/perspective/pull/186) +- provide framework for centering tooltips, and apply to treemap [#182](https://github.com/finos/perspective/pull/182) +- use the native d3 treemap functionality to calculate the new coordina… [#183](https://github.com/finos/perspective/pull/183) +- finos migration [#184](https://github.com/finos/perspective/pull/184) +- titling mechanism for gridLayout charts made generic and shared. [#180](https://github.com/finos/perspective/pull/180) +- Fixed docs links [#559](https://github.com/finos/perspective/pull/559) +- Feature/treemap resizing [#179](https://github.com/finos/perspective/pull/179) +- Test/more unit tests [#181](https://github.com/finos/perspective/pull/181) +- Feature/treemap chart [#168](https://github.com/finos/perspective/pull/168) +- Store legend position and size in settings [#176](https://github.com/finos/perspective/pull/176) +- Fix tooltip for sunburst with no color [#177](https://github.com/finos/perspective/pull/177) +- Treat negative sunburst size values as zero [#178](https://github.com/finos/perspective/pull/178) +- Made date zoom button names consistent [#175](https://github.com/finos/perspective/pull/175) +- Minor refactor to settings configuration [#174](https://github.com/finos/perspective/pull/174) +- Allow settings to be restored before draw [#173](https://github.com/finos/perspective/pull/173) +- Initial implemenetation of save and restore [#170](https://github.com/finos/perspective/pull/170) +- Zoom y-axis to data on OHLC/Candlestick charts [#171](https://github.com/finos/perspective/pull/171) +- Added fixed zoom ranges for date x axis [#169](https://github.com/finos/perspective/pull/169) +- Support dates on the y-axis for heatmaps [#165](https://github.com/finos/perspective/pull/165) +- Simplify plugin interface code [#167](https://github.com/finos/perspective/pull/167) + +# [v0.3.0-rc.1](https://github.com/perspective-dev/perspective/releases/tag/v0.3.0-rc.1) + +_30 April 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.2.23...v0.3.0-rc.1)) + +Features + +- Remove 'aggregate' syntax in engine [#552](https://github.com/finos/perspective/pull/552) +- Add 'styleElement' method to tell perspective-viewer to restyle picking up css changes [#553](https://github.com/finos/perspective/pull/553) + +Misc + +- Transfer perspective to finos [#558](https://github.com/finos/perspective/pull/558) +- D3fc default [#551](https://github.com/finos/perspective/pull/551) +- Filter control auto-focuses when dropped or modified [#557](https://github.com/finos/perspective/pull/557) +- Fixed issue when # of charts changes in highcharts multichart mode [#556](https://github.com/finos/perspective/pull/556) + +# [v0.2.23](https://github.com/perspective-dev/perspective/releases/tag/v0.2.23) + +_22 April 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.2.22...v0.2.23)) + +Misc + +- Misc cleanup [#550](https://github.com/finos/perspective/pull/550) +- Added table ownership flag to `<perspective-viewer>` `delete()` method [#549](https://github.com/finos/perspective/pull/549) +- Remote arrow [#547](https://github.com/finos/perspective/pull/547) +- Hypergrid hover theme fix [#546](https://github.com/finos/perspective/pull/546) +- Called save and restore with the correct context [#544](https://github.com/finos/perspective/pull/544) +- Link click event example in README [#545](https://github.com/finos/perspective/pull/545) +- Fixed hypergrid formatting issue when only row-pivots are changed [#542](https://github.com/finos/perspective/pull/542) +- Refactored data_slice to return t_tscalar [#541](https://github.com/finos/perspective/pull/541) +- Remove header rows for column-only views [#540](https://github.com/finos/perspective/pull/540) + +# [v0.2.22](https://github.com/perspective-dev/perspective/releases/tag/v0.2.22) + +_10 April 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.2.21...v0.2.22)) + +Features + +- D3fc plugin - Sunburst, color styles and bug fixes [#511](https://github.com/finos/perspective/pull/511) + +Misc + +- Updated Puppeteer docker image [#539](https://github.com/finos/perspective/pull/539) +- Hypergrid missing columns fix [#538](https://github.com/finos/perspective/pull/538) +- Upgraded Emscripten to 1.38.29 [#537](https://github.com/finos/perspective/pull/537) +- Updated benchmarked versions [#536](https://github.com/finos/perspective/pull/536) +- D3fc plugin - Resizable legend and other tweaks [#534](https://github.com/finos/perspective/pull/534) +- Updated benchmarks, removed IS_DELTA flag [#533](https://github.com/finos/perspective/pull/533) +- Added save() and restore() methods to plugin API [#532](https://github.com/finos/perspective/pull/532) +- update plugin [#166](https://github.com/finos/perspective/pull/166) +- Merge latest jpmorganchase/perspective master changes into develop [#164](https://github.com/finos/perspective/pull/164) +- Sort by hidden [#531](https://github.com/finos/perspective/pull/531) +- Column sort fix [#529](https://github.com/finos/perspective/pull/529) +- Removed old files [#530](https://github.com/finos/perspective/pull/530) +- Fixed header click behavior to not resize or scroll grid [#528](https://github.com/finos/perspective/pull/528) +- API Refactor [#527](https://github.com/finos/perspective/pull/527) +- "Disable" chart when there is no data [#162](https://github.com/finos/perspective/pull/162) +- Fully implement data slice API [#526](https://github.com/finos/perspective/pull/526) +- Fixed `docs` task [#520](https://github.com/finos/perspective/pull/520) +- Fixed Hypergrid scroll stuttering [#521](https://github.com/finos/perspective/pull/521) +- Add row delta to View [#517](https://github.com/finos/perspective/pull/517) +- Use the same color range for all charts when split [#160](https://github.com/finos/perspective/pull/160) +- Fix issue #522: row count is correct on column-only views [#523](https://github.com/finos/perspective/pull/523) +- Support for live theme changes [#161](https://github.com/finos/perspective/pull/161) +- Test UI interaction in tooltip component [#159](https://github.com/finos/perspective/pull/159) +- Feature/legend resizable [#131](https://github.com/finos/perspective/pull/131) +- Implemented an example UI/D3 based unit test [#158](https://github.com/finos/perspective/pull/158) +- Merge latest changes [#157](https://github.com/finos/perspective/pull/157) +- Feature/parameter names [#156](https://github.com/finos/perspective/pull/156) +- Fix occasional integration test failure and some IE issues [#155](https://github.com/finos/perspective/pull/155) +- Allow for 2-column display when only 2 charts (#151) [#152](https://github.com/finos/perspective/pull/152) +- Allow for 2-column display when only 2 charts [#151](https://github.com/finos/perspective/pull/151) +- d3fc_plugin - sunburst chart, color changes and bug fixes [#150](https://github.com/finos/perspective/pull/150) +- update screenshots [#149](https://github.com/finos/perspective/pull/149) +- sunburst - add labels, don't display if no crossValue is supplied [#148](https://github.com/finos/perspective/pull/148) +- Sunburst centre now shows current level instead of parent [#147](https://github.com/finos/perspective/pull/147) +- Match arc opacity [#146](https://github.com/finos/perspective/pull/146) +- Fixed sunburst legend overlap [#145](https://github.com/finos/perspective/pull/145) +- Tweaked sizing code for sunburst charts` [#144](https://github.com/finos/perspective/pull/144) +- Improved colour-legend display and labels [#143](https://github.com/finos/perspective/pull/143) +- Test/sunburst integration [#141](https://github.com/finos/perspective/pull/141) +- merge view.config fix [#142](https://github.com/finos/perspective/pull/142) +- Refactor axis to use common implementation for x and y [#140](https://github.com/finos/perspective/pull/140) +- Sunburst Chart [#129](https://github.com/finos/perspective/pull/129) +- Fix/use get config [#139](https://github.com/finos/perspective/pull/139) +- Update develop from remote master [#138](https://github.com/finos/perspective/pull/138) +- Hide labels that would be clipped by the window bounds [#135](https://github.com/finos/perspective/pull/135) +- update screenshot results with new colours [#134](https://github.com/finos/perspective/pull/134) +- Implemented themable color ranges for heatmap and X/Y [#133](https://github.com/finos/perspective/pull/133) +- Put color styles in settings so that they can be scoped to a chart [#125](https://github.com/finos/perspective/pull/125) + +# [v0.2.21](https://github.com/perspective-dev/perspective/releases/tag/v0.2.21) + +_3 April 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.2.20...v0.2.21)) + +Features + +- Preparation for merging of perspective-python [#515](https://github.com/finos/perspective/pull/515) +- Make row titles configurable via plugin API [#512](https://github.com/finos/perspective/pull/512) +- D3fc plugin [#498](https://github.com/finos/perspective/pull/498) +- D3fc plugin - Basic OHLC and Candlestick charts [#488](https://github.com/finos/perspective/pull/488) +- d3fc plugin - Label rotation, interpolated legend, zooming, scatter chart things [#479](https://github.com/finos/perspective/pull/479) +- Get version from package json: fixes #423 [#481](https://github.com/finos/perspective/pull/481) + +Misc + +- `on_update` fix [#519](https://github.com/finos/perspective/pull/519) +- New package `perspective-cli` [#516](https://github.com/finos/perspective/pull/516) +- Hypergrid virtual columns & sort by click [#506](https://github.com/finos/perspective/pull/506) +- Fixed merge error in new plugin API access to `view.config` [#507](https://github.com/finos/perspective/pull/507) +- Added `get_config()` API to `view` [#503](https://github.com/finos/perspective/pull/503) +- Fixed expand/collapse on 2-sided pivots [#502](https://github.com/finos/perspective/pull/502) +- Fix pivot null update bug [#501](https://github.com/finos/perspective/pull/501) +- Fix build error and add unit test support [#132](https://github.com/finos/perspective/pull/132) +- Merge unit test functionality from perspective master [#130](https://github.com/finos/perspective/pull/130) +- Switched to new d3fc canvas gridline component [#127](https://github.com/finos/perspective/pull/127) +- D3FC unit test [#494](https://github.com/finos/perspective/pull/494) +- update plugin [#124](https://github.com/finos/perspective/pull/124) +- Add git commit [#123](https://github.com/finos/perspective/pull/123) +- Refresh screenshots [#121](https://github.com/finos/perspective/pull/121) +- refresh screenshots and update shares-template [#119](https://github.com/finos/perspective/pull/119) +- Local puppeteer [#497](https://github.com/finos/perspective/pull/497) +- Update plugin [#120](https://github.com/finos/perspective/pull/120) +- Added integration screenshot tests for more d3fc charts [#117](https://github.com/finos/perspective/pull/117) +- Fixed legend when only one series [#118](https://github.com/finos/perspective/pull/118) +- Merge/jpmorganchase master [#116](https://github.com/finos/perspective/pull/116) +- `on_update` delta calculations are now lazy [#495](https://github.com/finos/perspective/pull/495) +- Do not show number on header indicator if only one column is sorted [#492](https://github.com/finos/perspective/pull/492) +- Fix sum abs agg [#493](https://github.com/finos/perspective/pull/493) +- Theme chart series from CSS and theme file [#114](https://github.com/finos/perspective/pull/114) +- Implemented selection events from d3fc plugin [#113](https://github.com/finos/perspective/pull/113) +- Added bollinger bands and moving average to candle/ohlc [#111](https://github.com/finos/perspective/pull/111) +- Implemented fix for missing final date value [#112](https://github.com/finos/perspective/pull/112) +- Fix webpack config load path [#491](https://github.com/finos/perspective/pull/491) +- Ensure that filter column stays active when filtering [#490](https://github.com/finos/perspective/pull/490) +- Switched ohlc and candlestick charts to canvas [#110](https://github.com/finos/perspective/pull/110) +- Refactored shared svg/canvas gridlines [#109](https://github.com/finos/perspective/pull/109) +- legend draggable and keys cropped [#98](https://github.com/finos/perspective/pull/98) +- Add candlestick/ohlc legend [#107](https://github.com/finos/perspective/pull/107) +- Add upColors and downColors to series [#102](https://github.com/finos/perspective/pull/102) +- update perspective-viewer-d3fc/test/results [#106](https://github.com/finos/perspective/pull/106) +- update perspective-viewer-d3fc/test/results [#105](https://github.com/finos/perspective/pull/105) +- Header sort indicator [#489](https://github.com/finos/perspective/pull/489) +- Basic OHLC and Candlestick charts [#104](https://github.com/finos/perspective/pull/104) +- Fixed some MS-Edge issues [#90](https://github.com/finos/perspective/pull/90) +- Removed old SVG X/Y chart and replace with the Canvas one [#88](https://github.com/finos/perspective/pull/88) +- Inverted y-axis domain for x-bar and heatmap to match highcharts [#79](https://github.com/finos/perspective/pull/79) +- Make sure colour-scale points still have semi-transparent fill [#75](https://github.com/finos/perspective/pull/75) +- Set back to reference "cjs" dependency, with yarn focus [#74](https://github.com/finos/perspective/pull/74) +- Apply XY point colour based on mainValue [#73](https://github.com/finos/perspective/pull/73) +- remove unnecessary slice [#101](https://github.com/finos/perspective/pull/101) +- D3fc plugin - "Nearby" tooltips for some charts [#484](https://github.com/finos/perspective/pull/484) +- Added window support to `to_arrow()` [#485](https://github.com/finos/perspective/pull/485) +- basic OHLC / candlestick charts [#95](https://github.com/finos/perspective/pull/95) +- Updated d3fc and used its new ordinalAxis [#99](https://github.com/finos/perspective/pull/99) +- Fixed lint_cpp script [#487](https://github.com/finos/perspective/pull/487) +- Fixed 0-sided schema [#486](https://github.com/finos/perspective/pull/486) +- Implement t_data_slice API for data extraction from 0 & 1-sided views [#483](https://github.com/finos/perspective/pull/483) +- Fixed some MS-Edge issues [#90](https://github.com/finos/perspective/pull/90) +- Removed old SVG X/Y chart and replace with the Canvas one [#88](https://github.com/finos/perspective/pull/88) +- Added candlestick chart [#97](https://github.com/finos/perspective/pull/97) +- Tooltip fixes [#96](https://github.com/finos/perspective/pull/96) +- Improved and simplified nearby-tooltip code and used on y-scatter [#92](https://github.com/finos/perspective/pull/92) +- Fix typo [#482](https://github.com/finos/perspective/pull/482) +- replace colour references with color [#91](https://github.com/finos/perspective/pull/91) +- Add tooltips for Area charts [#89](https://github.com/finos/perspective/pull/89) +- Fixed some MS-Edge issues [#90](https://github.com/finos/perspective/pull/90) +- Removed old SVG X/Y chart and replace with the Canvas one [#88](https://github.com/finos/perspective/pull/88) +- Updated d3fc and replaced local cartesianSvgChart [#87](https://github.com/finos/perspective/pull/87) +- Refactored tooltips into cartesian-chart component [#86](https://github.com/finos/perspective/pull/86) +- remove duplicate withOutOpacity method from seriesColours [#85](https://github.com/finos/perspective/pull/85) +- Inverted y-axis domain for x-bar and heatmap to match highcharts [#79](https://github.com/finos/perspective/pull/79) +- Make sure colour-scale points still have semi-transparent fill [#75](https://github.com/finos/perspective/pull/75) +- Set back to reference "cjs" dependency, with yarn focus [#74](https://github.com/finos/perspective/pull/74) +- Apply XY point colour based on mainValue [#73](https://github.com/finos/perspective/pull/73) +- Date filter issue [#478](https://github.com/finos/perspective/pull/478) + +# [v0.2.20](https://github.com/perspective-dev/perspective/releases/tag/v0.2.20) + +_7 March 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.2.19...v0.2.20)) + +Features + +- D3fc plugin - area and heatmap charts [#463](https://github.com/finos/perspective/pull/463) + +Misc + +- Add t_data_slice for data output [#473](https://github.com/finos/perspective/pull/473) +- Fixed “not in” filter to array conversion in viewer [#477](https://github.com/finos/perspective/pull/477) +- Fixed calls to `update()` with empty list from throwing exception [#476](https://github.com/finos/perspective/pull/476) +- D3FC fixes [#474](https://github.com/finos/perspective/pull/474) +- Added ‘not in’ filter to `perspective-viewer` defaults [#475](https://github.com/finos/perspective/pull/475) +- CSV integer parse bug [#471](https://github.com/finos/perspective/pull/471) +- Fix for Juypterlab build->dist [#470](https://github.com/finos/perspective/pull/470) +- Update remote API to accept a `perspective.table()` [#469](https://github.com/finos/perspective/pull/469) +- Ported Javascript build, test and clean tasks to Windows [#468](https://github.com/finos/perspective/pull/468) +- Updated README [#465](https://github.com/finos/perspective/pull/465) +- Add perspective click to usage [#464](https://github.com/finos/perspective/pull/464) +- area chart, heatmap chart, and more [#66](https://github.com/finos/perspective/pull/66) +- Feature/heatmap [#61](https://github.com/finos/perspective/pull/61) +- removed deprecated and unused styling directory recursively. [#64](https://github.com/finos/perspective/pull/64) +- Feature/scatter chart extent zeroing responsive to range [#63](https://github.com/finos/perspective/pull/63) +- Hide overlapping labels in crossAxis [#62](https://github.com/finos/perspective/pull/62) +- added optional includeAggregates parameter for splitIntoMultiSeries f… [#59](https://github.com/finos/perspective/pull/59) +- Area chart needs to use new version of axis components [#60](https://github.com/finos/perspective/pull/60) +- Implemented Area Chart [#56](https://github.com/finos/perspective/pull/56) +- Feature/scatter chart extent zeroing responsive to range [#57](https://github.com/finos/perspective/pull/57) +- Update d3fc with fixed seriesSvgGrouped [#55](https://github.com/finos/perspective/pull/55) +- Use a custom (fixed) version of the seriesSvgGrouped component [#52](https://github.com/finos/perspective/pull/52) + +# [v0.2.19](https://github.com/perspective-dev/perspective/releases/tag/v0.2.19) + +_1 March 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.2.18...v0.2.19)) + +Misc + +- Integration tests for D3FC [#458](https://github.com/finos/perspective/pull/458) +- Click event fix [#461](https://github.com/finos/perspective/pull/461) + +# [v0.2.18](https://github.com/perspective-dev/perspective/releases/tag/v0.2.18) + +_27 February 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.2.17...v0.2.18)) + +Misc + +- Added missing `@babel/runtime` dependency [#459](https://github.com/finos/perspective/pull/459) + +# [v0.2.17](https://github.com/perspective-dev/perspective/releases/tag/v0.2.17) + +_27 February 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.2.16...v0.2.17)) + +Features + +- D3fc plugin [#420](https://github.com/finos/perspective/pull/420) +- Add high chart click interactions [#444](https://github.com/finos/perspective/pull/444) + +Misc + +- Updated AUTHORS [#456](https://github.com/finos/perspective/pull/456) +- Removed NPM caching from travis config [#457](https://github.com/finos/perspective/pull/457) +- Fixes for `clean` and `bench` development scripts [#455](https://github.com/finos/perspective/pull/455) +- Port view to C++ [#452](https://github.com/finos/perspective/pull/452) +- Fixed babel compilation in `perspective-d3fc-plugin` package [#454](https://github.com/finos/perspective/pull/454) +- Fixed Github reported dependency vulnerabilities [#451](https://github.com/finos/perspective/pull/451) +- Added `shared_worker()` to ts definition [#453](https://github.com/finos/perspective/pull/453) +- Moved `perspective-jupyterlab` build from `build/` to `dist/` to work… [#450](https://github.com/finos/perspective/pull/450) +- Build cleanup [#448](https://github.com/finos/perspective/pull/448) +- Add tests for filtering datetime columns by string [#449](https://github.com/finos/perspective/pull/449) +- Purges phosphor and jlab tests [#437](https://github.com/finos/perspective/pull/437) +- Fix regression on view expand [#445](https://github.com/finos/perspective/pull/445) +- Interactions click dispatch [#439](https://github.com/finos/perspective/pull/439) +- Some styling and tooltip hover changes [#51](https://github.com/finos/perspective/pull/51) +- Implemented X-Y chart [#41](https://github.com/finos/perspective/pull/41) +- Add gridlines [#36](https://github.com/finos/perspective/pull/36) +- rename '[d3fc] Y Line Chart 2' to '[d3fc] Y Line Chart' [#40](https://github.com/finos/perspective/pull/40) +- Removed old code (and references to it) from before restructure [#39](https://github.com/finos/perspective/pull/39) +- Moved the getChartElement helper into a new module [#38](https://github.com/finos/perspective/pull/38) +- Added line chart [#35](https://github.com/finos/perspective/pull/35) +- Minimum 1-pixel bandwidth [#32](https://github.com/finos/perspective/pull/32) +- Fix data filter for multiple GroupBy [#31](https://github.com/finos/perspective/pull/31) +- Add date/time and linear cross-axis scales [#30](https://github.com/finos/perspective/pull/30) +- Fixed css labelling and spacing and some Edge issues [#28](https://github.com/finos/perspective/pull/28) + +# [v0.2.16](https://github.com/perspective-dev/perspective/releases/tag/v0.2.16) + +_19 February 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.2.15...v0.2.16)) + +Features + +- Phosphor bindings [#436](https://github.com/finos/perspective/pull/436) +- Write arrows [#435](https://github.com/finos/perspective/pull/435) +- Make sort syntax consistent in tests [#434](https://github.com/finos/perspective/pull/434) +- C++ subproject [#426](https://github.com/finos/perspective/pull/426) +- Tidy up dependencies [#427](https://github.com/finos/perspective/pull/427) +- Allow mac failures on travis, have travis fail fast [#428](https://github.com/finos/perspective/pull/428) +- [WIP] Better python build for location of shared objects. [#415](https://github.com/finos/perspective/pull/415) + +Misc + +- Add `replace()` and `clear()` to table [#431](https://github.com/finos/perspective/pull/431) +- adding autodocumentation for python and C++ [#414](https://github.com/finos/perspective/pull/414) +- fixes #411 [#424](https://github.com/finos/perspective/pull/424) +- 0.2.15 changelog [#422](https://github.com/finos/perspective/pull/422) +- Port View to C++ [#413](https://github.com/finos/perspective/pull/413) + +# [v0.2.15](https://github.com/perspective-dev/perspective/releases/tag/v0.2.15) + +_7 February 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.2.14...v0.2.15)) + +Misc + +- String column promotion [#421](https://github.com/finos/perspective/pull/421) +- limit to a single build (15 minutes) until we have more resources [#418](https://github.com/finos/perspective/pull/418) +- Fixed cross origin detection bug [#419](https://github.com/finos/perspective/pull/419) +- `null` category axis fix [#416](https://github.com/finos/perspective/pull/416) +- Fix isCrossOrigin function issue for non-webpack builds [#412](https://github.com/finos/perspective/pull/412) + +# [v0.2.14](https://github.com/perspective-dev/perspective/releases/tag/v0.2.14) + +_4 February 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.2.13...v0.2.14)) + +Misc + +- Webpack fix [#409](https://github.com/finos/perspective/pull/409) +- Added `flush()` method to `<perspective-viewer>` [#408](https://github.com/finos/perspective/pull/408) + +# [v0.2.13](https://github.com/perspective-dev/perspective/releases/tag/v0.2.13) + +_4 February 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.2.12...v0.2.13)) + +Misc + +- Date parsing bug [#407](https://github.com/finos/perspective/pull/407) +- Add publicPath support to webpack-plugin [#403](https://github.com/finos/perspective/pull/403) +- Fixed CSS regression in Chrome canary, fixed example docs [#405](https://github.com/finos/perspective/pull/405) +- add another IEX demo [#404](https://github.com/finos/perspective/pull/404) +- expand os for c++ testing [#402](https://github.com/finos/perspective/pull/402) +- adding iex streaming example to readme [#401](https://github.com/finos/perspective/pull/401) +- Empty filters [#387](https://github.com/finos/perspective/pull/387) +- perspective-webpack-plugin [#399](https://github.com/finos/perspective/pull/399) +- Point README links to blocks [#400](https://github.com/finos/perspective/pull/400) +- Adding FINOS badge [#397](https://github.com/finos/perspective/pull/397) +- Output CJS files perspective package [#392](https://github.com/finos/perspective/pull/392) +- C++ Date Validation [#396](https://github.com/finos/perspective/pull/396) +- Benchmark limit + minor changes to DataAccessor [#395](https://github.com/finos/perspective/pull/395) +- Update Webpack example to webpack 4 [#393](https://github.com/finos/perspective/pull/393) +- Output CJS module bundles for perspective-viewer packages [#391](https://github.com/finos/perspective/pull/391) +- Websocket heartbeat detection [#394](https://github.com/finos/perspective/pull/394) +- [WIP] Merging JS and Python binding code - Part 2 [#389](https://github.com/finos/perspective/pull/389) +- Remove cross /src imports in packages [#390](https://github.com/finos/perspective/pull/390) + +# [v0.2.12](https://github.com/perspective-dev/perspective/releases/tag/v0.2.12) + +_18 January 2019_ ([Full changelog](https://github.com/finos/perspective/compare/v0.2.11...v0.2.12)) + +Features + +- C++ tests [#383](https://github.com/finos/perspective/pull/383) +- Interoperability between CMAKE_BUILD_TYPE and PSP_DEBUG [#381](https://github.com/finos/perspective/pull/381) +- Invalid filters [#375](https://github.com/finos/perspective/pull/375) +- remove boost::format [#374](https://github.com/finos/perspective/pull/374) +- reorganize cpp test directory for better organization [#371](https://github.com/finos/perspective/pull/371) + +Fixes + +- Fixes local cpp build -> need to cd into build dir (not needed on docker, working dir is set) [#373](https://github.com/finos/perspective/pull/373) + +Misc + +- Re-enable cpp warnings [#386](https://github.com/finos/perspective/pull/386) +- Debug fix [#385](https://github.com/finos/perspective/pull/385) +- Correctly type parse numbers wrapped in strings [#370](https://github.com/finos/perspective/pull/370) +- fix mac ending [#369](https://github.com/finos/perspective/pull/369) +- Promote column [#367](https://github.com/finos/perspective/pull/367) +- adding readme for python [#366](https://github.com/finos/perspective/pull/366) +- Test and lint python [#365](https://github.com/finos/perspective/pull/365) +- Add missing dependencies [#364](https://github.com/finos/perspective/pull/364) +- Python build fix [#363](https://github.com/finos/perspective/pull/363) +- Removed regenerator-plugin in favor of fast-async [#357](https://github.com/finos/perspective/pull/357) +- Fix merge error with renamed types #358 [#361](https://github.com/finos/perspective/pull/361) +- Python Bindings [#356](https://github.com/finos/perspective/pull/356) +- Removed type abstractions [#355](https://github.com/finos/perspective/pull/355) +- Test quality-of-life updates [#354](https://github.com/finos/perspective/pull/354) +- Build & performance fixes [#353](https://github.com/finos/perspective/pull/353) +- Unified data access interface [#352](https://github.com/finos/perspective/pull/352) +- Adding test harness for jupyterlab extension [#351](https://github.com/finos/perspective/pull/351) + +# [v0.2.11](https://github.com/perspective-dev/perspective/releases/tag/v0.2.11) + +_20 December 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.2.10...v0.2.11)) + +Misc + +- New benchmarks [#350](https://github.com/finos/perspective/pull/350) +- making cpp a first class citizen again [#346](https://github.com/finos/perspective/pull/346) +- Change transpose button to two-way arrow [#348](https://github.com/finos/perspective/pull/348) +- adding support for apache arrow in jlab [#345](https://github.com/finos/perspective/pull/345) +- Fix #291 Null values in filters [#344](https://github.com/finos/perspective/pull/344) +- Empty string column type inference fix [#343](https://github.com/finos/perspective/pull/343) + +# [v0.2.10](https://github.com/perspective-dev/perspective/releases/tag/v0.2.10) + +_9 December 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.2.9...v0.2.10)) + +Misc + +- Cleanup for port_data_parser branch [#339](https://github.com/finos/perspective/pull/339) +- Port data_types and get_data_type [#336](https://github.com/finos/perspective/pull/336) +- Visual polish [#338](https://github.com/finos/perspective/pull/338) +- Test stability [#337](https://github.com/finos/perspective/pull/337) +- Port infer_type and column_names to C++ [#334](https://github.com/finos/perspective/pull/334) +- Build into dist, plays nicer with ipywidgets [#335](https://github.com/finos/perspective/pull/335) +- Parallel tests [#332](https://github.com/finos/perspective/pull/332) +- Material fixes [#330](https://github.com/finos/perspective/pull/330) +- Refactor parse_data [#329](https://github.com/finos/perspective/pull/329) +- Viewer memory leak on delete() [#328](https://github.com/finos/perspective/pull/328) + +# [v0.2.9](https://github.com/perspective-dev/perspective/releases/tag/v0.2.9) + +_25 November 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.2.8...v0.2.9)) + +Misc + +- UX Cleanup [#327](https://github.com/finos/perspective/pull/327) +- Added a new example [#324](https://github.com/finos/perspective/pull/324) +- Use Yarn workspaces as a drop-in for Lerna [#320](https://github.com/finos/perspective/pull/320) +- Fun Animations! [#326](https://github.com/finos/perspective/pull/326) +- Full column sort [#325](https://github.com/finos/perspective/pull/325) + +# [v0.2.8](https://github.com/perspective-dev/perspective/releases/tag/v0.2.8) + +_21 November 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.2.7...v0.2.8)) + +Misc + +- Column pivot sort [#319](https://github.com/finos/perspective/pull/319) +- Context sort [#317](https://github.com/finos/perspective/pull/317) +- Refactor make_table [#311](https://github.com/finos/perspective/pull/311) +- Further refactored PerspectiveViewer [#313](https://github.com/finos/perspective/pull/313) +- Fix weighted mean null/missing value handling [#315](https://github.com/finos/perspective/pull/315) +- docs: update installation guide [#312](https://github.com/finos/perspective/pull/312) +- Jupyterlab plugin: Cleanup and computed columns [#310](https://github.com/finos/perspective/pull/310) +- Typo [#309](https://github.com/finos/perspective/pull/309) +- Added Jupyterlab plugin install docs [#308](https://github.com/finos/perspective/pull/308) +- Jupyterlab plugin feature improvements [#307](https://github.com/finos/perspective/pull/307) +- Fixed webpack build for Jupyterlab [#306](https://github.com/finos/perspective/pull/306) + +# [v0.2.7](https://github.com/perspective-dev/perspective/releases/tag/v0.2.7) + +_12 November 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.2.6...v0.2.7)) + +Misc + +- Fixed package error which excluded perspective from it's own plugin [#304](https://github.com/finos/perspective/pull/304) + +# [v0.2.6](https://github.com/perspective-dev/perspective/releases/tag/v0.2.6) + +_12 November 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.2.5...v0.2.6)) + +Misc + +- Integration fixes [#303](https://github.com/finos/perspective/pull/303) +- Reorganize ViewPrivate [#302](https://github.com/finos/perspective/pull/302) + +# [v0.2.5](https://github.com/perspective-dev/perspective/releases/tag/v0.2.5) + +_9 November 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.2.4...v0.2.5)) + +Misc + +- Ported to babel 7 [#301](https://github.com/finos/perspective/pull/301) + +# [v0.2.4](https://github.com/perspective-dev/perspective/releases/tag/v0.2.4) + +_8 November 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.2.3...v0.2.4)) + +Misc + +- Toolbar [#299](https://github.com/finos/perspective/pull/299) +- Release bugs [#298](https://github.com/finos/perspective/pull/298) +- Move ViewPrivate into a separate file [#297](https://github.com/finos/perspective/pull/297) +- Moved babel-polyfill to avoid webpack/babel bug [#296](https://github.com/finos/perspective/pull/296) +- Worker loader [#295](https://github.com/finos/perspective/pull/295) +- Add warnings when data to be rendered will slow down browser [#290](https://github.com/finos/perspective/pull/290) +- Move drag/drop into separate file, clean up row, move detectIE/detectChrome from perspective into perspective-viewer [#293](https://github.com/finos/perspective/pull/293) +- Replace _column_view with semantically named methods [#294](https://github.com/finos/perspective/pull/294) +- Refactor __WORKER__ into proper singleton [#292](https://github.com/finos/perspective/pull/292) + +# [v0.2.3](https://github.com/perspective-dev/perspective/releases/tag/v0.2.3) + +_25 October 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.2.2...v0.2.3)) + +Features + +- Change `restore()` to be a promise which resolves on apply [#287](https://github.com/finos/perspective/pull/287) +- Shadow DOM [#286](https://github.com/finos/perspective/pull/286) +- Date types [#271](https://github.com/finos/perspective/pull/271) + +Fixes + +- Need to specify variable name in type declaration [#279](https://github.com/finos/perspective/pull/279) +- Computed columns broken with delta updates on non-dependent columns [#274](https://github.com/finos/perspective/pull/274) + +Misc + +- Fixed x axis rendering bug #164 [#289](https://github.com/finos/perspective/pull/289) +- Fixed empty hypergrid loading error #264 [#288](https://github.com/finos/perspective/pull/288) +- Fix computed column UX issues [#283](https://github.com/finos/perspective/pull/283) +- Expose view columns as TypedArrays [#273](https://github.com/finos/perspective/pull/273) +- Added developer docs and updated contributing.md [#282](https://github.com/finos/perspective/pull/282) +- update to jupyterlab 0.35.x [#284](https://github.com/finos/perspective/pull/284) +- Expansion depth should be stable across updates [#277](https://github.com/finos/perspective/pull/277) +- Move C++ to top level directory [#266](https://github.com/finos/perspective/pull/266) +- Cleanup cpp api [#275](https://github.com/finos/perspective/pull/275) + +# [v0.2.2](https://github.com/perspective-dev/perspective/releases/tag/v0.2.2) + +_8 October 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.2.1...v0.2.2)) + +Features + +- Added support for chunked arrows [#268](https://github.com/finos/perspective/pull/268) + +Fixes + +- Canary css fix [#269](https://github.com/finos/perspective/pull/269) + +Misc + +- New examples [#267](https://github.com/finos/perspective/pull/267) +- Can't remove from an array while iterating it's indices [#263](https://github.com/finos/perspective/pull/263) +- Misc fixes accumulated from external PRs [#261](https://github.com/finos/perspective/pull/261) +- Fix bug with pkey column update with missing agg value [#258](https://github.com/finos/perspective/pull/258) +- Bind functions [#260](https://github.com/finos/perspective/pull/260) +- Fixed bug which caused computed-columns creation to break updates in … [#259](https://github.com/finos/perspective/pull/259) +- Added options dict to viewer load method [#256](https://github.com/finos/perspective/pull/256) +- Add 'not in' filter operation [#255](https://github.com/finos/perspective/pull/255) +- Added positive/negative color & font to Hypergrid CSS style [#254](https://github.com/finos/perspective/pull/254) + +# [v0.2.1](https://github.com/perspective-dev/perspective/releases/tag/v0.2.1) + +_1 October 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.2.0...v0.2.1)) + +Misc + +- IE fix [#253](https://github.com/finos/perspective/pull/253) + +# [v0.2.0](https://github.com/perspective-dev/perspective/releases/tag/v0.2.0) + +_1 October 2018_ ([Full changelog](https://github.com/finos/perspective/compare/0.2.0-beta.3...v0.2.0)) + +Misc + +- Fixed hover tooltips on y_scatter charts [#252](https://github.com/finos/perspective/pull/252) +- Updated documentation [#250](https://github.com/finos/perspective/pull/250) +- Moved LESS formatting to prettier; added clean-css to LESS build [#251](https://github.com/finos/perspective/pull/251) +- Refactor examples [#249](https://github.com/finos/perspective/pull/249) +- 1D charts generated from columnar data [#231](https://github.com/finos/perspective/pull/231) + +# [0.2.0-beta.3](https://github.com/perspective-dev/perspective/releases/tag/0.2.0-beta.3) + +_24 September 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.2.0-beta.3...0.2.0-beta.3)) + + +# [v0.2.0-beta.3](https://github.com/perspective-dev/perspective/releases/tag/v0.2.0-beta.3) + +_24 September 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.2.0-beta.2...v0.2.0-beta.3)) + +Misc + +- Fixed cross origin asset resolution failure [#248](https://github.com/finos/perspective/pull/248) +- Column resize fix refactor [#246](https://github.com/finos/perspective/pull/246) +- Better documentation for `worker()` method usage in node.js & the bro… [#245](https://github.com/finos/perspective/pull/245) +- Clang format [#244](https://github.com/finos/perspective/pull/244) +- Add eslint-prettier [#242](https://github.com/finos/perspective/pull/242) + +# [v0.2.0-beta.2](https://github.com/perspective-dev/perspective/releases/tag/v0.2.0-beta.2) + +_17 September 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.2.0-beta.1...v0.2.0-beta.2)) + +Misc + +- Added `y_scatter` chart type [#241](https://github.com/finos/perspective/pull/241) + +# [v0.2.0-beta.1](https://github.com/perspective-dev/perspective/releases/tag/v0.2.0-beta.1) + +_17 September 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.1.18...v0.2.0-beta.1)) + +Misc + +- Computed persistence [#240](https://github.com/finos/perspective/pull/240) +- Limit tables [#239](https://github.com/finos/perspective/pull/239) +- Fixed column-oriented output with column-only pivot [#238](https://github.com/finos/perspective/pull/238) +- WebsocketHost HTTP support + GIT example [#237](https://github.com/finos/perspective/pull/237) +- Null fix [#233](https://github.com/finos/perspective/pull/233) +- Add ESLint [#236](https://github.com/finos/perspective/pull/236) +- Remove nested build dirs [#234](https://github.com/finos/perspective/pull/234) +- Added perspective gitter banner to README.md [#235](https://github.com/finos/perspective/pull/235) +- Added Jupyter API for incremental updates [#230](https://github.com/finos/perspective/pull/230) +- Cpp tests [#225](https://github.com/finos/perspective/pull/225) +- Add Travis node_modules caching [#229](https://github.com/finos/perspective/pull/229) +- Refactored `load()` method to a Promise; updated expand/collapse API … [#228](https://github.com/finos/perspective/pull/228) +- Computed columns with multiple parameters [#223](https://github.com/finos/perspective/pull/223) +- Jupyterlab plugin ipywidget [#222](https://github.com/finos/perspective/pull/222) +- Upgraded to emscripten 1.38.11 [#219](https://github.com/finos/perspective/pull/219) +- Added PSP_CPU_COUNT env var [#221](https://github.com/finos/perspective/pull/221) +- Simpler implementation of aggregate fix [#220](https://github.com/finos/perspective/pull/220) +- Null aggregate fix [#217](https://github.com/finos/perspective/pull/217) +- Asm.js tests [#215](https://github.com/finos/perspective/pull/215) +- Computed columns retain persistent aggregates and show/hide [#214](https://github.com/finos/perspective/pull/214) +- First & Last by Index Aggregates [#211](https://github.com/finos/perspective/pull/211) +- Column oriented partial update [#208](https://github.com/finos/perspective/pull/208) +- Mean fix [#206](https://github.com/finos/perspective/pull/206) +- bump dependencies [#203](https://github.com/finos/perspective/pull/203) +- More cleanup + OS X [#202](https://github.com/finos/perspective/pull/202) +- Fixed typescript/webpack file resolution error [#204](https://github.com/finos/perspective/pull/204) +- Drop python [#198](https://github.com/finos/perspective/pull/198) +- Computed columns [#197](https://github.com/finos/perspective/pull/197) +- Assorted bug fixes [#195](https://github.com/finos/perspective/pull/195) +- Memory leak fix [#194](https://github.com/finos/perspective/pull/194) +- Allow table.update() to accept partial rows [#192](https://github.com/finos/perspective/pull/192) +- Fixed Hypergrid regression in column-only pivot [#191](https://github.com/finos/perspective/pull/191) +- Remote performance [#190](https://github.com/finos/perspective/pull/190) +- Fixing filters when setting multiple filters per column [#189](https://github.com/finos/perspective/pull/189) +- Refactored remote library into regular `perspective.js` [#188](https://github.com/finos/perspective/pull/188) +- Remote perspective [#187](https://github.com/finos/perspective/pull/187) +- Add tests and fix for regressed column update. [#186](https://github.com/finos/perspective/pull/186) +- Schema fix [#185](https://github.com/finos/perspective/pull/185) +- Elaborated Jupyterlab plugin documentation, added example notebook [#184](https://github.com/finos/perspective/pull/184) +- Merge from fork [#182](https://github.com/finos/perspective/pull/182) +- Added new persistent, node.js benchmark suite [#181](https://github.com/finos/perspective/pull/181) +- Updated webcomponentsjs [#180](https://github.com/finos/perspective/pull/180) +- Refactor Material Theme [#178](https://github.com/finos/perspective/pull/178) + +# [v0.1.18](https://github.com/perspective-dev/perspective/releases/tag/v0.1.18) + +_1 August 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.1.17...v0.1.18)) + +Misc + +- Responsive sidebar [#175](https://github.com/finos/perspective/pull/175) +- Don't exclude @apache-arrow from babel [#177](https://github.com/finos/perspective/pull/177) +- upping jlab dependencies [#174](https://github.com/finos/perspective/pull/174) +- Fixed Jest regression [#176](https://github.com/finos/perspective/pull/176) +- Better comparisons for benchmarks [#173](https://github.com/finos/perspective/pull/173) +- Fixed hover colors [#172](https://github.com/finos/perspective/pull/172) +- Jupyter embedded mode [#170](https://github.com/finos/perspective/pull/170) +- fix for #160 [#168](https://github.com/finos/perspective/pull/168) +- Run tests in parallel [#166](https://github.com/finos/perspective/pull/166) +- Material tooltip [#167](https://github.com/finos/perspective/pull/167) +- small style fixes [#163](https://github.com/finos/perspective/pull/163) +- Highcharts Tooltips [#162](https://github.com/finos/perspective/pull/162) +- Forgo publishing cpp source code [#161](https://github.com/finos/perspective/pull/161) +- Add typing for perspective [#157](https://github.com/finos/perspective/pull/157) +- Release fixes [#158](https://github.com/finos/perspective/pull/158) +- Split Build Steps into individual tasks [#156](https://github.com/finos/perspective/pull/156) + +# [v0.1.17](https://github.com/perspective-dev/perspective/releases/tag/v0.1.17) + +_10 July 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.1.16...v0.1.17)) + +Misc + +- Fixed csv.html example [#155](https://github.com/finos/perspective/pull/155) +- Update fin-hypergrid-grouped-header-plugin to 1.2.4 [#154](https://github.com/finos/perspective/pull/154) +- Added simple clipboard API [#152](https://github.com/finos/perspective/pull/152) +- To csv [#150](https://github.com/finos/perspective/pull/150) +- Hypergrid expand/collapse on tree columns. [#148](https://github.com/finos/perspective/pull/148) +- Dropdown styling fix [#147](https://github.com/finos/perspective/pull/147) +- Bug fixes 2 [#145](https://github.com/finos/perspective/pull/145) + +# [v0.1.16](https://github.com/perspective-dev/perspective/releases/tag/v0.1.16) + +_2 July 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.1.15...v0.1.16)) + +Misc + +- Fixed hypergrid deletion bug. [#144](https://github.com/finos/perspective/pull/144) +- Fixed Hypergrid lazy data model scroll performance bug [#143](https://github.com/finos/perspective/pull/143) +- Added an example of loading a CSV client side [#142](https://github.com/finos/perspective/pull/142) +- Fix event ordering bug when switching visualization types [#141](https://github.com/finos/perspective/pull/141) +- Smart column selection when switching visualization plugin [#140](https://github.com/finos/perspective/pull/140) +- Hypergrid 3 [#139](https://github.com/finos/perspective/pull/139) +- New labels [#137](https://github.com/finos/perspective/pull/137) +- Fixed IE regressions [#134](https://github.com/finos/perspective/pull/134) + +# [v0.1.15](https://github.com/perspective-dev/perspective/releases/tag/v0.1.15) + +_11 June 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.1.14...v0.1.15)) + +Misc + +- Fixed datetime category axes [#133](https://github.com/finos/perspective/pull/133) +- Cleaned up code, fixed chart offset regression in material theme. [#132](https://github.com/finos/perspective/pull/132) +- Multi chart mode for Treemap and Sunburst charts [#131](https://github.com/finos/perspective/pull/131) +- Made y_area charts stacked by default [#130](https://github.com/finos/perspective/pull/130) +- Added category axis support to xy_ charts [#129](https://github.com/finos/perspective/pull/129) + +# [v0.1.14](https://github.com/perspective-dev/perspective/releases/tag/v0.1.14) + +_4 June 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.1.13...v0.1.14)) + +Misc + +- New docs [#127](https://github.com/finos/perspective/pull/127) +- Namespaced -viewer events, documented, added view update event. [#126](https://github.com/finos/perspective/pull/126) +- Ignore boost in perspective cpp [#124](https://github.com/finos/perspective/pull/124) +- Add `emscripten` docker container [#114](https://github.com/finos/perspective/pull/114) +- Boost mode for line charts [#125](https://github.com/finos/perspective/pull/125) +- Fixed scatter bug [#123](https://github.com/finos/perspective/pull/123) +- Highcharts refactor [#122](https://github.com/finos/perspective/pull/122) +- Adding dark mode via theme [#121](https://github.com/finos/perspective/pull/121) +- Grid styles [#120](https://github.com/finos/perspective/pull/120) + +# [v0.1.13](https://github.com/perspective-dev/perspective/releases/tag/v0.1.13) + +_22 May 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.1.12...v0.1.13)) + +Misc + +- UI theme support [#119](https://github.com/finos/perspective/pull/119) +- Adding template polyfill for browsers that do not support the template tag [#118](https://github.com/finos/perspective/pull/118) + +# [v0.1.12](https://github.com/perspective-dev/perspective/releases/tag/v0.1.12) + +_18 May 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.1.11...v0.1.12)) + +Misc + +- Fixed hidden column regression [#117](https://github.com/finos/perspective/pull/117) + +# [v0.1.11](https://github.com/perspective-dev/perspective/releases/tag/v0.1.11) + +_18 May 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.1.10...v0.1.11)) + +Misc + +- Re-added remove() feature from bad merge [#116](https://github.com/finos/perspective/pull/116) +- Added tests to verify deletions cause no errors [#115](https://github.com/finos/perspective/pull/115) +- Fixed heatmap legend direction [#113](https://github.com/finos/perspective/pull/113) + +# [v0.1.10](https://github.com/perspective-dev/perspective/releases/tag/v0.1.10) + +_17 May 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.1.9...v0.1.10)) + +Misc + +- Sunburst charts [#112](https://github.com/finos/perspective/pull/112) +- Added 'remove' command to perspective.table [#111](https://github.com/finos/perspective/pull/111) +- Support for initializing with schema [#110](https://github.com/finos/perspective/pull/110) +- Sort order [#107](https://github.com/finos/perspective/pull/107) +- Custom element decorators [#105](https://github.com/finos/perspective/pull/105) +- Allow empty strings in arrow columns (as disctinct from null values) [#103](https://github.com/finos/perspective/pull/103) +- Bug fixes 0.1.9 [#102](https://github.com/finos/perspective/pull/102) + +# [v0.1.9](https://github.com/perspective-dev/perspective/releases/tag/v0.1.9) + +_24 April 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.1.8...v0.1.9)) + +Misc + +- Fixed -examples modules [#98](https://github.com/finos/perspective/pull/98) +- Rebuild [#97](https://github.com/finos/perspective/pull/97) +- Removed perspective-common module [#96](https://github.com/finos/perspective/pull/96) +- Custom elements v1 [#95](https://github.com/finos/perspective/pull/95) +- comm is a promise now, upping jlab version, upping package version [#94](https://github.com/finos/perspective/pull/94) +- Date filters [#93](https://github.com/finos/perspective/pull/93) + +# [v0.1.8](https://github.com/perspective-dev/perspective/releases/tag/v0.1.8) + +_16 April 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.1.7...v0.1.8)) + +Misc + +- Add support for javascript defined custom columns [#88](https://github.com/finos/perspective/pull/88) +- New viewer filters [#92](https://github.com/finos/perspective/pull/92) +- Highcharts refactor [#91](https://github.com/finos/perspective/pull/91) +- Fixed perspective-viewer load order regression, when a schema is used… [#87](https://github.com/finos/perspective/pull/87) + +# [v0.1.7](https://github.com/perspective-dev/perspective/releases/tag/v0.1.7) + +_5 April 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.1.6...v0.1.7)) + +Misc + +- More highcharts modes [#86](https://github.com/finos/perspective/pull/86) + +# [v0.1.6](https://github.com/perspective-dev/perspective/releases/tag/v0.1.6) + +_3 April 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.1.5...v0.1.6)) + +Misc + +- Style fixes. [#85](https://github.com/finos/perspective/pull/85) +- Weighted mean API [#84](https://github.com/finos/perspective/pull/84) +- Fix setting the aggregate attribute on perspective-viewer [#83](https://github.com/finos/perspective/pull/83) +- Windows 10 installation instructions [#78](https://github.com/finos/perspective/pull/78) +- Better date formatting for plugins #67 [#82](https://github.com/finos/perspective/pull/82) + +# [v0.1.5](https://github.com/perspective-dev/perspective/releases/tag/v0.1.5) + +_30 March 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.1.4...v0.1.5)) + +Misc + +- Viewer fixes [#80](https://github.com/finos/perspective/pull/80) +- Properly handle case when there is no data [#77](https://github.com/finos/perspective/pull/77) +- Fixed ellipsis to show when column names extend past visible panel. [#74](https://github.com/finos/perspective/pull/74) +- Boost mode for Highcharts scatter+heatmaps [#73](https://github.com/finos/perspective/pull/73) + +# [v0.1.4](https://github.com/perspective-dev/perspective/releases/tag/v0.1.4) + +_12 March 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.1.3...v0.1.4)) + +Misc + +- Various fixes for layout, drag-and-drop [#69](https://github.com/finos/perspective/pull/69) +- Reset & more conservative default column selection [#68](https://github.com/finos/perspective/pull/68) + +# [v0.1.3](https://github.com/perspective-dev/perspective/releases/tag/v0.1.3) + +_8 March 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.1.2...v0.1.3)) + +Misc + +- Added table and view deletion callbacks [#64](https://github.com/finos/perspective/pull/64) +- Remove specification of index column type from API [#63](https://github.com/finos/perspective/pull/63) +- Scatter labels to show pivot values [#60](https://github.com/finos/perspective/pull/60) + +# [v0.1.2](https://github.com/perspective-dev/perspective/releases/tag/v0.1.2) + +_26 February 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.1.1...v0.1.2)) + +Misc + +- Bug Fixes [#58](https://github.com/finos/perspective/pull/58) +- Various bug fixes. [#57](https://github.com/finos/perspective/pull/57) +- Support Dict arrays with Binary vectors [#54](https://github.com/finos/perspective/pull/54) +- change mime renderer type to not interfere with saving notebook [#52](https://github.com/finos/perspective/pull/52) +- Bug fixes for perspective-viewer. [#50](https://github.com/finos/perspective/pull/50) +- Copy arrow dictionary directly [#49](https://github.com/finos/perspective/pull/49) +- Script fixes [#48](https://github.com/finos/perspective/pull/48) +- Add test file for internal apis [#47](https://github.com/finos/perspective/pull/47) +- Heatmap implementation for perspective-viewer-highcharts plugin [#46](https://github.com/finos/perspective/pull/46) +- Test for various arrow timestamp resolutions, and proper psp column types [#45](https://github.com/finos/perspective/pull/45) +- Update API to use transferable when available [#44](https://github.com/finos/perspective/pull/44) +- Refactored Highcharts imports to use heatmap module from base package. [#43](https://github.com/finos/perspective/pull/43) +- Add transferable support for binary arrow files [#42](https://github.com/finos/perspective/pull/42) +- Support ns and us time resolutions [#41](https://github.com/finos/perspective/pull/41) +- Actually the port number is 8080 NOT 8081 [#39](https://github.com/finos/perspective/pull/39) +- removing lantern dependency [#34](https://github.com/finos/perspective/pull/34) +- Add link for "emsdk" installation. [#31](https://github.com/finos/perspective/pull/31) +- Fixes #14: Fixing issue with encoding, data only [#29](https://github.com/finos/perspective/pull/29) +- README missing hyphen [#25](https://github.com/finos/perspective/pull/25) +- spelling fix, no content changes [#26](https://github.com/finos/perspective/pull/26) +- Fixes ScriptPath for script paths with query strings [#28](https://github.com/finos/perspective/pull/28) +- Remove seemingly unnecessary install step in README.md [#30](https://github.com/finos/perspective/pull/30) +- Support binary type (for non-unicode string data) [#24](https://github.com/finos/perspective/pull/24) +- bugfix for jupyter compat [#23](https://github.com/finos/perspective/pull/23) + +# [v0.1.1](https://github.com/perspective-dev/perspective/releases/tag/v0.1.1) + +_5 February 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.1.0...v0.1.1)) + +Misc + +- JupyterLab extension for perspective rendering [#17](https://github.com/finos/perspective/pull/17) +- Arrow improvements [#22](https://github.com/finos/perspective/pull/22) +- Firefox doesn't support 'new Date("date string")' for arbitrary date … [#20](https://github.com/finos/perspective/pull/20) +- Internal cleanup of gnode/pool api [#19](https://github.com/finos/perspective/pull/19) +- Refactor pkey/op creation for tables to work across text and arrow data [#18](https://github.com/finos/perspective/pull/18) + +# [v0.1.0](https://github.com/perspective-dev/perspective/releases/tag/v0.1.0) + +_31 January 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.0.2...v0.1.0)) + +Misc + +- Column-only pivoting. [#15](https://github.com/finos/perspective/pull/15) +- Initial support for loading Apache Arrow data [#11](https://github.com/finos/perspective/pull/11) +- Don't rely on internal embind implementation for the type of context … [#10](https://github.com/finos/perspective/pull/10) +- A new column chooser UX which also allows column reordering. [#9](https://github.com/finos/perspective/pull/9) + +# [v0.0.2](https://github.com/perspective-dev/perspective/releases/tag/v0.0.2) + +_8 January 2018_ ([Full changelog](https://github.com/finos/perspective/compare/v0.0.1...v0.0.2)) + +Misc + +- Hypergrid plugin modified to load data from perspective lazily. Significantly improves performance on large datasets. [#5](https://github.com/finos/perspective/pull/5) +- Incremental Hypergrid loading; viewer transform updates no longer stack. [#4](https://github.com/finos/perspective/pull/4) + +# [v0.0.1](https://github.com/perspective-dev/perspective/releases/tag/v0.0.1) + +_31 December 2017_ ([Full changelog](null)) + +Misc + +- General test suite improvements for speed, coverage, reproducibility, and developer quality-of-life. [#3](https://github.com/finos/perspective/pull/3) +- UI integration tests via Jest + Puppeteer. [#2](https://github.com/finos/perspective/pull/2) +- README corrections [#1](https://github.com/finos/perspective/pull/1) + diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..4249b50 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,6 @@ +# Code of Conduct + +Perspective is an [OpenJS Foundation](https://openjsf.org/) project. Please be +mindful of and adhere to the OpenJS Foundation's +[Code of Conduct](https://github.com/openjs-foundation/cross-project-council/blob/main/CODE_OF_CONDUCT.md) +when contributing to Perspective. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..31d2299 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,130 @@ +# Contributing + +Thank you for your interest in contributing to Perspective! + +## Guidelines + +When submitting or commenting on an Issue, please respect the following +guidelines. Github Issues are Perspective's project record of bugs and feature +development, e.g. for publishing a release's Changelog, and as such it is +important to keep them informative and on-topic. As such, please understand that +we may remove or reclassify comments, Issues or PRs which violate the +guidelines. + +Please note that due to the we may close your Issue or Pull Request for one of +the reasons listed here or in the associated contribution template. If you find +your contribution closed with a link to this document or a contribution +template, please make sure you've followed the instructions closely before +re-submitting. + +- Be respectful and civil! +- Use the provided Issue and Pull Request templates. If the templates don't fit + your need, please open a + [discussion](https://github.com/perspective-dev/perspective/discussions) + instead. +- Don't ask for issues to be assigned to you if you're a first-time contributor. + If you need help picking an issue to work on, please open a + [discussion](https://github.com/perspective-dev/perspective/discussions). +- Don't add comments asking when a feature will be delivered or a reported issue + fixed. The Issue will link any in-progress draft PRs or Milestones (if known). + +When submitting a Pull Request (PR), please respect the following coding +guidelines: + +- Don't open a PR without an associated + [Open Issue](https://github.com/perspective-dev/perspective/issues) which has + been tagged by a project maintainer. +- Make sure your PR passes _build_, _test_ and _lint_ steps _completely_ before + opening a PR. Make _sure_ you've run these locally, even if you think your + change will not impact this step! +- Sign commits (e.g. with `-s`) in accordance with the DCO policy detailed + below, _before_ opening a PR. +- Please make sure PRs include the following _not optional_ components: + - Tests asserting behavior of any new or modified features. + - Docs for any new or modified public APIs. + - [Benchmarks](https://perspective-dev.github.io/docs/development/#benchmark) + for any performance-critical changes. + +- Keep PRs clean, simple and to-the-point: + - Squash "WIP", "Reverting ..", etc., commits. + - No merge commits (`git merge master`), prefer `rebase` to resolve + conflicts with the `master` branch. + - Try to organize commits as functional components (as opposed to + timeline-of-development). + +## DCO + +> [!IMPORTANT] +> +> Signed commits are required for all PRs. + +The Perspective project requires contributors to affirm their contributions via +a [Developer Certificate of Origin](https://developercertificate.org), which +certifies that developers are authorized to make their contribution, either on +their own behalf or on behalf of their employer. + +In practice, this means that all commits to Perspective must be signed (e.g. +with `-s`/`-S`). Pull requests with any unsigned commits, or where the signer +does not match the commit author, can not be merged by Perspective's committers. +We ask that you check that you have a signed all your commits before making a +pull request. A [DCO enforcement bot](https://github.com/apps/dco) will +automatically scan and flag any pull requests that lack a valid sign-off. + +If you have any general questions about contributing to Perspective, please feel +free to open a discussion on +[github](https://github.com/perspective-dev/perspective/discussions) + +## AI Assistance Notice + +This section was forked from +[`ghostty`](https://github.com/ghostty-org/ghostty), +[MIT License](https://github.com/ghostty-org/ghostty/blob/main/LICENSE) +([File](https://github.com/ghostty-org/ghostty/pull/8289/files)) +([PR](https://github.com/ghostty-org/ghostty/pull/8289)). Thanks @mitchellh! + +> [!IMPORTANT] +> +> If you are using **any kind of AI assistance** to contribute to Perspective, +> it must be disclosed in the pull request. + +If you are using any kind of AI assistance while contributing to Perspective, +**this must be disclosed in the pull request**, along with the extent to which +AI assistance was used (e.g. docs only vs. code generation). If PR responses are +being generated by an AI, disclose that as well. As a small exception, trivial +tab-completion doesn't need to be disclosed, so long as it is limited to single +keywords or short phrases. + +An example disclosure: + +> This PR was written primarily by Claude Code. + +Or a more detailed disclosure: + +> I consulted ChatGPT to understand the codebase but the solution was fully +> authored manually by myself. + +Failure to disclose this is first and foremost rude to the human operators on +the other end of the pull request, but it also makes it difficult to determine +how much scrutiny to apply to the contribution. + +In a perfect world, AI assistance would produce equal or higher quality work +than any human. That isn't the world we live in today, and in most cases it's +generating slop. I say this despite being a fan of and using them successfully +myself (with heavy supervision)! + +Please be respectful to maintainers and disclose AI assistance. + +## Final Pull Request Checklist + +Before opening a Pull Request, be sure: + +- Includes a thorough Description which clearly states what problems the PR + solves. +- Description contains a link to the Github Issue, and any relevent Discussions, + this PR applies to. +- Include new tests that fail without this PR but passes with it. +- Include any relevent Documentation changes related to this change. +- Verify all commits have been _signed_ in accordance with the DCO policy. +- Disclosed AI tooling assistance. +- Reviewed PR commit history to remove unnecessary changes. +- Make sure your PR passes _build_, _test_ and _lint_ steps _completely_. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..28f5d45 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4345 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "arrow-array" +version = "57.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c8955af33b25f3b175ee10af580577280b4bd01f7e823d94c7cdef7cf8c9aef" +dependencies = [ + "ahash 0.8.12", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "hashbrown 0.16.1", + "num-complex", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-buffer" +version = "57.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c697ddca96183182f35b3a18e50b9110b11e916d7b7799cbfd4d34662f2c56c2" +dependencies = [ + "bytes", + "half", + "num-bigint", + "num-traits", +] + +[[package]] +name = "arrow-data" +version = "57.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fdd994a9d28e6365aa78e15da3f3950c0fdcea6b963a12fa1c391afb637b304" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-ipc" +version = "57.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abf7df950701ab528bf7c0cf7eeadc0445d03ef5d6ffc151eaae6b38a58feff1" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "flatbuffers", +] + +[[package]] +name = "arrow-schema" +version = "57.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c872d36b7bf2a6a6a2b40de9156265f0242910791db366a2c17476ba8330d68" + +[[package]] +name = "arrow-select" +version = "57.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68bf3e3efbd1278f770d67e5dc410257300b161b93baedb3aae836144edcaf4b" +dependencies = [ + "ahash 0.8.12", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num-traits", +] + +[[package]] +name = "async-lock" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b" +dependencies = [ + "event-listener", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425" +dependencies = [ + "axum-core", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.4.0", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-simd" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "781dd20c3aff0bd194fe7d2a977dd92f21c173891f3a03b677359e5fa457e5d5" +dependencies = [ + "simd-abstraction", +] + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "camino" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "276a59bf2b2c967788139340c9f0c5b12d7fd6630315c15c217e559de85d2609" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-bundle-licenses" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250a2335d1c5ef791cfc90bee35c87cc7893e826e5b2342138f56bee44f3b4b5" +dependencies = [ + "anyhow", + "cargo_metadata", + "clap", + "env_logger", + "git-version", + "home", + "itertools 0.14.0", + "log", + "regex", + "serde", + "serde_json", + "serde_yaml", + "slug", + "spdx", + "strum 0.27.2", + "thiserror 2.0.17", + "toml", +] + +[[package]] +name = "cargo-platform" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a0c0e6148f11f01f32650a2ea02d532b2ad4e81d8bd41e6e565b5adc5e6082" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform", + "semver 1.0.27", + "serde", + "serde_json", + "thiserror 2.0.17", +] + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width 0.1.14", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width 0.2.2", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "const-str" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21077772762a1002bb421c3af42ac1725fa56066bfc53d9a55bb79905df2aaf3" +dependencies = [ + "const-str-proc-macro", +] + +[[package]] +name = "const-str-proc-macro" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e1e0fdd2e5d3041e530e1b21158aeeef8b5d0e306bc5c1e3d6cf0930d10e25a" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9be934d936a0fbed5bcdc01042b770de1398bf79d0e192f49fa7faea0e99281e" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-color" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556c099a61d85989d7af52b692e35a8d68a57e7df8c6d07563dc0778b3960c9f" +dependencies = [ + "cssparser", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.111", +] + +[[package]] +name = "cxx" +version = "1.0.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7620f6cfc4dcca21f2b085b7a890e16c60fd66f560cd69ee60594908dc72ab1" +dependencies = [ + "cc", + "cxx-build", + "cxxbridge-cmd", + "cxxbridge-flags", + "cxxbridge-macro", + "foldhash 0.2.0", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9bc1a22964ff6a355fbec24cf68266a0ed28f8b84c0864c386474ea3d0e479" +dependencies = [ + "cc", + "codespan-reporting 0.13.1", + "indexmap 2.12.1", + "proc-macro2", + "quote", + "scratch", + "syn 2.0.111", +] + +[[package]] +name = "cxxbridge-cmd" +version = "1.0.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f29a879d35f7906e3c9b77d7a1005a6a0787d330c09dfe4ffb5f617728cb44" +dependencies = [ + "clap", + "codespan-reporting 0.13.1", + "indexmap 2.12.1", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d67109015f93f683e364085aa6489a5b2118b4a40058482101d699936a7836d6" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d187e019e7b05a1f3e69a8396b70800ee867aa9fc2ab972761173ccee03742df" +dependencies = [ + "indexmap 2.12.1", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "data-url" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a30bfce702bcfa94e906ef82421f2c0e61c076ad76030c16ee5d2e9a32fe193" +dependencies = [ + "matches", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "diffy" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e616e59155c92257e84970156f506287853355f58cd4a6eb167385722c32b790" +dependencies = [ + "nu-ansi-term 0.46.0", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "extend" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311a6d2f1f9d60bff73d2c78a0af97ed27f79672f15c238192a5bbb64db56d00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" +dependencies = [ + "bitflags", + "rustc_version", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22030e2c5a68ec659fde1e949a745124b48e6fa8b045b7ed5bd1fe4ccc5c4e5d" +dependencies = [ + "fallible-iterator", + "indexmap 1.9.3", + "stable_deref_trait", +] + +[[package]] +name = "git-version" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad568aa3db0fcbc81f2f116137f263d7304f512a1209b35b85150d3ef88ad19" +dependencies = [ + "git-version-macro", +] + +[[package]] +name = "git-version-macro" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gloo" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d15282ece24eaf4bd338d73ef580c6714c8615155c4190c781290ee3fa0fd372" +dependencies = [ + "gloo-console", + "gloo-dialogs", + "gloo-events", + "gloo-file", + "gloo-history", + "gloo-net", + "gloo-render", + "gloo-storage", + "gloo-timers", + "gloo-utils", + "gloo-worker", +] + +[[package]] +name = "gloo-console" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a17868f56b4a24f677b17c8cb69958385102fa879418052d60b50bc1727e261" +dependencies = [ + "gloo-utils", + "js-sys", + "serde", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-dialogs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4748e10122b01435750ff530095b1217cf6546173459448b83913ebe7815df" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-events" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c26fb45f7c385ba980f5fa87ac677e363949e065a083722697ef1b2cc91e41" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-file" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97563d71863fb2824b2e974e754a81d19c4a7ec47b09ced8a0e6656b6d54bd1f" +dependencies = [ + "gloo-events", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-history" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "903f432be5ba34427eac5e16048ef65604a82061fe93789f2212afc73d8617d6" +dependencies = [ + "getrandom 0.2.16", + "gloo-events", + "gloo-utils", + "serde", + "serde-wasm-bindgen", + "serde_urlencoded", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-net" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43aaa242d1239a8822c15c645f02166398da4f8b5c4bae795c1f5b44e9eee173" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils", + "http 0.2.12", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-render" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56008b6744713a8e8d98ac3dcb7d06543d5662358c9c805b4ce2167ad4649833" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-storage" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc8031e8c92758af912f9bc08fbbadd3c6f3cfcbf6b64cdf3d6a81f0139277a" +dependencies = [ + "gloo-utils", + "js-sys", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-worker" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "085f262d7604911c8150162529cefab3782e91adb20202e8658f7275d2aefe5d" +dependencies = [ + "bincode", + "futures", + "gloo-utils", + "gloo-worker-macros", + "js-sys", + "pinned", + "serde", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-worker-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "956caa58d4857bc9941749d55e4bd3000032d8212762586fa5705632967140e7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", + "serde", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http 1.4.0", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "id-arena" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25a2bc672d1148e28034f176e01fffebb08b35768468cc954630da77a1449005" +dependencies = [ + "rayon", +] + +[[package]] +name = "implicit-clone" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1689b939ee35e3a075b0834b5672efd43aec8a6e81a1c6002b76a5ca2f211ae0" +dependencies = [ + "implicit-clone-derive", + "indexmap 2.12.1", +] + +[[package]] +name = "implicit-clone-derive" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699c1b6d335e63d0ba5c1e1c7f647371ce989c3bcbe1f7ed2b85fa56e3bd1a21" +dependencies = [ + "quote", + "syn 2.0.111", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jiff" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "lightningcss" +version = "1.0.0-alpha.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb6314c2f0590ac93c86099b98bb7ba8abcf759bfd89604ffca906472bb54937" +dependencies = [ + "ahash 0.8.12", + "bitflags", + "const-str", + "cssparser", + "cssparser-color", + "dashmap", + "data-encoding", + "getrandom 0.3.4", + "indexmap 2.12.1", + "itertools 0.10.5", + "lazy_static", + "lightningcss-derive", + "parcel_selectors", + "parcel_sourcemap", + "pastey", + "pathdiff", + "rayon", + "serde", + "serde-content", + "smallvec", +] + +[[package]] +name = "lightningcss-derive" +version = "1.0.0-alpha.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c12744d1279367caed41739ef094c325d53fb0ffcd4f9b84a368796f870252" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "link-cplusplus" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f78c730aaa7d0b9336a299029ea49f9ee53b0ed06e9202e8cb7db9bae7b8c82" +dependencies = [ + "cc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "macro_rules_attribute" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520" +dependencies = [ + "macro_rules_attribute-proc_macro", + "paste", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metadata" +version = "0.0.0" +dependencies = [ + "cargo-bundle-licenses", + "perspective-client", + "perspective-js", + "perspective-server", + "perspective-viewer", + "ts-rs", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "outref" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f222829ae9293e33a9f5e9f440c6760a3d450a64affe1846486b140db81c1f4" + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "parcel_selectors" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54fd03f1ad26cb6b3ec1b7414fa78a3bd639e7dbb421b1a60513c96ce886a196" +dependencies = [ + "bitflags", + "cssparser", + "log", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "smallvec", +] + +[[package]] +name = "parcel_sourcemap" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "485b74d7218068b2b7c0e3ff12fbc61ae11d57cb5d8224f525bd304c6be05bbb" +dependencies = [ + "base64-simd", + "data-url", + "rkyv", + "serde", + "serde_json", + "vlq", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "perspective" +version = "4.5.2" +dependencies = [ + "async-lock", + "axum", + "fallible-iterator", + "futures", + "indexmap 2.12.1", + "perspective-client", + "perspective-server", + "prost", + "serde", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "perspective-bundle" +version = "0.0.0" +dependencies = [ + "clap", + "wasm-bindgen-cli-support", + "wasm-opt", +] + +[[package]] +name = "perspective-client" +version = "4.5.2" +dependencies = [ + "arrow-array", + "arrow-ipc", + "arrow-schema", + "arrow-select", + "async-lock", + "futures", + "getrandom 0.3.4", + "indexmap 2.12.1", + "itertools 0.10.5", + "num-traits", + "paste", + "prost", + "prost-build", + "prost-types", + "protobuf-src", + "rand 0.9.1", + "serde", + "serde_bytes", + "serde_json", + "talc", + "thiserror 1.0.69", + "tracing", + "ts-rs", +] + +[[package]] +name = "perspective-js" +version = "4.5.2" +dependencies = [ + "anyhow", + "arrow-array", + "arrow-ipc", + "arrow-schema", + "bytes", + "chrono", + "derivative", + "extend", + "futures", + "getrandom 0.2.16", + "indexmap 2.12.1", + "js-sys", + "perspective-client", + "prost", + "serde", + "serde-wasm-bindgen", + "serde_json", + "thiserror 2.0.17", + "tracing", + "tracing-subscriber", + "ts-rs", + "wasm-bindgen", + "wasm-bindgen-derive", + "wasm-bindgen-futures", + "wasm-bindgen-test", + "web-sys", +] + +[[package]] +name = "perspective-lint" +version = "4.5.2" +dependencies = [ + "glob", + "yew-fmt", +] + +[[package]] +name = "perspective-python" +version = "4.5.2" +dependencies = [ + "async-lock", + "bytes", + "chrono", + "cmake", + "extend", + "futures", + "indexmap 2.12.1", + "macro_rules_attribute", + "num_cpus", + "perspective-client", + "perspective-server", + "pollster", + "pyo3", + "pyo3-async-runtimes", + "pyo3-build-config", + "python-config-rs", + "pythonize", + "serde", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "perspective-server" +version = "4.5.2" +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", +] + +[[package]] +name = "perspective-viewer" +version = "4.5.2" +dependencies = [ + "anyhow", + "async-lock", + "base64", + "chrono", + "console_error_panic_hook", + "derivative", + "extend", + "futures", + "glob", + "itertools 0.14.0", + "js-sys", + "lightningcss", + "macro_rules_attribute", + "nom", + "perspective-client", + "perspective-js", + "serde", + "serde-wasm-bindgen", + "serde_json", + "strum 0.26.3", + "thiserror 1.0.69", + "tracing", + "tracing-subscriber", + "ts-rs", + "wasm-bindgen", + "wasm-bindgen-derive", + "wasm-bindgen-futures", + "wasm-bindgen-test", + "web-sys", + "yew", +] + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap 2.12.1", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pinned" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a829027bd95e54cfe13e3e258a1ae7b645960553fb82b75ff852c29688ee595b" +dependencies = [ + "futures", + "rustversion", + "thiserror 1.0.69", +] + +[[package]] +name = "pollster" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "portable-atomic-util" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.111", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" +dependencies = [ + "bytes", + "heck 0.4.1", + "itertools 0.12.1", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.111", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" +dependencies = [ + "anyhow", + "itertools 0.12.1", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "prost-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0" +dependencies = [ + "prost", +] + +[[package]] +name = "protobuf-src" +version = "2.1.1+27.1" +source = "git+https://github.com/carlocorradini/rust-protobuf-native.git?rev=1aba500e469f8bdc384a0fe9e69c189fda72e059#1aba500e469f8bdc384a0fe9e69c189fda72e059" +dependencies = [ + "cmake", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "pyo3" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8970a78afe0628a3e3430376fc5fd76b6b45c4d43360ffd6cdd40bdde72b682a" +dependencies = [ + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "serde", + "unindent", +] + +[[package]] +name = "pyo3-async-runtimes" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d73cc6b1b7d8b3cef02101d37390dbdfe7e450dfea14921cae80a9534ba59ef2" +dependencies = [ + "futures", + "once_cell", + "pin-project-lite", + "pyo3", + "pyo3-async-runtimes-macros", + "tokio", +] + +[[package]] +name = "pyo3-async-runtimes-macros" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca31e43a0f205f2960208938135e37e579e61e10b36b4e7f49b0e8f60fab5b83" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "pyo3-build-config" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "458eb0c55e7ece017adeba38f2248ff3ac615e53660d7c71a238d7d2a01c7598" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7114fe5457c61b276ab77c5055f206295b812608083644a5c5b2640c3102565c" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8725c0a622b374d6cb051d11a0983786448f7785336139c3c94f5aa6bef7e50" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4109984c22491085343c05b0dbc54ddc405c3cf7b4374fc533f5c3313a572ccc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "python-config-rs" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24336969a51336e6d27256c81456d25f6b533952c60ef495b33e3288461b9484" +dependencies = [ + "semver 0.9.0", +] + +[[package]] +name = "pythonize" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597907139a488b22573158793aa7539df36ae863eba300c75f3a0d65fc475e27" +dependencies = [ + "pyo3", + "serde", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +dependencies = [ + "rand_chacha", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rust-axum" +version = "4.5.2" +dependencies = [ + "axum", + "futures", + "perspective", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver 1.0.27", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scratch" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2" + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "semver" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" +dependencies = [ + "semver-parser", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "semver-parser" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-content" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3753ca04f350fa92d00b6146a3555e63c55388c9ef2e11e09bce2ff1c0b509c6" +dependencies = [ + "serde", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "indexmap 2.12.1", + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.12.1", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shrimple-parser" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9f95dc24585a3bbb0e368d98636146cf8aed7a5f7a1e6651e2851bdb4f216f6" +dependencies = [ + "either", + "proc-macro2", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +dependencies = [ + "libc", +] + +[[package]] +name = "simd-abstraction" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cadb29c57caadc51ff8346233b5cec1d240b68ce55cf1afc764818791876987" +dependencies = [ + "outref", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "slug" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spdx" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41cf87c0efffc158b9dde4d6e0567a43e4383adc4c949e687a2039732db2f23a" +dependencies = [ + "smallvec", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] + +[[package]] +name = "strum_macros" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn 1.0.109", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.111", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "talc" +version = "4.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3ae828aa394de34c7de08f522d1b86bd1c182c668d27da69caadda00590f26d" +dependencies = [ + "lock_api", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "target-lexicon" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokise" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf97738ce15b9e9cc1671ea29b0f6c56538719e1a092d19cc2134bf144e40e" +dependencies = [ + "futures", + "gloo", + "num_cpus", + "once_cell", + "pin-project", + "pinned", + "tokio", + "tokio-stream", + "wasm-bindgen-futures", +] + +[[package]] +name = "toml" +version = "0.9.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +dependencies = [ + "indexmap 2.12.1", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.14", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.12.1", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow 0.7.14", +] + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http 1.4.0", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "tracing-core" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term 0.50.3", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "ts-rs" +version = "11.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4994acea2522cd2b3b85c1d9529a55991e3ad5e25cdcd3de9d505972c4379424" +dependencies = [ + "serde_json", + "thiserror 2.0.17", + "ts-rs-macros", +] + +[[package]] +name = "ts-rs-macros" +version = "11.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6ff59666c9cbaec3533964505d39154dc4e0a56151fdea30a09ed0301f62e2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", + "termcolor", +] + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.9.1", + "sha1", + "thiserror 2.0.17", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vlq" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65dd7eed29412da847b0f78bcec0ac98588165988a8cfe41d4ea1d429f8ccfff" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "walrus" +version = "0.24.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff282e73d21b86a9d397f42570d158eb9e5c521d0ead4d13cd049fd7cb45c467" +dependencies = [ + "anyhow", + "gimli", + "id-arena", + "leb128", + "log", + "rayon", + "walrus-macro", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "walrus-macro" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ef06db404cbaed87cb25fd2ca3a62502af485f43383c9641ffcf1479d02fffd" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-cli-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2be60cf36510aa4702ce189517229c3091f4a322a5ec2665a7737ea5956c2b3a" +dependencies = [ + "anyhow", + "base64", + "leb128", + "log", + "rustc-demangle", + "serde", + "serde_json", + "walrus", + "wasm-bindgen-shared", + "wasmparser", +] + +[[package]] +name = "wasm-bindgen-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ab8fdb87408dab27d43267c311c89135b35d13f8b3081e88451ddeff742c93a" +dependencies = [ + "js-sys", + "wasm-bindgen", + "wasm-bindgen-derive-macro", +] + +[[package]] +name = "wasm-bindgen-derive-macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2fbc080f15cb38f447d52bbae64630c2d4925a9ecb5d140d56c0910b69b4cc7" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.111", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-bindgen-test" +version = "0.3.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45649196a53b0b7a15101d845d44d2dda7374fc1b5b5e2bbf58b7577ff4b346d" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term 0.50.3", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f579cdd0123ac74b94e1a4a72bd963cf30ebac343f2df347da0b8df24cdebed2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8145dd1593bf0fb137dbfa85b8be79ec560a447298955877804640e40c2d6ea" + +[[package]] +name = "wasm-encoder" +version = "0.240.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06d642d8c5ecc083aafe9ceb32809276a304547a3a6eeecceb5d8152598bc71f" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-opt" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd87a4c135535ffed86123b6fb0f0a5a0bc89e50416c942c5f0662c645f679c" +dependencies = [ + "anyhow", + "libc", + "strum 0.24.1", + "strum_macros 0.24.3", + "tempfile", + "thiserror 1.0.69", + "wasm-opt-cxx-sys", + "wasm-opt-sys", +] + +[[package]] +name = "wasm-opt-cxx-sys" +version = "0.116.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c57b28207aa724318fcec6575fe74803c23f6f266fce10cbc9f3f116762f12e" +dependencies = [ + "anyhow", + "cxx", + "cxx-build", + "wasm-opt-sys", +] + +[[package]] +name = "wasm-opt-sys" +version = "0.116.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a1cce564dc768dacbdb718fc29df2dba80bd21cb47d8f77ae7e3d95ceb98cbe" +dependencies = [ + "anyhow", + "cc", + "cxx", + "cxx-build", +] + +[[package]] +name = "wasmparser" +version = "0.240.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b722dcf61e0ea47440b53ff83ccb5df8efec57a69d150e4f24882e4eba7e24a4" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap 2.12.1", + "semver 1.0.27", + "serde", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.48.0", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yew" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3346273ed61b636f5d84e6c696d40f380045b5565b36c5c47f8fc634b8bf5be6" +dependencies = [ + "console_error_panic_hook", + "futures", + "gloo", + "implicit-clone", + "indexmap 2.12.1", + "js-sys", + "rustversion", + "serde", + "slab", + "thiserror 2.0.17", + "tokio", + "tokise", + "tracing", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "yew-macro", +] + +[[package]] +name = "yew-fmt" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d636b752a4c40bc7c2ba3310ee22f34b36fc1b48f5a697300cec66b421c13e21" +dependencies = [ + "anyhow", + "basic-toml", + "bumpalo", + "clap", + "codespan-reporting 0.11.1", + "diffy", + "dirs", + "either", + "proc-macro2", + "quote", + "serde", + "shrimple-parser", + "syn 2.0.111", +] + +[[package]] +name = "yew-macro" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479e94d645dde3749e81d488c1d32987509dd3b8c31650fcf6e3af1f370e913b" +dependencies = [ + "once_cell", + "prettyplease", + "proc-macro-error", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.111", +] + +[[package]] +name = "zerocopy" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ac82cdc --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,56 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +[workspace] +resolver = "2" +default-members = [ + "rust/perspective", + "rust/perspective-client", + "rust/perspective-js", + "rust/perspective-python", + "rust/perspective-server", + "rust/perspective-viewer", +] +members = [ + "rust/lint", + "rust/metadata", + "rust/bundle", + "rust/perspective", + "rust/perspective-client", + "rust/perspective-js", + "rust/perspective-python", + "rust/perspective-server", + "rust/perspective-viewer", + "examples/rust-axum", +] + +[profile.dev] +panic = "abort" +opt-level = "s" + +[profile.release] +panic = "abort" +opt-level = "z" +codegen-units = 1 +lto = true +strip = true + +# These are only respected when `cargo` is invoked from the project root +[patch.crates-io] +# simd-adler32 = { git = "https://github.com/mcountryman/simd-adler32.git", rev = "b279034d9eb554c3e5e0af523db044f08d8297ba" } +protobuf-src = { git = "https://github.com/carlocorradini/rust-protobuf-native.git", rev = "1aba500e469f8bdc384a0fe9e69c189fda72e059" } +perspective-client = { path = "rust/perspective-client" } +perspective-server = { path = "rust/perspective-server" } +perspective-js = { path = "rust/perspective-js" } +perspective = { path = "rust/perspective" } +perspective-viewer = { path = "rust/perspective-viewer" } +perspective-python = { path = "rust/perspective-python" } diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..807ad8e --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,230 @@ +# Developer Guide (How to build Perspective from this repo) + +This guide will teach you everything you need to know to get started hacking on +the Perspective codebase. Please see [`CONTRIBUTING.md`](CONTRIBUTING.md) for +contribution guidelines. + +If you're coming to this project as principally a JavaScript developer, please +be aware that Perspective is quite a bit more complex than a typical NPM package +due to the mixed-language nature of the project; we've done quite a bit to make +sure the newcomer experience is as straightforward as possible, but some things +might not work the way you're used to! + +Perspective is organized as a +[monorepo](https://github.com/babel/babel/blob/master/doc/design/monorepo.md), +and uses [lerna](https://lernajs.io/) to manage dependencies. + +This guide provides instructions for both the JavaScript and Python libraries. +To switch your development toolchain between the two, use `pnpm run setup`. Once +the setup script has been run, common commands like `pnpm run build` and +`pnpm run test` automatically call the correct build and test tools. + +### System Dependencies + +`Perspective.js` and `perspective-python` **require** the following system +dependencies to be installed: + +- [CMake](https://cmake.org/) (version 3.29.5 or higher) +- [pnpm](https://pnpm.io/). + +**_This list may be non-exhaustive depending on your OS/environment; please open +a thread in +[Discussions](https://github.com/perspective-dev/perspective/discussions) if you +have any questions_** + +## Build + +Make sure you have the system dependencies installed. For specifics depending on +your OS, check the [system-specific instructions](#system-specific-instructions) +below. + +To run a build, use + +```bash +pnpm run build +``` + +If this is the first time you've built Perspective, you'll be asked to generate +a `.perspectiverc` via a short survey. This can be later re-configured via + +```bash +pnpm run setup +``` + +If everything is successful, you should be able to run any of the `examples/` +packages, e.g. `examples/blocks` like so: + +```bash +pnpm run start blocks +``` + +## `Perspective.js` + +To build the JavaScript library, which includes WebAssembly compilation, +[Emscripten](https://github.com/kripken/emscripten) and its prerequisites are +required. + +`Perspective.js` specifies its Emscripten version dependency in `package.json`, +and the correct version of Emscripten will be installed with other JS +dependencies by running `pnpm install`. + +#### Building via local EMSDK + +To build using an Emscripten install on your local system and not the Emscripten +bundled with Perspective in its `package.json`, +[install](https://emscripten.org/docs/getting_started/downloads.html) the +Emscripten SDK, then activate and export the latest `emsdk` environment via +[`emsdk_env.sh`](https://github.com/juj/emsdk): + +```bash +source emsdk/emsdk_env.sh +``` + +Deviating from this specific version of Emscripten specified in the project's +`package.json` can introduce various errors that are extremely difficult to +debug. + +To install a specific version of Emscripten (e.g. `2.0.6`): + +```bash +./emsdk install 2.0.6 +``` + +--- + +## `perspective-python` + +To build the Python library, first configure your project to build Python via +`pnpm run setup`. Then, install the requirements corresponding to your version +of python, e.g. + +```bash +pip install -r rust/perspective-python/requirements.txt +``` + +`perspective-python` supports Python 3.11 and upwards. + +### `perspective-jupyterlab` + +To install the Jupyterlab/Jupyter Notebook plugins from your local working +directory, simply install `python/perspective` with `pip` as you might normally +do. + +```bash +# builds labextension to the perspective-python python package root directory +PACKAGE=perspective-jupyterlab pnpm run build +# editable install of the python package +pnpm -F @perspective-dev/python develop:maturin +# set up symlink of our labextension to jupyter share directory +# this directory's path is in the output of `jupyter labextension list` +pnpm -F @perspective-dev/python develop:labextension +``` + +Afterwards, you should see it listed as a "local extension" when you run +`jupyter labextension list` and as a normal extension when you run +`jupyter nbextension list`. + +--- + +## System-Specific Instructions + +### MacOS/OSX + +Install system dependencies through Homebrew: + +```bash +brew install cmake llvm@17 +brew link llvm@17 # optional, see below +``` + +On M1 (Apple Silicon) systems, make sure your brew-installed dependencies are in +`/opt/homebrew` (the default location), and that `/opt/homebrew/bin` is on the +`PATH`. + +If you do not want to link the llvm@17 keg, then while developing ensure it is +on your PATH too, like this: + +``` +PATH=$(brew --prefix llvm@17)/bin:$PATH +``` + +**Note**: Perspective vendors its C++ extensions, so you may run into trouble +building if you have `brew`-installed versions of libraries, such as +`flatbuffers`. + +### Windows 10+ + +You need to use bash in order to build Perspective packages. To successfully +build on Windows 10+, enable +[Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/install-win10) +(WSL) and install the Linux distribution of your choice. + +Create symbolic links to easily access Windows directories and projects modified +via Windows. This way, you can modify any of the Perspective files using your +favorite editors on Windows and build via Linux. + +Follow the Linux specific instructions to install Emscripten and all +prerequisite tools. + +### Ubuntu/Debian + +On Ubuntu, CMake will mistakenly resolve the system headers in `/usr/include` +rather than the emscripten supplied versions. You can resolve this by moving +`boost` dependencies to somewhere other than `/usr/include` - into Perspective's +own `src` dir (as per +[here](http://vclf.blogspot.com/2014/08/emscripten-linking-to-boost-libraries.html)). + +```bash +apt-get install libboost-all-dev +cp -r /usr/include/boost ./packages/perspective/src/include/ +``` + +--- + +## Test + +You can run the test suite simply with the standard NPM command, which will both +build the test suite for every package and run them. + +```bash +pnpm run test +``` + +### JavaScript + +The JavaScript test suite is composed of two sections: a Node.js test, which +asserts behavior of the `@perspective-dev/client` library, and a suite of +[Playwright](https://playwright.dev/) tests, which assert the behavior of the +rest of the UI facing packages. + +```bash +pnpm run test --update-snapshots +``` + +### Troubleshooting installation from source + +If you are installing from a source distribution (sdist), make sure you have the +[System Dependencies](#system-dependencies) installed. + +Try installing in verbose mode: + +```bash +pip install -vv perspective-python +``` + +The most common culprits are: + +- CMake version is too old +- Boost headers are missing or too old + +--- + +## Benchmark + +You can generate benchmarks specific to your machine's OS and CPU architecture +with Perspective's benchmark suite, which will host a live dashboard at +http://localhost:8080 as well as output a result `benchmark.arrow` file. + +```bash +pnpm run bench +``` diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000..15c5d68 --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,15 @@ +# `perspective` Project Governance + +## Maintainers + +- [@texodus](https://github.com/texodus) +- [@timkpaine](https://github.com/timkpaine) + +Maintainers are responsible for issue/PR triage, feature additions, maintenance, +bugfixes, security fixes, releases, promoting existing contributors to +maintainers, managing repo and CI configuration, etc. + +## Contributors + +Anyone who contributes code or content or time, via issues or pull requests or +otherwise. Contributors do not have any additional permissions on the project. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..1ffb266 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,193 @@ +# Apache License + +_Version 2.0, January 2004_ +_<>_ + +### Terms and Conditions for use, reproduction, and distribution + +#### 1. Definitions + +“License” shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +“Licensor” shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +“Legal Entity” shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, “control” means **(i)** the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the +outstanding shares, or **(iii)** beneficial ownership of such entity. + +“You” (or “Your”) shall mean an individual or Legal Entity exercising +permissions granted by this License. + +“Source” form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +“Object” form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +“Work” shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +“Derivative Works” shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +“Contribution” shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +“submitted” means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as “Not a Contribution.” + +“Contributor” shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +#### 2. Grant of Copyright License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +#### 3. Grant of Patent License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +#### 4. Redistribution + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +- **(a)** You must give any other recipients of the Work or Derivative Works a copy of + this License; and +- **(b)** You must cause any modified files to carry prominent notices stating that You + changed the files; and +- **(c)** You must retain, in the Source form of any Derivative Works that You distribute, + all copyright, patent, trademark, and attribution notices from the Source form + of the Work, excluding those notices that do not pertain to any part of the + Derivative Works; and +- **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any + Derivative Works that You distribute must include a readable copy of the + attribution notices contained within such NOTICE file, excluding those notices + that do not pertain to any part of the Derivative Works, in at least one of the + following places: within a NOTICE text file distributed as part of the + Derivative Works; within the Source form or documentation, if provided along + with the Derivative Works; or, within a display generated by the Derivative + Works, if and wherever such third-party notices normally appear. The contents of + the NOTICE file are for informational purposes only and do not modify the + License. You may add Your own attribution notices within Derivative Works that + You distribute, alongside or as an addendum to the NOTICE text from the Work, + provided that such additional attribution notices cannot be construed as + modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +#### 5. Submission of Contributions + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +#### 6. Trademarks + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +#### 7. Disclaimer of Warranty + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +#### 8. Limitation of Liability + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +#### 9. Accepting Warranty or Additional Liability + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +_END OF TERMS AND CONDITIONS_ + +### APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets `[]` replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included on +the same “printed page” as the copyright notice for easier identification within +third-party archives. + + Copyright 2019 The Perspective Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/PROJECT_CHARTER.md b/PROJECT_CHARTER.md new file mode 100644 index 0000000..b4bd8d2 --- /dev/null +++ b/PROJECT_CHARTER.md @@ -0,0 +1,72 @@ +# `perspective` Charter + +`perspective` is a data visualization and analytics component, especially +well-suited for large and/or streaming datasets. + +## Section 0: Guiding Principles + +The `perspective` project is part of the [OpenJS Foundation][openjs foundation], +which operates transparently, openly, collaboratively, and ethically. Project +proposals, timelines, and status must not merely be open, but also easily +visible to outsiders. + +## Section 1: Scope + +`perspective` is a data visualization component, any features related to data +visualization on any platform are potentially in scope. + +## Section 2: Relationship with OpenJS Foundation CPC. + +Technical leadership for the projects within the [OpenJS +Foundation][openjs foundation] is delegated to the projects through their +project charters by the +[OpenJS Foundation Cross-Project Council](https://openjsf.org/about/governance/) +(CPC). In the case of the `perspective` project, it is delegated to the +[`perspective` Maintainers](GOVERNANCE.md#maintainers) (the “Maintainers”). The +OpenJS Foundation's business leadership is the Board of Directors (the “Board”). + +This `perspective` Charter reflects a carefully constructed balanced role for +the Maintainers and the CPC in the governance of the OpenJS Foundation. The +charter amendment process is for the Maintainers to propose changes using simple +majority of the full Maintainers, the proposed changes being subject to review +and approval by the CPC. The CPC may additionally make amendments to the project +charter at any time, though the CPC will not interfere with day-to-day +discussions, votes or meetings of the Maintainers. + +### 2.1 Other Formal Project Relationships + +Section Intentionally Left Blank + +## Section 3: `perspective`'s Maintainers Governing Body + +`perspective` is governed by its [maintainers](GOVERNANCE.md#maintainers). + +## Section 4: Roles & Responsibilities + +The roles and responsibilities of `perspective`'s Maintainers are described in +[GOVERNANCE.md](./GOVERNANCE.md). + +### Section 4.1 Project Operations & Management + +Section Intentionally Left Blank + +### Section 4.2: Decision-making, Voting, and/or Elections + +Section Intentionally Left Blank + +### Section 4.3: Other Project Roles + +Section Intentionally Left Blank + +## Section 5: Definitions + +- _Contributors_: contribute code or other artifacts, but do not have the right + to commit to the codebase. Contributors work with the project’s maintainers to + have code committed to the code base. A Contributor may be promoted to a + Maintainer by the Maintainers. Contributors should rarely be encumbered by the + Maintainers and never by the CPC or OpenJS Foundation Board. + +- _Maintainers_: Contributors with any kind of decision-making authority in the + project. + +[openjs foundation]: https://openjsf.org diff --git a/README.md b/README.md new file mode 100644 index 0000000..6df4065 --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +
+ + + + + + + +

+ +[![Build Status](https://img.shields.io/github/actions/workflow/status/perspective-dev/perspective/build.yaml?event=push&style=for-the-badge)](https://github.com/perspective-dev/perspective/actions/workflows/build.yaml) +[![npm](https://img.shields.io/npm/v/@perspective-dev/client.svg?style=for-the-badge)](https://www.npmjs.com/package/@perspective-dev/client) +[![PyPI](https://img.shields.io/pypi/v/perspective-python.svg?style=for-the-badge)](https://pypi.python.org/pypi/perspective-python) +[![crates.io](https://img.shields.io/crates/v/perspective?style=for-the-badge)](https://crates.io/crates/perspective) + +
+ +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 framework-agnostic user interface packaged as a + [Custom Element](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements), + 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 notebooks. + +## Documentation + +- [Project Site](https://perspective-dev.github.io/) +- [User Guide](https://perspective-dev.github.io/guide/) +- JavaScript API + - [`@perspective-dev/client` Browser](https://perspective-dev.github.io/browser/modules/src_ts_perspective.browser.ts.html) + - [`@perspective-dev/client` Node.js](https://perspective-dev.github.io/node/modules/src_ts_perspective.node.ts.html) + - [`@perspective-dev/client` Clickhouse Virtual Server](https://perspective-dev.github.io/browser/modules/dist_esm_virtual_servers_clickhouse.js.html) + - [`@perspective-dev/client` DuckDB Virtual Server](https://perspective-dev.github.io/browser/modules/dist_esm_virtual_servers_duckdb.js.html) + - [`@perspective-dev/viewer` Web Component](https://perspective-dev.github.io/viewer/modules/perspective-viewer.html) +- Python API + - [`perspective`](https://perspective-dev.github.io/python/index.html) + - [`perspective.widget`](https://perspective-dev.github.io/python/perspective/widget.html) + - [`perspective.handlers.aiohttp`](https://perspective-dev.github.io/python/perspective/handlers/aiohttp.html) + - [`perspective.handlers.starlette`](https://perspective-dev.github.io/python/perspective/handlers/starlett.html) + - [`perspective.handlers.tornado`](https://perspective-dev.github.io/python/perspective/handlers/tornado.html) + - [`perspective.virtual_servers.clickhouse`](https://perspective-dev.github.io/python/perspective/virtual_servers/clickhouse.html) + - [`perspective.virtual_servers.duckdb`](https://perspective-dev.github.io/python/perspective/virtual_servers/duckdb.html) +- Rust API + - [`perspective`](https://docs.rs/perspective/latest/perspective/) + - [`perspective-client`](https://docs.rs/perspective-client/latest/perspective_client/) + - [`perspective-server`](https://docs.rs/perspective-server/latest/perspective_server/) + - [`perspective-python`](https://docs.rs/perspective-python/latest/perspective_python/) + - [`perspective-js`](https://docs.rs/perspective-js/latest/perspective_js/) + - [`perspective-viewer`](https://docs.rs/perspective-viewer/latest/perspective_viewer/) + +## Examples + + +
editablefileduckdb
fractalmarketraycasting
evictionsnypdstreaming
covidwebcammovies
superstoreolympicsdataset
+ + +## Media + + + + + + + + + + + + + + + + + + + + + + +
@timkpaine@timbess@sc1f
@texodus@texodus


+ +--- + +
+ + + + +
+
+
+ +The Perspective project is a member of the +[The OpenJS Foundation](https://openjsf.org/). + +Copyright [OpenJS Foundation](https://openjsf.org) and Perspective contributors. +All rights reserved. The [OpenJS Foundation](https://openjsf.org) has registered +trademarks and uses trademarks. For a list of trademarks of the +[OpenJS Foundation](https://openjsf.org), please see our +[Trademark Policy](https://trademark-policy.openjsf.org/) and +[Trademark List](https://trademark-list.openjsf.org/). Trademarks and logos not +indicated on the +[list of OpenJS Foundation trademarks](https://trademark-list.openjsf.org) are +trademarks™ or registered® trademarks of their respective holders. Use of them +does not imply any affiliation with or endorsement by them. + +[The OpenJS Foundation](https://openjsf.org/) | +[Terms of Use](https://terms-of-use.openjsf.org/) | +[Privacy Policy](https://privacy-policy.openjsf.org/) | +[Bylaws](https://bylaws.openjsf.org/) | +[Code of Conduct](https://code-of-conduct.openjsf.org) | +[Trademark Policy](https://trademark-policy.openjsf.org/) | +[Trademark List](https://trademark-list.openjsf.org/) | +[Cookie Policy](https://www.linuxfoundation.org/cookies/) diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..6cae066 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`perspective-dev/perspective` +- 原始仓库:https://github.com/perspective-dev/perspective +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..af61794 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,78 @@ +# Security Policy + +## Supported Versions + +Security updates are applied only to the latest release. + +## Reporting a Vulnerability + +To report a security issue, please use the GitHub Security Advisory +["Report a Vulnerability"](https://github.com/perspective-dev/perspective/security/advisories/new) +tab. + +Report security bugs in third-party modules to the person or team maintaining +the module. You can also report a vulnerability through the +[npm contact form](https://www.npmjs.com/support) by selecting "I'm reporting a +security vulnerability". + +## Escalation + +If you do not receive an acknowledgement of your report within 6 business days, +or if you cannot find a private security contact for the project, you may +escalate to the OpenJS Foundation CNA at `security@lists.openjsf.org`. + +If the project acknowledges your report but does not provide any further +response or engagement within 14 days, escalation is also appropriate. + +## Threat Model + +The Perspective WebSocket `Server` (the Python `tornado.py`/`aiohttp.py`/ +`starlette.py` adapters and the Node `WebSocketServer`) is not a security +boundary against its `Client`. Any `Client` that can send messages to a +`Server` is treated as the author of the queries it submits, and is permitted +to create or delete `Table`/`View` resources, author arbitrary +[expression columns](./docs/md/explanation/view/config/expressions.md), and — +for `Virtual Server` backends (DuckDB, ClickHouse, Polars, custom +`VirtualServerHandler`) — author SQL fragments executed under the configured +database role. The `Virtual Server` SQL builder does not parameterize or +validate client-supplied identifiers, expressions, or operators, because +there is no privilege boundary inside the engine for it to enforce. + +The bundled WebSocket adapters above are reference integrations: they do not +implement authentication, authorization, CSRF protection, rate limiting, or +origin enforcement, and are not intended to be exposed directly to untrusted +networks. Production deployments must place an authenticating reverse proxy, +application-framework middleware, or API gateway between the network and the +`Server`. + +### In-browser WASM deployments are not affected + +This applies only when the `Server` runs in a separate process reached +over a network transport (WebSocket). In-browser deployments — including +`perspective` running entirely in a Web Worker, the +[`perspective-server` WASM build](./docs/md/explanation/architecture.md), +[`duckdb-wasm`](./docs/md/how_to/javascript/virtual_server/duckdb.md), +and any other `Virtual Server` whose backend executes inside the browser +tab — do not have this concern. The `Client` and `Server` share a single +security context (the browser tab, under the same-origin policy of the +embedding page), there is no network transport for a third-party principal +to reach, and the only principal who can submit queries is the same user who +loaded the page. SQL or expression "injection" by that user against a backend +running inside their own tab is not a privilege escalation. + +### In scope + +The following remain in scope for security reports: + +- Memory-safety bugs in the C++ engine, Rust crates, or WASM module. +- Bugs in the `` Shadow DOM, CSS, or sanitization paths + that allow injected markup or styles to escape the component or affect + the embedding page. +- Crashes, hangs, panics, or denial-of-service in the engine reachable from + well-formed protobuf messages. +- Breaches of the trust model above — for example, a `Client` causing effects + on a different `Client`'s `Server` state in a configuration where those + `Client`s share a `Server` but are intended to be isolated, or an + expression column reaching state outside the `Server` it was authored + against. +- Vulnerabilities in the published artifacts themselves (supply-chain). diff --git a/docs/book.toml b/docs/book.toml new file mode 100644 index 0000000..a988f42 --- /dev/null +++ b/docs/book.toml @@ -0,0 +1,39 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +[book] +authors = ["Andrew Stein"] +language = "en" +src = "md" +title = "Perspective" + +[build] +build-dir = "static/guide" + +[output.html] +# theme = "my-theme" +# default-theme = "light" +# preferred-dark-theme = "navy" +# smart-punctuation = true +# mathjax-support = false +git-repository-url = "https://github.com/perspective-dev/perspective" +site-url = "https://perspective-dev.github.io/guide/" +additional-css = [ + "md/perspective.css", + "node_modules/@perspective-dev/viewer/dist/css/themes.css", +] +# additional-js = [] +# no-section-label = false +# edit-url-template = "https://github.com/rust-lang/mdBook/edit/master/guide/{path}" +# site-url = "/guide/" +# cname = "myproject.rs" +# input-404 = "not-found.md" diff --git a/docs/build.config.mjs b/docs/build.config.mjs new file mode 100644 index 0000000..0dec5d7 --- /dev/null +++ b/docs/build.config.mjs @@ -0,0 +1,176 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 esbuild from "esbuild"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { createRequire } from "module"; +import { bundleAsync as bundleCssAsync, composeVisitors } from "lightningcss"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DIST = path.join(__dirname, "dist"); + +function copyRecursive(src, dest) { + if (!fs.existsSync(src)) return; + const stat = fs.statSync(src); + if (stat.isDirectory()) { + fs.mkdirSync(dest, { recursive: true }); + for (const child of fs.readdirSync(src)) { + copyRecursive(path.join(src, child), path.join(dest, child)); + } + } else { + fs.copyFileSync(src, dest); + } +} + +// Inline url() asset references as data URIs. +export function inlineUrlVisitor(fromFile) { + const dir = path.dirname(fromFile); + return composeVisitors([ + { + Url(url) { + const ext = path.extname(url.url).toLowerCase(); + if (![".svg", ".png", ".gif"].includes(ext)) { + return; + } + + const resolved = path.resolve(dir, url.url); + if (!fs.existsSync(resolved)) { + throw new Error(`File not found ${url.url}`); + // return; + } + + const content = fs.readFileSync(resolved); + const mime = + ext === ".svg" + ? "image/svg+xml" + : ext === ".png" + ? "image/png" + : "image/gif"; + + const new_content = content + .toString("base64") + .split("\n") + .map((x) => x.trim()) + .join(""); + + return { + url: `data:${mime};base64,${new_content}`, + loc: url.loc, + }; + }, + }, + ]); +} + +export const resolveNPM = (url) => ({ + read(filePath) { + if (filePath.startsWith("http")) { + return `@import url("${filePath}");`; + } + + return fs.readFileSync(filePath, "utf8"); + }, + resolve(specifier, from) { + if (specifier.startsWith("http")) { + return { external: specifier }; + } + + const _require = createRequire(url); + + if (specifier.startsWith(".") || specifier.startsWith("/")) { + return path.resolve(path.dirname(from), specifier); + } + + return _require.resolve(specifier); + }, +}); + +async function build() { + // Clean and create dist + fs.mkdirSync(DIST, { recursive: true }); + + // Bundle CSS + const { code: cssCode } = await bundleCssAsync({ + filename: path.join(__dirname, "./src/css/style.css"), + minify: true, + resolver: resolveNPM(import.meta.url), + visitor: inlineUrlVisitor("./src/css/style.css"), + }); + + fs.mkdirSync(path.join(DIST, "css"), { recursive: true }); + fs.writeFileSync(path.join(DIST, "style.css"), cssCode); + + // Bundle JS entry points + await esbuild.build({ + entryPoints: [ + path.join(__dirname, "src/index.ts"), + path.join(__dirname, "src/examples.ts"), + path.join(__dirname, "src/block.ts"), + ], + bundle: true, + splitting: true, + format: "esm", + outdir: DIST, + minify: true, + sourcemap: true, + target: ["es2022"], + define: { + global: "window", + }, + loader: { + ".wasm": "file", + ".arrow": "file", + }, + }); + + // Copy HTML files + for (const html of ["index.html", "examples.html", "block.html"]) { + fs.copyFileSync( + path.join(__dirname, "src", html), + path.join(DIST, html), + ); + } + + // Copy static assets + copyRecursive(path.join(__dirname, "static"), DIST); + + // Generate blocks manifest + const blocksDir = path.join(DIST, "blocks"); + if (fs.existsSync(blocksDir)) { + const manifest = {}; + for (const example of fs.readdirSync(blocksDir)) { + const exDir = path.join(blocksDir, example); + if (!fs.statSync(exDir).isDirectory()) continue; + manifest[example] = fs + .readdirSync(exDir) + .filter( + (f) => + !f.startsWith(".") && + !f.endsWith(".png") && + !f.endsWith(".arrow"), + ); + } + fs.writeFileSync( + path.join(blocksDir, "manifest.json"), + JSON.stringify(manifest), + ); + } + + console.log("Build complete: dist/"); +} + +build().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/docs/build.mjs b/docs/build.mjs new file mode 100644 index 0000000..1df02a3 --- /dev/null +++ b/docs/build.mjs @@ -0,0 +1,157 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 puppeteer from "puppeteer"; +import * as fs from "node:fs"; +import * as cp from "node:child_process"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +import EXAMPLES from "./src/data/features.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// features.js uses CJS exports.default, import it dynamically +// const EXAMPLES = (await import("./src/data/features.ts")).default; + +const perspective = import( + "@perspective-dev/client/dist/esm/perspective.node.js" +); + +const DEFAULT_VIEWPORT = { + width: 400, + height: 300, +}; + +async function run_with_theme(page, is_dark = false, order) { + await page.goto("http://localhost:8080/"); + await page.setContent(template(is_dark)); + await page.setViewport(DEFAULT_VIEWPORT); + await page.evaluate(async () => { + while (!window.__TEST_PERSPECTIVE_READY__) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + }); + + await page.evaluate(async function () { + const viewer = document.querySelector("perspective-viewer"); + await viewer.flush(); + await viewer.toggleConfig(); + }); + + for (const idx in EXAMPLES) { + const { config, viewport } = EXAMPLES[idx]; + await page.setViewport(viewport || DEFAULT_VIEWPORT); + const new_config = Object.assign( + { + plugin: "Datagrid", + group_by: [], + expressions: {}, + split_by: [], + sort: [], + aggregates: {}, + }, + config, + ); + console.log(JSON.stringify(new_config)); + + await page.evaluate(async (config) => { + const viewer = document.querySelector("perspective-viewer"); + await viewer.reset(); + await viewer.restore(config); + }, new_config); + + const screenshot = await page.screenshot({ + captureBeyondViewport: false, + fullPage: true, + }); + + const name = `static/features/feature_${idx}${ + is_dark ? "_dark" : "" + }.png`; + + fs.writeFileSync(name, screenshot); + cp.execSync(`convert ${name} -resize 200x150 ${name}`); + } + + const suffix = is_dark ? "_dark" : ""; + const montage_files = order.map( + (idx) => `static/features/feature_${idx}${suffix}.png`, + ); + + cp.execSync( + `montage -mode concatenate -background none -tile 5x ${montage_files.join( + " ", + )} static/features/montage${is_dark ? "_dark" : "_light"}.png`, + ); +} + +async function run() { + if ( + !fs.existsSync("static/features") || + fs.readdirSync("static/features").length === 0 + ) { + console.log("Generating feature screenshots!"); + fs.mkdirSync(path.join(__dirname, "static/features"), { + recursive: true, + }); + + const x = await perspective; + const server = new x.WebSocketServer({ + assets: [ + path.join(__dirname, "."), + path.join(__dirname, "../node_modules"), + ], + }); + + const indices = Array.from({ length: EXAMPLES.length }, (_, i) => i); + for (let i = indices.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [indices[i], indices[j]] = [indices[j], indices[i]]; + } + + const browser = await puppeteer.launch({ headless: true }); + const page = await browser.newPage(); + await run_with_theme(page, false, indices); + await run_with_theme(page, true, indices); + await page.close(); + await browser.close(); + await server.close(); + + fs.writeFileSync( + path.join(__dirname, "static/features/montage_map.json"), + JSON.stringify({ + tile_width: 200, + tile_height: 150, + columns: 5, + order: indices, + }), + ); + } + + if (!fs.existsSync("static/blocks")) { + fs.mkdirSync("static/blocks"); + } + + const { dist_examples } = await import("../examples/blocks/index.mjs"); + await dist_examples(`${__dirname}/static/blocks`); +} + +function template(is_dark) { + return fs + .readFileSync(path.join(__dirname, "template.html")) + .toString() + .replace("/css/pro.css", is_dark ? "/css/pro-dark.css" : "/css/pro.css") + .trim(); +} + +run(); diff --git a/docs/compose.yml b/docs/compose.yml new file mode 100644 index 0000000..801c87a --- /dev/null +++ b/docs/compose.yml @@ -0,0 +1,16 @@ +services: + mdbook: + container_name: mdbook + image: peaceiris/mdbook:v0.5.0 + stdin_open: true + tty: true + ports: + - 3000:3000 + - 3001:3001 + volumes: + - ${PWD}/..:/repo + working_dir: /repo/docs + command: + - serve + - --hostname + - "0.0.0.0" diff --git a/docs/deploy.mjs b/docs/deploy.mjs new file mode 100644 index 0000000..bc78605 --- /dev/null +++ b/docs/deploy.mjs @@ -0,0 +1,68 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 "node:fs"; +import * as path from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, ".."); +const DIST = path.join(__dirname, "dist"); +const STAGING = path.join(REPO_ROOT, "dist-gh-pages"); +const BRANCH = "gh-pages"; + +function git(args, opts = {}) { + return execFileSync("git", args, { + stdio: "inherit", + cwd: REPO_ROOT, + ...opts, + }); +} + +function copyRecursive(src, dest) { + const stat = fs.statSync(src); + if (stat.isDirectory()) { + fs.mkdirSync(dest, { recursive: true }); + for (const child of fs.readdirSync(src)) { + copyRecursive(path.join(src, child), path.join(dest, child)); + } + } else { + fs.copyFileSync(src, dest); + } +} + +if (!fs.existsSync(DIST)) { + console.error(`Missing ${DIST} — run \`npm run build\` first.`); + process.exit(1); +} + +if (!fs.existsSync(STAGING)) { + git(["worktree", "add", STAGING, BRANCH]); +} else { + git(["fetch", "origin", BRANCH]); + git(["checkout", `origin/${BRANCH}`], { cwd: STAGING }); +} + +// Clear tracked + untracked content in the staging worktree, preserving +// the worktree's `.git` link. +git(["rm", "-rf", "--quiet", "--ignore-unmatch", "."], { cwd: STAGING }); +git(["clean", "-fdx"], { cwd: STAGING }); + +for (const entry of fs.readdirSync(DIST)) { + copyRecursive(path.join(DIST, entry), path.join(STAGING, entry)); +} + +git(["add", "-A"], { cwd: STAGING }); + +console.log(`Staged dist/ onto ${BRANCH} at ${STAGING}`); +console.log(`Review with \`git -C ${STAGING} status\`, then commit and push.`); diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..38c0950 --- /dev/null +++ b/docs/index.html @@ -0,0 +1 @@ + diff --git a/docs/md/FAQ.md b/docs/md/FAQ.md new file mode 100644 index 0000000..2174498 --- /dev/null +++ b/docs/md/FAQ.md @@ -0,0 +1,571 @@ +# FAQ + +## Installation + +### Python installation fails on Windows + +Python wheels are published for supported Python versions and platforms. On +Windows, ensure you have a compatible Python version and architecture. Install +with: + +```bash +pip install perspective-python +``` + +If you encounter C++ binding errors or link errors, make sure you are using a +supported Python version and that your `pip` is up to date. Pre-built wheels +eliminate the need for a C++ compiler in most cases. + + + +### Python `import perspective` fails with `ImportError` or undefined symbol + +This typically happens when the C++ shared library (`libpsp.so`) cannot be found +or was built against a different Python version. Ensure your Python version +matches the installed wheel. On Linux, verify that required system libraries are +present. If you see errors about `libpsp.so` or undefined symbols, try +reinstalling in a clean virtual environment. + + + +### Python installation fails on macOS + +On Apple Silicon (M1/M2/M3), make sure you are using a native ARM Python build, +not one running under Rosetta. The published wheels include `aarch64` variants +for supported platforms. + + + +### How do I install Perspective in a Docker container? + +Perspective's Python wheels are built against `manylinux_2_28` containers (see +[`.github/workflows/build.yaml`](../../.github/workflows/build.yaml)), so they +are compatible with most Linux distributions based on glibc 2.28+ (e.g., Debian +10+, Ubuntu 20.04+, RHEL 8+). Use a compatible base image: + +```dockerfile +FROM python:3.12-slim +RUN pip install perspective-python +``` + +Alpine Linux uses musl instead of glibc and is **not** compatible with the +published wheels. + + + +## JavaScript Bundling + +### How do I use Perspective with Vite, Webpack, or esbuild? + +Perspective no longer exports bundler plugins. Instead, you must manually +bootstrap the WASM binaries using your bundler's asset handling. See +[Importing with or without a bundler](./how_to/javascript/importing.md) for +complete examples for Vite, Webpack, esbuild, CDN, and inline builds. + + + +## Framework Integration + +### How do I use Perspective with React? + +Perspective provides a dedicated +[React component](./how_to/javascript/react.md). You must also still initialize +Perspective's WebAssembly as per your bundler — see +[Importing with or without a bundler](./how_to/javascript/importing.md). + + + +### How do I use Perspective with Next.js? + +Perspective relies on Web Workers and WASM, which require client-side rendering. +Use dynamic imports with `ssr: false` in Next.js to load Perspective components +only on the client. + + + +### How do I use Perspective with Vue.js/Angular/etc? + +As a standard Web Component, `` works in most JavaScript web +frameworks directly via standard HTML/DOM APIs, but does not have dedicated +integration libraries for these frameworks. + + + +## Expressions + +### How do I create computed/expression columns? + +Use the [`expressions`](./explanation/view/config/expressions.md) config option +in your `View` to define new columns with ExprTK syntax, which must then be +_used_ somewhere else in your config (like `columns`) to actually be visible & +calculated. In ``, expression columns can be created from +the UI column sidebar by clicking the "New Column" button. + + + +### Can I reference one expression column from another? + +No, you must duplicate calculations that are shared between expression columns. + + + +### Can I do date arithmetic in expressions? + +Yes, but they must be converted to `float` values first (`integer` is an `i32` +which is too small). See +[Expressions](./explanation/view/config/expressions.md). + + + +### Can I do rolling sums or cumulative calculations? + +Not in Perspective's built-in engine, but as an alternative, DuckDB supports +[rolling and cumulative sums via `WINDOW` functions](https://duckdb.org/docs/stable/sql/functions/window_functions), +and DuckDB now has +[native Perspective Virtual Server support](./explanation/virtual_servers.md) +which allows arbitrary DuckDB queries (as a `TABLE` or `VIEW`) to be +`` `Table`s. + + + +## Filters + +### Can I compose filters with OR logic? + +Perspective +[filters](./explanation/view/config/selection_and_ordering.md#filter) are +composed with AND logic by default. As an alternative, you can use +[expression columns](./explanation/view/config/expressions.md) to create a +boolean column that encodes your OR logic (or any arbitrary multi-column +predicate), then filter on that column: + +```javascript +const view = await table.view({ + expressions: { + or_filter: + "if (\"State\" == 'Texas') true; else if (\"State\" == 'California') true; else false", + }, + filter: [["or_filter", "==", true]], +}); +``` + + + +### How do I update filters programmatically? + +Set the [`filter`](./explanation/view/config/selection_and_ordering.md#filter) +property on a `View` config, or use the `` +[`.restore()`](./how_to/javascript/save_restore.md) method to update filters at +runtime. + + + +### Does date filtering support ranges? + +Date columns can be +[filtered](./explanation/view/config/selection_and_ordering.md#filter) with +comparison operators (`>`, `<`, `>=`, `<=`) to achieve range-based filtering. +Apply two filters on the same date column for a range. + + + +## JupyterLab + +### `PerspectiveWidget` is not loading in JupyterLab + +See the [`PerspectiveWidget` guide](./how_to/python/jupyterlab.md) for full +setup details. Ensure the JupyterLab extension version matches your +`perspective-python` version. Make sure you are using a compatible JupyterLab +for your Perspective version (JupyterLab 4+ currently). + +Check that the extension is enabled with `jupyter labextension list`. + + + +## Memory and Performance + +### Perspective has a memory leak + +Maybe, but please review the +[Cleaning up resources](./how_to/javascript/deleting.md) docs carefully before +opening an Issue reporting it (and of course review +[`CONTRIBUTING.md`](https://github.com/perspective-dev/perspective/blob/master/CONTRIBUTING.md) +before opening _any_ Issue). Ensure you call `.delete()` on Views, Tables, and +`` instances when they are no longer needed, in reverse +dependency order. + + + +### How many rows can Perspective's built-in engine handle? + +Perspective is designed for large datasets and can handle millions of rows +depending on the number of columns and available memory. Performance also +significantly depends on column types (`"string"` being slower and larger than +other types due to dictionary interning). + +For larger datasets or out-of-memory virtualized datasets, see +[Virtual Servers](./explanation/virtual_servers.md). + + + +### How do I control threading in `perspective-python`? + +The Python library uses a thread pool internally. For advanced threading +control, consult the +[multithreading documentation](./how_to/python/multithreading.md). + + + +## Theming and Styling + +### How do I enable dark theme? + +Import `themes.css` (see [Theming](./how_to/javascript/theming.md)) and set the +theme via `restore()`: + +```javascript +await viewer.restore({ theme: "Pro Dark" }); +``` + +Or import just the dark theme directly: +`import "@perspective-dev/viewer/dist/css/pro-dark.css";` + + + +### Can I create a custom cell renderer for the datagrid? + +The datagrid plugin supports custom styling via +[`column_config`](https://perspective-dev.github.io/viewer/types/src_ts_ts-rs_ColumnConfigValues.ts.ColumnConfigValues.html) +and CSS custom properties, but custom cell renderers require building a custom +plugin. + + + +### How do I customize chart colors? + +Chart colors can be customized via +[CSS custom properties](./how_to/javascript/theming.md#custom-themes) on the +`` element. + + + +## Streaming and Real-Time Updates + +### How do I stream data into a Perspective table? + +Use [`table.update()`](./explanation/table/update_and_remove.md) to push new +data incrementally. For [indexed](./explanation/table/options.md) tables, +updates with matching index values will replace existing rows. + + + +### `table.update()` raises "No Running Event Loop" + +Perspective 3+ is now threadsafe by default and no longer requires special loop +integration. + + + +### How do I listen for data updates? + +Use `view.on_update()` to register a callback that fires when the underlying +table data changes. See [Listening for events](./how_to/javascript/events.md) +and [Advanced View Operations](./explanation/view/advanced.md#update-callbacks). + + + +## Server Architecture + +### What is the difference between Client-only, Client/Server, and Server-only modes? + +- **Client-only**: The Perspective engine runs entirely in the browser via WASM. + Best for small to medium datasets. +- **Client/Server (replicated)**: Data is hosted on a server and replicated to + the client. The client has a full copy and performs queries locally. +- **Server-only**: All queries are executed on the server. The client only + renders results. Best for very large datasets. + +See [Data Architecture](./explanation/architecture.md) for detailed explanations +of each mode. + + + +### Is the WebSocket Perspective `Server` safe to expose to untrusted clients? + +No. The WebSocket `Server` is not a security boundary. Every connected `Client` +is treated as the author of the queries it submits, and is permitted to create +and delete `Table`/`View` resources, author arbitrary +[expression columns](./explanation/view/config/expressions.md), and — for +[Virtual Server](./explanation/virtual_servers.md) backends like DuckDB or +ClickHouse — author SQL fragments executed under the configured database +role. The bundled WebSocket adapters +(`tornado.py`/`aiohttp.py`/`starlette.py`/`WebSocketServer`) are reference +integrations and do not authenticate, authorize, or enforce origin policy. + +WebSocket Deployments that need per-user isolation must put an authenticating +proxy in front of the `Server`, run a least-privileged database role for any +`Virtual Server` backend, and/or isolate users into separate `Server` +instances. See [`SECURITY.md`](../../SECURITY.md) for the full threat model +and deployment guidance. + +Obviously, none of this applies to WASM DBs like Perspective and DuckDB. + +### Does Perspective sanitize SQL `Virtual Server`s? + +No, by design. [Virtual Server](./explanation/virtual_servers.md) backends +interpolate client-supplied `view_id`, `table_id`, `column_name`, expression +strings, and filter operators directly into SQL templates without +parameterization or whitelist validation. The `Client` is the author of the +queries — there is no privilege boundary inside the engine for sanitization +to enforce. If your deployment needs to restrict the SQL surface area exposed +to a `Client`, the supported boundary is the database role the `Virtual Server` +is configured with (read-only etc), or better complete isolation via WASM +backend. + +### How do I set up WebSocket authentication? + +The [`WebSocketServer`](./how_to/javascript/nodejs_server.md) does not include +built-in authentication. Implement authentication at the transport layer (e.g., +via middleware in your HTTP server) before the WebSocket upgrade. For more +complex needs, `WebSocketServer` is a simple example server based on the +`node:http` module which can serve as a starting point for a custom server. + + + +### Can I bind Perspective to a database? + +Perspective supports [Virtual Servers](./explanation/virtual_servers.md) that +proxy queries to external data sources, with built-in implementations for e.g. +[DuckDB](./how_to/javascript/virtual_server/duckdb.md). + + + +## Aggregation + +### Can I apply multiple aggregates to the same column? + +Yes, by creating a duplicate/alias for your column via +[`expressions`](./explanation/view/config/expressions.md): + +```javascript +await viewer.restore({ + columns: ["Sales", "Sales 2"], + expresions: { "Sales 2": '"Sales"' }, + aggregate: { + Sales: "sum", + "Sales 2": "avg", + }, +}); +``` + + + +### Can I compute a ratio between aggregated columns? + +Use [expression columns](./explanation/view/config/expressions.md) on an +aggregated View to compute ratios. Define an expression that divides one column +by another. + + + +## Data Loading and Arrow + +### How do I load Apache Arrow data into Perspective? + +Perspective natively accepts +[Apache Arrow format](./explanation/table/loading_data.md). Pass an +`ArrayBuffer` containing Arrow IPC data directly to `table()` or +`table.update()`. + + + +### What data formats does Perspective accept? + +Perspective accepts (see [Loading data](./explanation/table/loading_data.md)): + +- **JavaScript**: JSON (row-oriented or column-oriented objects), CSV strings, + Apache Arrow `ArrayBuffer` +- **Python**: `dict`, `list`, `pandas.DataFrame`, `pyarrow.Table`, CSV strings, + Apache Arrow bytes + + + +### CSV update fails but CSV creation works + +When updating a table created with a schema, ensure the CSV column names and +types match the schema exactly. Mismatched column names or types will cause +update failures. + + + +## Export + +### Can I export the viewer to HTML, PNG or PDF? + +HTML and PNG exports are available via `viewer.export("html")` and +`viewer.export("png")`, respectively. For PDF, render the viewer and use browser +or headless browser screenshot capabilities. + + + +### Can I export data to Excel? + +Perspective does not have built-in Excel export. Export data via +`view.to_csv()`, `view.to_json()`, or `view.to_arrow()` (see +[Serializing data](./how_to/javascript/serializing.md)) and convert to Excel +using a library like `xlsx` (JavaScript) or `openpyxl` (Python). + + + +### How do I copy data from a cell or row? + +Use the `"text"` export mode when data is selected: +`await viewer.export("text")`. + + + +## Table Operations + +### `table.remove()` does not update the viewer + +The [`remove()`](./explanation/table/update_and_remove.md) method requires an +[indexed](./explanation/table/options.md) table. Ensure your table was created +with an `index` option, and pass the index values to remove. + + + +## Viewer Configuration + +### How do I save and restore the viewer state? + +Use +[`viewer.save()` and `viewer.restore()`](./how_to/javascript/save_restore.md) to +serialize and deserialize the full viewer configuration. + + + +### Can I hide the configuration panel? + +The settings panel can be toggled programmatically via +`await viewer.restore({ settings: false })`. + + + +### Can I collapse row groups by default? + +Row group can be closed imperatively via +[`view.set_depth()`](./explanation/view/advanced.md). Expansion state is not +persisted or configurable via the `save`/`restore` API currently. + + + +## Internationalization + +### Can I change the UI language? + +Perspective's UI text is defined via CSS variables, which can be customized per +theme. See the +[Icons and Translation](./how_to/javascript/theming.md#icons-and-translation) +section of the theming guide for details. + + + +## Rust + +### How do I build Perspective from Rust? + +See the [Getting Started](./how_to/rust.md) guide for Rust. The Rust crate wraps +the C++ engine and requires a C++ toolchain. You need `cmake` installed and on +your path to build the engine. + + + +## Miscellaneous + +### Can I use Perspective without ``? + +Yes. The `perspective` library (data engine) can be used independently for +server-side data processing without any UI. Use +[`table()` and `view()`](./how_to/javascript/worker.md) directly to query data. + + + +### Can I use Perspective in Pyodide? + +There is an emscripten wheel +[published via Releases](https://github.com/perspective-dev/perspective/releases), +but it must be downloaded and hosted manually and is only built for specific +pyodide versions. + + + +### How do I handle row selection events? + +Listen for +[`perspective-click` and `perspective-select`](./how_to/javascript/events.md) +events on the `` element. + + diff --git a/docs/md/SUMMARY.md b/docs/md/SUMMARY.md new file mode 100644 index 0000000..a6948f6 --- /dev/null +++ b/docs/md/SUMMARY.md @@ -0,0 +1,83 @@ +# Summary + +[What is Perspective](./perspective.md) + +# Concepts + +- [Data Architecture](./explanation/architecture.md) + - [Client-only](./explanation/architecture/client_only.md) + - [Client/Server replicated](./explanation/architecture/client_server.md) + - [Server only](./explanation/architecture/server_only.md) +- [Virtual Servers](./explanation/virtual_servers.md) +- [`Table`](./explanation/table.md) + - [Schema and column types](./explanation/table/schema.md) + - [Loading data](./explanation/table/loading_data.md) + - [Construct an empty `Table` from a schema](./explanation/table/constructing_schema.md) + - [`index` and `limit` options](./explanation/table/options.md) + - [`update()` and `remove()` streaming methods](./explanation/table/update_and_remove.md) + - [`clear()` and `replace()` start-over methods](./explanation/table/clear_and_replace.md) +- [`View`](./explanation/view.md) + - [Querying data](./explanation/view/querying.md) + - [Grouping and Pivots](./explanation/view/config/grouping_and_pivots.md) + - [Selection and Ordering](./explanation/view/config/selection_and_ordering.md) + - [`expressions`](./explanation/view/config/expressions.md) + - [Advanced View Operations](./explanation/view/advanced.md) +- [`Join`](./explanation/join.md) + - [Join Types](./explanation/join/join_types.md) + - [Join Options](./explanation/join/options.md) + - [Reactivity and Constraints](./explanation/join/reactivity.md) + +# JavaScript + +- [Installation and Module Structure](./how_to/javascript/installation.md) +- [Importing with or without a bundler](./how_to/javascript/importing.md) +- [`perspective` data engine library](./how_to/javascript/worker.md) + - [Serializing data](./how_to/javascript/serializing.md) + - [Cleaning up resources](./how_to/javascript/deleting.md) + - [Hosting a `WebSocketServer` in Node.js](./how_to/javascript/nodejs_server.md) + - [Customizing `perspective.worker()`](./how_to/javascript/custom_worker.md) + - [Joining Tables](./how_to/javascript/join.md) +- [`perspective-viewer` Custom Element library](./how_to/javascript/viewer.md) + - [Loading data](./how_to/javascript/loading_data.md) + - [Theming](./how_to/javascript/theming.md) + - [Saving and restoring UI state](./how_to/javascript/save_restore.md) + - [Listening for events](./how_to/javascript/events.md) + - [Plugin render limits](./how_to/javascript/plugin_settings.md) +- [Virtual Servers](./how_to/javascript/virtual_server.md) + - [DuckDB](./how_to/javascript/virtual_server/duckdb.md) + - [ClickHouse](./how_to/javascript/virtual_server/clickhouse.md) + - [Custom](./how_to/javascript/virtual_server/custom.md) +- [React Component](./how_to/javascript/react.md) + +# Python + +- [Overview](./explanation/python.md) +- [Installation](./how_to/python/installation.md) +- [Loading data into a `Table`](./how_to/python/table.md) + - [`pandas`, `polars` and `pyarrow` integration](./how_to/python/table_data.md) +- [Callbacks and events](./how_to/python/callbacks.md) +- [Multithreading](./how_to/python/multithreading.md) +- [Hosting a WebSocket server](./how_to/python/websocket.md) +- [Joining Tables](./how_to/python/join.md) +- [`PerspectiveWidget` for JupyterLab](./how_to/python/jupyterlab.md) +- [Virtual Servers](./how_to/python/virtual_server.md) + - [DuckDB](./how_to/python/virtual_server/duckdb.md) + - [ClickHouse](./how_to/python/virtual_server/clickhouse.md) + - [Polars](./how_to/python/virtual_server/polars.md) + - [Custom](./how_to/python/virtual_server/custom.md) + +# Rust + +- [Getting Started](./how_to/rust.md) + +# Tutorials + +- [A `tornado` server in Python](./tutorials/python/tornado.md) + +# API Reference + +- [API Reference](./api_reference.md) + +# FAQ + +- [FAQ](./FAQ.md) diff --git a/docs/md/api_reference.md b/docs/md/api_reference.md new file mode 100644 index 0000000..d91655b --- /dev/null +++ b/docs/md/api_reference.md @@ -0,0 +1,22 @@ +# API Reference + +Perspective's complete API is hosted on `docs.rs`: + +- Python API + - [`perspective`](https://perspective-dev.github.io/python/index.html) + - [`perspective.widget`](https://perspective-dev.github.io/python/perspective/widget.html) + - [`perspective.handlers.aiohttp`](https://perspective-dev.github.io/python/perspective/handlers/aiohttp.htm) + - [`perspective.handlers.starlette`](https://perspective-dev.github.io/python/perspective/handlers/starlett.htm) + - [`perspective.handlers.tornado`](https://perspective-dev.github.io/python/perspective/handlers/tornado.htm) +- JavaScript API + - [`@perspective-dev/client` Browser](https://perspective-dev.github.io/browser/modules/src_ts_perspective.browser.ts.html) + - [`@perspective-dev/client` Node.js](https://perspective-dev.github.io/node/modules/src_ts_perspective.node.ts.html) + - [`@perspective-dev/viewer`](https://perspective-dev.github.io/viewer/modules/perspective-viewer.html) + - [`@perspective-dev/react`](https://perspective-dev.github.io/react/index.html) +- Rust API + - [`perspective`](https://docs.rs/perspective/latest/perspective/) + - [`perspective-client`](https://docs.rs/perspective-client/latest/perspective_client/) + - [`perspective-server`](https://docs.rs/perspective-server/latest/perspective_server/) + - [`perspective-python`](https://docs.rs/perspective-python/latest/perspective_python/) + - [`perspective-js`](https://docs.rs/perspective-js/latest/perspective_js/) + - [`perspective-viewer`](https://docs.rs/perspective-viewer/latest/perspective_viewer/) diff --git a/docs/md/explanation.md b/docs/md/explanation.md new file mode 100644 index 0000000..6820863 --- /dev/null +++ b/docs/md/explanation.md @@ -0,0 +1,4 @@ +# Overview + +This section covers Perspective's core concepts: data architecture patterns, +the `Table` and `View` data model, and language-specific module details. diff --git a/docs/md/explanation/architecture.md b/docs/md/explanation/architecture.md new file mode 100644 index 0000000..2739c61 --- /dev/null +++ b/docs/md/explanation/architecture.md @@ -0,0 +1,15 @@ +# Data Architecture + +Application developers can choose from +[Client (WebAssembly)](./architecture/client_only.md), +[Server (Python/Node)](./architecture/server_only.md) or +[Client/Server Replicated](./architecture/client_server.md) designs to bind +data, and a web application can use one or a mix of these designs as needed. By +serializing to Apache Arrow, tables are duplicated and synchronized across +runtimes efficiently. + +Perspective is a multi-language platform. The examples in this section use +Python and JavaScript as an example, but the same general principles apply to +any `Client`/`Server` combination. + + diff --git a/docs/md/explanation/architecture/architecture.dot b/docs/md/explanation/architecture/architecture.dot new file mode 100644 index 0000000..c487c22 --- /dev/null +++ b/docs/md/explanation/architecture/architecture.dot @@ -0,0 +1,197 @@ +digraph G { + bgcolor=transparent + graph [rankdir="LR" fontname="helvetica" labeljust="l"] + node [shape="box" fontname="monospace" fontsize=8 color=gray70 style=filled fillcolor=white]; + edge [color="#EDEBDF" arrowsize=0.8] + subgraph cluster_11 { + label="\lPython Server"; + fontcolor=gray30 + margin=10 + color=none + subgraph cluster_thread_2 { + graph [ + label="\lPerspectiveManager - Thread 2"; + style=filled + fillcolor="#91A4A8" + color=none + fontcolor="#EDEBDF" + fontsize=10 + margin=10 + ] + table_thread_2 [ + label="table(df)" + width=1 + color=none + fillcolor="#E6E2DA" + ] + view_thread_2 [ + label="view({\l group_by: [\"State\"]]\l})\l" + width=2.5 + color=none + fillcolor="#EDEBDF" + ] + view_thread_2_2 [ + label="view({\l group_by: [\"City\"]]\l})\l" + width=2.5 + color=none + fillcolor="#EDEBDF" + ] + table_thread_2 -> view_thread_2_2; + table_thread_2 -> view_thread_2; + } + subgraph cluster_thread_1 { + graph [ + label="\lPerspectiveManager - Thread 1"; + style=filled + fillcolor="#91A4A8" + color=none + fontcolor="#EDEBDF" + fontsize=10 + margin=10 + ] + table_thread_1 [ + label="table(arrow)" + width=1 + color=none + fillcolor="#E6E2DA" + ] + view_thread_1 [ + label="view({\l group_by:'[\"State\"]\l split_by:'[\"Segment\"]'\l})\l" + width=2.5 + color=none + fillcolor="#EDEBDF" + ] + table_thread_1 -> view_thread_1; + } + } + subgraph cluster_browser { + graph [ + label="\lBrowser"; + color="#CADEE1"; + margin=10 + style=filled; + fontcolor=gray30 + ] + subgraph cluster_2 { + graph [ + label="\lWebWorker 1"; + style=filled + margin=10 + fillcolor="#2D4C68" + color=none + fontcolor="#EDEBDF" + fontsize=10 + ] + table1 [ + label="table(csv)" + width=1 + color=none + fillcolor="#E6E2DA" + ] + table_remote_view [ + label="table(json)" + width=1 + color=none + fillcolor="#E6E2DA" + ] + view1 [ + label="view({\l group_by: [\"Category\"]\l filter: [[\"State\",\"==\",\"Texas\"]]\l})\l" + width=2.5 + color=none + fillcolor="#EDEBDF" + ] + view2 [ + label="view({\l group_by: [\"Sub-Category\"]\l})\l" + width=2.5 + color=none + fillcolor="#EDEBDF" + ] + view3 [ + label="view()\l" + width=2.5 + color=none + fillcolor="#EDEBDF" + ] + table1 -> {view1 view2}; + table_remote_view -> view3; + } + + subgraph cluster_webworker2 { + graph [ + label="\lWebWorker 2"; + style=filled + margin=10 + fillcolor="#2D4C68" + color=none + fontcolor="#EDEBDF" + fontsize=10 + ] + table12 [ + label="table(...)" + width=1 + color=none + fillcolor="#E6E2DA" + ] + + view12 [ + label="view({\l group_by: [\"Category\"]\l filter: [[\"State\",\"==\",\"Texas\"]]\l})\l" + width=2.5 + color=none + fillcolor="#EDEBDF" + ] + + table12 -> {view12} [color="#E6E2DA"]; + } + + view_thread_2 -> table12 [penwidth=2 style=dashed arrowhead=none color="#D1A043"]; + + view1 -> viewer1 [penwidth=2 style=dashed arrowhead=none color="#666"]; + view2 -> viewer2 [penwidth=2 style=dashed arrowhead=none color="#666"]; + view3 -> viewer3 [penwidth=2 style=dashed arrowhead=none color="#666"]; + + subgraph cluster_41 { + graph [ + label="\l"; + color=none + fillcolor=white + fontcolor=gray30 + fontsize=10 + fontname="monospace" fontsize=8 color=none + + ] + viewer1 [ + label = "\l" + width=2.8 + ]; + viewer2 [ + label = "\l" + width=2.8 + color=lightgrey + ]; + viewer3 [ + label = "\l" + width=2.8 + ]; + viewerN [ + style=invis + ] + + viewer5 [ + label = "\l" + width=2.8 + ]; + viewerZ [ + style=invis + height=0.3 + ] + viewer4 [ + label = "\l" + width=2.8 + ]; + view_thread_1 -> viewer4 [penwidth=2 style=dashed arrowhead=none color="#D1A043" constraint=false]; + view12 -> viewer5 [penwidth=2 style=dashed arrowhead=none color="#666"]; + } + + + } +} \ No newline at end of file diff --git a/docs/md/explanation/architecture/architecture.sub1.dot b/docs/md/explanation/architecture/architecture.sub1.dot new file mode 100644 index 0000000..9c5b6ee --- /dev/null +++ b/docs/md/explanation/architecture/architecture.sub1.dot @@ -0,0 +1,66 @@ +digraph G { + bgcolor=transparent + graph [rankdir="LR" fontname="helvetica" labeljust="l"] + node [shape="box" fontname="monospace" fontsize=8 color=gray70 style=filled fillcolor=white]; + edge [color="#EDEBDF" arrowsize=0.8] + + subgraph cluster_browser { + graph [ + label="\lBrowser"; + color="#CADEE1"; + margin=10 + style=filled; + fontcolor=gray30 + ] + subgraph cluster_2 { + graph [ + label="\lWebWorker 1"; + style=filled + margin=10 + fillcolor="#2D4C68" + color=none + fontcolor="#EDEBDF" + fontsize=10 + ] + table1 [ + label="table(csv)" + width=1 + color=none + fillcolor="#E6E2DA" + ] + + + view2 [ + label="view({\l group_by: [\"Sub-Category\"]\l})\l" + width=2.5 + color=none + fillcolor="#EDEBDF" + ] + + table1 -> {view2}; + } + + view2 -> viewer2 [penwidth=2 style=dashed arrowhead=none color="#666"]; + + subgraph cluster_41 { + graph [ + label="\l"; + color=none + fillcolor=white + fontcolor=gray30 + fontsize=10 + fontname="monospace" fontsize=8 color=none + + ] + + viewer2 [ + label = "\l" + width=2.8 + color=lightgrey + ]; + + } + + + } +} \ No newline at end of file diff --git a/docs/md/explanation/architecture/architecture.sub1.svg b/docs/md/explanation/architecture/architecture.sub1.svg new file mode 100644 index 0000000..1593018 --- /dev/null +++ b/docs/md/explanation/architecture/architecture.sub1.svg @@ -0,0 +1,60 @@ + + + + + + +G + +cluster_browser + +Browser + + +cluster_2 + +WebWorker 1 + + +cluster_41 + +<html> + + + +table1 + +table(csv) + + + +view2 + +view({ +    group_by: ["Sub-Category"] +}) + + + +table1->view2 + + + + + +viewer2 + +<perspective-viewer +    view="xy_scatter" +    row-pivots='["Sub-Category"]'> + + + +view2->viewer2 + + + + diff --git a/docs/md/explanation/architecture/architecture.sub2.dot b/docs/md/explanation/architecture/architecture.sub2.dot new file mode 100644 index 0000000..f8d27ac --- /dev/null +++ b/docs/md/explanation/architecture/architecture.sub2.dot @@ -0,0 +1,66 @@ +digraph G { + bgcolor=transparent + graph [rankdir="LR" fontname="helvetica" labeljust="l"] + node [shape="box" fontname="monospace" fontsize=8 color=gray70 style=filled fillcolor=white]; + edge [color="#EDEBDF" arrowsize=0.8] + subgraph cluster_11 { + label="\lPython Server"; + fontcolor=gray30 + margin=10 + color=none + + subgraph cluster_thread_1 { + graph [ + label="\lPerspectiveManager - Thread 1"; + style=filled + fillcolor="#91A4A8" + color=none + fontcolor="#EDEBDF" + fontsize=10 + margin=10 + ] + table_thread_1 [ + label="table(arrow)" + width=1 + color=none + fillcolor="#E6E2DA" + ] + view_thread_1 [ + label="view({\l group_by:'[\"State\"]\l split_by:'[\"Segment\"]'\l})\l" + width=2.5 + color=none + fillcolor="#EDEBDF" + ] + table_thread_1 -> view_thread_1; + } + } + subgraph cluster_browser { + graph [ + label="\lBrowser"; + color="#CADEE1"; + margin=10 + style=filled; + fontcolor=gray30 + ] + + subgraph cluster_41 { + graph [ + label="\l"; + color=none + fillcolor=white + fontcolor=gray30 + fontsize=10 + fontname="monospace" fontsize=8 color=none + + ] + + viewer4 [ + label = "\l" + width=2.8 + ]; + view_thread_1 -> viewer4 [penwidth=2 style=dashed arrowhead=none color="#D1A043"]; + } + + + } +} \ No newline at end of file diff --git a/docs/md/explanation/architecture/architecture.sub2.svg b/docs/md/explanation/architecture/architecture.sub2.svg new file mode 100644 index 0000000..7cc220e --- /dev/null +++ b/docs/md/explanation/architecture/architecture.sub2.svg @@ -0,0 +1,67 @@ + + + + + + +G + +cluster_11 + +Python Server + + +cluster_thread_1 + +PerspectiveManager - Thread 1 + + +cluster_browser + +Browser + + +cluster_41 + +<html> + + + +table_thread_1 + +table(arrow) + + + +view_thread_1 + +view({ +    group_by:'["State"] +    split_by:'["Segment"]' +}) + + + +table_thread_1->view_thread_1 + + + + + +viewer4 + +<perspective-viewer +    view="heatmap" +    row-pivots='["State"] +    column-pivots='["Segment"]'> + + + +view_thread_1->viewer4 + + + + diff --git a/docs/md/explanation/architecture/architecture.sub3.dot b/docs/md/explanation/architecture/architecture.sub3.dot new file mode 100644 index 0000000..5b71c24 --- /dev/null +++ b/docs/md/explanation/architecture/architecture.sub3.dot @@ -0,0 +1,97 @@ +digraph G { + bgcolor=transparent + graph [rankdir="LR" fontname="helvetica" labeljust="l"] + node [shape="box" fontname="monospace" fontsize=8 color=gray70 style=filled fillcolor=white]; + edge [color="#EDEBDF" arrowsize=0.8] + subgraph cluster_11 { + label="\lPython Server"; + fontcolor=gray30 + margin=10 + color=none + subgraph cluster_thread_2 { + graph [ + label="\lPerspectiveManager - Thread 2"; + style=filled + fillcolor="#91A4A8" + color=none + fontcolor="#EDEBDF" + fontsize=10 + margin=10 + ] + table_thread_2 [ + label="table(df)" + width=1 + color=none + fillcolor="#E6E2DA" + ] + view_thread_2 [ + label="view({\l group_by: [\"State\"]]\l})\l" + width=2.5 + color=none + fillcolor="#EDEBDF" + ] + + + table_thread_2 -> view_thread_2; + } + + } + subgraph cluster_browser { + graph [ + label="\lBrowser"; + color="#CADEE1"; + margin=10 + style=filled; + fontcolor=gray30 + ] + + subgraph cluster_webworker2 { + graph [ + label="\lWebWorker 2"; + style=filled + margin=10 + fillcolor="#2D4C68" + color=none + fontcolor="#EDEBDF" + fontsize=10 + ] + table12 [ + label="table(...)" + width=1 + color=none + fillcolor="#E6E2DA" + ] + + view12 [ + label="view({\l group_by: [\"Category\"]\l filter: [[\"State\",\"==\",\"Texas\"]]\l})\l" + width=2.5 + color=none + fillcolor="#EDEBDF" + ] + + table12 -> {view12} [color="#E6E2DA"]; + } + + view_thread_2 -> table12 [penwidth=2 style=dashed arrowhead=none color="#D1A043"]; + + subgraph cluster_41 { + graph [ + label="\l"; + color=none + fillcolor=white + fontcolor=gray30 + fontsize=10 + fontname="monospace" fontsize=8 color=none + + ] + + viewer5 [ + label = "\l" + width=2.8 + ]; + view12 -> viewer5 [penwidth=2 style=dashed arrowhead=none color="#666"]; + } + + + } +} \ No newline at end of file diff --git a/docs/md/explanation/architecture/architecture.sub3.svg b/docs/md/explanation/architecture/architecture.sub3.svg new file mode 100644 index 0000000..e6b3d9c --- /dev/null +++ b/docs/md/explanation/architecture/architecture.sub3.svg @@ -0,0 +1,97 @@ + + + + + + +G + +cluster_11 + +Python Server + + +cluster_thread_2 + +PerspectiveManager - Thread 2 + + +cluster_browser + +Browser + + +cluster_webworker2 + +WebWorker 2 + + +cluster_41 + +<html> + + + +table_thread_2 + +table(df) + + + +view_thread_2 + +view({ +    group_by: ["State"]] +}) + + + +table_thread_2->view_thread_2 + + + + + +table12 + +table(...) + + + +view_thread_2->table12 + + + + +view12 + +view({ +    group_by: ["Category"] +    filter: [["State","==","Texas"]] +}) + + + +table12->view12 + + + + + +viewer5 + +<perspective-viewer +    view="heatmap" +    row-pivots='["State"] +    column-pivots='["Segment"]'> + + + +view12->viewer5 + + + + diff --git a/docs/md/explanation/architecture/architecture.svg b/docs/md/explanation/architecture/architecture.svg new file mode 100644 index 0000000..4302eea --- /dev/null +++ b/docs/md/explanation/architecture/architecture.svg @@ -0,0 +1,250 @@ + + + + + + +G + +cluster_11 + +Python Server + + +cluster_thread_2 + +PerspectiveManager - Thread 2 + + +cluster_thread_1 + +PerspectiveManager - Thread 1 + + +cluster_browser + +Browser + + +cluster_2 + +WebWorker 1 + + +cluster_webworker2 + +WebWorker 2 + + +cluster_41 + +<html> + + + +table_thread_2 + +table(df) + + + +view_thread_2 + +view({ +    group_by: ["State"]] +}) + + + +table_thread_2->view_thread_2 + + + + + +view_thread_2_2 + +view({ +    group_by: ["City"]] +}) + + + +table_thread_2->view_thread_2_2 + + + + + +table12 + +table(...) + + + +view_thread_2->table12 + + + + +table_thread_1 + +table(arrow) + + + +view_thread_1 + +view({ +    group_by:'["State"] +    split_by:'["Segment"]' +}) + + + +table_thread_1->view_thread_1 + + + + + +viewer4 + +<perspective-viewer +    view="heatmap" +    row-pivots='["State"] +    column-pivots='["Segment"]'> + + + +view_thread_1->viewer4 + + + + +table1 + +table(csv) + + + +view1 + +view({ +    group_by: ["Category"] +    filter: [["State","==","Texas"]] +}) + + + +table1->view1 + + + + + +view2 + +view({ +    group_by: ["Sub-Category"] +}) + + + +table1->view2 + + + + + +table_remote_view + +table(json) + + + +view3 + +view() + + + +table_remote_view->view3 + + + + + +viewer1 + +<perspective-viewer +    view="Y Bar" +    row-pivots='["Category"]' +    filters='[["State","==","Texas"]]'> + + + +view1->viewer1 + + + + +viewer2 + +<perspective-viewer +    view="xy_scatter" +    row-pivots='["Sub-Category"]'> + + + +view2->viewer2 + + + + +viewer3 + +<perspective-viewer +    view="grid"> + + + +view3->viewer3 + + + + +view12 + +view({ +    group_by: ["Category"] +    filter: [["State","==","Texas"]] +}) + + + +table12->view12 + + + + + +viewer5 + +<perspective-viewer +    view="heatmap" +    row-pivots='["State"] +    column-pivots='["Segment"]'> + + + +view12->viewer5 + + + + + + diff --git a/docs/md/explanation/architecture/client_only.md b/docs/md/explanation/architecture/client_only.md new file mode 100644 index 0000000..ca800eb --- /dev/null +++ b/docs/md/explanation/architecture/client_only.md @@ -0,0 +1,39 @@ +# Client-only + + + +_For static datasets, datasets provided by the user, and simple server-less and +read-only web applications._ + +In this design, Perspective is run as a client Browser WebAssembly library, the +dataset is downloaded entirely to the client and all calculations and UI +interactions are performed locally. Interactive performance is very good, using +WebAssembly engine for near-native runtime plus WebWorker isolation for parallel +rendering within the browser. Operations like scrolling and creating new views +are responsive. However, the entire dataset must be downloaded to the client. +Perspective is not a typical browser component, and datset sizes of 1gb+ in +Apache Arrow format will load fine with good interactive performance! + +Horizontal scaling is a non-issue, since here is no concurrent state to scale, +and only uses client-side computation via WebAssembly client. Client-only +perspective can support as many concurrent users as can download the web +application itself. Once the data is loaded, no server connection is needed and +all operations occur in the client browser, imparting no additional runtime cost +on the server beyond initial load. This also means updates and edits are local +to the browser client and will be lost when the page is refreshed, unless +otherwise persisted by your application. + +As the client-only design starts with creating a client-side Perspective +`Table`, data can be provided by any standard web service in any Perspective +compatible format (JSON, CSV or Apache Arrow). + +## Javascript client + +```javascript +const worker = await perspective.worker(); +const table = await worker.table(csv); + +const viewer = document.createElement("perspective-viewer"); +document.body.appendChild(viewer); +await viewer.load(table); +``` diff --git a/docs/md/explanation/architecture/client_server.md b/docs/md/explanation/architecture/client_server.md new file mode 100644 index 0000000..cd7e60a --- /dev/null +++ b/docs/md/explanation/architecture/client_server.md @@ -0,0 +1,58 @@ +# Client/Server replicated + + + +_For medium-sized, real-time, synchronized and/or editable data sets with many +concurrent users._ + +The dataset is instantiated in-memory with a Python or Node.js Perspective +server, and web applications create duplicates of these tables in a local +WebAssembly client in the browser, synchonized efficiently to the server via +Apache Arrow. This design scales well with additional concurrent users, as +browsers only need to download the initial data set and subsequent update +deltas, while operations like scrolling, pivots, sorting, etc. are performed on +the client. + +Python servers can make especially good use of additional threads, as +Perspective will release the GIL for almost all operations. Interactive +performance on the client is very good and identical to client-only +architecture. Updates and edits are seamlessly synchonized across clients via +their virtual server counterparts using websockets and Apache Arrow. + +## Python and Tornado server + +```python +from perspective import Server, PerspectiveTornadoHandler + +server = Server() +client = server.new_local_client() +client.table(csv, name="my_table") +routes = [( + r"/websocket", + perspective.handlers.tornado.PerspectiveTornadoHandler, + {"perspective_server": server}, +)] + +app = tornado.web.Application(routes) +app.listen(8080) +loop = tornado.ioloop.IOLoop.current() +loop.start() +``` + +## Javascript client + +Perspective's websocket client interfaces with the Python server, then +_replicates_ the server-side Table. + +```javascript +const websocket = await perspective.websocket("ws://localhost:8080"); +const server_table = await websocket.open_table("my_table"); +const server_view = await server_table.view(); + +const worker = await perspective.worker(); +const client_table = await worker.table(server_view); + +const viewer = document.createElement("perspective-viewer"); +document.body.appendChild(viewer); +await viewer.load(client_table); +``` diff --git a/docs/md/explanation/architecture/server_only.md b/docs/md/explanation/architecture/server_only.md new file mode 100644 index 0000000..09084c4 --- /dev/null +++ b/docs/md/explanation/architecture/server_only.md @@ -0,0 +1,33 @@ +# Server-only + + + +_For extremely large datasets with a small number of concurrent users._ + +The dataset is instantiated in-memory with a Python or Node.js server, and web +applications connect virtually. Has very good initial load performance, since no +data is downloaded. Group-by and other operations will run column-parallel if +configured. + +But interactive performance is poor, as every user interaction must page the +server to render. Operations like scrolling are not as responsive and can be +impacted by network latency. Web applications must be "always connected" to the +server via WebSocket. Disconnecting will prevent any interaction, scrolling, +etc. of the UI. Does not use WebAssembly. + +Each connected browser will impact server performance as long as the connection +is open, which in turn impacts interactive performance of every client. This +ultimately limits the horizontal scalabity of this architecture. Since each +client reads the perspective `Table` virtually, changes like edits and updates +are automatically reflected to all clients and persist across browser refresh. +Using the same Python server as the previous design, we can simply skip the +intermediate WebAssembly `Table` and pass the virtual table directly to `load()` + +```javascript +const websocket = await perspective.websocket("ws://localhost:8080"); +const server_table = await websocket.open_table("my_table"); + +const viewer = document.createElement("perspective-viewer"); +document.body.appendChild(viewer); +await viewer.load(server_table); +``` diff --git a/docs/md/explanation/join.md b/docs/md/explanation/join.md new file mode 100644 index 0000000..cf11979 --- /dev/null +++ b/docs/md/explanation/join.md @@ -0,0 +1,12 @@ +# Join + +`Client::join` creates a read-only `Table` by joining two source tables on a +shared key column. The `left` and `right` arguments can be `Table` objects or +string table names (as returned by `get_hosted_table_names()`). The resulting +table is _reactive_: whenever either source table is updated, the join is +automatically recomputed and any `View` derived from the joined table will +update accordingly. + +Joined tables support the full `View` API — you can apply `group_by`, +`split_by`, `sort`, `filter`, `expressions`, and all other `View` operations on +the result, just as you would with any other `Table`. diff --git a/docs/md/explanation/join/join_types.md b/docs/md/explanation/join/join_types.md new file mode 100644 index 0000000..a64dc5b --- /dev/null +++ b/docs/md/explanation/join/join_types.md @@ -0,0 +1,25 @@ +# Join Types + +`Client::join` supports three join types, specified via the `join_type` option. +The default is `"inner"`. + +## Inner Join (default) + +An inner join includes only rows where the key column exists in _both_ source +tables. Rows from either table that have no match in the other are excluded. + +## Left Join + +A left join includes all rows from the left table. For left rows that have no +match in the right table, right-side columns are filled with `null`. + +## Outer Join + +An outer join includes all rows from both tables. Unmatched rows on either side +have their missing columns filled with `null`. + +| `join_type` | Left-only rows | Right-only rows | +| ----------- | -------------- | --------------- | +| `"inner"` | excluded | excluded | +| `"left"` | included | excluded | +| `"outer"` | included | included | diff --git a/docs/md/explanation/join/options.md b/docs/md/explanation/join/options.md new file mode 100644 index 0000000..37aa87c --- /dev/null +++ b/docs/md/explanation/join/options.md @@ -0,0 +1,35 @@ +# Join Options + +## `on` — Join Key Column + +The `on` parameter specifies the column name used to match rows between the left +and right tables. This column must exist in the left table and, by default, must +also exist in the right table with the same name and compatible type. + +The join key column becomes the index of the resulting table. + +## `right_on` — Different Right Key Column + +When the join key has a different name in the right table, use `right_on` to +specify the right table's column name. The left table's column name (`on`) is +used in the output schema; the right key column is excluded from the result. + +The `on` and `right_on` columns must have compatible types. An error is thrown +if the types do not match. + +## `join_type` — Join Type + +Controls which rows are included in the result. See +[Join Types](./join_types.md) for details. + +| Value | Behavior | +| ----------- | ----------------------------------------------------- | +| `"inner"` | Only rows with matching keys in both tables (default) | +| `"left"` | All left rows; unmatched right columns are `null` | +| `"outer"` | All rows from both tables; unmatched columns are `null` | + +## `name` — Table Name + +An optional name for the resulting joined table. If omitted, a random name is +generated. This name is used to identify the table in the server's hosted table +registry. diff --git a/docs/md/explanation/join/reactivity.md b/docs/md/explanation/join/reactivity.md new file mode 100644 index 0000000..13c89b1 --- /dev/null +++ b/docs/md/explanation/join/reactivity.md @@ -0,0 +1,46 @@ +# Reactivity and Constraints + +## Reactive Updates + +Joined tables are fully reactive. When either source table receives an +`update()`, the join is automatically recomputed and any `View` created from the +joined table will reflect the new data. This includes: + +- Updates that modify existing rows in either source table. +- New rows added to either source table that create new matches. +- Chained joins — if a joined table is itself used as input to another join, + updates propagate through the entire chain. + +## Duplicate Keys + +Like SQL, `join()` produces a cross-product for each matching key value. When +multiple rows in the left table share the same key, each is paired with every +matching row in the right table (and vice versa). The number of output rows for +a given key is `left_count × right_count`. + +This behavior depends on whether the source tables are _indexed_: + +- **Unindexed tables** (no `index` option) — rows are appended, so duplicate + keys accumulate naturally. Each `update()` appends new rows, which may + introduce additional duplicates. +- **Indexed tables** (`index` set to the join key) — each key appears at most + once per table, so the join produces at most one row per key. Updates replace + existing rows in-place rather than appending. + +## Read-Only + +Joined tables are read-only. Calling `update()`, `remove()`, `clear()`, or +`replace()` on a joined table will throw an error. Data can only change +indirectly, by updating the source tables. + +## Column Name Conflicts + +The left and right tables must not have overlapping column names (other than the +join key). If a non-key column name appears in both tables, `join()` throws an +error. Rename columns in your source data or use `View` expressions to avoid +conflicts. + +## Source Table Deletion + +A source table cannot be deleted while a joined table depends on it. You must +delete the joined table first, then delete the source tables. diff --git a/docs/md/explanation/python.md b/docs/md/explanation/python.md new file mode 100644 index 0000000..daf2237 --- /dev/null +++ b/docs/md/explanation/python.md @@ -0,0 +1,77 @@ +# What is `perspective-python` + +Perspective for Python uses the exact same C++ data engine used by the +[WebAssembly version](https://docs.rs/perspective-js/latest/perspective_js/) and +[Rust version](https://docs.rs/crate/perspective/latest). The library consists +of many of the same abstractions and API as in JavaScript, as well as +Python-specific data loading support for [NumPy](https://numpy.org/), +[Pandas](https://pandas.pydata.org/) (and +[Apache Arrow](https://arrow.apache.org/), as in JavaScript). + +Additionally, `perspective-python` provides a session manager suitable for +integration into server systems such as +[Tornado websockets](https://www.tornadoweb.org/en/stable/websocket.html), +[AIOHTTP](https://docs.aiohttp.org/en/stable/web_quickstart.html#websockets), or +[Starlette](https://www.starlette.io/websockets/)/[FastAPI](https://fastapi.tiangolo.com/advanced/websockets/), +which allows fully _virtual_ Perspective tables to be interacted with by +multiple `` in a web browser. You can also interact with a +Perspective table from python clients, and to that end client libraries are +implemented for both Tornado and AIOHTTP. + +## Example + +A simple example which loads an [Apache Arrow](https://arrow.apache.org/) and +computes a "Group By" operation, returning a new Arrow. + +```python +from perspective import Server + +client = Server().new_local_client() +table = client.table(arrow_bytes_data) +view = table.view(group_by = ["CounterParty", "Security"]) +arrow = view.to_arrow() +``` + +[More Examples](https://github.com/perspective-dev/perspective/tree/master/examples) +are available on GitHub. + +## What's included + +The `perspective` module exports several tools: + +- `Server` the constructor for a new instance of the Perspective data engine. +- The `perspective.widget` module exports `PerspectiveWidget`, the JupyterLab + widget for interactive visualization in a notebook cell. +- The `perspective.handlers` modules exports web frameworks handlers that + interface with a `perspective-client` in JavaScript. + - `perspective.handlers.tornado.PerspectiveTornadoHandler` for + [Tornado](https://www.tornadoweb.org/) + - `perspective.handlers.starlette.PerspectiveStarletteHandler` for + [Starlette](https://www.starlette.io/) and + [FastAPI](https://fastapi.tiangolo.com) + - `perspective.handlers.aiohttp.PerspectiveAIOHTTPHandler` for + [AIOHTTP](https://docs.aiohttp.org), + +### Virtual UI server + +As `` or any other Perspective `Client` will only consume +the data necessary to render the current screen (or whatever else was requested +via the API), this runtime mode allows large datasets without the need to copy +them entirely to the Browser, at the expense of network latency on UI +interaction/API calls. + +### Jupyterlab + +`PerspectiveWidget` is a JupyterLab widget that implements the same API as +``, allows running such a viewer in +[JupyterLab](https://jupyterlab.readthedocs.io/en/stable/) in either server or +client (via WebAssembly) mode. `PerspectiveWidget` is compatible with Jupyterlab +3 and Jupyter Notebook 6 via a +[prebuilt extension](https://jupyterlab.readthedocs.io/en/stable/extension/extension_dev.html#prebuilt-extensions). +To use it, simply install `perspective-python` and the extensions should be +available. + +`perspective-python`'s JupyterLab extension also provides convenient builtin +viewers for `csv`, `json`, or `arrow` files. Simply right-click on a file with +this extension and choose the appropriate `Perpective` option from the context +menu. diff --git a/docs/md/explanation/table.md b/docs/md/explanation/table.md new file mode 100644 index 0000000..2514959 --- /dev/null +++ b/docs/md/explanation/table.md @@ -0,0 +1,14 @@ +# Table + +`Table` is Perspective's columnar data frame, analogous to a Pandas `DataFrame` +or Apache Arrow, supporting append & in-place updates, removal by index, and +update notifications. + +A `Table` contains columns, each of which have a unique name, are strongly and +consistently typed, and contains rows of data conforming to the column's type. +Each column in a `Table` must have the same number of rows, though not every row +must contain data; null-values are used to indicate missing values in the +dataset. The schema of a `Table` is _immutable after creation_, which means the +column names and data types cannot be changed after the `Table` has been +created. Columns cannot be added or deleted after creation either, but a `View` +can be used to select an arbitrary set of columns from the `Table`. diff --git a/docs/md/explanation/table/clear_and_replace.md b/docs/md/explanation/table/clear_and_replace.md new file mode 100644 index 0000000..4143265 --- /dev/null +++ b/docs/md/explanation/table/clear_and_replace.md @@ -0,0 +1,23 @@ +# `Table::clear` and `Table::replace` + +Calling `Table::clear` will remove all data from the underlying `Table`. Calling +`Table::replace` with new data will clear the `Table`, and update it with a new +dataset that conforms to Perspective's data types and the existing schema on the +`Table`. + +
+ +```javascript +table.clear(); +table.replace(json); +``` + +
+
+ +```python +table.clear() +table.replace(df) +``` + +
diff --git a/docs/md/explanation/table/constructing_schema.md b/docs/md/explanation/table/constructing_schema.md new file mode 100644 index 0000000..b135e11 --- /dev/null +++ b/docs/md/explanation/table/constructing_schema.md @@ -0,0 +1,47 @@ +# Construct a Table + +Examples of constructing an empty `Table` from a schema. + +
+ +JavaScript: + +```javascript +var schema = { + x: "integer", + y: "string", + z: "boolean", +}; + +const table2 = await worker.table(schema); +``` + +
+
+ +Python: + +```python +from datetime import date, datetime + +schema = { + "x": "integer", + "y": "string", + "z": "boolean", +} + +table2 = perspective.table(schema) +``` + +
+
+ +Rust: + +```rust +let data = TableData::Schema(vec![(" a".to_string(), ColumnType::FLOAT)]); +let options = TableInitOptions::default(); +let table = client.table(data.into(), options).await?; +``` + +
diff --git a/docs/md/explanation/table/loading_data.md b/docs/md/explanation/table/loading_data.md new file mode 100644 index 0000000..4ea3464 --- /dev/null +++ b/docs/md/explanation/table/loading_data.md @@ -0,0 +1,87 @@ +# Loading data + +A `Table` may also be created-or-updated by data in CSV, +[Apache Arrow](https://arrow.apache.org/), JSON row-oriented or JSON +column-oriented formats. In addition to these, `perspective-python` additionally +supports `pyarrow.Table`, `polars.DataFrame` and `pandas.DataFrame` objects +directly. These formats are otherwise identical to the built-in formats and +don't exhibit any additional support or type-awareness; e.g., `pandas.DataFrame` +support is _just_ `pyarrow.Table.from_pandas` piped into Perspective's Arrow +reader. + +`Client::table` and `Table::update` perform _coercion_ on their input for all +input formats _except_ Arrow (which comes with its own schema and has no need +for coercion). `"date"` and `"datetime"` column types do not have native JSON +representations, so these column types _cannot_ be inferred from JSON input. +Instead, for columns of these types for JSON input, a `Table` must first be +constructed with a _schema_. Next, call `Table::update` with the JSON input - +Perspective's JSON reader may _coerce_ a `date` or `datetime` from these native +JSON types: + +- `integer` as milliseconds-since-epoch. +- `string` as a any of Perspective's built-in date format formats. +- JavaScript `Date` and Python `datetime.date` and `datetime.datetime` are _not_ + supported directly. However, in JavaScript `Date` types are automatically + coerced to correct `integer` timestamps by default when converted to JSON. + +## Apache Arrow + +The most efficient way to load data into Perspective, encoded as +[Apache Arrow IPC format](https://arrow.apache.org/docs/python/ipc.html). In +JavaScript: + +```javascript +const resp = await fetch( + "https://cdn.jsdelivr.net/npm/superstore-arrow/superstore.lz4.arrow", +); + +const arrow = await resp.arrayBuffer(); +``` + +Apache Arrow input do not support type coercion, preferring Arrow's internal +self-describing schema. + +## CSV + +Perspective relies on Apache Arrow's CSV parser, and as such uses mostly the +same column-type inference logic as Arrow itself would use for parsing CSV. + +## Row Oriented JSON + +Row-oriented JSON is in the form of a list of objects. Each object in the list +corresponds to a row in the table. For example: + +```json +[ + { "a": 86, "b": false, "c": "words" }, + { "a": 0, "b": true, "c": "" }, + { "a": 12345, "b": false, "c": "here" } +] +``` + +## Column Oriented JSON + +Column-Oriented JSON comes in the form of an object of lists. Each key of the +object is a column name, and each element of the list is the corresponding value +in the row. + +```json +{ + "a": [86, 0, 12345], + "b": [false, true, false], + "c": ["words", "", "here"] +} +``` + +## NDJSON + +[NDJSON](https://github.com/ndjson/ndjson-spec) (sometimes also referred to as +JSONL) is a streaming-friendly format where each line is a valid JSON object, +separated by newlines. It is commonly used in data streaming and messaging +queues. + +```json +{ "a": 86, "b": false, "c": "words" } +{ "a": 0, "b": true, "c": "" } +{ "a": 12345, "b": false, "c": "here" } +``` diff --git a/docs/md/explanation/table/options.md b/docs/md/explanation/table/options.md new file mode 100644 index 0000000..e18d50d --- /dev/null +++ b/docs/md/explanation/table/options.md @@ -0,0 +1,59 @@ +## Index and Limit + +
`limit` cannot be used in conjunction with `index`.
+ +Initializing a `Table` with an `index` tells Perspective to treat a column as +the primary key, allowing in-place updates of rows. Only a single column (of any +type) can be used as an `index`. Indexed `Table` instances allow: + +- In-place _updates_ whenever a new row shares an `index` values with an + existing row +- _Partial updates_ when a data batch omits some column. +- _Removes_ to delete a row by `index`. + +To create an indexed `Table`, provide the `index` property with a string column +name to be used as an index: + +
+ +JavaScript: + +```javascript +const indexed_table = await perspective.table(data, { index: "a" }); +``` + +
+
+ +Python + +```python +indexed_table = perspective.Table(data, index="a"); +``` + +
+ +Initializing a `Table` with a `limit` sets the total number of rows the `Table` +is allowed to have. When the `Table` is updated, and the resulting size of the +`Table` would exceed its `limit`, rows that exceed `limit` overwrite the oldest +rows in the `Table`. To create a `Table` with a `limit`, provide the `limit` +property with an integer indicating the maximum rows: + +
+ +JavaScript: + +```javascript +const limit_table = await perspective.table(data, { limit: 1000 }); +``` + +
+
+ +Python: + +```python +limit_table = perspective.Table(data, limit=1000); +``` + +
diff --git a/docs/md/explanation/table/schema.md b/docs/md/explanation/table/schema.md new file mode 100644 index 0000000..2e36342 --- /dev/null +++ b/docs/md/explanation/table/schema.md @@ -0,0 +1,41 @@ +# Schema and column types + +The mapping of a `Table`'s column names to data types is referred to as a +`schema`. Each column has a unique name and a single data type, one of + +- `float` +- `integer` +- `boolean` +- `date` +- `datetime` +- `string` + +A `Table` schema is fixed at construction, either by explicitly passing a schema +dictionary to the `Client::table` method, or by passing _data_ to this method +from which the schema is _inferred_ (if CSV or JSON format) or inherited (if +Arrow). + +## Type inference + +When passing CSV or JSON data to the `Client::table` constructor, the type of +each column is inferred automatically. In some cases, the inference algorithm +may not return exactly what you'd like. For example, a column may be interpreted +as a `datetime` when you intended it to be a `string`, or a column may have no +values at all (yet), as it will be updated with values from a real-time data +source later on. In these cases, create a `table()` with a _schema_. + +Once the `Table` has been created, further `Table::update` calls will perform +limited type _coercion_ based on the schema. While _coercion_ works similarly to +_inference_, in that input data may be parsed based on the expected column type, +`Table::update` will not _change_ the column's type further. For example, a +number literal `1234` would be _inferred_ as an `"integer"`, but _in the context +of an `Table::update` call on a known `"string"` column_, this will be parsed as +the _string_ `"1234"`. + +## `date` and `datetime` inference + +Various string representations of `date` and `datetime` format columns can be +_inferred_ as well _coerced_ from strings if they match one of Perspective's +internal known datetime parsing formats, for example +[ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) (which is also the format +Perspective will _output_ these types for CSV). diff --git a/docs/md/explanation/table/update_and_remove.md b/docs/md/explanation/table/update_and_remove.md new file mode 100644 index 0000000..87bb1b2 --- /dev/null +++ b/docs/md/explanation/table/update_and_remove.md @@ -0,0 +1,88 @@ +# `Table::update` and `Table::remove` + +Once a `Table` has been created, it can be updated with new data conforming to +the `Table`'s schema. `Table::update` supports the same data formats as +`Client::table`, minus _schema_. + +
+ +```javascript +const schema = { + a: "integer", + b: "float", +}; + +const table = await perspective.table(schema); +table.update(new_data); +``` + +
+
+ +```python +schema = {"a": "integer", "b": "float"} + +table = perspective.Table(schema) +table.update(new_data) +``` + +
+ +Without an `index` set, calls to `update()` _append_ new data to the end of the +`Table`. Otherwise, Perspective allows +[_partial updates_ (in-place)](#index-and-limit) using the `index` to determine +which rows to update: + +
+ +```javascript +indexed_table.update({ id: [1, 4], name: ["x", "y"] }); +``` + +
+
+ +```python +indexed_table.update({"id": [1, 4], "name": ["x", "y"]}) +``` + +
+ +Any value on a `Client::table` can be unset using the value `null` in JSON or +Arrow input formats. Values may be unset on construction, as any `null` in the +dataset will be treated as an unset value. `Table::update` calls do not need to +provide _all columns_ in the `Table`'s schema; missing columns will be omitted +from the `Table`'s updated rows. + +
+ +```javascript +table.update([{ x: 3, y: null }]); // `z` missing +``` + +
+
+ +```python +table.update([{"x": 3, "y": None}]) # `z` missing +``` + +
+ +Rows can also be removed from an indexed `Table`, by calling `Table::remove` +with an array of index values: + +
+ +```javascript +indexed_table.remove([1, 4]); +``` + +
+
+ +```python +indexed_table.remove([1, 4]) +``` + +
diff --git a/docs/md/explanation/view.md b/docs/md/explanation/view.md new file mode 100644 index 0000000..02a05ed --- /dev/null +++ b/docs/md/explanation/view.md @@ -0,0 +1,70 @@ +# View + +The [`View`] struct is Perspective's query and serialization interface. It +represents a query on the `Table`'s dataset and is always created from an +existing `Table` instance via the [`Table::view`] method. + +[`View`]s are immutable with respect to the arguments provided to the +[`Table::view`] method; to change these parameters, you must create a new +[`View`] on the same [`Table`]. However, each [`View`] is _live_ with respect to +the [`Table`]'s data, and will (within a conflation window) update with the +latest state as its parent [`Table`] updates, including incrementally +recalculating all aggregates, pivots, filters, etc. [`View`] query parameters +are composable, in that each parameter works independently _and_ in conjunction +with each other, and there is no limit to the number of pivots, filters, etc. +which can be applied. + +
+
+The examples in this module are in JavaScript. See perspective docs for the Rust API. +
+
+
+
+The examples in this module are in Python. See perspective docs for the Rust API. +
+
+ +# Examples + +
+ +```javascript +const table = await perspective.table({ + id: [1, 2, 3, 4], + name: ["a", "b", "c", "d"], +}); + +const view = await table.view({ columns: ["name"] }); +const json = await view.to_json(); +await view.delete(); +``` + +
+
+ +```python +table = perspective.Table({ + "id": [1, 2, 3, 4], + "name": ["a", "b", "c", "d"] +}); + +view = table.view(columns=["name"]) +arrow = view.to_arrow() +view.delete() +``` + +
+
+ +```rust +let opts = TableInitOptions::default(); +let data = TableData::Update(UpdateData::Csv("x,y\n1,2\n3,4".into())); +let table = client.table(data, opts).await?; + +let view = table.view(None).await?; +let arrow = view.to_arrow().await?; +view.delete().await?; +``` + +
diff --git a/docs/md/explanation/view/advanced.md b/docs/md/explanation/view/advanced.md new file mode 100644 index 0000000..8d7266f --- /dev/null +++ b/docs/md/explanation/view/advanced.md @@ -0,0 +1,231 @@ +# Advanced View Operations + +Beyond the standard query configuration, `View` provides additional methods for +interacting with hierarchical results and introspecting data. + +## Tree Hierarchy Operations + +When a `View` has `group_by` applied, the results form a tree hierarchy. +Perspective provides methods to control which levels of the tree are expanded or +collapsed: + +
+ +```javascript +const view = await table.view({ group_by: ["Region", "Country", "City"] }); + +// Collapse the tree at row index 5 +await view.collapse(5); + +// Expand the tree at row index 5 +await view.expand(5); + +// Set the expansion depth (0 = fully collapsed, 1 = first level, etc.) +await view.set_depth(1); +``` + +
+
+ +Using the sync API + +```python +view = table.view(group_by=["Region", "Country", "City"]) + +view.collapse(5) +view.expand(5) +view.set_depth(1) +``` + +
+
+ +```rust +let view = table.view(Some(ViewConfigUpdate { + group_by: Some(vec!["Region".into(), "Country".into(), "City".into()]), + ..ViewConfigUpdate::default() +})).await?; + +view.collapse(5).await?; +view.expand(5).await?; +view.set_depth(1).await?; +``` + +
+ +Perspective's built-in engine is lazy — aggregates for +collapsed rows are not recalculated when the underlying `Table` is updated. +Updates are only computed for rows that are currently visible (expanded). When a +collapsed row is later expanded, its aggregates are calculated at that +point. + +## Column Range Queries + +`View::get_min_max` returns the minimum and maximum values for a given column, +which is useful for setting up scales in custom visualizations: + +
+ +```javascript +const [min, max] = await view.get_min_max("Sales"); +``` + +
+
+ +```python +min_val, max_val = view.get_min_max("Sales") +``` + +
+ +## Expression Validation + +Before creating a `View` with expressions, you can validate them against the +table's schema using `Table::validate_expressions`. This returns information +about which expressions are valid and their inferred types: + +
+ +```javascript +const result = await table.validate_expressions({ + expr1: '"Sales" + "Profit"', + expr2: "invalid_column + 1", +}); +// result.expression_schema contains valid expressions and their types +// result.errors contains invalid expressions and error messages +``` + +
+
+ +```python +result = table.validate_expressions(['"Sales" + "Profit"', 'invalid + 1']) +``` + +
+ +## View Dimensions + +`View::dimensions` returns the number of rows and columns in the current view, +including information about group-by header rows: + +
+ +```javascript +const dims = await view.dimensions(); +// { num_view_rows, num_view_columns, num_table_rows, num_table_columns, ... } +``` + +
+
+ +```python +dims = view.dimensions() +``` + +
+ +## View Configuration Introspection + +`View::get_config` returns the full configuration used to create the view: + +
+ +```javascript +const config = await view.get_config(); +// { group_by: [...], split_by: [...], sort: [...], filter: [...], ... } +``` + +
+
+ +```python +config = view.get_config() +``` + +
+ +## Update Callbacks + +Register a callback to be notified whenever the underlying `Table` is updated +and the `View` has been recalculated: + +
+ +```javascript +view.on_update( + (updated) => { + console.log("View updated", updated.port_id); + }, + { mode: "row" }, +); + +// Later, remove the callback +view.remove_update(callback); +``` + +
+
+ +```python +def on_update(port_id, delta): + print("View updated", port_id) + +view.on_update(on_update, mode="row") +view.remove_update(on_update) +``` + +
+ +When `mode` is set to `"row"`, the callback receives a delta of only the rows +that changed (as Apache Arrow), which is useful for efficiently synchronizing +tables across clients. + +## Flattening a View into a Table + +In Javascript, a [`Table`] can be constructed on a [`Table::view`] instance, +which will return a new [`Table`] based on the [`Table::view`]'s dataset, and +all future updates that affect the [`Table::view`] will be forwarded to the new +[`Table`]. This is particularly useful for implementing a +[Client/Server Replicated](server.md#clientserver-replicated) design, by +serializing the `View` to an arrow and setting up an `on_update` callback. + +
+ +```javascript +const worker1 = perspective.worker(); +const table = await worker.table(data); +const view = await table.view({ filter: [["State", "==", "Texas"]] }); +const table2 = await worker.table(view); +table.update([{ State: "Texas", City: "Austin" }]); +``` + +
+
+ +```python +table = perspective.Table(data); +view = table.view(filter=[["State", "==", "Texas"]]) +table2 = perspective.Table(view.to_arrow()); + +def updater(port, delta): + table2.update(delta) + +view.on_update(updater, mode="Row") +table.update([{"State": "Texas", "City": "Austin"}]) +``` + +
+
+ +```rust +let opts = TableInitOptions::default(); +let data = TableData::Update(UpdateData::Csv("x,y\n1,2\n3,4".into())); +let table = client.table(data, opts).await?; +let view = table.view(None).await?; +let table2 = client.table(TableData::View(view)).await?; +table.update(data).await?; +``` + +
diff --git a/docs/md/explanation/view/config/expressions.md b/docs/md/explanation/view/config/expressions.md new file mode 100644 index 0000000..e32dd12 --- /dev/null +++ b/docs/md/explanation/view/config/expressions.md @@ -0,0 +1,149 @@ +# Expressions + +The `expressions` property specifies _new_ columns in Perspective that are +created using existing column values or arbitrary scalar values defined within +the expression. In ``, expressions are added using the "New +Column" button in the side panel. + +Expressions are strings parsed by Perspective's expression engine (based on +[ExprTK](https://github.com/ArashPartow/exprtk)). Column names are referenced by +wrapping them in double quotes, e.g. `"Sales"`: + +
+ +```javascript +const view = await table.view({ + expressions: { + "Profit Ratio": '"Profit" / "Sales"', + }, +}); +``` + +
+
+ +```python +view = table.view(expressions={'Profit Ratio': '"Profit" / "Sales"'}) +``` + +
+
+ +```rust +let view = table.view(Some(ViewConfigUpdate { + expressions: Some(Expressions([ + ("Profit Ratio", "\"Profit\" / \"Sales\"".into()) + ].into_iter().collect())), + ..ViewConfigUpdate::default() +})).await?; +``` + +
+ +## Type Conversion and Coercion + +Perspective expressions are strongly typed — each column and literal has a fixed +type, and most operators require matching types on both sides. To work across +types, use the conversion functions: + +| Function | Description | +| --------------- | ------------------------------------------------------------ | +| `to_string(x)` | Convert any type to string | +| `to_integer(x)` | Convert to integer (null if not parsable) | +| `to_float(x)` | Convert to float (null if not parsable) | +| `to_boolean(x)` | Convert to boolean (truthy/falsy) | +| `integer(x)` | Alias for `to_integer(x)` | +| `float(x)` | Alias for `to_float(x)` | +| `datetime(x)` | Construct a datetime from a POSIX timestamp (ms since epoch) | +| `date(y, m, d)` | Construct a date from year, month, day | + +### How coercion works + +Perspective does not implicitly coerce types. For example, you cannot directly +add an `integer` to a `float` — you must cast one side explicitly. Similarly, +`datetime` and `date` values are not numeric: to perform arithmetic on them, you +must first convert to a numeric representation, do the math, then convert back. + +Internally, `datetime` values are stored as milliseconds since the Unix epoch +(1970-01-01T00:00:00Z). Converting a `datetime` to a `float` yields this +millisecond timestamp, and `datetime()` accepts a millisecond timestamp to +produce a `datetime`. + +### Example: offsetting a datetime by 7 days + +This expression takes a `"Shipped Date"` column, converts it to its +millisecond-epoch representation, adds 7 days worth of milliseconds (7 × +24 × 60 × 60 × 1000 = 604800000), and converts the result back +to a `datetime`: + +``` +// Due Date +datetime(float("Shipped Date") + 604800000) +``` + +## Operators + +Standard arithmetic and comparison operators are supported: + +| Operator | Description | +| -------------------------------- | ----------- | +| `+`, `-`, `*`, `/` | Arithmetic | +| `%` | Modulo | +| `==`, `!=`, `<`, `>`, `<=`, `>=` | Comparison | +| `and`, `or`, `not` | Logical | +| `if ... else ...` | Conditional | + +## Numeric Functions + +ExprTK provides a rich set of built-in numeric functions including `abs`, +`ceil`, `floor`, `round`, `exp`, `log`, `log10`, `sqrt`, `min`, `max`, `pow`, +`clamp`, `iclamp`, `inrange`, and trigonometric functions (`sin`, `cos`, `tan`, +`asin`, `acos`, `atan`). + +## String Functions + +| Function | Description | +| ------------------------------- | ------------------------------------------------------- | +| `concat(a, b, ...)` | Concatenate strings | +| `upper(s)` | Convert to uppercase | +| `lower(s)` | Convert to lowercase | +| `length(s)` | String length | +| `contains(s, substr)` | Whether `s` contains `substr` | +| `order(col, 'B', 'C', 'A')` | Custom sort order for a string column | +| `match(s, pattern)` | Regex partial match (returns boolean) | +| `match_all(s, pattern)` | Regex full match (returns boolean) | +| `search(s, pattern)` | First capturing group match | +| `indexof(s, pattern)` | Start index of first regex match | +| `substring(s, start, end)` | Substring from `start` (inclusive) to `end` (exclusive) | +| `replace(s, repl, pattern)` | Replace first regex match | +| `replace_all(s, repl, pattern)` | Replace all regex matches | + +## Date/Datetime Functions + +| Function | Description | +| ------------------------ | ------------------------------------------------------------------------ | +| `today()` | Current date | +| `now()` | Current datetime | +| `date(year, month, day)` | Construct a date | +| `datetime(timestamp_ms)` | Construct a datetime from a POSIX timestamp (ms since epoch) | +| `hour_of_day(dt)` | Hour component (0-23) | +| `day_of_week(dt)` | Day of the week as a string | +| `month_of_year(dt)` | Month of the year as a string | +| `bucket(dt, unit)` | Bucket datetime by unit: `'s'`, `'m'`, `'h'`, `'D'`, `'W'`, `'M'`, `'Y'` | + +`bucket` also works on numeric columns: `bucket("Price", 10)` rounds values down +to the nearest multiple of 10. + +## Other Functions + +| Function | Description | +| ------------------------- | ----------------------------------------------------- | +| `is_null(x)` | Whether the value is null | +| `is_not_null(x)` | Whether the value is not null | +| `percent_of(a, b)` | `a` as a percentage of `b` | +| `inrange(low, val, high)` | Whether `val` is between `low` and `high` (inclusive) | +| `min(a, b, ...)` | Minimum of inputs | +| `max(a, b, ...)` | Maximum of inputs | +| `random()` | Random float between 0.0 and 1.0 | +| `col(name)` | Look up a column by string name at runtime | +| `vlookup(col, key)` | Look up a value in another column by row key | diff --git a/docs/md/explanation/view/config/grouping_and_pivots.md b/docs/md/explanation/view/config/grouping_and_pivots.md new file mode 100644 index 0000000..5e1689b --- /dev/null +++ b/docs/md/explanation/view/config/grouping_and_pivots.md @@ -0,0 +1,147 @@ +# Grouping and Pivots + +## Group By + +A group by _groups_ the dataset by the unique values of each column used as a +group by - a close analogue in SQL to the `GROUP BY` statement. The underlying +dataset is aggregated to show the values belonging to each group, and a total +row is calculated for each group, showing the currently selected aggregated +value (e.g. `sum`) of the column. Group by are useful for hierarchies, +categorizing data and attributing values, i.e. showing the number of units sold +based on State and City. In Perspective, group by are represented as an array of +string column names to pivot, are applied in the order provided; For example, a +group by of `["State", "City", "Postal Code"]` shows the values for each Postal +Code, which are grouped by City, which are in turn grouped by State. + +
+ +```javascript +const view = await table.view({ group_by: ["a", "c"] }); +``` + +
+
+ +```python +view = table.view(group_by=["a", "c"]) +``` + +
+
+ +```rust +let view = table.view(Some(ViewConfigUpdate { + group_by: Some(vec!["a".into(), "c".into()]), + ..ViewConfigUpdate::default() +})).await?; +``` + +
+ +## Split By + +A split by _splits_ the dataset by the unique values of each column used as a +split by. The underlying dataset is not aggregated, and a new column is created +for each unique value of the split by. Each newly created column contains the +parts of the dataset that correspond to the column header, i.e. a `View` that +has `["State"]` as its split by will have a new column for each state. In +Perspective, Split By are represented as an array of string column names to +pivot: + +
+ +```javascript +const view = await table.view({ split_by: ["a", "c"] }); +``` + +
+
+ +```python +view = table.view(split_by=["a", "c"]) +``` + +
+
+ +```rust +let view = table.view(Some(ViewConfigUpdate { + split_by: Some(vec!["a".into(), "c".into()]), + ..ViewConfigUpdate::default() +})).await?; +``` + +
+ +## Aggregates + +Aggregates perform a calculation over an entire column, and are displayed when +one or more [Group By](#group-by) are applied to the `View`. Aggregates can be +specified by the user, or Perspective will use the following sensible default +aggregates based on column type: + +- "sum" for `integer` and `float` columns +- "count" for all other columns + +Perspective provides a selection of aggregate functions that can be applied to +columns in the `View` constructor using a dictionary of column name to aggregate +function name. + +
+ +```javascript +const view = await table.view({ + aggregates: { + a: "avg", + b: "distinct count", + }, +}); +``` + +
+
+ +```python +view = table.view( + aggregates={ + "a": "avg", + "b": "distinct count" + } +) +``` + +
+
+ +```rust +use std::collections::HashMap; +let view = table.view(Some(ViewConfigUpdate { + aggregates: Some(HashMap::from([ + ("a".into(), "avg".into()), + ("b".into(), "distinct count".into()), + ])), + ..ViewConfigUpdate::default() +})).await?; +``` + +
+ +The available aggregate functions depend on the column type: + +**Numeric columns** (`integer`, `float`): `sum`, `abs sum`, `sum abs`, +`sum not null`, `any`, `avg`, `mean`, `count`, `distinct count`, `dominant`, +`first`, `last`, `last by index`, `high`, `low`, `max`, `min`, +`high minus low`, `last minus first`, `median`, `q1`, `q3`, +`pct sum parent`, `pct sum total`, `stddev`, `var`, `unique`, +`weighted mean`, `min by`, `max by`. + +**String columns**: `count`, `any`, `distinct count`, `dominant`, `first`, +`last`, `last by index`, `join`, `median`, `q1`, `q3`, `unique`, `min by`, +`max by`. + +**Date/Datetime columns**: `count`, `any`, `avg`, `distinct count`, `dominant`, +`first`, `last`, `last by index`, `high`, `low`, `max`, `min`, `median`, +`q1`, `q3`, `unique`. + +**Boolean columns**: `count`, `any`, `distinct count`, `dominant`, `first`, +`last`, `last by index`, `unique`. diff --git a/docs/md/explanation/view/config/selection_and_ordering.md b/docs/md/explanation/view/config/selection_and_ordering.md new file mode 100644 index 0000000..6fb20c6 --- /dev/null +++ b/docs/md/explanation/view/config/selection_and_ordering.md @@ -0,0 +1,138 @@ +# Selection and Ordering + +## Columns + +The `columns` property specifies which columns should be included in the +`View`'s output. This allows users to show or hide a specific subset of columns, +as well as control the order in which columns appear to the user. This is +represented in Perspective as an array of string column names: + +
+ +```javascript +const view = await table.view({ + columns: ["a"], +}); +``` + +
+
+ +```python +view = table.view(columns=["a"]) +``` + +
+
+ +```rust +let view = table.view(Some(ViewConfigUpdate { + columns: Some(vec![Some("a".into())]), + ..ViewConfigUpdate::default() +})).await?; +``` + +
+ +## Sort + +The `sort` property specifies columns on which the query should be sorted, +analogous to `ORDER BY` in SQL. A column can be sorted regardless of its data +type, and sorts can be applied in ascending or descending order. Perspective +represents `sort` as an array of arrays, with the values of each inner array +being a string column name and a string sort direction. When `split_by` are +applied, the additional sort directions `"col asc"` and `"col desc"` will +determine the order of pivot column groups. + +
+ +```javascript +const view = await table.view({ + sort: [["a", "asc"]], +}); +``` + +
+
+ +```python +view = table.view(sort=[["a", "asc"]]) +``` + +
+
+ +```rust +let view = table.view(Some(ViewConfigUpdate { + sort: Some(vec![Sort("a".into(), SortDir::Asc)]), + ..ViewConfigUpdate::default() +})).await?; +``` + +
+ +The available sort directions are: + +| Direction | Description | +|---|---| +| `"asc"` | Ascending order | +| `"desc"` | Descending order | +| `"asc abs"` | Ascending by absolute value | +| `"desc abs"` | Descending by absolute value | +| `"col asc"` | Ascending order for pivot column groups (requires `split_by`) | +| `"col desc"` | Descending order for pivot column groups (requires `split_by`) | +| `"col asc abs"` | Ascending by absolute value for pivot column groups | +| `"col desc abs"` | Descending by absolute value for pivot column groups | + +## Filter + +The `filter` property specifies columns on which the query can be filtered, +returning rows that pass the specified filter condition. This is analogous to +the `WHERE` clause in SQL. There is no limit on the number of columns where +`filter` is applied, but the resulting dataset is one that passes all the filter +conditions, i.e. the filters are joined with an `AND` condition. The join +condition can be changed to `OR` via the `filter_op` property. + +Perspective represents `filter` as an array of arrays, with the values of each +inner array being a string column name, a string filter operator, and a filter +operand in the type of the column: + +
+ +```javascript +const view = await table.view({ + filter: [["a", "<", 100]], +}); +``` + +
+
+ +```python +view = table.view(filter=[["a", "<", 100]]) +``` + +
+
+ +```rust +let view = table.view(Some(ViewConfigUpdate { + filter: Some(vec![Filter::new("a", "<", FilterTerm::Scalar(Scalar::Float(100.0)))]), + ..ViewConfigUpdate::default() +})).await?; +``` + +
+ +The available filter operators depend on the column type: + +**String columns**: `==`, `!=`, `>`, `>=`, `<`, `<=`, `begins with`, +`contains`, `ends with`, `in`, `not in`, `is not null`, `is null`. + +**Numeric columns** (`integer`, `float`): `==`, `!=`, `>`, `>=`, `<`, `<=`, +`is not null`, `is null`. + +**Boolean columns**: `==`, `is not null`, `is null`. + +**Date/Datetime columns**: `==`, `!=`, `>`, `>=`, `<`, `<=`, `is not null`, +`is null`. diff --git a/docs/md/explanation/view/querying.md b/docs/md/explanation/view/querying.md new file mode 100644 index 0000000..35d62c6 --- /dev/null +++ b/docs/md/explanation/view/querying.md @@ -0,0 +1,50 @@ +# Querying data + +To query the table, create a [`Table::view`] on the table instance with an +optional configuration object. A [`Table`] can have as many [`View`]s associated +with it as you need - Perspective conserves memory by relying on a single +[`Table`] to power multiple [`View`]s concurrently: + +
+ +```javascript +const view = await table.view({ + columns: ["Sales"], + aggregates: { Sales: "sum" }, + group_by: ["Region", "Country"], + filter: [["Category", "in", ["Furniture", "Technology"]]], +}); +``` + +
+
+ +```python +view = table.view( + columns=["Sales"], + aggregates={"Sales": "sum"}, + group_by=["Region", "Country"], + filter=[["Category", "in", ["Furniture", "Technology"]]] +) +``` + +
+
+ +```rust +use crate::config::*; +let view = table + .view(Some(ViewConfigUpdate { + columns: Some(vec![Some("Sales".into())]), + aggregates: Some(HashMap::from_iter(vec![("Sales".into(), "sum".into())])), + group_by: Some(vec!["Region".into(), "Country".into()]), + filter: Some(vec![Filter::new("Category", "in", &[ + "Furniture", + "Technology", + ])]), + ..ViewConfigUpdate::default() + })) + .await?; +``` + +
diff --git a/docs/md/explanation/virtual_servers.md b/docs/md/explanation/virtual_servers.md new file mode 100644 index 0000000..d39a773 --- /dev/null +++ b/docs/md/explanation/virtual_servers.md @@ -0,0 +1,83 @@ +# Virtual Servers + +A Virtual Server allows Perspective to query external data sources (such as +DuckDB or ClickHouse) without loading the entire dataset into Perspective's +built-in data engine. Instead, Perspective translates its query operations +(group by, sort, filter, etc.) into queries the external data source can execute +natively, and only transfers the data needed for the current view. + +The Virtual Server API works on any platform that has a Perspective Client — +including JavaScript (both Node.js and the browser via WebAssembly), Python, and +Rust. In the browser, this means a virtual server can front a WASM-based engine +like `@duckdb/duckdb-wasm`, giving `` the ability to query a +database running entirely client-side without loading data into Perspective's +own engine. + +This is useful when: + +- The dataset is too large to fit in browser memory or a single process. +- Data already lives in a database and you want to avoid duplicating it. +- You want to leverage a database's native query optimizations. +- A WASM build of the data source is available in the browser (e.g. + `@duckdb/duckdb-wasm`) and you want to query it directly. + +## How it works + +A virtual server implements a handler interface that Perspective calls to +satisfy `Table` and `View` operations. The handler translates Perspective's view +configuration into the external system's query language (typically SQL), +executes the query, and returns the results as columnar data. Because the +handler speaks the standard Perspective Client protocol, it can run anywhere a +Client can — in-process, in a WebWorker, or on a remote server. + +``` +┌──────────────────────────────────────────────────┐ +│ │ +└──┬───────────────────────────────────────────────┘ + │ ┌──────────────────────────────────────────────────┐ + └──►│ Perspective Virtual Server Handler │ + └──┬───────────────────────────────────────────────┘ + │ ┌──────────────────────────────────────────────────┐ + └──►│ External DB (DuckDB, ClickHouse, …). │ + └──────────────────────────────────────────────────┘ +``` + +The viewer communicates with the virtual server handler the same way it would +with a regular Perspective server. The handler advertises its capabilities +(which operations it supports) via a _features_ object, and the viewer UI adapts +accordingly — disabling controls for unsupported operations. + +## Built-in implementations + +Perspective ships with virtual server implementations for: + +- **DuckDB** — query DuckDB databases in-browser via WASM + ([JavaScript](../how_to/javascript/virtual_server/duckdb.md)) or server-side + ([Python](../how_to/python/virtual_server/duckdb.md)). +- **ClickHouse** — query a ClickHouse server from the browser + ([JavaScript](../how_to/javascript/virtual_server/clickhouse.md)) or from + Python ([Python](../how_to/python/virtual_server/clickhouse.md)). + +## Custom implementations + +You can implement your own virtual server to connect Perspective to any data +source. See the language-specific guides: + +- [JavaScript: Implementing a custom Virtual Server](../how_to/javascript/virtual_server/custom.md) +- [Python: Implementing a custom Virtual Server](../how_to/python/virtual_server/custom.md) + +## Features declaration + +The `get_features()` / `getFeatures()` method returns an object that tells +Perspective which query operations the virtual server supports. The viewer will +only show controls for supported operations: + +| Field | Type | Description | +| ------------- | ------ | ----------------------------------------------------------- | +| `group_by` | `bool` | Whether group-by aggregation is supported | +| `split_by` | `bool` | Whether split-by (pivot) is supported | +| `sort` | `bool` | Whether sorting is supported | +| `expressions` | `bool` | Whether computed expressions are supported | +| `filter_ops` | `dict` | Map of column type to list of supported filter operators | +| `aggregates` | `dict` | Map of column type to list of supported aggregate functions | +| `on_update` | `bool` | Whether update callbacks are supported | diff --git a/docs/md/getting_started.md b/docs/md/getting_started.md new file mode 100644 index 0000000..14ee471 --- /dev/null +++ b/docs/md/getting_started.md @@ -0,0 +1,5 @@ +# Getting Started + +Guides for installing and using Perspective in JavaScript (Browser & Node.js), +Python and Rust. Each section includes installation steps, basic usage examples, +and language-specific integration details. diff --git a/docs/md/how_to.md b/docs/md/how_to.md new file mode 100644 index 0000000..1118fc5 --- /dev/null +++ b/docs/md/how_to.md @@ -0,0 +1,3 @@ +# How To + +Step-by-step guides for common Perspective tasks, organized by language. diff --git a/docs/md/how_to/javascript.md b/docs/md/how_to/javascript.md new file mode 100644 index 0000000..89384cf --- /dev/null +++ b/docs/md/how_to/javascript.md @@ -0,0 +1,4 @@ +# JavaScript + +Guides for using Perspective in the browser and Node.js, including the +`perspective` data engine and the `` UI component. diff --git a/docs/md/how_to/javascript/custom_worker.md b/docs/md/how_to/javascript/custom_worker.md new file mode 100644 index 0000000..aad5eec --- /dev/null +++ b/docs/md/how_to/javascript/custom_worker.md @@ -0,0 +1,73 @@ +# Customizing `perspective.worker()` + +`perspective.worker()` creates a `Client` that connects to a Perspective data +engine. By default it spins up a dedicated `Worker` running the built-in +WebAssembly engine, but you can pass an argument to change this behavior: + +- A **`Worker`**, **`SharedWorker`**, or **`ServiceWorker`** — runs the + built-in engine in a different worker context. +- A **`MessagePort`** from `createMessageHandler()` — connects to a + [Virtual Server](virtual_server/custom.md) instead of the built-in engine. + +## Built-in engine with a custom Worker + +Pass a `Worker`, `SharedWorker`, or `ServiceWorker` that loads the worker script +distributed at +`"@perspective-dev/client/dist/cdn/perspective-server.worker.js"`. + +`SharedWorker` and `ServiceWorker` have more complicated +behavior compared to a dedicated `Worker`, and will need special consideration +to integrate (or debug). + +### Dedicated `Worker` + +```javascript +const worker = await perspective.worker(new Worker(url)); +``` + +### `SharedWorker` + +```javascript +const worker = await perspective.worker(new SharedWorker(url)); +``` + +### `ServiceWorker` + +```javascript +const registration = await navigator.serviceWorker.register(url, { + scope: "", // Your scope here +}); + +const worker = await perspective.worker(registration.active); +``` + +## Virtual Server + +Instead of the built-in WebAssembly engine, `perspective.worker()` can connect +to a Virtual Server — an adapter that translates Perspective queries into +operations on an external data source such as +[DuckDB](virtual_server/duckdb.md) or +[ClickHouse](virtual_server/clickhouse.md). + +Use `perspective.createMessageHandler()` with a `VirtualServerHandler` to create +a `MessagePort`, then pass it to `worker()`: + +```javascript +import perspective from "@perspective-dev/client"; + +const handler = { + /* VirtualServerHandler implementation */ +}; + +const server = perspective.createMessageHandler(handler); +const client = await perspective.worker(server); +const table = await client.open_table("my_table"); +``` + +The returned `Client` works identically to one backed by the built-in engine — +you can pass it to `.load()`, call `open_table()`, etc. The +difference is that queries are fulfilled by your handler rather than the WASM +engine. + +For the full `VirtualServerHandler` interface and a worked example, see +[Implementing a custom Virtual Server](virtual_server/custom.md). diff --git a/docs/md/how_to/javascript/deleting.md b/docs/md/how_to/javascript/deleting.md new file mode 100644 index 0000000..c14d5aa --- /dev/null +++ b/docs/md/how_to/javascript/deleting.md @@ -0,0 +1,25 @@ +# Deleting a `table()` or `view()` + +Unlike standard JavaScript objects, Perspective objects such as `table()` and +`view()` store their associated data in the WebAssembly heap. Because of this, +as well as the current lack of a hook into the JavaScript runtime's garbage +collector from WebAssembly, the memory allocated to these Perspective objects +does not automatically get cleaned up when the object falls out of scope. + +In order to prevent memory leaks and reclaim the memory associated with a +Perspective `table()` or `view()`, you must call the `delete()` method: + +```javascript +await view.delete(); + +// This method will throw an exception if there are still `view()`s depending +// on this `table()`! +await table.delete(); +``` + +Similarly, `` Custom Elements do not delete the memory +allocated for the UI when they are removed from the DOM. + +```javascript +await viewer.delete(); +``` diff --git a/docs/md/how_to/javascript/events.md b/docs/md/how_to/javascript/events.md new file mode 100644 index 0000000..93aee17 --- /dev/null +++ b/docs/md/how_to/javascript/events.md @@ -0,0 +1,40 @@ +# Listening for events + +The `` Custom Element fires all the same HTML `Event`s that +standard DOM `HTMLElement` objects fire, in addition to a few custom +`CustomEvent`s which relate to UI updates including those initiaed through user +interaction. + +## Update events + +Whenever a ``s underlying `table()` is changed via the +`load()` or `update()` methods, a `perspective-view-update` DOM event is fired. +Similarly, `view()` updates instigated either through the Attribute API or +through user interaction will fire a `perspective-config-update` event: + +```javascript +elem.addEventListener("perspective-config-update", function (event) { + var config = elem.save(); + console.log("The view() config has changed to " + JSON.stringify(config)); +}); +``` + +## Click events + +Whenever a ``'s grid or chart is clicked, a +`perspective-click` DOM event is fired containing a detail object with `config`, +`column_names`, and `row`. + +The `config` object contains an array of `filters` that can be applied to a +`` through the use of `restore()` updating it to show the +filtered subset of data. + +The `column_names` property contains an array of matching columns, and the `row` +property returns the associated row data. + +```javascript +elem.addEventListener("perspective-click", function (event) { + var config = event.detail.config; + elem.restore(config); +}); +``` diff --git a/docs/md/how_to/javascript/importing.md b/docs/md/how_to/javascript/importing.md new file mode 100644 index 0000000..9e5a53a --- /dev/null +++ b/docs/md/how_to/javascript/importing.md @@ -0,0 +1,172 @@ +# JavaScript - Importing with or without a bundler + +Perspective requires the browser to have access to Perspective's `.wasm` +binaries _in addition_ to the bundled `.js` files, and as a result the build +process requires a few extra steps. Perspective's NPM releases come with +multiple prebuilt configurations. + +## ESM builds with a bundler + +The recommended builds for production use are packaged as ES Modules and require +a _bootstrapping_ step in order to acquire the `.wasm` binaries and initialize +Perspective's JavaScript with them. Because they have no hard-coded dependencies +on the `.wasm` paths, they are ideal for use with JavaScript bundlers such as +ESBuild, Rollup, Vite or Webpack. + +ESM builds must be _bootstrapped_ with their `.wasm` binaries to initialize. The +`wasm` binaries can be found in their respective `dist/wasm` directories. + +```javascript +import perspective_viewer from "@perspective-dev/viewer"; +import perspective from "@perspective-dev/client"; + +// TODO These paths must be provided by the bundler! +const SERVER_WASM = ... // "@perspective-dev/server/dist/wasm/perspective-server.wasm" +const CLIENT_WASM = ... // "@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm" + +await Promise.all([ + perspective.init_server(SERVER_WASM), + perspective_viewer.init_client(CLIENT_WASM), +]); + +// Now Perspective API will work! +const worker = await perspective.worker(); +const viewer = document.createElement("perspective-viewer"); +``` + +The exact syntax will vary slightly depending on the bundler. + +### Vite + +```javascript +import SERVER_WASM from "@perspective-dev/server/dist/wasm/perspective-server.wasm?url"; +import CLIENT_WASM from "@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm?url"; + +await Promise.all([ + perspective.init_server(fetch(SERVER_WASM)), + perspective_viewer.init_client(fetch(CLIENT_WASM)), +]); +``` + +You'll also need to target `esnext` in your `vite.config.js` in order to run the +`build` step: + +```javascript +import { defineConfig } from "vite"; +export default defineConfig({ + build: { + target: "esnext", + }, +}); +``` + +### ESBuild + +```javascript +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)), +]); +``` + +ESBuild config JSON to encode this asset as a `file`: + +```javascript +{ + // ... + "loader": { + // ... + ".wasm": "file" + } +} +``` + +### Webpack + +```javascript +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(SERVER_WASM), + perspective_viewer.init_client(CLIENT_WASM), +]); +``` + +Webpack config: + +```javascript +{ + // ... + module: { + // ... + rules: [ + // ... + { + test: /\.wasm$/, + type: "asset/resource" + }, + ] + }, + experiments: { + // ... + asyncWebAssembly: false, + syncWebAssembly: false, + }, +} +``` + +## Inline builds with a bundler + +Inline builds are deprecated and will be removed in a +future release. + +Perspective's _Inline_ Builds work by _inlining_ WebAssembly binary content as +a base64-encoded string. While inline builds work with most bundlers and _do +not_ require bootstrapping, there is an inherent file-size and boot-performance +penalty. Prefer your bundler's inlining features and Perspective ESM builds +where possible. + +```javascript +import "@perspective-dev/viewer/dist/esm/perspective-viewer.inline.js"; +import psp from "@perspective-dev/client/dist/esm/perspective.inline.js"; +``` + +## CDN builds + +Perspective's CDN builds are good for non-bundled scenarios, such as importing +directly from a ` +``` + +## Node.js builds + +The Node.js runtime for the `@perspective-dev/client` module runs in-process by +default and does not implement a `child_process` interface. Hence, there is no +`worker()` method, and the module object itself directly exports the full +`perspective` API. + +```javascript +const perspective = require("@perspective-dev/client"); +``` + +In Node.js, perspective does not run in a WebWorker (as this API does not exist +in Node.js), so no need to call the `.worker()` factory function - the +`perspective` library exports the functions directly and run synchronously in +the main process. diff --git a/docs/md/how_to/javascript/installation.md b/docs/md/how_to/javascript/installation.md new file mode 100644 index 0000000..8471fb4 --- /dev/null +++ b/docs/md/how_to/javascript/installation.md @@ -0,0 +1,54 @@ +# JavaScript Installation and Module Structure + +Perspective is designed for flexibility, allowing developers to pick and choose +which modules they need. The main modules are: + +- `@perspective-dev/client` + The data engine library, as both a browser ES6 and Node.js module. Provides a + WebAssembly, WebWorker (browser) and Process (node.js) runtime. + +- `@perspective-dev/viewer` + A user-configurable visualization widget, bundled as a + [Web Component](https://www.webcomponents.org/introduction). This module + includes the core data engine module as a dependency. + +`` by itself only implements a trivial debug renderer, which +prints the currently configured `view()` as a CSV. Plugin modules are packaged +separately and must be imported individually. + +- `@perspective-dev/viewer-datagrid` + A custom high-performance data-grid component based on HTML ``. + +- `@perspective-dev/viewer-charts` + A set of charting components base on WebGL. + +When imported after `@perspective-dev/viewer`, the plugin modules will register +themselves automatically, and the renderers they export will be available in the +`plugin` dropdown in the `` UI. + +## Browser + +Perspective's WebAssembly data engine is available via NPM in the same package +as its Node.js counterpart, `@perspective-dev/client`. The Perspective Viewer UI +(which has no Node.js component) must be installed separately: + +```bash +$ npm add @perspective-dev/client @perspective-dev/viewer +``` + +By itself, `@perspective-dev/viewer` does not provide any visualizations, only +the UI framework. Perspective _Plugins_ provide visualizations and must be +installed separately. All Plugins are optional - but a `` +without Plugins would be rather boring! + +```bash +$ npm add @perspective-dev/viewer-charts @perspective-dev/viewer-datagrid +``` + +## Node.js + +To use Perspective from a Node.js server, simply install via NPM. + +```bash +$ npm add @perspective-dev/client +``` diff --git a/docs/md/how_to/javascript/join.md b/docs/md/how_to/javascript/join.md new file mode 100644 index 0000000..aba7932 --- /dev/null +++ b/docs/md/how_to/javascript/join.md @@ -0,0 +1,65 @@ +# Joining Tables + +`perspective.join()` creates a read-only `Table` by joining two source tables on +a shared key column. The result is reactive — it updates automatically when +either source table changes. See [`Join`](../../explanation/join.md) for +conceptual details. + +## Basic Inner Join + +```javascript +const orders = await perspective.table([ + { id: 1, product_id: 101, qty: 5 }, + { id: 2, product_id: 102, qty: 3 }, + { id: 3, product_id: 101, qty: 7 }, +]); + +const products = await perspective.table([ + { product_id: 101, name: "Widget" }, + { product_id: 102, name: "Gadget" }, +]); + +const joined = await perspective.join(orders, products, "product_id"); +const view = await joined.view(); +const json = await view.to_json(); +// [ +// { product_id: 101, id: 1, qty: 5, name: "Widget" }, +// { product_id: 101, id: 3, qty: 7, name: "Widget" }, +// { product_id: 102, id: 2, qty: 3, name: "Gadget" }, +// ] +``` + +## Join Types + +Pass `join_type` in the options to select inner, left, or outer join behavior: + +```javascript +// Left join: all left rows, nulls for unmatched right columns +const left_joined = await perspective.join(left, right, "id", { + join_type: "left", +}); + +// Outer join: all rows from both tables +const outer_joined = await perspective.join(left, right, "id", { + join_type: "outer", +}); +``` + +## Reactive Updates + +The joined table recomputes automatically when either source table is updated: + +```javascript +const left = await perspective.table([{ id: 1, x: 10 }]); +const right = await perspective.table([{ id: 2, y: "b" }]); + +const joined = await perspective.join(left, right, "id"); +const view = await joined.view(); + +let json = await view.to_json(); +// [] — no matching keys yet + +await right.update([{ id: 1, y: "a" }]); +json = await view.to_json(); +// [{ id: 1, x: 10, y: "a" }] — new match detected +``` diff --git a/docs/md/how_to/javascript/loading_data.md b/docs/md/how_to/javascript/loading_data.md new file mode 100644 index 0000000..08cf7c4 --- /dev/null +++ b/docs/md/how_to/javascript/loading_data.md @@ -0,0 +1,76 @@ +# Loading data from a Table + +Data can be loaded into `` in the form of a `Table()` or a +`Promise
` via the `load()` method. + +```javascript +// Create a new worker, then a new table promise on that worker. +const worker = await perspective.worker(); +const table = await worker.table(data); + +// Bind a viewer element to this table. +await viewer.load(table); +``` + +## Sharing a `Table` between multiple ``s + +Multiple ``s can share a `table()` by passing the `table()` +into the `load()` method of each viewer. Each `perspective-viewer` will update +when the underlying `table()` is updated, but `table.delete()` will fail until +all `perspective-viewer` instances referencing it are also deleted: + +```javascript +const viewer1 = document.getElementById("viewer1"); +const viewer2 = document.getElementById("viewer2"); + +// Create a new WebWorker +const worker = await perspective.worker(); + +// Create a table in this worker +const table = await worker.table(data); + +// Load the same table in 2 different elements +await viewer1.load(table); +await viewer2.load(table); + +// Both `viewer1` and `viewer2` will reflect this update +await table.update([{ x: 5, y: "e", z: true }]); +``` + +## Loading from a virtual `Table` + +Loading a virtual (server-only) `Table` works just like loading a local/Web +Worker `Table` — just pass the virtual `Table` to `viewer.load()`. In the +browser: + +```javascript +const elem = document.getElementsByTagName("perspective-viewer")[0]; + +// Bind to the server's worker instead of instantiating a Web Worker. +const websocket = await perspective.websocket( + window.location.origin.replace("http", "ws") +); + +// Bind the viewer to the preloaded data source. `table` and `view` objects +// live on the server. +const server_table = await websocket.open_table("table_one"); +await elem.load(server_table); +``` + +Alternatively, data can be _cloned_ from a server-side virtual `Table` into a +client-side WebAssembly `Table`. The browser clone will be synced via delta +updates transferred via Apache Arrow IPC format, but local `View`s created will +be calculated locally on the client browser. + +```javascript +const worker = await perspective.worker(); +const server_view = await server_table.view(); +const client_table = worker.table(server_view); +await elem.load(client_table); +``` + +`` instances bound in this way are otherwise no different +than ``s which rely on a Web Worker, and can even share a +host application with Web Worker-bound `table()`s. The same `promise`-based API +is used to communicate with the server-instantiated `view()`, only in this case +it is over a websocket. diff --git a/docs/md/how_to/javascript/nodejs_server.md b/docs/md/how_to/javascript/nodejs_server.md new file mode 100644 index 0000000..e7967ea --- /dev/null +++ b/docs/md/how_to/javascript/nodejs_server.md @@ -0,0 +1,38 @@ +# Server-only via `WebSocketServer()` and Node.js + +For exceptionally large datasets, a `Client` can be bound to a +`perspective.table()` instance running in Node.js/Python/Rust remotely, rather +than creating one in a Web Worker and downloading the entire data set. This +trades off network bandwidth and server resource requirements for a smaller +browser memory and CPU footprint. + +An example in Node.js: + +```javascript +const { WebSocketServer, table } = require("@perspective-dev/client"); +const fs = require("fs"); + +// Start a WS/HTTP host on port 8080. The `assets` property allows +// the `WebSocketServer()` to also serves the file structure rooted in this +// module's directory. +const host = new WebSocketServer({ assets: [__dirname], port: 8080 }); + +// Read an arrow file from the file system and host it as a named table. +const arr = fs.readFileSync(__dirname + "/superstore.lz4.arrow"); +await table(arr, { name: "table_one" }); +``` + +... and the [`Client`] implementation in the browser: + +```javascript +const elem = document.getElementsByTagName("perspective-viewer")[0]; + +// Bind to the server's worker instead of instantiating a Web Worker. +const websocket = await perspective.websocket( + window.location.origin.replace("http", "ws"), +); + +// Create a virtual `Table` to the preloaded data source. `table` and `view` +// objects live on the server. +const server_table = await websocket.open_table("table_one"); +``` diff --git a/docs/md/how_to/javascript/plugin_settings.md b/docs/md/how_to/javascript/plugin_settings.md new file mode 100644 index 0000000..3d44622 --- /dev/null +++ b/docs/md/how_to/javascript/plugin_settings.md @@ -0,0 +1,31 @@ +# Plugin render limits + +`` plugins (especially charts) may in some cases generate +extremely large output which may lock up the browser. In order to prevent +accidents (which generally require a browser refresh to fix), each plugin has a +`max_cells` and `max_columns` heuristic which requires the user to opt-in to +fully rendering `View`s which exceed these limits. To override this behavior, +set these values for each plugin type individually, _before_ the plugin itself +is rendered (e.g. calling `HTMLPerspectiveViewerElement::restore` with the +respective `plugin` name). + +If you have a `` instance, you can configure plugins via +`HTMLPerspectiveViewerElement::getPlugin` and +`HTMLPerspectiveViewerElement::getAllPlugins`: + +```javascript +const viewer = document.querySelector("perspective-viewer"); +const plugin = viewer.getPlugin("Treemap"); +plugin.max_cells = 1_000_000; +plugin.max_columns = 1000; +``` + +... Or alternatively, you can look up the Custom Element classes and set the +static variants if you know the element name (you can e.g. look this up in your +browser's DOM inspector): + +```javascript +const plugin = customElements.get("perspective-viewer-charts-treemap"); +plugin.max_cells = 1_000_000; +plugin.max_columns = 1000; +``` diff --git a/docs/md/how_to/javascript/react.md b/docs/md/how_to/javascript/react.md new file mode 100644 index 0000000..03cf7a0 --- /dev/null +++ b/docs/md/how_to/javascript/react.md @@ -0,0 +1,57 @@ +# React Component + +We provide a React wrapper to prevent common issues and mistakes associated with +using the perspective-viewer web component in the context of React. + +Before trying this example, please take a look at +[how to bootstrap perspective](./importing.md). + +## `PerspectiveViewer` + +A simple example using the `PerspectiveViewer` component: + +```typescript +import React, { useCallback, useEffect, useRef } from "react"; +import { + PerspectiveViewer, +} from "@perspective-dev/react"; +import perspective from "@perspective-dev/client"; + +function App() { + const worker = useRef(null); + + useEffect(() => { + (async () => { + worker.current = await perspective.worker(); + const resp = await fetch("data.arrow"); + const arrow = await resp.arrayBuffer(); + await worker.current.table(arrow, { name: "my_table" }); + })(); + }, []); + + return ( + + ); +} +``` + +## `PerspectiveWorkspace` + +For multi-viewer layouts, use `PerspectiveWorkspace`: + +```typescript +import { PerspectiveWorkspace } from "@perspective-dev/react"; + +const WORKSPACE_CONFIG = // ... + +function Dashboard() { + return ( + + ); +} +``` diff --git a/docs/md/how_to/javascript/save_restore.md b/docs/md/how_to/javascript/save_restore.md new file mode 100644 index 0000000..e9f21c5 --- /dev/null +++ b/docs/md/how_to/javascript/save_restore.md @@ -0,0 +1,99 @@ +# Saving and restoring UI state. + +`` is _persistent_, in that its entire state (sans the data +itself) can be serialized or deserialized. This include all column, filter, +pivot, expressions, etc. properties, as well as datagrid style settings, config +panel visibility, and more. This overloaded feature covers a range of use cases: + +- Setting a ``'s initial state after a `load()` call. +- Updating a single or subset of properties, without modifying others. +- Resetting some or all properties to their data-relative default. +- Persisting a user's configuration to `localStorage` or a server. + +## Serializing and deserializing the viewer state + +To retrieve the entire state as a JSON-ready JavaScript object, use the `save()` +method. `save()` also supports a few other formats such as `"arraybuffer"` and +`"string"` (base64, not JSON), which you may choose for size at the expense of +easy migration/manual-editing. + +```javascript +const json_token = await elem.save(); +const string_token = await elem.save("string"); +``` + +For any format, the serialized token can be restored to any +`` with a `Table` of identical schema, via the `restore()` +method. Note that while the data for a token returned from `save()` may differ, +generally its schema may not, as many other settings depend on column names and +types. + +```javascript +await elem.restore(json_token); +await elem.restore(string_token); +``` + +As `restore()` dispatches on the token's type, it is important to make sure that +these types match! A common source of error occurs when passing a +JSON-stringified token to `restore()`, which will assume base64-encoded msgpack +when a string token is used. + +```javascript +// This will error! +await elem.restore(JSON.stringify(json_token)); +``` + +### Updating individual properties + +Using the JSON format, every facet of a ``'s configuration +can be manipulated from JavaScript using the `restore()` method. The valid +structure of properties is described via the +[`ViewerConfig`](https://github.com/perspective-dev/perspective/blob/ebced4caa/rust/perspective-viewer/src/ts/viewer.ts#L16) +and embedded +[`ViewConfig`](https://github.com/perspective-dev/perspective/blob/ebced4caa19435a2a57d4687be7e428a4efc759b/packages/perspective/index.d.ts#L140) +type declarations, and [`View`](view.md) chapter of the documentation which has +several interactive examples for each `ViewConfig` property. + +```javascript +// Set the plugin (will also update `columns` to plugin-defaults) +await elem.restore({ plugin: "X Bar" }); + +// Update plugin and columns (only draws once) +await elem.restore({ plugin: "X Bar", columns: ["Sales"] }); + +// Open the config panel +await elem.restore({ settings: true }); + +// Create an expression +await elem.restore({ + columns: ['"Sales" + 100'], + expressions: { "New Column": '"Sales" + 100' }, +}); + +// ERROR if the column does not exist in the schema or expressions +// await elem.restore({columns: ["\"Sales\" + 100"], expressions: {}}); + +// Add a filter +await elem.restore({ filter: [["Sales", "<", 100]] }); + +// Add a sort, don't remove filter +await elem.restore({ sort: [["Prodit", "desc"]] }); + +// Reset just filter, preserve sort +await elem.restore({ filter: undefined }); + +// Reset all properties to default e.g. after `load()` +await elem.reset(); +``` + +Another effective way to quickly create a token for a desired configuration is +to simply copy the token returned from `save()` after settings the view manually +in the browser. The JSON format is human-readable and should be quite easy to +tweak once generated, as `save()` will return even the default settings for all +properties. You can call `save()` in your application code, or e.g. through the +Chrome developer console: + +```javascript +// Copy to clipboard +copy(await document.querySelector("perspective-viewer").save()); +``` diff --git a/docs/md/how_to/javascript/serializing.md b/docs/md/how_to/javascript/serializing.md new file mode 100644 index 0000000..7332fa8 --- /dev/null +++ b/docs/md/how_to/javascript/serializing.md @@ -0,0 +1,21 @@ +### Serializing data + +The `view()` allows for serialization of data to JavaScript through the +`to_json()`, `to_ndjson()`, `to_columns()`, `to_csv()`, and `to_arrow()` methods +(the same data formats supported by the `Client::table` factory function). These +methods return a `promise` for the calculated data: + +```javascript +const view = await table.view({ group_by: ["State"], columns: ["Sales"] }); + +// JavaScript Objects +console.log(await view.to_json()); +console.log(await view.to_columns()); + +// String +console.log(await view.to_csv()); +console.log(await view.to_ndjson()); + +// ArrayBuffer +console.log(await view.to_arrow()); +``` diff --git a/docs/md/how_to/javascript/theming.md b/docs/md/how_to/javascript/theming.md new file mode 100644 index 0000000..ebfe66b --- /dev/null +++ b/docs/md/how_to/javascript/theming.md @@ -0,0 +1,96 @@ +# Theming + +Theming is supported in `perspective-viewer` and its accompanying plugins. A +number of themes come bundled with `perspective-viewer`; you can import any of +these themes directly into your app, and the `perspective-viewer`s will be +themed accordingly: + +```javascript +// Themes based on Thought Merchants's Prospective design +import "@perspective-dev/viewer/dist/css/pro.css"; +import "@perspective-dev/viewer/dist/css/pro-dark.css"; + +// Other themes +import "@perspective-dev/viewer/dist/css/solarized.css"; +import "@perspective-dev/viewer/dist/css/solarized-dark.css"; +import "@perspective-dev/viewer/dist/css/monokai.css"; +import "@perspective-dev/viewer/dist/css/vaporwave.css"; +``` + +Alternatively, you may use `themes.css`, which bundles all default themes + +```javascript +import "@perspective-dev/viewer/dist/css/themes.css"; +``` + +If you choose not to bundle the themes yourself, they are available through +[CDN](https://cdn.jsdelivr.net/npm/@perspective-dev/viewer/dist/css/). These can +be directly linked in your HTML file: + +```html + +``` + +Note the `crossorigin="anonymous"` attribute. When including a theme from a +cross-origin context, this attribute may be required to allow +`` to detect the theme. If this fails, additional themes are +added to the `document` after `` init, or for any other +reason theme auto-detection fails, you may manually inform +`` of the available theme names with the `.resetThemes()` +method. + +```javascript +// re-auto-detect themes +viewer.resetThemes(); + +// Set available themes explicitly (they still must be imported as CSS!) +viewer.resetThemes(["Pro Light", "Pro Dark"]); +``` + +`` will default to the first loaded theme when initialized. +You may override this via `.restore()`, or provide an initial theme by setting +the `theme` attribute: + +```html + +``` + +or + +```javascript +const viewer = document.querySelector("perspective-viewer"); +await viewer.restore({ theme: "Pro Dark" }); +``` + +## Custom Themes + +The best way to write a new theme is to +[fork and modify an existing theme](https://github.com/perspective-dev/perspective/tree/master/rust/perspective-viewer/src/themes), +which are _just_ collections of regular CSS variables (no preprocessor is +required, though Perspective's own themes use one). `` is +not "themed" by default and will lack icons and label text in addition to colors +and fonts, so starting from an empty theme forces you to define _every_ +theme-able variable to get a functional UI. + +### Icons and Translation + +UI icons are defined by CSS variables provided by +[`@perspective-dev/viewer/dist/css/icons.css`](https://github.com/perspective-dev/perspective/blob/master/rust/perspective-viewer/src/themes/icons.css). +These variables must be defined for the UI icons to work - there are no default +icons without a theme. + +UI text is also defined in CSS variables provided by +[`@perspective-dev/viewer/dist/css/intl.css`](https://github.com/perspective-dev/perspective/blob/master/rust/perspective-viewer/src/themes/intl.css), +and has identical import requirements. Some _example definitions_ +(automatically-translated sans-editing) can be found +[`@perspective-dev/viewer/dist/css/intl/` folder](https://github.com/perspective-dev/perspective/tree/master/rust/perspective-viewer/src/themes/intl). + +Importing the pre-built `themes.css` stylesheet as well as a custom theme will +define Icons and Translation globally as a side-effect. You can still customize +icons in this mode with rules (of the appropriate specificity), _but_ if you do +not still remember to define these variables yourself, your theme will not work +without the base `themes.css` package available. diff --git a/docs/md/how_to/javascript/viewer.md b/docs/md/how_to/javascript/viewer.md new file mode 100644 index 0000000..7b80755 --- /dev/null +++ b/docs/md/how_to/javascript/viewer.md @@ -0,0 +1,62 @@ +# `` Custom Element library + +`` provides a complete graphical UI for configuring the +`perspective` library and formatting its output to the provided visualization +plugins. + +Once imported and initialized in JavaScript, the `` Web +Component will be available in any standard HTML on your site. A simple example: + +```html + + +``` + +## Attributes + +`` can be configured via HTML attributes or JavaScript +properties. When set as attributes, the viewer will apply the configuration on +initialization: + +```html + + +``` + +## UI Features + +The viewer provides an interactive side panel with: + +- **Column list** - drag and drop columns to configure `group_by`, `split_by`, + `sort`, and `filter` fields. +- **New Column** button - opens an expression editor for creating computed + columns via the [expression language](../../explanation/view/config/expressions.md). +- **Plugin selector** - switch between visualization plugins such as Datagrid, + X/Y Line, X/Y Scatter, Treemap, Sunburst, and Heatmap. +- **Theme** selector - toggle between available themes. +- **Export** - download the current view as CSV or Arrow. +- **Copy** - copy the current view to the clipboard. +- **Reset** - restore the viewer to its default configuration. + +## Methods + +Key methods on the `` element: + +| Method | Description | +|---|---| +| `load(table)` | Bind a `Table` to the viewer | +| `restore(config)` | Apply a saved configuration object | +| `save()` | Serialize the current configuration | +| `reset(all)` | Reset configuration (pass `true` to also reset expressions) | +| `getTable()` | Get the bound `Table` | +| `flush()` | Wait for any pending UI updates to complete | diff --git a/docs/md/how_to/javascript/virtual_server.md b/docs/md/how_to/javascript/virtual_server.md new file mode 100644 index 0000000..741a3d8 --- /dev/null +++ b/docs/md/how_to/javascript/virtual_server.md @@ -0,0 +1,19 @@ +# Virtual Servers + +Perspective's Virtual Server feature lets you connect `` to +external data sources without loading data into Perspective's built-in engine. +Instead, queries are translated and executed natively by the external database. + +For a detailed explanation of how virtual servers work, see the +[Virtual Servers](../../explanation/virtual_servers.md) concepts page. + +Perspective ships with built-in virtual server implementations for: + +- [**DuckDB**](./virtual_server/duckdb.md) — query DuckDB databases in-browser + via `@duckdb/duckdb-wasm`, or on the server via Node.js. +- [**ClickHouse**](./virtual_server/clickhouse.md) — query a ClickHouse server + directly from the browser or from Node.js. + +You can also [**implement your own**](./virtual_server/custom.md) virtual server +to connect Perspective to any data source by implementing the +`VirtualServerHandler` interface. diff --git a/docs/md/how_to/javascript/virtual_server/clickhouse.md b/docs/md/how_to/javascript/virtual_server/clickhouse.md new file mode 100644 index 0000000..af70a7f --- /dev/null +++ b/docs/md/how_to/javascript/virtual_server/clickhouse.md @@ -0,0 +1,43 @@ +# ClickHouse Virtual Server + +Perspective provides a built-in virtual server for +[ClickHouse](https://clickhouse.com/), allowing `` to query +ClickHouse tables directly from the browser. + +For server-side Python usage, see the +[Python ClickHouse guide](../../python/virtual_server/clickhouse.md). + +## Installation + +```bash +npm install @perspective-dev/client @perspective-dev/viewer @clickhouse/client-web +``` + +## Usage + +Connect to a ClickHouse instance and bind it to a Perspective viewer: + +```javascript +import perspective from "@perspective-dev/client"; +import "@perspective-dev/viewer"; +import { createClient } from "@clickhouse/client-web"; + +// Connect to ClickHouse +const clickhouseClient = createClient({ + url: "http://localhost:8123", + database: "default", +}); + +// Create a Perspective virtual server backed by ClickHouse +const handler = perspective.ClickhouseHandler(clickhouseClient); +const messageHandler = perspective.createMessageHandler(handler); + +// Connect a viewer +const client = await perspective.worker(messageHandler); +const table = await client.open_table("my_table"); +document.getElementById("viewer").load(table); +``` + +## Examples + +- [Browser ClickHouse example](https://github.com/perspective-dev/perspective/tree/master/examples/esbuild-clickhouse-virtual) diff --git a/docs/md/how_to/javascript/virtual_server/custom.md b/docs/md/how_to/javascript/virtual_server/custom.md new file mode 100644 index 0000000..38e7a3a --- /dev/null +++ b/docs/md/how_to/javascript/virtual_server/custom.md @@ -0,0 +1,80 @@ +# Implementing a custom Virtual Server + +You can connect Perspective to any data source by implementing the +`VirtualServerHandler` interface and passing it to `createMessageHandler()`. + +For background on virtual servers, see the +[Virtual Servers overview](../../../explanation/virtual_servers.md). + +## Example + +```typescript +import perspective from "@perspective-dev/client"; +import type { + VirtualServerHandler, + ColumnType, + ViewConfig, + ViewWindow, + VirtualDataSlice, +} from "@perspective-dev/client"; + +const handler = { + async getHostedTables(): Promise { + return ["my_table"]; + }, + + async tableSchema(tableId: string): Promise> { + return { name: "string", price: "float", date: "date" }; + }, + + async tableSize(tableId: string): Promise { + return 1000; + }, + + async tableMakeView( + tableId: string, + viewId: string, + config: ViewConfig, + ): Promise { + // Translate `config` (group_by, sort, filter, etc.) into a query + // against your data source. Store the query keyed by `viewId` + // for later data retrieval. + }, + + async viewDelete(viewId: string): Promise { + // Clean up resources for this view + }, + + async viewGetData( + viewId: string, + config: ViewConfig, + schema: Record, + viewport: ViewWindow, + dataSlice: VirtualDataSlice, + ): Promise { + // Query your data source using `config` and `viewport` for the + // row/column window. Push columnar results via `dataSlice.setCol()`. + }, + + getFeatures() { + return { + group_by: true, + sort: true, + filter_ops: { + string: ["==", "!=", "contains", "is null", "is not null"], + float: ["==", "!=", ">", "<", ">=", "<="], + }, + aggregates: { + float: ["sum", "avg", "count", "min", "max"], + string: ["count", "any"], + }, + }; + }, +} satisfies VirtualServerHandler; + +// Create a message handler and use it like a worker +const messageHandler = perspective.createMessageHandler(handler); +const client = await perspective.worker(messageHandler); +const table = await client.open_table("my_table"); +document.getElementById("viewer").load(table); +``` diff --git a/docs/md/how_to/javascript/virtual_server/duckdb.md b/docs/md/how_to/javascript/virtual_server/duckdb.md new file mode 100644 index 0000000..23bfbe2 --- /dev/null +++ b/docs/md/how_to/javascript/virtual_server/duckdb.md @@ -0,0 +1,49 @@ +# DuckDB Virtual Server + +Perspective provides a built-in virtual server for +[DuckDB](https://duckdb.org/), allowing `` to query +DuckDB-WASM databases directly in the browser. + +For server-side Python usage, see the +[Python DuckDB guide](../../python/virtual_server/duckdb.md). + +## Installation + +```bash +npm install @perspective-dev/client @perspective-dev/viewer @duckdb/duckdb-wasm +``` + +## Usage + +Initialize DuckDB-WASM, load data, and connect it to a Perspective viewer: + +```javascript +import perspective from "@perspective-dev/client"; +import "@perspective-dev/viewer"; +import * as duckdb from "@duckdb/duckdb-wasm"; + +// Initialize DuckDB-WASM +const DUCKDB_BUNDLES = duckdb.getJsDelivrBundles(); +const bundle = await duckdb.selectBundle(DUCKDB_BUNDLES); +const worker = await duckdb.createWorker(bundle.mainWorker); +const logger = new duckdb.ConsoleLogger(); +const db = new duckdb.AsyncDuckDB(logger, worker); +await db.instantiate(bundle.mainModule); + +// Load data into DuckDB +const conn = await db.connect(); +await conn.query(`CREATE TABLE my_table AS SELECT * FROM 'data.parquet'`); + +// Create a Perspective virtual server backed by DuckDB +const handler = perspective.DuckDBHandler(db); +const messageHandler = perspective.createMessageHandler(handler); + +// Connect a viewer +const client = await perspective.worker(messageHandler); +const table = await client.open_table("my_table"); +document.getElementById("viewer").load(table); +``` + +## Examples + +- [Browser DuckDB example](https://github.com/perspective-dev/perspective/tree/master/examples/esbuild-duckdb-virtual) diff --git a/docs/md/how_to/javascript/worker.md b/docs/md/how_to/javascript/worker.md new file mode 100644 index 0000000..07dc816 --- /dev/null +++ b/docs/md/how_to/javascript/worker.md @@ -0,0 +1,44 @@ +# Accessing the Perspective engine via a `Client` instance + +An instance of a `Client` is needed to talk to a Perspective `Server`, of which +there are a few varieties available in JavaScript. + +## Web Worker (Browser) + +Perspective's Web Worker client is actually a `Client` and `Server` rolled into +one. Instantiating this `Client` will also create a _dedicated_ Perspective +`Server` in a Web Worker process. + +To use it, you'll need to instantiate a Web Worker `perspective` engine via the +`worker()` method. This will create a new Web Worker (browser) and load the +WebAssembly binary. All calculation and data accumulation will occur in this +separate process. + +```javascript +const client = await perspective.worker(); +``` + +The `worker` symbol will expose the full `perspective` API for one managed Web +Worker process. You are free to create as many as your browser supports, but be +sure to keep track of the `worker` instances themselves, as you'll need them to +interact with your data in each instance. + +## Websocket (Browser) + +Alternatively, with a Perspective server running in Node.js, Python or Rust, you +can create a _virtual_ `Client` via the `websocket()` method. + +```javascript +const client = perspective.websocket("http://localhost:8080/"); +``` + +## Node.js + +The Node.js runtime for the `@perspective-dev/client` module runs in-process by +default and does not implement a `child_process` interface, so no need to call +the `.worker()` factory function. Instead, the `perspective` library exports the +functions directly and run synchronously in the main process. + +```javascript +const client = require("@perspective-dev/client"); +``` diff --git a/docs/md/how_to/python.md b/docs/md/how_to/python.md new file mode 100644 index 0000000..f124b1c --- /dev/null +++ b/docs/md/how_to/python.md @@ -0,0 +1,4 @@ +# Python + +Guides for using `perspective-python`, including data loading, callbacks, +multithreading, WebSocket servers, and JupyterLab integration. diff --git a/docs/md/how_to/python/callbacks.md b/docs/md/how_to/python/callbacks.md new file mode 100644 index 0000000..cc6b28d --- /dev/null +++ b/docs/md/how_to/python/callbacks.md @@ -0,0 +1,34 @@ +# Callbacks and Events + +`perspective.Table` allows for `on_update` and `on_delete` callbacks to be +set—simply call `on_update` or `on_delete` with a reference to a function or a +lambda without any parameters: + +```python +def update_callback(): + print("Updated!") + +# set the update callback +on_update_id = view.on_update(update_callback) + + +def delete_callback(): + print("Deleted!") + +# set the delete callback +on_delete_id = view.on_delete(delete_callback) + +# set a lambda as a callback +view.on_delete(lambda: print("Deleted x2!")) +``` + +If the callback is a named reference to a function, it can be removed with +`remove_update` or `remove_delete`: + +```python +view.remove_update(on_update_id) +view.remove_delete(on_delete_id) +``` + +Callbacks defined with a lambda function cannot be removed, as lambda functions +have no identifier. diff --git a/docs/md/how_to/python/installation.md b/docs/md/how_to/python/installation.md new file mode 100644 index 0000000..ae2558a --- /dev/null +++ b/docs/md/how_to/python/installation.md @@ -0,0 +1,27 @@ +# Installation + +`perspective-python` contains full bindings to the Perspective API, a JupyterLab +widget, and WebSocket handlers for several webserver libraries that allow you to +host Perspective using server-side Python. + +## PyPI + +`perspective-python` can be installed from [PyPI](https://pypi.org) via `pip`: + +```bash +pip install perspective-python +``` + +That's it! If JupyterLab is installed in this Python environment, you'll also +get the `perspective.widget.PerspectiveWidget` class when you import +`perspective` in a Jupyter Lab kernel. + + diff --git a/docs/md/how_to/python/join.md b/docs/md/how_to/python/join.md new file mode 100644 index 0000000..4f1c1ba --- /dev/null +++ b/docs/md/how_to/python/join.md @@ -0,0 +1,64 @@ +# Joining Tables + +`perspective.join()` creates a read-only `Table` by joining two source tables on +a shared key column. The result is reactive — it updates automatically when +either source table changes. See [`Join`](../../explanation/join.md) for +conceptual details. + +## Basic Inner Join + +```python +orders = perspective.table([ + {"id": 1, "product_id": 101, "qty": 5}, + {"id": 2, "product_id": 102, "qty": 3}, + {"id": 3, "product_id": 101, "qty": 7}, +]) + +products = perspective.table([ + {"product_id": 101, "name": "Widget"}, + {"product_id": 102, "name": "Gadget"}, +]) + +joined = perspective.join(orders, products, "product_id") +view = joined.view() +json = view.to_json() +``` + +## Join Types + +Pass `join_type` to select inner, left, or outer join behavior: + +```python +# Left join: all left rows, nulls for unmatched right columns +left_joined = perspective.join(left, right, "id", join_type="left") + +# Outer join: all rows from both tables +outer_joined = perspective.join(left, right, "id", join_type="outer") +``` + +## Reactive Updates + +The joined table recomputes automatically when either source table is updated: + +```python +left = perspective.table([{"id": 1, "x": 10}]) +right = perspective.table([{"id": 2, "y": "b"}]) + +joined = perspective.join(left, right, "id") +view = joined.view() + +json = view.to_json() +# [] — no matching keys yet + +right.update([{"id": 1, "y": "a"}]) +json = view.to_json() +# [{"id": 1, "x": 10, "y": "a"}] — new match detected +``` + +## Async Client + +The async client has the same API: + +```python +joined = await client.join(orders, products, "product_id", join_type="left") +``` diff --git a/docs/md/how_to/python/jupyterlab.md b/docs/md/how_to/python/jupyterlab.md new file mode 100644 index 0000000..33f6aea --- /dev/null +++ b/docs/md/how_to/python/jupyterlab.md @@ -0,0 +1,61 @@ +# `PerspectiveWidget` for JupyterLab + +Building on top of the API provided by `perspective.Table`, the +`PerspectiveWidget` is a JupyterLab plugin that offers the entire functionality +of Perspective within the Jupyter environment. It supports the same API +semantics of ``, along with the additional data types +supported by `perspective.Table`. `PerspectiveWidget` takes keyword arguments +for the managed `View`: + +```python +from perspective.widget import PerspectiveWidget +w = perspective.PerspectiveWidget( + data, + plugin="X Bar", + aggregates={"datetime": "any"}, + sort=[["date", "desc"]] +) +``` + +## Creating a widget + +A widget is created through the `PerspectiveWidget` constructor, which takes as +its first, required parameter a `perspective.Table`, a dataset, a schema, or +`None`, which serves as a special value that tells the Widget to defer loading +any data until later. In maintaining consistency with the Javascript API, +Widgets cannot be created with empty dictionaries or lists — `None` should be +used if the intention is to await data for loading later on. A widget can be +constructed from a dataset: + +```python +from perspective.widget import PerspectiveWidget +PerspectiveWidget(data, group_by=["date"]) +``` + +.. or a schema: + +```python +PerspectiveWidget({"a": int, "b": str}) +``` + +.. or an instance of a `perspective.Table`: + +```python +table = perspective.table(data) +PerspectiveWidget(table) +``` + +## Updating a widget + +`PerspectiveWidget` shares a similar API to the `` Custom +Element, and has similar `save()` and `restore()` methods that +serialize/deserialize UI state for the widget. + + diff --git a/docs/md/how_to/python/multithreading.md b/docs/md/how_to/python/multithreading.md new file mode 100644 index 0000000..706ec8a --- /dev/null +++ b/docs/md/how_to/python/multithreading.md @@ -0,0 +1,67 @@ +# Multi-threading + +Perspective's API is thread-safe, so methods may be called from different +threads without additional consideration for safety/exclusivity/correctness. All +`perspective.Client` and `perspective.Server` API methods release the GIL, which +can be exploited for parallelism. + +Interally, `perspective.Server` also dispatches to a thread pool for some +operations, enabling better parallelism and overall better query performance. +This independent threadpool size can be controlled via +`perspective.set_num_cpus()`, or the `OMP_NUM_THREADS` environment variable. + +```python +import perspective + +perspective.set_num_cpus(2) +``` + +## Server handlers + +Perspective's server handler implementations each take an optional `executor` +constructor argument, which (when provided) will configure the handler to +process WebSocket `Client` requests on a thread pool. + +```python +from concurrent.futures import ThreadPoolExecutor +from tornado.web import Application +from perspective.handlers.tornado import PerspectiveTornadoHandler +from perspective import Server + +args = {"perspective_server": Server(), "executor": ThreadPoolExecutor()} + +app = Application( + [ + (r"/websocket", PerspectiveTornadoHandler, args), + + # ... + + ] +) +``` + +## `on_poll_request` + +`on_poll_request` is an optional keyword argument for `Server()`, which which +can be applied in cases where overlapping `Table.update` calls can be safely +deferred. + +When providing a callback function to `on_poll_request`, the `Server` will +invoke your callback when there are updates that need to be flushed, after which +you must _eventually_ call `Server.poll` (or else no updates will be processed). + +The exact implementation of `on_poll_request` will depend on the context. A +simple example which batches calls via `threading.Lock`: + +```python +lock = threading.Lock() + +def on_poll_request(perspective_server): + if lock.acquire(blocking=False): + try: + perspective_server.poll() + finally: + lock.release() + +server = Server(on_poll_request=on_poll_request) +``` diff --git a/docs/md/how_to/python/table.md b/docs/md/how_to/python/table.md new file mode 100644 index 0000000..c815dd7 --- /dev/null +++ b/docs/md/how_to/python/table.md @@ -0,0 +1,90 @@ +# Loading data into a Table + +A `Table` can be created from a dataset or a schema, the specifics of which are +[discussed](#loading-data-with-table) in the JavaScript section of the user's +guide. In Python, however, Perspective supports additional data types that are +commonly used when processing data: + +- `pandas.DataFrame` +- `polars.DataFrame` +- `bytes` (encoding an Apache Arrow) +- `objects` (either extracting a repr or via reference) +- `str` (encoding as a CSV) + +A `Table` is created in a similar fashion to its JavaScript equivalent: + +```python +from datetime import date, datetime +import numpy as np +import pandas as pd +import perspective + +data = pd.DataFrame({ + "int": np.arange(100), + "float": [i * 1.5 for i in range(100)], + "bool": [True for i in range(100)], + "date": [date.today() for i in range(100)], + "datetime": [datetime.now() for i in range(100)], + "string": [str(i) for i in range(100)] +}) + +table = perspective.table(data, index="float") +``` + +Likewise, a `View` can be created via the `view()` method: + +```python +view = table.view(group_by=["float"], filter=[["bool", "==", True]]) +column_data = view.to_columns() +row_data = view.to_json() +``` + +## Polars Support + +Polars `DataFrame` types work similarly to Apache Arrow input, which Perspective +uses to interface with Polars. + +```python +df = polars.DataFrame({"a": [1,2,3,4,5]}) +table = perspective.table(df) +``` + +## Pandas Support + +Perspective's `Table` can be constructed from `pandas.DataFrame` objects. +Internally, this just uses +[`pyarrow::from_pandas`](https://arrow.apache.org/docs/python/pandas.html), +which dictates behavior of this feature including type support. + +If the dataframe does not have an index set, an integer-typed column named +`"index"` is created. If you want to preserve the indexing behavior of the +dataframe passed into Perspective, simply create the `Table` with +`index="index"` as a keyword argument. This tells Perspective to once again +treat the index as a primary key: + +```python +data.set_index("datetime") +table = perspective.table(data, index="index") +``` + +## Time Zone Handling + +When parsing `"datetime"` strings, times without an explicit timezone offset are +interpreted as _UTC_. Strings with a timezone offset (e.g., `+05:00`) are +converted to UTC. All `"datetime"` values are stored internally as milliseconds +since the Unix epoch, and are _output_ as integer timestamps (milliseconds since +epoch) from methods like `to_columns()` and `to_json()`. + +Python `datetime` objects are serialized to strings before parsing. Naive +`datetime` objects (without `tzinfo`) produce strings without timezone +information and are therefore treated as UTC. Timezone-aware `datetime` objects +include their offset in the serialized string, which is used to convert to UTC. + +`"date"` values are timezone-agnostic calendar days with no time component. +They are _output_ as integer timestamps at _UTC midnight_ of the calendar day +(equivalent to Arrow `date32` day arithmetic), and integer timestamp _input_ to +a `"date"` column is likewise interpreted as UTC. The host process timezone +never affects `"date"` values — a `Viewer` renders them in UTC, recovering the +stored calendar day exactly. Datetime expression functions such as +`bucket("x", 'D')`, `day_of_week("x")` and `hour_of_day("x")` also compute in +UTC. diff --git a/docs/md/how_to/python/table_data.md b/docs/md/how_to/python/table_data.md new file mode 100644 index 0000000..8c095cf --- /dev/null +++ b/docs/md/how_to/python/table_data.md @@ -0,0 +1,81 @@ +# DataFrame and Arrow Compatibility + +`perspective-python` accepts a `Table` constructor argument from any of the +common Python columnar data libraries. In all three cases, `perspective.table` +(and `Table.update()`) consume the input directly — there is no need to +serialize to Apache Arrow IPC bytes yourself. However, note is +still the most efficient way to bulk load data into `Table`. + +## PyArrow + +```python +import pyarrow as pa +import perspective + +arrow_table = pa.table({ + "int": pa.array([1, 2, 3], type=pa.int64()), + "float": pa.array([1.5, 2.5, 3.5], type=pa.float64()), + "string": pa.array(["a", "b", "c"], type=pa.string()), +}) + +table = perspective.table(arrow_table) +``` + +The same applies to `Table.update()`: + +```python +table.update(arrow_table) +``` + +If you have Arrow data already in IPC format (e.g. read from disk, received +over the wire, or produced by another tool), pass the raw `bytes` directly — +both stream and file formats are auto-detected: + +```python +with open("data.arrow", "rb") as f: + table = perspective.table(f.read()) +``` + +## Polars + +```python +import polars as pl +import perspective + +df = pl.DataFrame({ + "a": [1, 2, 3, 4, 5], + "b": ["x", "y", "z", "x", "y"], +}) + +table = perspective.table(df) +``` + +Internally, the `DataFrame` is converted to a `pyarrow.Table` before +ingestion, so Polars columns inherit the Arrow type mapping above. + +See also Perspective [Virtual Server support for `polars.DataFrame`](./virtual_server/polars.md) + +## Pandas + +`pandas.DataFrame` is supported via `pyarrow.Table.from_pandas`, which +dictates behavior including type support — see the +[pyarrow pandas docs](https://arrow.apache.org/docs/python/pandas.html) for +details on which pandas dtypes round-trip cleanly. + +```python +from datetime import date, datetime +import numpy as np +import pandas as pd +import perspective + +data = pd.DataFrame({ + "int": np.arange(100), + "float": [i * 1.5 for i in range(100)], + "bool": [True for i in range(100)], + "date": [date.today() for i in range(100)], + "datetime": [datetime.now() for i in range(100)], + "string": [str(i) for i in range(100)], +}) + +table = perspective.table(data, index="float") +``` diff --git a/docs/md/how_to/python/virtual_server.md b/docs/md/how_to/python/virtual_server.md new file mode 100644 index 0000000..f7b4333 --- /dev/null +++ b/docs/md/how_to/python/virtual_server.md @@ -0,0 +1,20 @@ +# Virtual Servers + +Perspective's Virtual Server feature lets you connect `` to +external data sources without loading data into Perspective's built-in engine. +Instead, queries are translated and executed natively by the external database. + +For a detailed explanation of how virtual servers work, see the +[Virtual Servers](../../explanation/virtual_servers.md) concepts page. + +Perspective ships with built-in virtual server implementations for: + +- [**DuckDB**](./virtual_server/duckdb.md) — query DuckDB databases using the + `duckdb` Python package. +- [**ClickHouse**](./virtual_server/clickhouse.md) — query a ClickHouse server + using the `clickhouse-connect` Python package. +- [**Polars**](./virtual_server/polars.md) — query in-memory Polars DataFrames + using the `polars` Python package. + +You can also [**implement your own**](./virtual_server/custom.md) virtual server +to connect Perspective to any data source by subclassing `VirtualServerHandler`. diff --git a/docs/md/how_to/python/virtual_server/clickhouse.md b/docs/md/how_to/python/virtual_server/clickhouse.md new file mode 100644 index 0000000..3078d2b --- /dev/null +++ b/docs/md/how_to/python/virtual_server/clickhouse.md @@ -0,0 +1,52 @@ +# ClickHouse Virtual Server + +Perspective provides a built-in virtual server for +[ClickHouse](https://clickhouse.com/), allowing `` clients +to query a ClickHouse server over WebSocket. + +For browser-only usage, see the +[JavaScript ClickHouse guide](../../javascript/virtual_server/clickhouse.md). + +## Installation + +```bash +pip install perspective-python clickhouse-connect +``` + +## Usage + +Create a server that exposes ClickHouse tables to browser clients: + +```python +import clickhouse_connect +import tornado.web +import tornado.ioloop +from perspective import ClickhouseVirtualServer +from perspective.handlers.tornado import PerspectiveTornadoHandler + +# Connect to ClickHouse +client = clickhouse_connect.get_client(host="localhost") + +# Create virtual server backed by ClickHouse +server = ClickhouseVirtualServer(client) + +# Serve over WebSocket +app = tornado.web.Application([ + (r"/websocket", PerspectiveTornadoHandler, {"perspective_server": server}), +]) + +app.listen(8080) +tornado.ioloop.IOLoop.current().start() +``` + +Connect from the browser: + +```javascript +const websocket = await perspective.websocket("ws://localhost:8080/websocket"); +const table = await websocket.open_table("my_table"); +document.getElementById("viewer").load(table); +``` + +## Examples + +- [Python ClickHouse example](https://github.com/perspective-dev/perspective/tree/master/examples/python-clickhouse-virtual) diff --git a/docs/md/how_to/python/virtual_server/custom.md b/docs/md/how_to/python/virtual_server/custom.md new file mode 100644 index 0000000..c7c66e2 --- /dev/null +++ b/docs/md/how_to/python/virtual_server/custom.md @@ -0,0 +1,64 @@ +# Implementing a custom Virtual Server + +You can connect Perspective to any data source by subclassing +`VirtualServerHandler` and wrapping it with `VirtualServer`. + +For background on virtual servers, see the +[Virtual Servers overview](../../../explanation/virtual_servers.md). + +## Example + +```python +from perspective import VirtualServerHandler, VirtualServer + +class MyModel(VirtualServerHandler): + def get_features(self): + return { + "group_by": True, + "split_by": False, + "sort": True, + "filter_ops": { + "string": ["==", "!=", "contains"], + "float": ["==", "!=", ">", "<"], + }, + "aggregates": { + "float": ["sum", "avg", "count"], + "string": ["count"], + }, + } + + def get_hosted_tables(self): + return ["my_table"] + + def table_schema(self, table_name): + return {"name": "string", "price": "float"} + + def table_size(self, table_name): + return 1000 + + def table_make_view(self, table_name, view_id, config): + # Translate `config` (group_by, sort, filter, etc.) into a + # query against your data source. Store the query keyed by + # `view_id` for later data retrieval. + pass + + def view_delete(self, view_id): + # Clean up resources for this view + pass + + def view_get_data(self, view_id, start_row, end_row, start_col, end_col, ctx): + # Execute the stored query with the given row/column window. + # Push results via `ctx`. + pass +``` + +The `VirtualServer` instance can then be passed to a Tornado, Starlette, or +AIOHTTP handler just like a regular `Server`: + +```python +from perspective.handlers.tornado import PerspectiveTornadoHandler + +app = tornado.web.Application([ + (r"/websocket", PerspectiveTornadoHandler, {"perspective_server": VirtualServer(MyModel)}), +]) +``` diff --git a/docs/md/how_to/python/virtual_server/duckdb.md b/docs/md/how_to/python/virtual_server/duckdb.md new file mode 100644 index 0000000..8a040ed --- /dev/null +++ b/docs/md/how_to/python/virtual_server/duckdb.md @@ -0,0 +1,53 @@ +# DuckDB Virtual Server + +Perspective provides a built-in virtual server for +[DuckDB](https://duckdb.org/), allowing `` clients to query +a server-side DuckDB database over WebSocket. + +For browser-only usage via DuckDB-WASM, see the +[JavaScript DuckDB guide](../../javascript/virtual_server/duckdb.md). + +## Installation + +```bash +pip install perspective-python duckdb +``` + +## Usage + +Create a server that exposes a DuckDB database to browser clients: + +```python +import duckdb +import tornado.web +import tornado.ioloop +from perspective import DuckDBVirtualServer +from perspective.handlers.tornado import PerspectiveTornadoHandler + +# Create DuckDB connection and load data +conn = duckdb.connect() +conn.execute("CREATE TABLE my_table AS SELECT * FROM 'data.parquet'") + +# Create virtual server backed by DuckDB +server = DuckDBVirtualServer(conn) + +# Serve over WebSocket +app = tornado.web.Application([ + (r"/websocket", PerspectiveTornadoHandler, {"perspective_server": server}), +]) + +app.listen(8080) +tornado.ioloop.IOLoop.current().start() +``` + +Connect from the browser: + +```javascript +const websocket = await perspective.websocket("ws://localhost:8080/websocket"); +const table = await websocket.open_table("my_table"); +document.getElementById("viewer").load(table); +``` + +## Examples + +- [Python DuckDB example](https://github.com/perspective-dev/perspective/tree/master/examples/python-duckdb-virtual) diff --git a/docs/md/how_to/python/virtual_server/polars.md b/docs/md/how_to/python/virtual_server/polars.md new file mode 100644 index 0000000..cae56ba --- /dev/null +++ b/docs/md/how_to/python/virtual_server/polars.md @@ -0,0 +1,49 @@ +# Polars Virtual Server + +Perspective provides a built-in virtual server for +[Polars](https://pola.rs/), allowing `` clients to query +in-memory Polars DataFrames over WebSocket. + +## Installation + +```bash +pip install perspective-python polars +``` + +## Usage + +Create a server that exposes Polars DataFrames to browser clients: + +```python +import polars as pl +import tornado.web +import tornado.ioloop +from perspective.virtual_servers.polars import PolarsVirtualServer +from perspective.handlers.tornado import PerspectiveTornadoHandler + +# Load data into Polars DataFrames +df = pl.read_parquet("data.parquet") + +# Create virtual server backed by Polars (dict of name -> DataFrame) +server = PolarsVirtualServer({"my_table": df}) + +# Serve over WebSocket +app = tornado.web.Application([ + (r"/websocket", PerspectiveTornadoHandler, {"perspective_server": server}), +]) + +app.listen(8080) +tornado.ioloop.IOLoop.current().start() +``` + +Connect from the browser: + +```javascript +const websocket = await perspective.websocket("ws://localhost:8080/websocket"); +const table = await websocket.open_table("my_table"); +document.getElementById("viewer").load(table); +``` + +## Examples + +- [Python Polars example](https://github.com/perspective-dev/perspective/tree/master/examples/python-polars-virtual) diff --git a/docs/md/how_to/python/websocket.md b/docs/md/how_to/python/websocket.md new file mode 100644 index 0000000..e7e932c --- /dev/null +++ b/docs/md/how_to/python/websocket.md @@ -0,0 +1,129 @@ +# Hosting a WebSocket server + +An in-memory `Server` "hosts" all `perspective.Table` and `perspective.View` +instances created by its connected `Client`s. Hosted tables/views can have their +methods called from other sources than the Python server, i.e. by a +`perspective-viewer` running in a JavaScript client over the network, +interfacing with `perspective-python` through the websocket API. + +The server has full control of all hosted `Table` and `View` instances, and can +call any public API method on hosted instances. This makes it extremely easy to +stream data to a hosted `Table` using `.update()`: + +```python +server = perspective.Server() +client = server.new_local_client() +table = client.table(data, name="data_source") + +for i in range(10): + # updates continue to propagate automatically + table.update(new_data) +``` + +The `name` provided is important, as it enables Perspective in JavaScript to +look up a `Table` and get a handle to it over the network. Otherwise, `name` +will be assigned randomly and the `Client` must look this up with +`Client.get_hosted_table_names()` + +## Client/Server Replicated Mode + +Using Tornado and +[`PerspectiveTornadoHandler`](python.md#perspectivetornadohandler), as well as +`Perspective`'s JavaScript library, we can set up "distributed" Perspective +instances that allows multiple browser `perspective-viewer` clients to read from +a common `perspective-python` server, as in the +[Tornado Example Project](https://github.com/perspective-dev/perspective/tree/master/examples/python-tornado). + +This architecture works by maintaining two `Tables`—one on the server, and one +on the client that mirrors the server's `Table` automatically using `on_update`. +All updates to the table on the server are automatically applied to each client, +which makes this architecture a natural fit for streaming dashboards and other +distributed use-cases. In conjunction with [multithreading](#multi-threading), +distributed Perspective offers consistently high performance over large numbers +of clients and large datasets. + +_*server.py*_ + +```python +from perspective import Server +from perspective.handlers.tornado import PerspectiveTornadoHandler + +# Create an instance of Server, and host a Table +SERVER = Server() +CLIENT = SERVER.new_local_client() + +# The Table is exposed at `localhost:8888/websocket` with the name `data_source` +client.table(data, name = "data_source") + +app = tornado.web.Application([ + # create a websocket endpoint that the client JavaScript can access + (r"/websocket", PerspectiveTornadoHandler, {"perspective_server": SERVER}) +]) + +# Start the Tornado server +app.listen(8888) +loop = tornado.ioloop.IOLoop.current() +loop.start() +``` + +Instead of calling `load(server_table)`, create a `View` using `server_table` +and pass that into `viewer.load()`. This will automatically register an +`on_update` callback that synchronizes state between the server and the client. + +_*index.html*_ + +```html + + + +``` + +For a more complex example that offers distributed editing of the server +dataset, see +[client_server_editing.html](https://github.com/perspective-dev/perspective/blob/master/examples/python-tornado/client_server_editing.html). + +We also provide examples for Starlette/FastAPI and AIOHTTP: + +- [Starlette Example Project](https://github.com/perspective-dev/perspective/tree/master/examples/python-starlette). +- [AIOHTTP Example Project](https://github.com/perspective-dev/perspective/tree/master/examples/python-aiohttp). + +## Server-only Mode + +The server setup is identical to +[Client/Server Replicated Mode](#client-server-replicated-mode) above, but +instead of creating a `View`, the client calls `load(server_table)`: In Python, +use `Server` and `PerspectiveTornadoHandler` to create a websocket server that +exposes a `Table`. In this example, `table` is a proxy for the `Table` we +created on the server. All API methods are available on _proxies_, e.g. +calling `view()`, `schema()`, `update()` on `table` will pass those operations +to the Python `Table`, execute the commands, and return the result back to +Javascript. + +```html + +``` + +```javascript +const websocket = perspective.websocket("ws://localhost:8888/websocket"); +const table = websocket.open_table("data_source"); +document.getElementById("viewer").load(table); +``` diff --git a/docs/md/how_to/rust.md b/docs/md/how_to/rust.md new file mode 100644 index 0000000..3a7331c --- /dev/null +++ b/docs/md/how_to/rust.md @@ -0,0 +1,89 @@ +# Rust + +Install via `cargo`: + +```bash +cargo add perspective +``` + +# Example + +Initialize a server and client + +```rust +let server = Server::default(); +let client = server.new_local_client(); +``` + +Load an Arrow + +```rust +let mut file = File::open(std::path::Path::new(ROOT_PATH).join(ARROW_FILE_PATH))?; +let mut feather = Vec::with_capacity(file.metadata()?.len() as usize); +file.read_to_end(&mut feather)?; +let data = UpdateData::Arrow(feather.into()); +let mut options = TableInitOptions::default(); +options.set_name("my_data_source"); +client.table(data.into(), options).await?; +``` + +# Joining Tables + +`Client::join` creates a read-only `Table` by joining two source tables on a +shared key column. The result is reactive — it updates automatically when +either source table changes. See [`Join`](../explanation/join.md) for +conceptual details. + +```rust +let orders = client.table( + TableData::Update(UpdateData::JsonRows( + "[{\"id\":1,\"product_id\":101,\"qty\":5},{\"id\":2,\"product_id\":102,\"qty\":3}]".into(), + )), + TableInitOptions::default(), +).await?; + +let products = client.table( + TableData::Update(UpdateData::JsonRows( + "[{\"product_id\":101,\"name\":\"Widget\"},{\"product_id\":102,\"name\":\"Gadget\"}]".into(), + )), + TableInitOptions::default(), +).await?; + +let joined = client.join( + (&orders).into(), + (&products).into(), + "product_id", + JoinOptions::default(), +).await?; + +let view = joined.view(None).await?; +let json = view.to_json().await?; +``` + +Use `JoinOptions` to configure the join type, table name, or `right_on` column: + +```rust +let options = JoinOptions { + join_type: Some(JoinType::Left), + name: Some("orders_with_products".into()), + right_on: None, +}; + +let joined = client.join( + (&orders).into(), + (&products).into(), + "product_id", + options, +).await?; +``` + +You can also join by table name strings instead of `Table` references: + +```rust +let joined = client.join( + "orders".into(), + "products".into(), + "product_id", + JoinOptions::default(), +).await?; +``` diff --git a/docs/md/javascript.md b/docs/md/javascript.md new file mode 100644 index 0000000..0539dd6 --- /dev/null +++ b/docs/md/javascript.md @@ -0,0 +1,3 @@ +# JavaScript + +Overview and module structure for Perspective's JavaScript packages. diff --git a/docs/md/perspective.css b/docs/md/perspective.css new file mode 100644 index 0000000..6107625 --- /dev/null +++ b/docs/md/perspective.css @@ -0,0 +1,15 @@ +perspective-viewer { + height: 500px; +} + +div.javascript:before { + content: "JavaScript:"; +} + +div.python:before { + content: "Python:"; +} + +div.rust:before { + content: "Rust:"; +} diff --git a/docs/md/perspective.js b/docs/md/perspective.js new file mode 100644 index 0000000..09fceb2 --- /dev/null +++ b/docs/md/perspective.js @@ -0,0 +1,29 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 "https://cdn.jsdelivr.net/npm/@perspective-dev/viewer/dist/cdn/perspective-viewer.js"; +import "https://cdn.jsdelivr.net/npm/@perspective-dev/viewer-datagrid/dist/cdn/perspective-viewer-datagrid.js"; +import "https://cdn.jsdelivr.net/npm/@perspective-dev/viewer-charts/dist/cdn/perspective-viewer-charts.js"; +import perspective from "https://cdn.jsdelivr.net/npm/@perspective-dev/client/dist/cdn/perspective.js"; + +const WASM_URL = + "https://cdn.jsdelivr.net/npm/superstore-arrow/superstore.lz4.arrow"; + +const worker = await perspective.worker(); + +const table = await fetch(WASM_URL) + .then((x) => x.arrayBuffer()) + .then((x) => worker.table(x)); + +for (const viewer of document.querySelectorAll("perspective-viewer")) { + viewer.load(table); +} diff --git a/docs/md/perspective.md b/docs/md/perspective.md new file mode 100644 index 0000000..676d71c --- /dev/null +++ b/docs/md/perspective.md @@ -0,0 +1 @@ +{{#include ../../README.md}} diff --git a/docs/md/tutorials/python/tornado.md b/docs/md/tutorials/python/tornado.md new file mode 100644 index 0000000..0f55b09 --- /dev/null +++ b/docs/md/tutorials/python/tornado.md @@ -0,0 +1,109 @@ +# Tutorial: A tornado server in Python + +Perspective ships with a pre-built Tornado handler that makes integration with +`tornado.websockets` extremely easy. This allows you to run an instance of +`Perspective` on a server using Python, open a websocket to a `Table`, and +access the `Table` in JavaScript and through ``. All +instructions sent to the `Table` are processed in Python, which executes the +commands, and returns its output through the websocket back to Javascript. + +### Python setup + +Make sure Perspective and Tornado are installed! + +```bash +pip install perspective-python tornado +``` + +To use the handler, we need to first have a `Server`, a `Client` and an instance +of a `Table`: + +```python +import perspective + +SERVER = perspective.Server() +CLIENT = SERVER.new_local_client() +``` + +Once the server has been created, create a `Table` instance with a name. The +name that you host the table under is important — it acts as a unique accessor +on the JavaScript side, which will look for a Table hosted at the websocket with +the name you specify. + +```python +TABLE = client.table(data, name="data_source_one") +``` + +After the server and table setup is complete, create a websocket endpoint and +provide it a reference to `PerspectiveTornadoHandler`. You must provide the +configuration object in the route tuple, and it must contain +`"perspective_server"`, which is a reference to the `Server` you just created. + +```python +from perspective.handlers.tornado import PerspectiveTornadoHandler + +app = tornado.web.Application([ + + # ... other handlers ... + + # Create a websocket endpoint that the client JavaScript can access + (r"/websocket", PerspectiveTornadoHandler, {"perspective_server": SERVER, "check_origin": True}) +]) +``` + +Optionally, the configuration object can also include `check_origin`, a boolean +that determines whether the websocket accepts requests from origins other than +where the server is hosted. See +[Tornado docs](https://www.tornadoweb.org/en/stable/websocket.html#tornado.websocket.WebSocketHandler.check_origin) +for more details. + +### JavaScript setup + +Once the server is up and running, you can access the Table you just hosted +using `perspective.websocket` and `open_table()`. First, create a client that +expects a Perspective server to accept connections at the specified URL: + +```javascript +import "@perspective-dev/viewer"; +import "@perspective-dev/viewer-datagrid"; +import perspective from "@perspective-dev/client"; + +const websocket = await perspective.websocket("ws://localhost:8888/websocket"); +``` + +Next open the `Table` we created on the server by name: + +```javascript +const table = await websocket.open_table("data_source_one"); +``` + +`table` is a proxy for the `Table` we created on the server. All operations that +are possible through the JavaScript API are possible on the Python API as well, +thus calling `view()`, `schema()`, `update()` etc. on `const table` will pass +those operations to the Python `Table`, execute the commands, and return the +result back to JavaScript. Similarly, providing this `table` to a +`` instance will allow virtual rendering: + +```javascript +const viewer = document.createElement("perspective-viewer"); +viewer.style.height = "500px"; +document.body.appendChild(viewer); +await viewer.load(table); +``` + +`perspective.websocket` expects a Websocket URL where it will send instructions. +When `open_table` is called, the name to a hosted Table is passed through, and a +request is sent through the socket to fetch the Table. No actual `Table` +instance is passed inbetween the runtimes; all instructions are proxied through +websockets. + +This provides for great flexibility — while `Perspective.js` is full of +features, browser WebAssembly runtimes currently have some performance +restrictions on memory and CPU feature utilization, and the architecture in +general suffers when the dataset itself is too large to download to the client +in full. + +The Python runtime does not suffer from memory limitations, utilizes Apache +Arrow internal threadpools for threading and parallel processing, and generates +architecture optimized code, which currently makes it more suitable as a +server-side runtime than `node.js`. diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..e825950 --- /dev/null +++ b/docs/package.json @@ -0,0 +1,32 @@ +{ + "name": "@perspective-dev/docs", + "version": "4.5.2", + "private": true, + "type": "module", + "scripts": { + "build": "npm run mdbook && node build.mjs && node build.config.mjs", + "start": "node server.mjs", + "serve": "node server.mjs", + "clean": "rm -rf dist", + "deploy": "node deploy.mjs", + "mdbook": "docker compose run --rm mdbook build" + }, + "dependencies": { + "@perspective-dev/viewer-charts": "workspace:", + "@perspective-dev/viewer-datagrid": "workspace:", + "@perspective-dev/viewer": "workspace:", + "@perspective-dev/client": "workspace:", + "@perspective-dev/server": "workspace:", + "blocks": "workspace:", + "puppeteer": "catalog:", + "prismjs": "^1.29.0", + "superstore-arrow": "catalog:" + }, + "devDependencies": { + "@types/prismjs": "^1.26.0", + "@zip.js/zip.js": "catalog:", + "esbuild": "catalog:", + "lightningcss": "catalog:", + "typescript": "catalog:" + } +} diff --git a/docs/server.mjs b/docs/server.mjs new file mode 100644 index 0000000..ca9db3f --- /dev/null +++ b/docs/server.mjs @@ -0,0 +1,25 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { WebSocketServer } from "@perspective-dev/client"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +new WebSocketServer({ + assets: [ + `${__dirname}/dist`, + `${__dirname}/static`, + `${__dirname}/../node_modules`, + ], +}); diff --git a/docs/src/block.html b/docs/src/block.html new file mode 100644 index 0000000..27b7d5b --- /dev/null +++ b/docs/src/block.html @@ -0,0 +1,58 @@ + + + + + + Perspective - Block + + + + + +
+ + +
+
+

+
+ +
+ +
+
+ + + diff --git a/docs/src/block.ts b/docs/src/block.ts new file mode 100644 index 0000000..a235571 --- /dev/null +++ b/docs/src/block.ts @@ -0,0 +1,93 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { initTheme, createThemeToggle } from "./components/theme.js"; +import Prism from "prismjs"; +import "prismjs/components/prism-json"; +import "prismjs/components/prism-markdown"; +import "prismjs/components/prism-css"; + +initTheme(); +document.getElementById("theme-toggle")!.replaceWith(createThemeToggle()); + +const EXT_TO_LANG: Record = { + js: "javascript", + mjs: "javascript", + ts: "javascript", + html: "markup", + css: "css", + json: "json", + md: "markdown", +}; + +const container = document.getElementById("block-detail")!; +const params = new URL(document.location.href).searchParams; +const example = params.get("example"); + +if (!example) { + container.innerHTML = "

No example specified.

"; +} else { + document.title = `Perspective - ${example}`; + + const h1 = document.createElement("h1"); + h1.textContent = example; + container.appendChild(h1); + + const iframe = document.createElement("iframe"); + iframe.width = "960"; + iframe.height = "640"; + iframe.src = `/blocks/${example}/index.html`; + container.appendChild(iframe); + + const br = document.createElement("br"); + container.appendChild(br); + + const link = document.createElement("a"); + link.href = `/blocks/${example}/index.html`; + link.className = "block-detail__link"; + link.textContent = "Open in New Tab"; + link.target = "_blank"; + container.appendChild(link); + + const br2 = document.createElement("br"); + container.appendChild(br2); + + // Fetch manifest and display all source files + fetch("/blocks/manifest.json") + .then((res) => res.json()) + .then(async (manifest: Record) => { + const files = manifest[example] || []; + for (const filename of files) { + const res = await fetch(`/blocks/${example}/${filename}`); + if (!res.ok) continue; + const contents = await res.text(); + + const title = document.createElement("div"); + title.className = "block-detail__file-title"; + title.textContent = filename; + container.appendChild(title); + + const pre = document.createElement("pre"); + const code = document.createElement("code"); + const ext = filename.split(".").pop() || ""; + const lang = EXT_TO_LANG[ext] || "plain"; + const grammar = Prism.languages[lang]; + if (grammar) { + code.innerHTML = Prism.highlight(contents, grammar, lang); + } else { + code.textContent = contents; + } + pre.appendChild(code); + container.appendChild(pre); + } + }); +} diff --git a/docs/src/components/demo.ts b/docs/src/components/demo.ts new file mode 100644 index 0000000..d31234a --- /dev/null +++ b/docs/src/components/demo.ts @@ -0,0 +1,132 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { random_row } from "../data/random.js"; +import { LAYOUTS } from "../data/layouts.js"; +import { getPerspectiveTheme } from "./theme.js"; + +let TABLE: any; +let VIEWER: any; +let FREQ = 100; +let REALTIME_PAUSED = true; +let selectedId = "sparkgrid"; + +function update(table: any, viewer: any) { + if (!REALTIME_PAUSED && FREQ <= 189.9) { + const viewport_height = document.documentElement.clientHeight; + if (viewport_height - window.scrollY > 0) { + const arr = []; + for (let i = 0; i < 10; i++) { + arr.push(random_row()); + } + table.update(arr); + } + } + + setTimeout(() => update(table, viewer), FREQ); +} + +function select(viewer: any, id: string, extra: any = {}) { + selectedId = id; + viewer.restore({ ...LAYOUTS[id], ...extra }); +} + +async function startStreaming(perspective: any, viewer: any) { + const data = []; + for (let x = 0; x < 1000; x++) { + data.push(random_row()); + } + + const worker = await perspective.worker(); + const tbl = worker.table(data, { index: "id" }); + setTimeout(async () => { + const table = await tbl; + update(table, viewer); + }); + + return tbl; +} + +export async function initDemo(container: HTMLElement) { + const [perspectiveMod] = await Promise.all([ + import("../data/worker.js"), + import("@perspective-dev/viewer"), + import("@perspective-dev/viewer-datagrid"), + import("@perspective-dev/viewer-charts"), + ]); + + const wrapper = document.createElement("div"); + wrapper.className = "demo"; + + const viewer = document.createElement("perspective-viewer") as any; + viewer.className = "nosuperstore"; + wrapper.appendChild(viewer); + + const visButtons = document.createElement("div"); + visButtons.className = "demo__vis-buttons"; + + for (const key of Object.keys(LAYOUTS)) { + const btn = document.createElement("div"); + btn.className = "demo__vis-button"; + if (key === selectedId) { + btn.classList.add("demo__vis-button--active"); + } + btn.id = key; + btn.textContent = key; + btn.addEventListener("mouseover", () => { + visButtons + .querySelectorAll(".demo__vis-button") + .forEach((b) => b.classList.remove("demo__vis-button--active")); + btn.classList.add("demo__vis-button--active"); + select(viewer, key); + }); + visButtons.appendChild(btn); + } + + wrapper.appendChild(visButtons); + + const timeControls = document.createElement("div"); + timeControls.className = "demo__time-controls"; + + const freqLabel = document.createElement("span"); + freqLabel.textContent = + FREQ >= 189 ? "paused" : `${((1000 / FREQ) * 10).toFixed(0)} msg/s`; + timeControls.appendChild(freqLabel); + + const slider = document.createElement("input"); + slider.type = "range"; + slider.className = "demo__freq-slider"; + slider.setAttribute( + "aria-label", + "Demo update rate in messages per second", + ); + slider.value = String(Math.round((FREQ - 190) * (5 / -9))); + slider.addEventListener("input", () => { + FREQ = (-9 / 5) * Number(slider.value) + 190; + freqLabel.textContent = + FREQ >= 189 ? "paused" : `${((1000 / FREQ) * 10).toFixed(0)} msg/s`; + }); + timeControls.appendChild(slider); + + wrapper.appendChild(timeControls); + container.appendChild(wrapper); + + REALTIME_PAUSED = false; + + if (TABLE === undefined) { + TABLE = await startStreaming(perspectiveMod, viewer); + } + + VIEWER = viewer; + VIEWER.load(TABLE); + select(viewer, selectedId, { theme: getPerspectiveTheme() }); +} diff --git a/docs/src/components/gallery.ts b/docs/src/components/gallery.ts new file mode 100644 index 0000000..5a8bf9e --- /dev/null +++ b/docs/src/components/gallery.ts @@ -0,0 +1,77 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 EXAMPLES from "../data/features.js"; +import { WORKER, SUPERSTORE_TABLE } from "../data/superstore.js"; +import { getColorMode, getPerspectiveTheme } from "./theme.js"; + +function showOverlay(index: number) { + const overlay = document.createElement("div"); + overlay.className = "gallery-overlay"; + + const viewer = document.createElement("perspective-viewer") as any; + viewer.setAttribute("theme", getPerspectiveTheme()); + overlay.appendChild(viewer); + + overlay.addEventListener("click", (event) => { + if (event.target === overlay) { + overlay.remove(); + } + }); + + document.body.appendChild(overlay); + + SUPERSTORE_TABLE.then((table: any) => { + viewer.load(WORKER); + viewer.restore({ + plugin: "Datagrid", + table: "superstore", + group_by: [], + expressions: {}, + split_by: [], + sort: [], + aggregates: {}, + ...EXAMPLES[index].config, + settings: true, + }); + }); +} + +interface MontageMap { + tile_width: number; + tile_height: number; + columns: number; + order: number[]; +} + +export async function initGallery(container: HTMLElement) { + const resp = await fetch("/features/montage_map.json"); + const map: MontageMap = await resp.json(); + const rows = Math.ceil(map.order.length / map.columns); + const isDark = getColorMode() === "dark"; + const img = document.createElement("img"); + img.alt = "Perspective feature gallery"; + img.src = `/features/montage${isDark ? "_dark" : "_light"}.png`; + img.addEventListener("click", (event: MouseEvent) => { + const col = Math.floor((event.offsetX / img.offsetWidth) * map.columns); + const row = Math.floor((event.offsetY / img.offsetHeight) * rows); + const tileIndex = row * map.columns + col; + const featureIndex = map.order[tileIndex]; + if (featureIndex === undefined) { + return; + } + + showOverlay(featureIndex); + }); + + container.appendChild(img); +} diff --git a/docs/src/components/theme.ts b/docs/src/components/theme.ts new file mode 100644 index 0000000..df5251c --- /dev/null +++ b/docs/src/components/theme.ts @@ -0,0 +1,107 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { HTMLPerspectiveViewerElement } from "@perspective-dev/viewer"; + +const SUN_SVG = ``; +const MOON_SVG = ``; + +export function getColorMode(): "dark" | "light" { + const stored = localStorage.getItem("perspective-theme"); + if (stored === "dark" || stored === "light") { + return stored; + } + + return "dark"; +} + +export function applyTheme(mode?: "dark" | "light") { + const theme = mode ?? getColorMode(); + document.documentElement.setAttribute("data-theme", theme); + localStorage.setItem("perspective-theme", theme); + + const viewer = document.querySelector( + ".demo perspective-viewer", + ) as HTMLPerspectiveViewerElement; + viewer?.restore?.({ theme: theme === "dark" ? "Pro Dark" : "Pro Light" }); + + // Swap navbar logo + const navLogo = document.querySelector( + ".navbar__logo img", + ) as HTMLImageElement | null; + if (navLogo) { + navLogo.src = + theme === "dark" + ? "/svg/perspective-logo-dark.svg" + : "/svg/perspective-logo-light.svg"; + } + + // Swap footer logo + const footerLogo = document.getElementById("footer-logo") as + | HTMLImageElement + | undefined; + if (footerLogo) { + footerLogo.src = + theme === "dark" + ? "/img/openjs_foundation-logo-horizontal-white.png" + : "/img/openjs_foundation-logo-horizontal-black.png"; + } + + // Swap gallery montage + const galleryImg = document.querySelector( + ".gallery img", + ) as HTMLImageElement | null; + if (galleryImg) { + galleryImg.src = + theme === "dark" + ? "/features/montage_dark.png" + : "/features/montage_light.png"; + } + + // Update toggle button icon + const toggleBtn = document.getElementById("theme-toggle-btn"); + if (toggleBtn) { + toggleBtn.innerHTML = theme === "dark" ? SUN_SVG : MOON_SVG; + toggleBtn.setAttribute( + "aria-label", + theme === "dark" ? "Switch to light mode" : "Switch to dark mode", + ); + } +} + +export function initTheme() { + applyTheme(); +} + +export function createThemeToggle(): HTMLElement { + const li = document.createElement("li"); + const btn = document.createElement("button"); + btn.id = "theme-toggle-btn"; + btn.className = "theme-toggle"; + btn.type = "button"; + const current = getColorMode(); + btn.innerHTML = current === "dark" ? SUN_SVG : MOON_SVG; + btn.setAttribute( + "aria-label", + current === "dark" ? "Switch to light mode" : "Switch to dark mode", + ); + btn.addEventListener("click", () => { + const next = getColorMode() === "dark" ? "light" : "dark"; + applyTheme(next); + }); + li.appendChild(btn); + return li; +} + +export function getPerspectiveTheme(): string { + return getColorMode() === "dark" ? "Pro Dark" : "Pro Light"; +} diff --git a/docs/src/css/style.css b/docs/src/css/style.css new file mode 100644 index 0000000..8426459 --- /dev/null +++ b/docs/src/css/style.css @@ -0,0 +1,653 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ 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 url("https://fonts.googleapis.com/css?display=block&family=Open+Sans:400"); + +@import "../../node_modules/@perspective-dev/viewer/dist/css/themes.css"; + +/* --- Reset --- */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: + "Open Sans", + system-ui, + -apple-system, + sans-serif; + font-weight: 400; + line-height: 1.65; + -webkit-font-smoothing: antialiased; +} + +a { + text-decoration: none; +} + +hr { + width: 100%; + border: none; + border-top: 1px solid var(--border-color); + margin: 0; +} + +/* --- Theme Variables --- */ + +:root { + --color-primary: #2e8555; + --bg-color: #ffffff; + --bg-surface: #f2f4f6; + --text-color: #1c1e21; + --text-muted: #666; + --border-color: #dadde1; + --navbar-bg: #ffffff; + --navbar-height: 60px; + --content-width: 590px; + --logo-url: url("../../static/svg/perspective-logo-light.svg"); + color-scheme: light; +} + +[data-theme="dark"] { + --color-primary: #25c2a0; + --bg-color: #1b1b1d; + --bg-surface: #242526; + --text-color: #e3e3e3; + --text-muted: #999; + --border-color: #444; + --navbar-bg: #242526; + --logo-url: url("../../static/svg/perspective-logo-dark.svg"); + color-scheme: dark; +} + +html { + background-color: var(--bg-color); + color: var(--text-color); +} + +/* --- Regular Table Reset --- */ + +td, +th, +table, +tbody, +table thead { + border: 0; + font-size: 12px; + font-family: + "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", "Consolas", + "Liberation Mono", monospace; + margin: 0; + padding: 0; + vertical-align: middle; + background-color: transparent; +} + +table tr { + border-top: none; +} + +table tr:nth-child(2n) { + background-color: transparent; +} + +table th, +table td { + border: none; + padding: 0px 5px; +} + +/* --- Navbar --- */ + +.navbar { + display: flex; + justify-content: center; + align-items: center; + height: var(--navbar-height); + background-color: var(--navbar-bg); + z-index: 100000; + position: sticky; + top: 0; + box-shadow: none; + margin-bottom: -60px; +} + +.navbar__inner { + display: flex; + align-items: center; + justify-content: space-between; + width: var(--content-width); +} + +.navbar__logo { + display: flex; + align-items: center; + height: 21px; +} + +.navbar__logo img { + height: 100%; +} + +.navbar__links { + display: flex; + align-items: center; + gap: 20px; + list-style: none; + margin: 0; + padding: 0; + font-size: 14px; +} + +.navbar__links a { + color: var(--text-color); + opacity: 0.8; + transition: opacity 0.2s; +} + +.navbar__links a:hover { + opacity: 1; + color: var(--color-primary); +} + +/* --- Theme Toggle --- */ + +.theme-toggle { + background: none; + border: none; + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + color: var(--text-color); + opacity: 0.8; + transition: opacity 0.2s; +} + +.theme-toggle:hover { + opacity: 1; +} + +/* --- Header shift (homepage only) --- */ + +.header-shift .navbar { + position: absolute; + top: 0; + left: 0; + right: 0; + padding-top: 100px; +} + +.header-shift .main-wrapper { + padding-top: 0; +} + +.main-wrapper { + background-color: var(--navbar-bg); +} + +/* --- Hero Banner --- */ + +.hero-banner { + padding: 4rem 0; + text-align: center; + position: relative; + overflow: hidden; + background-color: var(--navbar-bg); +} + +@media screen and (max-width: 996px) { + .hero-banner { + padding: 2rem; + } +} + +.hero-banner__buttons { + display: flex; + align-items: center; + justify-content: center; +} + +/* --- Demo --- */ + +.demo { + display: flex; + flex-direction: column; + align-items: center; +} + +.demo perspective-viewer { + width: 1000px; + height: 600px; + margin-top: 24px; + margin-bottom: 48px; +} + +.demo__vis-buttons { + display: flex; + flex-direction: row; + margin-bottom: 24px; +} + +.demo__vis-button { + font-size: 12px; + border: 1px solid; + padding: 8px; + border-radius: 3px; + margin-right: 2px; + opacity: 0.3; + cursor: pointer; + text-transform: uppercase; +} + +.demo__vis-button--active { + opacity: 1; +} + +.demo__time-controls { + display: flex; + align-items: center; + margin-top: 12px; +} + +.demo__time-controls > * { + margin-right: 15px; +} + +.demo__time-controls span { + text-align: end; + width: 80px; + font-family: + "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", "Consolas", + "Liberation Mono", monospace; + opacity: 0.5; + font-size: 12px; +} + +.demo__freq-slider { + -webkit-appearance: none; + background: transparent; + border: 1px solid; + width: 200px; + height: 10px; + border-radius: 5px; + outline: none; + opacity: 0.7; + transition: opacity 0.2s; +} + +.demo__freq-slider::-webkit-slider-thumb { + background: var(--bg-color); + -webkit-appearance: none; + appearance: none; + width: 15px; + height: 15px; + border-radius: 50%; + border: 1px solid; + cursor: pointer; +} + +.demo__freq-slider::-moz-range-thumb { + width: 25px; + height: 25px; + border-radius: 50%; + border-color: #fff; + cursor: pointer; +} + +/* --- perspective-viewer shared --- */ + +perspective-viewer { + text-align: initial; + border-radius: 10px; + border: 1px solid #bebebe; + overflow: hidden; +} + +[data-theme="dark"] perspective-viewer { + border-color: #666; +} + +/* --- Features Section --- */ + +.features { + display: flex; + align-items: center; + padding: 2rem 0; + width: 100%; + background-color: var(--bg-surface); +} + +.features__container { + margin: auto; + max-width: var(--content-width); + padding: 0 1rem; +} + +.features__container hr { + margin: 2rem 0; +} + +.feature-item { + display: inline-flex; + min-height: 150px; +} + +.feature-item__icon { + min-width: 53px; + margin-right: 50px; + stroke: #242526; + fill: none; + stroke-width: 2; +} + +[data-theme="dark"] .feature-item__icon { + stroke: white; +} + +.feature-item__text p { + margin: 0; +} + +/* --- Gallery Section --- */ + +.gallery-section { + background-color: var(--bg-surface); + padding: 2rem 0; +} + +.gallery { + max-width: 638px; + margin: 0 auto; +} + +.gallery img { + width: 100%; + cursor: pointer; +} + +/* --- Gallery Overlay --- */ + +.gallery-overlay { + position: fixed; + background-color: rgba(0, 0, 0, 0.5); + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + justify-content: center; + padding: 80px; + z-index: 200000; +} + +.gallery-overlay perspective-viewer { + max-width: 1200px; + max-height: 1000px; + flex: 1 1 auto; + height: auto; +} + +/* --- Examples Page --- */ + +.examples-grid { + display: flex; + margin: 0 auto; + width: 600px; + max-width: 90%; +} + +.examples-grid table { + width: 100%; +} + +.examples-grid td { + max-width: 400px; + padding: 10px; +} + +.examples-grid a { + color: var(--text-color); +} + +.examples-grid h4 { + margin: 0.5em 0; +} + +.examples-grid img { + width: 100%; + border-radius: 10px; + border: 1px solid var(--border-color); +} + +/* --- Block Page --- */ + +.block-detail { + width: 960px; + margin: 0 auto; + display: flex; + align-items: stretch; + flex-direction: column; + padding: 0 1rem; +} + +.block-detail h1 { + margin: 1rem 0; +} + +.block-detail iframe { + border: 1px solid var(--border-color); + border-radius: 10px; +} + +.block-detail__link { + align-self: flex-end; + margin: 1rem 0; + color: var(--color-primary); +} + +.block-detail pre { + background-color: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 1rem; + overflow-x: auto; + font-size: 13px; + line-height: 1.5; + margin: 1rem 0; +} + +.block-detail pre code { + font-family: + "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", "Consolas", + "Liberation Mono", monospace; +} + +.block-detail__file-title { + font-size: 14px; + font-weight: 600; + margin: 1.5rem 0 0 0; + padding: 0.5rem 1rem; + background-color: var(--bg-surface); + border: 1px solid var(--border-color); + border-bottom: none; + border-radius: 6px 6px 0 0; + font-family: + "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", "Consolas", + "Liberation Mono", monospace; +} + +.block-detail__file-title + pre { + margin-top: 0; + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +/* --- Footer --- */ + +.footer { + background-color: var(--bg-surface); + padding: 2rem 0; +} + +.footer__inner { + max-width: var(--content-width); + margin: auto; + text-align: center; + padding: 0 1rem; +} + +.footer__logo { + width: 160px; + margin-bottom: 1rem; +} + +.footer__copyright { + font-size: 11px; + color: var(--text-muted); + line-height: 1.8; +} + +.footer__copyright a { + color: var(--text-muted); + text-decoration: underline; +} + +/* --- Page container --- */ + +.page-container { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.page-container > main { + flex: 1; + margin-top: 100px; +} + +.page-container.examples > main { + margin-top: 60px; +} + +/* --- Prism Syntax Highlighting --- */ + +.token.comment, +.token.prolog, +.token.doctype, +.token.cdata { + color: #708090; +} + +.token.punctuation { + color: #999; +} + +.token.property, +.token.tag, +.token.boolean, +.token.number, +.token.constant, +.token.symbol, +.token.deleted { + color: #905; +} + +.token.selector, +.token.attr-name, +.token.string, +.token.char, +.token.builtin, +.token.inserted { + color: #690; +} + +.token.operator, +.token.entity, +.token.url { + color: #9a6e3a; +} + +.token.atrule, +.token.attr-value, +.token.keyword { + color: #07a; +} + +.token.function, +.token.class-name { + color: #dd4a68; +} + +.token.regex, +.token.important, +.token.variable { + color: #e90; +} + +[data-theme="dark"] .token.comment, +[data-theme="dark"] .token.prolog, +[data-theme="dark"] .token.doctype, +[data-theme="dark"] .token.cdata { + color: #6272a4; +} + +[data-theme="dark"] .token.punctuation { + color: #f8f8f2; +} + +[data-theme="dark"] .token.property, +[data-theme="dark"] .token.tag, +[data-theme="dark"] .token.boolean, +[data-theme="dark"] .token.number, +[data-theme="dark"] .token.constant, +[data-theme="dark"] .token.symbol, +[data-theme="dark"] .token.deleted { + color: #ff79c6; +} + +[data-theme="dark"] .token.selector, +[data-theme="dark"] .token.attr-name, +[data-theme="dark"] .token.string, +[data-theme="dark"] .token.char, +[data-theme="dark"] .token.builtin, +[data-theme="dark"] .token.inserted { + color: #50fa7b; +} + +[data-theme="dark"] .token.operator, +[data-theme="dark"] .token.entity, +[data-theme="dark"] .token.url { + color: #f8f8f2; +} + +[data-theme="dark"] .token.atrule, +[data-theme="dark"] .token.attr-value, +[data-theme="dark"] .token.keyword { + color: #8be9fd; +} + +[data-theme="dark"] .token.function, +[data-theme="dark"] .token.class-name { + color: #f1fa8c; +} + +[data-theme="dark"] .token.regex, +[data-theme="dark"] .token.important, +[data-theme="dark"] .token.variable { + color: #ffb86c; +} diff --git a/docs/src/data/features.js b/docs/src/data/features.js new file mode 100644 index 0000000..ba0c62c --- /dev/null +++ b/docs/src/data/features.js @@ -0,0 +1,1326 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +export default [ + { + name: "Default", + description: "Data grid", + config: { + title: "Default", + }, + }, + { + name: "Group By 1", + description: "Data grid with 1 level of groups for rows", + config: { + title: "Group By 1", + group_by: ["Sub-Category"], + }, + aggregates: "dominant", + }, + { + name: "Group By 2", + description: "Data grid with 2 levels of groups for rows", + config: { + title: "Group By 2", + group_by: ["Category", "Sub-Category"], + }, + }, + { + name: "Split By", + description: "Data grid with 1 level of categories for columns", + config: { + title: "Split By", + split_by: ["Category"], + columns: ["Sales", "Quantity", "Discount", "Profit"], + }, + }, + { + name: "Split By 2", + description: "Data grid with 2 levels of categories and columns", + config: { + title: "Split By 2", + split_by: ["Category", "Sub-Category"], + columns: ["Sales", "Quantity", "Discount", "Profit"], + }, + }, + { + name: "Both", + description: "Data grid with grouped rows and categorized columns", + config: { + title: "Both", + group_by: ["Region"], + split_by: ["Category"], + columns: ["Sales", "Quantity", "Discount", "Profit"], + }, + }, + { + name: "Both 2", + description: + "Data grid with 2 levels of groups and 2 levels of categories", + config: { + title: "Both 2", + group_by: ["Region", "State"], + split_by: ["Category", "Sub-Category"], + columns: ["Sales", "Quantity", "Discount", "Profit"], + }, + }, + { + name: "Flat Group By 1", + description: + "Flat data grid with 1 level of groups for rows, no rollup rows", + config: { + title: "Flat Group By 1", + group_by: ["Sub-Category"], + group_rollup_mode: "flat", + }, + }, + { + name: "Flat Group By 2", + description: + "Flat data grid with 2 levels of groups for rows, no rollup rows", + config: { + title: "Flat Group By 2", + group_by: ["Category", "Sub-Category"], + group_rollup_mode: "flat", + }, + }, + { + name: "Flat Both", + description: + "Flat data grid with grouped rows and categorized columns, no rollup rows", + config: { + title: "Flat Both", + group_by: ["Region"], + split_by: ["Category"], + columns: ["Sales", "Quantity", "Discount", "Profit"], + group_rollup_mode: "flat", + }, + }, + { + name: "Flat Both 2", + description: + "Flat data grid with 2 levels of groups and 2 levels of categories, no rollup rows", + config: { + title: "Flat Both 2", + group_by: ["Region", "State"], + split_by: ["Category", "Sub-Category"], + columns: ["Sales", "Quantity", "Discount", "Profit"], + group_rollup_mode: "flat", + }, + }, + { + name: "Background Row And Split By", + description: + "Data grid with groups, categories, and solid color conditional highlighting", + config: { + title: "Background Row And Split By", + group_by: ["State"], + split_by: ["Sub-Category"], + columns: ["Profit"], + sort: [["Profit", "col asc"]], + columns_config: { + Profit: { + number_bg_mode: "color", + }, + }, + }, + }, + { + name: "Gradient Row And Split By", + description: + "Data grid with groups, categories, and gradient conditional highlighting", + config: { + title: "Gradient Row And Split By", + group_by: ["State"], + split_by: ["Sub-Category"], + columns: ["Profit"], + sort: [["Profit", "col asc"]], + columns_config: { + Profit: { + number_bg_mode: "gradient", + bg_gradient: 1600, + }, + }, + }, + }, + { + name: "Background Row And Split By", + description: "Data grid with groups, categories, and a bar chart", + config: { + title: "Background Row And Split By", + group_by: ["State"], + split_by: ["Sub-Category"], + columns: ["Sales"], + aggregates: { Sales: "avg" }, + columns_config: { + Sales: { + number_fg_mode: "bar", + fg_gradient: 600, + }, + }, + }, + }, + { + name: "Foreground Colors", + description: + "Data grid with 6 categories. 3 categories have conditional formatting using foreground colors.", + config: { + title: "Foreground Colors", + columns: [ + "Category", + "Sales", + "Discount", + "Profit", + "Sub-Category", + "Order Date", + ], + columns_config: { + Profit: { + pos_fg_color: "#32cd82", + neg_fg_color: "#f50fed", + }, + Sales: { + pos_fg_color: "#780aff", + neg_fg_color: "#f5ac0f", + }, + Discount: { + pos_fg_color: "#f5ac0f", + neg_fg_color: "#780aff", + }, + }, + sort: [["Sub-Category", "desc"]], + }, + }, + { + name: "Background Colors", + description: + "Data grid with 6 categories. 3 categories have custom, solid color conditional formatting.", + config: { + title: "Background Colors", + plugin: "Datagrid", + columns_config: { + Discount: { + neg_bg_color: "#780aff", + number_bg_mode: "color", + number_fg_mode: "disabled", + pos_bg_color: "#f5ac0f", + }, + Profit: { + neg_bg_color: "#f50fed", + number_bg_mode: "color", + number_fg_mode: "disabled", + pos_bg_color: "#32cd82", + }, + Sales: { + neg_bg_color: "#f5ac0f", + number_bg_mode: "color", + number_fg_mode: "disabled", + pos_bg_color: "#780aff", + }, + }, + plugin_config: { + scroll_lock: true, + }, + columns: [ + "Category", + "Sales", + "Discount", + "Profit", + "Sub-Category", + "Order Date", + ], + sort: [["Sub-Category", "desc"]], + }, + }, + + { + name: "Gradient Colors", + description: + "Data grid with 6 categories. 3 categories have custom, gradient color conditional formatting.", + config: { + title: "Gradient Colors", + columns: [ + "Category", + "Sales", + "Discount", + "Profit", + "Sub-Category", + "Order Date", + ], + columns_config: { + Profit: { + number_bg_mode: "gradient", + bg_gradient: 600, + pos_bg_color: "#32cd82", + neg_bg_color: "#f50fed", + }, + Sales: { + number_bg_mode: "gradient", + bg_gradient: 2268, + pos_bg_color: "#780aff", + neg_bg_color: "#f5ac0f", + }, + Discount: { + number_bg_mode: "gradient", + bg_gradient: 0.8, + pos_bg_color: "#f5ac0f", + neg_bg_color: "#780aff", + }, + }, + sort: [["Sub-Category", "desc"]], + }, + }, + { + name: "Bar Colors", + description: + "Data grid with 6 categories. 3 categories have custom colored bars.", + config: { + title: "Bar Colors", + columns: [ + "Category", + "Sales", + "Discount", + "Profit", + "Sub-Category", + "Order Date", + ], + columns_config: { + Profit: { + number_fg_mode: "bar", + fg_gradient: 600, + pos_fg_color: "#32cd82", + neg_fg_color: "#f50fed", + }, + Sales: { + number_fg_mode: "bar", + fg_gradient: 2268, + pos_fg_color: "#780aff", + neg_fg_color: "#f5ac0f", + }, + Discount: { + number_fg_mode: "bar", + fg_gradient: 0.8, + pos_fg_color: "#f5ac0f", + neg_fg_color: "#780aff", + }, + }, + sort: [["Sub-Category", "desc"]], + }, + }, + { + name: "Label Bar", + description: + "Data grid with 6 categories. 3 categories have label bars showing the value overlaid on the bar.", + config: { + title: "Label Bar", + columns: [ + "Category", + "Sales", + "Discount", + "Profit", + "Sub-Category", + "Order Date", + ], + columns_config: { + Profit: { + number_fg_mode: "label-bar", + fg_gradient: 600, + }, + Sales: { + number_fg_mode: "label-bar", + fg_gradient: 2268, + }, + Discount: { + number_fg_mode: "label-bar", + fg_gradient: 0.8, + }, + }, + sort: [["Sub-Category", "desc"]], + }, + }, + { + name: "Label Bar Colors", + description: + "Data grid with 6 categories. 3 categories have custom colored label bars.", + config: { + title: "Label Bar Colors", + columns: [ + "Category", + "Sales", + "Discount", + "Profit", + "Sub-Category", + "Order Date", + ], + columns_config: { + Profit: { + number_fg_mode: "label-bar", + fg_gradient: 600, + pos_fg_color: "#32cd82", + neg_fg_color: "#f50fed", + }, + Sales: { + number_fg_mode: "label-bar", + fg_gradient: 2268, + pos_fg_color: "#780aff", + neg_fg_color: "#f5ac0f", + }, + Discount: { + number_fg_mode: "label-bar", + fg_gradient: 0.8, + pos_fg_color: "#f5ac0f", + neg_fg_color: "#780aff", + }, + }, + sort: [["Sub-Category", "desc"]], + }, + }, + { + name: "Thermometer", + description: + "Data grid with 1 group and 3 columns. 2 columns use bars and 1 column uses gradient conditional highlighting.", + config: { + title: "Thermometer", + group_by: ["State"], + columns: ["Profit (-)", "Profit", "Profit (+)"], + columns_config: { + "Profit (-)": { + number_fg_mode: "bar", + fg_gradient: 10000, + }, + Profit: { + number_bg_mode: "gradient", + bg_gradient: 10000, + }, + "Profit (+)": { + number_fg_mode: "bar", + fg_gradient: 10000, + }, + }, + expressions: { + [`Profit (-)`]: `if("Profit"<0){"Profit"}else{0}`, + [`Profit (+)`]: `if("Profit">0){"Profit"}else{0}`, + }, + }, + }, + + // Y Bar + + { + name: "Y Bar", + description: + "Bar chart with 17 bars. X is 'Sub-Category', and Y is 'Sales'.", + config: { + title: "Y Bar", + plugin: "Y Bar", + group_by: ["Sub-Category"], + columns: ["Sales"], + }, + }, + { + name: "Y Bar, Sorted Desc By Y-Axis", + description: + "Bar chart with 17 bars sorted descending. X is 'Sub-Category', and Y is 'Sales'.", + config: { + title: "Y Bar, Sorted Desc By Y-Axis", + plugin: "Y Bar", + group_by: ["Sub-Category"], + columns: ["Sales"], + sort: [["Sales", "desc"]], + }, + }, + { + name: "Y Bar - Row And Split By", + description: + "Stacked bar chart with 17 bars. X is 'Sub-Category', Y is 'Sales', and subgroup is 'Ship Mode'", + config: { + title: "Y Bar - Row And Split By", + plugin: "Y Bar", + group_by: ["Sub-Category"], + split_by: ["Ship Mode"], + columns: ["Sales"], + }, + }, + + { + name: "Y Bar - Group By 2 Sorted", + description: + "Bar chart with 3 categories and 17 total subcategories, sorted descending within each category.", + config: { + title: "Y Bar - Group By 2 Sorted", + plugin: "Y Bar", + group_by: ["Category", "Sub-Category"], + columns: ["Sales"], + sort: [["Sales", "desc"]], + }, + }, + { + name: "Y Bar - Group By 2 And Split By Sorted", + description: + "Bar chart with 3 colored categories and 17 total subcategories, sorted descending within each category.", + config: { + title: "Y Bar - Group By 2 And Split By Sorted", + plugin: "Y Bar", + group_by: ["Category", "Sub-Category"], + split_by: ["Category"], + columns: ["Sales"], + sort: [["Sales", "desc"]], + }, + }, + { + name: "Y Bar - Group By 2 And 2 Split By Sorted", + description: + "Stacked bar chart with 3 categories and 17 total subcategories, sorted descending within each category. X is 'Category, Sub-Category', y is 'Sales', subgroup is 'Region'", + config: { + title: "Y Bar - Group By 2 And 2 Split By Sorted", + plugin: "Y Bar", + group_by: ["Category", "Sub-Category"], + split_by: ["Category", "Region"], + columns: ["Sales"], + sort: [["Sales", "desc"]], + }, + }, + { + name: "Y Bar - Group By 2 And 2 Split By Sorted 2", + description: + "Bar chart. X is 'State', Y is 'Profit'. Each state has, when applicable, 2 bars: Profit and Loss. States are sorted by Net.", + config: { + title: "Y Bar - Group By 2 And 2 Split By Sorted 2", + plugin: "Y Bar", + group_by: ["State"], + split_by: ["Profit (-/+)"], + columns: ["Profit"], + expressions: { "Profit (-/+)": `if("Profit"<0){1}else{0}` }, + sort: [["Profit", "desc"]], + }, + viewport: { width: 600, height: 450 }, + }, + + // Y Bar Multi Axis + + { + name: "Y Bar Multi Axis", + description: + "Groupd bar chart. X is 'Sub-Category', Y is 'Quantity' and 'Sales'. Each X value has 2 bars.", + config: { + title: "Y Bar Multi Axis", + plugin: "Y Bar", + group_by: ["Sub-Category"], + columns: ["Quantity", "Sales"], + aggregates: { Sales: "avg" }, + sort: [["Sales", "desc"]], + }, + }, + { + name: "Y Bar Multi Axis - SPlit", + description: + "Groupd bar chart. X is 'Sub-Category', Y is 'Quantity', and the second Y is 'Sales'. Each X value has 2 bars.", + config: { + title: "Y Bar Multi Axis - SPlit", + plugin: "Y Bar", + group_by: ["Sub-Category"], + columns: ["Quantity", "Sales"], + sort: [["Sales", "desc"]], + aggregates: { Sales: "avg" }, + plugin_config: { splitMainValues: ["Sales"] }, + }, + }, + + // X Bar + + { + name: "X Bar", + description: + "Horizontal bar chart with 17 bars. X is 'Sub-Category' and Y is 'Sales'.", + config: { + title: "X Bar", + plugin: "X Bar", + group_by: ["Sub-Category"], + columns: ["Sales"], + }, + }, + { + name: "X Bar", + description: + "Grouped horizontal bar chart. X is 'Sub-Category' and Y is 'Quantity, Profit'.", + config: { + title: "X Bar", + plugin: "X Bar", + group_by: ["Sub-Category"], + columns: ["Quantity", "Profit"], + }, + }, + { + name: "X Bar, Sorted Desc By X-Axis", + description: + "Sorted horizontal bar chart. X is 'Sub-Category' and Y is 'Sales'.", + config: { + title: "X Bar, Sorted Desc By X-Axis", + plugin: "X Bar", + group_by: ["Sub-Category"], + columns: ["Sales"], + sort: [["Sales", "asc"]], + }, + }, + { + name: "X Bar - Row And Split By", + description: + "Stacked horizontal bar chart. X is 'Sub-Category', Y is 'Sales', and subgroup is 'Region'", + config: { + title: "X Bar - Row And Split By", + plugin: "X Bar", + group_by: ["Sub-Category"], + split_by: ["Region"], + columns: ["Sales"], + }, + }, + { + name: "X Bar - Row And Split By", + description: + "Sorted stacked horizontal bar chart. X is 'Sub-Category', Y is 'Sales', and subgroup is 'Region'", + config: { + title: "X Bar - Row And Split By", + plugin: "X Bar", + group_by: ["Sub-Category"], + split_by: ["Region"], + columns: ["Sales"], + sort: [["Sales", "asc"]], + }, + }, + + // Y Line + + { + name: "Y Line - Datetime Axis", + description: + "Line chart. X is Order Date from January 2014 to December 2017. Y is 'Sales'.", + config: { + title: "Y Line - Datetime Axis", + plugin: "Y Line", + group_by: ["Order Date"], + columns: ["Sales"], + }, + }, + { + name: "Y Line - Datetime Axis", + description: + "Line chart with 3 lines. X is Order Date, Y is Sales, and the lines are 'Consumer', 'Corporate', and 'Home Office'.", + config: { + title: "Y Line - Datetime Axis", + plugin: "Y Line", + group_by: ["Order Date"], + split_by: ["Segment"], + columns: ["Sales"], + }, + }, + { + name: "Y Line - Datetime Axis Computed", + description: + "Line Chart. X is 'Order Date', summarized by month. Y is 'Sales'", + config: { + title: "Y Line - Datetime Axis Computed", + plugin: "Y Line", + group_by: ["bucket(\"Order Date\", 'M')"], + expressions: { + "bucket(\"Order Date\", 'M')": "bucket(\"Order Date\", 'M')", + }, + columns: ["Sales"], + }, + }, + { + name: "Y Line - Datetime Axis", + description: + "Line Chart with 4 lines. X is 'Order Date' from January 2014 to December 2017. Y is 'Sales'. Each line is for each year.", + config: { + title: "Y Line - Datetime Axis", + plugin: "Y Line", + group_by: ["bucket(\"Order Date\", 'M')"], + split_by: ["bucket(\"Order Date\", 'Y')"], + expressions: { + "bucket(\"Order Date\", 'M')": "bucket(\"Order Date\", 'M')", + "bucket(\"Order Date\", 'Y')": "bucket(\"Order Date\", 'Y')", + }, + columns: ["Sales"], + }, + }, + { + name: "Y Line - Datetime Axis And Split By", + description: + "Line Chart with 4 lines. X is Order Date, Y is Sales, and the lines track different regions.", + config: { + title: "Y Line - Datetime Axis And Split By", + plugin: "Y Line", + group_by: ["bucket(\"Order Date\", 'M')"], + split_by: ["Region"], + expressions: { + "bucket(\"Order Date\", 'M')": "bucket(\"Order Date\", 'M')", + }, + columns: ["Sales"], + }, + }, + { + name: "Y Line - Category Axis", + description: "Line Chart. X is State and Y is Sales.", + config: { + title: "Y Line - Category Axis", + plugin: "Y Line", + group_by: ["State"], + columns: ["Sales"], + }, + viewport: { width: 600, height: 450 }, + }, + { + name: "Y Line - Category Axis", + description: + "Line Chart. X is State and Y is Sales. Sorted descending.", + config: { + title: "Y Line - Category Axis", + plugin: "Y Line", + group_by: ["State"], + columns: ["Sales"], + sort: [["Sales", "desc"]], + }, + viewport: { width: 600, height: 450 }, + }, + { + name: "Y Line - Row and Split By", + description: + "Line chart with 3 lines. X is State, Y is Sales, and each line tracks a customer segment. States are sorted descending by total sales.", + config: { + title: "Y Line - Row and Split By", + plugin: "Y Line", + group_by: ["State"], + split_by: ["Segment"], + columns: ["Sales"], + sort: [["Sales", "desc"]], + }, + viewport: { width: 600, height: 450 }, + }, + { + name: "Y Line - Multi Axis Split", + description: + "Line chart with 2 lines. X is State, Y is Profit, and second Y is Sales. States are sorted descending by Sales.", + config: { + title: "Y Line - Multi Axis Split", + plugin: "Y Line", + group_by: ["State"], + columns: ["Sales", "Profit"], + plugin_config: { splitMainValues: ["Sales"] }, + sort: [["Sales", "desc"]], + }, + viewport: { width: 600, height: 450 }, + }, + { + name: "Y Line - Multi Axis Split", + description: + "Line chart with 2 lines. X is State, Y is Profit, second Y is Sales, and each line is profit/sales for each customer segment. States are sorted descending by total Sales.", + config: { + title: "Y Line - Multi Axis Split", + plugin: "Y Line", + group_by: ["State"], + split_by: ["Segment"], + columns: ["Sales", "Profit"], + plugin_config: { splitMainValues: ["Sales"] }, + sort: [["Sales", "desc"]], + }, + viewport: { width: 600, height: 450 }, + }, + + // Y Area + + { + name: "Y Area - Datetime Axis Computed", + description: + "Area chart. X is 'Order Date', bucketed by month. Y is Sales.", + config: { + title: "Y Area - Datetime Axis Computed", + plugin: "Y Area", + group_by: ["bucket(\"Order Date\", 'M')"], + expressions: { + "bucket(\"Order Date\", 'M')": "bucket(\"Order Date\", 'M')", + }, + columns: ["Sales"], + }, + }, + { + name: "Y Area - Datetime Axis", + description: + "Area chart. X is 'Order Date', bucketed by month. Y is sales. Each year is its own color and area.", + config: { + title: "Y Area - Datetime Axis", + plugin: "Y Area", + group_by: ["bucket(\"Order Date\", 'M')"], + split_by: ["bucket(\"Order Date\", 'Y')"], + expressions: { + "bucket(\"Order Date\", 'M')": "bucket(\"Order Date\", 'M')", + "bucket(\"Order Date\", 'Y')": "bucket(\"Order Date\", 'Y')", + }, + columns: ["Sales"], + }, + }, + { + name: "Y Area - Datetime Axis And Split By", + description: + "Stacked area chart. X is 'Order Date', bucketed by month. Y is sales. Subgroup is Region.", + config: { + title: "Y Area - Datetime Axis And Split By", + plugin: "Y Area", + group_by: ["bucket(\"Order Date\", 'M')"], + split_by: ["Region"], + expressions: { + "bucket(\"Order Date\", 'M')": "bucket(\"Order Date\", 'M')", + }, + columns: ["Sales"], + }, + }, + { + name: "Y Area - Category Axis", + description: "Area chart. X is 'State'. Y is Sales.", + config: { + title: "Y Area - Category Axis", + plugin: "Y Area", + group_by: ["State"], + columns: ["Sales"], + }, + viewport: { width: 600, height: 450 }, + }, + { + name: "Y Area - Row and Split By", + description: + "Stacked area chart. X is 'State'. Y is 'Sales'. Subgroup is customer segment.", + config: { + title: "Y Area - Row and Split By", + plugin: "Y Area", + group_by: ["State"], + split_by: ["Ship Mode"], + columns: ["Sales"], + }, + viewport: { width: 600, height: 450 }, + }, + { + name: "Y Area - 2 Group By", + description: + "Grouped area chart. X is State, grouped by Region. Y is sales. Sorted descending by Sales for each region.", + config: { + title: "Y Area - 2 Group By", + plugin: "Y Area", + group_by: ["Region", "State"], + columns: ["Sales"], + sort: [["Sales", "desc"]], + }, + viewport: { width: 600, height: 450 }, + }, + { + name: "Y Area - Row and Split By", + description: + "Grouped area chart. X is State, grouped by Region. Y is sales. Sorted descending by Sales for each region. Each group is a different color.", + config: { + title: "Y Area - Row and Split By", + plugin: "Y Area", + group_by: ["Region", "State"], + split_by: ["Region"], + columns: ["Sales"], + sort: [["Sales", "desc"]], + }, + viewport: { width: 600, height: 450 }, + }, + { + name: "Y Area - Row and Split By 2", + description: + "Stacked grouped area chart. X is State, grouped by Region. Y is sales. Subgroup is customer segment. Sorted descending by total sales.", + config: { + title: "Y Area - Row and Split By 2", + plugin: "Y Area", + group_by: ["Region", "State"], + split_by: ["Ship Mode"], + columns: ["Sales"], + sort: [["Sales", "desc"]], + }, + viewport: { width: 600, height: 450 }, + }, + + // X/Y Scatter + + { + name: "X/Y Scatter", + description: "Scatter plot. X is Sales. Y is Quantity.", + config: { + title: "X/Y Scatter", + plugin: "X/Y Scatter", + group_by: ["City"], + columns: ["Sales", "Quantity"], + aggregates: { Sales: "avg", Profit: "avg", Quantity: "avg" }, + }, + }, + { + name: "X/Y Scatter - Split By", + description: + "Grouped Scatter plot. X is Sales. Y is Quantity. Group is Region.", + config: { + title: "X/Y Scatter - Split By", + plugin: "X/Y Scatter", + group_by: ["City"], + split_by: ["Region"], + columns: ["Sales", "Quantity"], + aggregates: { Sales: "avg", Profit: "avg", Quantity: "avg" }, + }, + }, + { + name: "X/Y Scatter - Color By Float", + description: + "Scatter plot. X is Sales. Y is Quantity. Each dot is colored by Profit.", + config: { + title: "X/Y Scatter - Color By Float", + plugin: "X/Y Scatter", + group_by: ["State"], + columns: ["Sales", "Quantity", "Profit"], + aggregates: { Sales: "avg", Profit: "avg", Quantity: "avg" }, + sort: [["Profit", "desc"]], + }, + }, + { + name: "X/Y Scatter - Bubble", + description: "Bubble plot. X is Sales. Y is Quantity. Size is Profit.", + config: { + title: "X/Y Scatter - Bubble", + plugin: "X/Y Scatter", + group_by: ["Sub-Category"], + columns: ["Sales", "Quantity", null, "Profit"], + aggregates: { Sales: "avg", Profit: "avg", Quantity: "avg" }, + }, + viewport: { width: 600, height: 450 }, + }, + { + name: "X/Y Scatter - Bubble", + description: + "Grouped bubble plot. X is Sales. Y is Quantity. Size is Profit. Group is Product Sub-Category.", + config: { + title: "X/Y Scatter - Bubble", + plugin: "X/Y Scatter", + group_by: ["Sub-Category"], + split_by: ["Category"], + columns: ["Sales", "Quantity", null, "Profit"], + aggregates: { Sales: "avg", Profit: "avg", Quantity: "avg" }, + }, + viewport: { width: 600, height: 450 }, + }, + { + name: "X/Y Scatter - Bubble", + description: + "Colored bubble plot. X is Sales. Y is Quantity. Size is Row ID. Color is Profit.", + config: { + title: "X/Y Scatter - Bubble", + plugin: "X/Y Scatter", + group_by: ["Sub-Category"], + columns: ["Sales", "Quantity", "Profit", "Row ID"], + sort: [["Profit", "desc"]], + aggregates: { + Sales: "avg", + Profit: "sum", + Quantity: "avg", + "Row ID": "avg", + }, + }, + viewport: { width: 600, height: 450 }, + }, + + { + name: "X/Y Scatter - Category Y Axis", + description: + "Horizontal Bubble plot. X is State. Y is Profit. Size is Quantity.", + config: { + title: "X/Y Scatter - Category Y Axis", + plugin: "X/Y Scatter", + columns: ["Profit", "State", null, "Quantity"], + group_by: ["City"], + aggregates: { + State: "dominant", + }, + }, + viewport: { width: 600, height: 450 }, + }, + + { + name: "X/Y Scatter - Category Y Axis And Size And Color", + description: + "Colored Bubble plot. X is State. Y is product sub-category. Size is Sales. Color is Quantity.", + config: { + title: "X/Y Scatter - Category Y Axis And Size And Color", + plugin: "X/Y Scatter", + columns: ["State", "Sub-Category", "Quantity", "Sales", null], + group_by: ["State", "Sub-Category"], + aggregates: { + State: "dominant", + "Sub-Category": "dominant", + Profit: "low", + }, + }, + viewport: { width: 600, height: 450 }, + }, + + { + name: "X/Y Scatter - Label", + description: + "Labeled scatter plot. X is Sales. Y is Quantity. Each dot is labeled by Sub-Category.", + config: { + title: "X/Y Scatter - Label", + plugin: "X/Y Scatter", + group_by: ["Sub-Category"], + columns: ["Sales", "Quantity", null, null, null, "Sub-Category"], + aggregates: { + Sales: "avg", + Quantity: "avg", + "Sub-Category": "dominant", + }, + }, + viewport: { width: 600, height: 450 }, + }, + { + name: "X/Y Scatter - Label And Color", + description: + "Labeled colored scatter plot. X is Sales. Y is Quantity. Color is Profit. Each dot is labeled by State.", + config: { + title: "X/Y Scatter - Label And Color", + plugin: "X/Y Scatter", + group_by: ["State"], + columns: ["Sales", "Quantity", "Profit", null, null, "State"], + aggregates: { + Sales: "avg", + Profit: "avg", + Quantity: "avg", + State: "dominant", + }, + sort: [["Profit", "desc"]], + }, + viewport: { width: 600, height: 450 }, + }, + { + name: "X/Y Scatter - Label And Size", + description: + "Labeled bubble plot. X is Sales. Y is Quantity. Size is Profit. Each bubble is labeled by Sub-Category.", + config: { + title: "X/Y Scatter - Label And Size", + plugin: "X/Y Scatter", + group_by: ["Sub-Category"], + columns: [ + "Sales", + "Quantity", + null, + "Profit", + null, + "Sub-Category", + ], + aggregates: { + Sales: "avg", + Profit: "avg", + Quantity: "avg", + "Sub-Category": "dominant", + }, + }, + viewport: { width: 600, height: 450 }, + }, + + // X/Y Line + + { + name: "X/Y Line", + description: + "Line chart with 4 lines. X is Sales. Y is Profit. Each line represents a region.", + config: { + title: "X/Y Line", + columns: ["Sales", "Profit"], + plugin: "X/Y Line", + aggregates: { + "Order Date": "dominant", + Sales: "avg", + Profit: "avg", + }, + group_by: ["State"], + sort: [ + ["Profit", "desc"], + ["Sales", "desc"], + ], + split_by: ["Region"], + }, + }, + + // Treemap + + { + name: "Treemap", + description: + "Treemap with 1-level hierarchy and 17 tiles. Each tile is a product sub-category, and the tile's size represents sales", + config: { + title: "Treemap", + plugin: "Treemap", + group_by: ["Sub-Category"], + columns: ["Sales"], + }, + }, + { + name: "Treemap - 2 Group By", + description: + "Treemap with 2-level hierarchies for Category and Sub-Category. Size is Sales.", + config: { + title: "Treemap - 2 Group By", + plugin: "Treemap", + group_by: ["Category", "Sub-Category", "Segment"], + columns: ["Sales"], + }, + }, + { + name: "Treemap - Float Color", + description: + "Colored treemap with 2-level hierarchies for Category and Sub-Category. Size is Sales. Color is Quantity.", + config: { + title: "Treemap - Float Color", + plugin: "Treemap", + group_by: ["Category", "Sub-Category"], + columns: ["Sales", "Quantity"], + sort: [["Quantity", "desc"]], + }, + }, + { + name: "Treemap - Category Color", + description: + "Treemap with 2-level hierarchies for Category and Sub-Category, with each Category colored differently. Size is Sales.", + config: { + title: "Treemap - Category Color", + plugin: "Treemap", + group_by: ["Category", "Sub-Category"], + columns: ["Sales", "Category"], + aggregates: { Category: "dominant" }, + }, + }, + { + name: "Treemap - Row And Split By Float Color", + description: + "Colored treemap with 3-level hierarchies for Region, Category, and Sub-Category. Size is Sales. Color is Quantity.", + config: { + title: "Treemap - Row And Split By Float Color", + plugin: "Treemap", + group_by: ["Category", "Sub-Category"], + split_by: ["Region"], + columns: ["Sales", "Quantity"], + sort: [["Quantity", "desc"]], + }, + viewport: { width: 800, height: 600 }, + }, + { + name: "Treemap - Row And Split By Category Color", + description: + "Treemap with 3-level hierarchies for Region, Category, and Sub-Category, with each Region colored differently. Size is Sales.", + config: { + title: "Treemap - Row And Split By Category Color", + plugin: "Treemap", + group_by: ["Category", "Sub-Category"], + split_by: ["Region"], + columns: ["Sales", "Region"], + aggregates: { Region: "dominant" }, + }, + viewport: { width: 800, height: 600 }, + }, + { + name: "Treemap - Row And Split By Category Color 2", + description: + "Treemap with 3-level hierarchies for Region, Category, and Sub-Category, with each Category colored differently. Size is Sales.", + config: { + title: "Treemap - Row And Split By Category Color 2", + plugin: "Treemap", + group_by: ["Category", "Sub-Category"], + split_by: ["Region"], + columns: ["Sales", "Category"], + aggregates: { Category: "dominant" }, + }, + viewport: { width: 800, height: 600 }, + }, + // Sunburst + + { + name: "Sunburst", + description: + "Sunburst with 1-level hierarchy. Hierarchy is Category. Size is Sales.", + config: { + title: "Sunburst", + plugin: "Sunburst", + group_by: ["Sub-Category"], + columns: ["Sales"], + }, + }, + { + name: "Sunburst - 2 Group By", + description: + "Sunburst with 3-level hierarchy. Hierarchies are Category, Sub-category, and Customer Segment.", + config: { + title: "Sunburst - 2 Group By", + plugin: "Sunburst", + group_by: ["Category", "Sub-Category", "Segment"], + columns: ["Sales"], + }, + }, + { + name: "Sunburst - Float Color", + description: + "Colored sunburst with 2-level hierarchy. Hierarchies are Category and Sub-category. Size is Quantity. Color is Sales.", + config: { + title: "Sunburst - Float Color", + plugin: "Sunburst", + group_by: ["Category", "Sub-Category"], + columns: ["Quantity", "Sales"], + sort: [["Quantity", "desc"]], + }, + }, + { + name: "Sunburst - Category Color", + description: + "Colored sunburst with 2-level hierarchy. Hierarchies are Category and Sub-category. Size is Sales. Color is also Category.", + config: { + title: "Sunburst - Category Color", + plugin: "Sunburst", + group_by: ["Category", "Sub-Category"], + columns: ["Sales", "Category"], + aggregates: { Category: "dominant" }, + }, + }, + { + name: "Sunburst - Row And Split By", + description: + "Grouped sunbursts with 2-level hierarchy. Hierarchies are Category and Sub-category. Size is Sales. Each sunburst is a different region.", + config: { + title: "Sunburst - Row And Split By", + plugin: "Sunburst", + group_by: ["Category", "Sub-Category"], + split_by: ["Region"], + columns: ["Sales"], + }, + viewport: { width: 600, height: 450 }, + }, + { + name: "Sunburst - Row And Split By Float Color", + description: + "Grouped colored sunbursts with 2-level hierarchy. Hierarchies are Category and Sub-category. Size is Sales. Color is Quantity. Each sunburst is a different region.", + config: { + title: "Sunburst - Row And Split By Float Color", + plugin: "Sunburst", + group_by: ["Category", "Sub-Category"], + split_by: ["Region"], + columns: ["Sales", "Quantity"], + }, + viewport: { width: 600, height: 450 }, + }, + { + name: "Sunburst - Row And Split By Category Color", + description: + "Grouped sunbursts with 2-level hierarchy. Hierarchies are Category and Sub-category. Size is Sales. Each sunburst is a different region and color.", + config: { + title: "Sunburst - Row And Split By Category Color", + plugin: "Sunburst", + group_by: ["Category", "Sub-Category"], + split_by: ["Region"], + columns: ["Sales", "Region"], + aggregates: { Region: "dominant" }, + }, + viewport: { width: 600, height: 450 }, + }, + { + name: "Sunburst - Row And Split By Category Color 2", + description: + "Grouped sunbursts with 2-level hierarchy. Hierarchies are Category and Sub-category. Size is Sales. Each sunburst is a different region, and each category is colored.", + config: { + title: "Sunburst - Row And Split By Category Color 2", + plugin: "Sunburst", + group_by: ["Category", "Sub-Category"], + split_by: ["Region"], + columns: ["Sales", "Category"], + aggregates: { Category: "dominant" }, + }, + viewport: { width: 600, height: 450 }, + }, + + // Heatmap + + { + name: "Heatmap", + description: + "Heatmap. Columns are Sub-Category. Rows are Region. Color is Profit.", + config: { + title: "Heatmap", + plugin: "Heatmap", + group_by: ["Sub-Category"], + split_by: ["Region"], + columns: ["Profit"], + sort: [ + ["Profit", "desc"], + ["Profit", "col desc"], + ], + // aggregates: {Category: "dominant"} + }, + // viewport: {width: 600, height: 200} + }, + { + name: "Heatmap 2", + description: + "Heatmap. Columns are State. Rows are Sub-Category. Color is Profit.", + config: { + title: "Heatmap 2", + plugin: "Heatmap", + group_by: ["State"], + split_by: ["Sub-Category"], + columns: ["Profit"], + sort: [ + ["Profit", "desc"], + ["Profit", "col desc"], + ], + // aggregates: {Profit: "low"} + }, + viewport: { width: 600, height: 450 }, + }, + { + name: "Heatmap 3", + description: + "Heatmaps. Columns are order date, bucketed by month. Rows are Sub-Category. Color is Discount.", + config: { + title: "Heatmap 3", + columns: ["Discount"], + plugin: "Heatmap", + expressions: { + "bucket(\"Order Date\", 'M')": "bucket(\"Order Date\", 'M')", + }, + aggregates: { + "Order Date": "dominant", + Sales: "avg", + }, + split_by: ["Sub-Category"], + group_by: ["bucket(\"Order Date\", 'M')"], + sort: [["Discount", "col asc"]], + }, + viewport: { width: 600, height: 450 }, + }, + { + name: "Heatmap 4", + description: + "Heatmap. Columns are order date, bucketed by month. Rows are Profit, bucketed by 100. Color is Profit.", + config: { + title: "Heatmap 4", + plugin: "Heatmap", + columns: ["Profit"], + expressions: { + 'bucket("Profit", 100)': 'bucket("Profit", 100)', + "bucket(\"Order Date\", 'M')": "bucket(\"Order Date\", 'M')", + }, + group_by: ["bucket(\"Order Date\", 'M')"], + split_by: ['bucket("Profit", 100)'], + }, + viewport: { width: 600, height: 450 }, + }, +]; diff --git a/docs/src/data/layouts.ts b/docs/src/data/layouts.ts new file mode 100644 index 0000000..e7c7b7b --- /dev/null +++ b/docs/src/data/layouts.ts @@ -0,0 +1,122 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +export const LAYOUTS: Record = { + sparkgrid: { + plugin: "Datagrid", + plugin_config: { + columns: {}, + scroll_lock: true, + }, + columns_config: { + "chg (-)": { + number_fg_mode: "bar", + fg_gradient: 14.34, + }, + chg: { + number_bg_mode: "gradient", + bg_gradient: 29.17, + }, + "chg (+)": { + number_fg_mode: "bar", + fg_gradient: 17.4, + }, + }, + group_rollup_mode: "rollup", + settings: true, + title: "Market Monitor", + group_by: ["name"], + split_by: ["client"], + columns: ["chg (-)", "chg", "chg (+)"], + filter: [], + sort: [["chg", "desc"]], + expressions: { + "chg (+)": 'if("chg">0){"chg"}else{0}', + "chg (-)": 'if("chg"<0){"chg"}else{0}', + }, + aggregates: { + chg: "avg", + "chg (+)": "avg", + "chg (-)": "avg", + }, + }, + datagrid: { + plugin: "datagrid", + title: "Blotter", + columns: ["ask", "bid", "chg"], + group_rollup_mode: "rollup", + sort: [ + ["name", "desc"], + ["lastUpdate", "desc"], + ], + aggregates: { name: "last", lastUpdate: "last" }, + group_by: ["name", "lastUpdate"], + split_by: ["client"], + plugin_config: {}, + }, + "x bar": { + title: "Px (Δ)", + group_rollup_mode: "flat", + columns: ["chg"], + plugin: "X Bar", + sort: [["chg", "asc"]], + group_by: ["name"], + split_by: ["client"], + }, + "y line": { + title: "Time Series (Px)", + group_rollup_mode: "flat", + plugin: "Y Line", + group_by: ["lastUpdate"], + sort: [["lastUpdate", "desc"]], + split_by: ["client"], + columns: ["bid"], + aggregates: { bid: "avg", chg: "avg", name: "last" }, + }, + "xy scatter": { + title: "Spread Scatter", + group_rollup_mode: "flat", + plugin: "X/Y Scatter", + group_by: ["name"], + split_by: [], + columns: ["bid", "ask", "chg", "vol", null, "name"], + aggregates: { bid: "avg", ask: "avg", vol: "avg", name: "dominant" }, + sort: [], + }, + treemap: { + plugin: "Treemap", + group_rollup_mode: "flat", + title: "Volume Map", + group_by: ["name", "client"], + split_by: [], + columns: ["vol", "chg"], + aggregates: { bid: "sum", chg: "sum", name: "last" }, + sort: [ + ["name", "desc"], + ["chg", "desc"], + ], + }, + heatmap: { + group_rollup_mode: "flat", + title: "Spread Heatmap", + columns: ["name"], + plugin: "Heatmap", + expressions: { + 'bucket("bid",2)': 'bucket("bid",2)', + 'bucket("ask",2)': `bucket("ask",2)`, + }, + group_by: [`bucket("bid",2)`], + split_by: [`bucket("ask",2)`], + sort: [], + aggregates: {}, + }, +}; diff --git a/docs/src/data/random.ts b/docs/src/data/random.ts new file mode 100644 index 0000000..8d27b56 --- /dev/null +++ b/docs/src/data/random.ts @@ -0,0 +1,75 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +const SECURITIES = [ + "AAPL.N", + "MSFT.N", + "AMZN.N", + "GOOGL.N", + "FB.N", + "TSLA.N", + "BABA.N", + "TSM.N", + "V.N", + "NVDA.N", + "JPM.N", + "JNJ.N", + "WMT.N", + "UNH.N", + "MA.N", + "BAC.N", + "DIS.N", + "ASML.N", + "ADBE.N", + "CMCSA.N", + "NKE.N", + "XOM.N", + "TM.N", + "KO.N", + "ORCL.N", + "NFLX.N", +]; + +const CLIENTS = [ + "Homer", + "Marge", + "Bart", + "Lisa", + "Maggie", + "Barney", + "Ned", + "Moe", +]; + +let id = 0; + +function randn_bm(): number { + let u = 0, + v = 0; + while (u === 0) u = Math.random(); + while (v === 0) v = Math.random(); + return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v); +} + +export function random_row() { + id = id % 1000; + return { + name: SECURITIES[Math.floor(Math.random() * SECURITIES.length)], + client: CLIENTS[Math.floor(Math.random() * CLIENTS.length)], + lastUpdate: new Date(), + chg: randn_bm() * 10, + bid: randn_bm() * 5 + 95, + ask: randn_bm() * 5 + 105, + vol: randn_bm() * 5 + 105, + id: id++, + }; +} diff --git a/docs/src/data/superstore.ts b/docs/src/data/superstore.ts new file mode 100644 index 0000000..ce3f23a --- /dev/null +++ b/docs/src/data/superstore.ts @@ -0,0 +1,25 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { worker } from "./worker.js"; +// @ts-ignore +import SUPERSTORE_URL from "superstore-arrow/superstore.lz4.arrow"; + +export const WORKER = worker(); + +export const SUPERSTORE_TABLE = (async function () { + const req = await fetch(SUPERSTORE_URL); + const arrow = await req.arrayBuffer(); + return await WORKER.then((w) => + w.table(arrow.slice(), { name: "superstore" }), + ); +})(); diff --git a/docs/src/data/worker.ts b/docs/src/data/worker.ts new file mode 100644 index 0000000..64b6bde --- /dev/null +++ b/docs/src/data/worker.ts @@ -0,0 +1,34 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +const WORKER = (async () => { + const perspective = await import("@perspective-dev/client"); + const perspective_viewer = await import("@perspective-dev/viewer"); + const server_wasm = import( + "@perspective-dev/server/dist/wasm/perspective-server.wasm" + ); + + const client_wasm = import( + "@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm" + ); + + await Promise.all([ + perspective.init_server(server_wasm.then((x: any) => x.default)), + perspective_viewer.init_client(client_wasm.then((x: any) => x.default)), + ]); + + return await perspective.worker(); +})(); + +export function worker() { + return WORKER; +} diff --git a/docs/src/examples.html b/docs/src/examples.html new file mode 100644 index 0000000..19c2f7e --- /dev/null +++ b/docs/src/examples.html @@ -0,0 +1,59 @@ + + + + + + Perspective - Examples + + + + + +
+ + +
+
+
+

+
+ +
+ +
+
+ + + diff --git a/docs/src/examples.ts b/docs/src/examples.ts new file mode 100644 index 0000000..934e70f --- /dev/null +++ b/docs/src/examples.ts @@ -0,0 +1,75 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { initTheme, createThemeToggle } from "./components/theme.js"; + +initTheme(); +document.getElementById("theme-toggle")!.replaceWith(createThemeToggle()); + +const LOCAL_EXAMPLES = [ + "editable", + "file", + "duckdb", + "fractal", + "market", + "raycasting", + "evictions", + "nypd", + "streaming", + "covid", + "webcam", + "movies", + "superstore", + "olympics", + "dataset", +]; + +function partition(input: T[], spacing: number): T[][] { + const output: T[][] = []; + for (let i = 0; i < input.length; i += spacing) { + output.push(input.slice(i, i + spacing)); + } + return output; +} + +const grid = document.getElementById("examples-grid")!; + +const table = document.createElement("table"); +const tbody = document.createElement("tbody"); + +for (const group of partition(LOCAL_EXAMPLES, 2)) { + const tr = document.createElement("tr"); + for (const name of group) { + const td = document.createElement("td"); + const a = document.createElement("a"); + a.href = `/block.html?example=${name}`; + + const br = document.createElement("br"); + a.appendChild(br); + + const h4 = document.createElement("h4"); + h4.textContent = name; + a.appendChild(h4); + + const img = document.createElement("img"); + img.width = 400; + img.src = `/blocks/${name}/preview.png`; + a.appendChild(img); + + td.appendChild(a); + tr.appendChild(td); + } + tbody.appendChild(tr); +} + +table.appendChild(tbody); +grid.appendChild(table); diff --git a/docs/src/index.html b/docs/src/index.html new file mode 100644 index 0000000..21c7139 --- /dev/null +++ b/docs/src/index.html @@ -0,0 +1,354 @@ + + + + + + Perspective + + + + + +
+ + +
+
+
+
+ +
+
+
+


+
+

What is Perspective?

+

+ 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, then deploy stand-alone in the + browser, or in concert with Python and/or + Jupyterlab. +

+
+

Features

+
+
+ + + + + + + + + + + +
+

+ A fast, memory efficient streaming query + engine, written in C++ and compiled for + both + WebAssembly + and + Python, with read/write/streaming for + Apache Arrow, and a high-performance columnar + expression language based on + ExprTK. +

+
+
+
+ + + + + +
+

+ A framework-agnostic User Interface + packaged as a + Custom Element, powered either in-browser via + WebAssembly or virtually via WebSocket + server (Python/Node). +

+
+
+
+ + + + + + + + + + + + + + + + + + + + +
+

+ A + JupyterLab + widget and Python client library, for + interactive data analysis in a notebook, + as well as scalable production + Voila + applications. +

+
+
+
+ +
+


+
+

JavaScript

+

+ Interactive dashboards built on Perspective.js + Custom Elements + are easy to integrate into any web application + framework. +

+

+ Using Perspective's simple query language, + elements like + <perspective-viewer> can be + symmetrically configured via API or User + interaction. Web Applications built with + Perspective Custom Elements can be re-hydrated + from their serialized state, driven from external + Events, or persisted to any store. Workspaces + can mix virtual, server-side Python data with + in-browser client data seamlessly, and + independent data Views can be cross-filtered, + duplicated, exported, stacked and saved. +

+

+ To achieve Desktop-like performance in the + Browser, Perspective.js relies on + WebAssembly + for excellent query calculation time, and + Apache Arrow + for its conservative memory footprint and + efficient data serialization. +

+
+ +
+


+
+

Python

+

+ perspective-python, built on the + same C++ data engine used by the + WebAssembly version, implements the Perspective API directly in + Python, either as a virtualized server for + Production, or as an embedded JupyterLab Widget + for Research. +

+

+ For Application Developers, virtualized + <perspective-viewer> will only + consume the data necessary to render the current + screen, enabling ludicrous size datasets + with nearly instant load. Or - efficiently stream + the entire dataset to the WebAssembly runtime via + Apache Arrow, and give your server a break! +

+

+ For Researchers and Data Scientists, + PerspectiveWidget is available as a + Jupyter/JupyterLab + widget, allowing interactive + Pandas + and + Apache Arrow + visualization within a notebook. +

+
+
+
+
+
+ + +
+ +
+ +
+
+ + + diff --git a/docs/src/index.ts b/docs/src/index.ts new file mode 100644 index 0000000..9c91757 --- /dev/null +++ b/docs/src/index.ts @@ -0,0 +1,37 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { initTheme, createThemeToggle } from "./components/theme.js"; +import { initDemo } from "./components/demo.js"; +import { initGallery } from "./components/gallery.js"; + +initTheme(); +document.getElementById("theme-toggle")!.replaceWith(createThemeToggle()); + +const page = document.getElementById("page")!; + +function handleScroll() { + const scrollTop = document.documentElement.scrollTop; + if (scrollTop > 90 && page.classList.contains("header-shift")) { + page.classList.remove("header-shift"); + } else if (scrollTop <= 90 && !page.classList.contains("header-shift")) { + page.classList.add("header-shift"); + } +} + +document.addEventListener("scroll", handleScroll); + +const demoContainer = document.getElementById("demo-container")!; +initDemo(demoContainer); + +const gallery = document.getElementById("gallery")!; +initGallery(gallery); diff --git a/docs/static/.nojekyll b/docs/static/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/docs/static/404.html b/docs/static/404.html new file mode 100644 index 0000000..e69de29 diff --git a/docs/static/CNAME b/docs/static/CNAME new file mode 100644 index 0000000..33ec490 --- /dev/null +++ b/docs/static/CNAME @@ -0,0 +1 @@ +perspective-dev.github.io \ No newline at end of file diff --git a/docs/static/img/jupyterlab.png b/docs/static/img/jupyterlab.png new file mode 100644 index 0000000000000000000000000000000000000000..39eeb7a58b16617007ce7eb74e7915da97b5c01a GIT binary patch literal 19207 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 literal 0 HcmV?d00001 diff --git a/docs/static/img/logo/logo-dark.png b/docs/static/img/logo/logo-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..d7d15021ecbad05ad0e3850dfe1809636924efc0 GIT binary patch literal 2737 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 literal 0 HcmV?d00001 diff --git a/docs/static/img/logo/logo-light.png b/docs/static/img/logo/logo-light.png new file mode 100644 index 0000000000000000000000000000000000000000..4469b30e92c167003e3544fb883a8b25371d890d GIT binary patch literal 2849 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

Ai?!tlKua8VOlIeIuTVG>{ykrjt>&BZea5 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®4Sih5l^_ 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

@y3R^$ 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

Bql@&5_3AI>U z8?Uiu8tLi={@%AB2&nULeGX800d}6u{796@bcJ<70Ae*Yv442BJHV5Hm=^ZdvsO-} z=U12e{OIr%UFt5=^0l%;-w!5lYnPyPkp0~UkquCff&L~u*?KFJasH~~&5bqr5L-uC zGySmts4Id!EhE_^%BT&lq$!#_u&HeN-nhvVm~-`;EEzN~v9l8ffjBD5j7k)AB$tzVaMQAa7hwf0*Y<<%!lJ4+}$Y<5yEE7}rcqrzHfQ#}gH4L?Q;J zFi;4WOOFbNm4kjU@E%HGYzwj7?6+*Q7Wy}oaBxvd755K%!K}woX131#i65qzuJkY_ zTOmt6%4n$3LNi8kbjFS0$FPOIDQl47C_j97R)kT^8A55*=T>5LMGxrcKN=MNeq

    0U-pv3&actby_4&S>wJ~Z8M^7^&#@|W_H`R`Ck1__C0z(Z$dV(R9?a?Ry{><55dVAA7q zC3tPmeORCb*U|9WG`4f;VU(zW5AZ6h5!OxHpa@ryTk_?A&du9Plf)99m zx=E~tH;91<`|`43JG{&DQ=mz2P;+F?M(I`>yVwV(HBAkp2H%B?ZLf!&bQOa}3?fll zfyBmaE6d$<3}P!`ip-nBUmaBb741eJJ~lGh`0&I^_8Ii3!1um>NnWq8>2WwqsUy?<_YIjIf>vh zdS&Y!=GYT#g)aNl#>AUxykrw3hKNa!0>IDHRh!#zLFgu~sG-XS4m}=VOaLz6+ak{! zhG}aE-ap*8m3a3H@AbQzZu2UL!(fgN!D|tUwF_|rUXvI29eab*62ag9>cwwNLm-1{ z?-6n)M!tV32y_t6lJDBRL?;u(;zbB)0RjiLVN2}dr2O=Hhu+_J1H$r2wi9GennOY& zLQI+K99_SB z{cg*Ur*tlCD?%P_<~hXf7+Z_rhg|w4`BOTX{8>eHMYYA0(v0?KaldMJkxuPJcG}ZB zYGu~gWcmDY*nW2lB_X=WD?8yJ?la+4IT-#klwdjnLU@!i*4D17ExR}SI8)s2BQ+`) zovsz5u!(O5x5LfmS${Ml_Bs0=G5fp~v9zHldqY)V#je5ldr*8;S&yKuE4h<#wT}(f7m@f_t2vR+$BD=|ZdP&;>YZJ7Z^-hReQSnJi7z36%Zo@^Teh=U zuvoIBoF#e38eK-l?cUP5oX{?^j1-!BE-F#vo$+1Eeb6(!(3*gmu)KP5M8|$ zG`UZ|OV+Q~rJ2*P_q1!@?;}LJ!WvJfQ zqcc#)s8bvN@VT@$MQul|^aIB`S{ArpX6CQ_Tu^FGs}a5k8r>&quJM}p7_XTd-FeaP z&3m$vm_@&_Dp;Ek$00xE2JPHPiVsm1uwxdW0jikso@|WuIb>o2j3nrubhh9KSW&Qe z1jwXy;mAY8rMkdm)s~jAF$I58h_SJwL!5&{&9tMuaQNcFU7b5)q)QB>WFW>BD75Is z{U)r4LGBe%-efgs$`C{$gndTew?|#U4EkD%|AmUv5m5ve6wsr)GPKCAG4 z5(|!i1y`&eV(vI~4jv7cKRj}wK^8&2LA_))PzFI#J=`7gQ#lc-+}w#E_3jQyjlmW& zsGh_LO9W)v%jsEJ$iiSGPEH}gI1ObAx$+LZ^%9+lMx4UY?iUaQECPH>V+Q*x{fS-l~%NZL1_RG{(|;A`?4o&Z~F zt(ZdO5KN^zY0fI7KI8JW6E@>w88fiPUPGu!DfIK@DCQHf%4m6@U#RSuC%>-Os01X) z4G)Ml_NTP)jdFw4;lep<`WM4qiNtMP+P~u|-Rl$=uk?$=gKaY*ut2xU* z)2B@0Y4j!w7apjPjt{54m5xugYoc+Y`>>^^mdpPE%`3_@f#H`pV(K&6xBar>v7i6s z5efl#N5~o8`#eRNJ=$VBlv}Ch0$ip3N76Y)RrZoWIuP$f35Sb)thtf`-^?;{n;g4TQ9O&9;fU*B?}BK?lvFZjC!Qb0YIsL z20EsQ!*Il>d;n8NQ@iIsXe+&z*HS;swGt4KL8JJx+TQC?J0_Krq}re+Ju>W9RR}C3 z%t?+tI*K$~=oB67ve;EdiG4^Wod)K_QaFeLnqP>W*+|3;sxT&jGCfsxBhy?zr=L~) z9fw?q1H=mQp7dw_5o1LpG^EVX)Tl9He$%G>!yRWPZERWCnY>$Mtm0)FQoK9=fH4j7N=A5AD1o%|s}*@+!2(nSpS*%pU%duKqm; zq>4!{l);>+1pB#4e}7k0yFGoplhQF__k`(BVo@^-dR5_(KjMD}2(w;MCMr>rkapql zlSb`K0wte*7K%T!XJr%=)>EEgU^7@FZGy}polb&aureae3wsV}XOX73^H@POQs=t` zcH)1H{1;*=WV~8%r1n5&Z{^7P$Bl3BUAsJW8FUEO7hy?!ZP$qvpw) z-{_=^+J2Rkv#~ig1oU&d9ItI4QXDHm`K;po{rfZ!UH=FLXx#j8Nqw1}U$g|K`aj4b z3+RF7)XWlV4vW!6M_Z~YJT$n=6X5@?@()Z6^@gqpGpHB{(81L)*8i!L58C12{)EItd<=knEhXrpG;(pUy->rVFy zl6-vEQ3SCBJtIN64d#=;G=KFRjsXAPLeN+tLY=y@v}^tBBr3(6j1ADCM~K|e8>AHa zxrz;z!`^GePd-~bh5fO0^zzt*WUkEm$qG{{Y1dre#S`b4TEXGHnXL zX)HDRcA;GQJQNgFe_HJp^C%3*A1K+`t99>Vqi~sRb-#0-jQp%A8mX`(#3e7@pE1SW zOD|A`p!xbS8dmi5?n`DtgGzD;m+=eoYR3>i>O~V_&(XKWh+y%xQxWtyz+?6rFpi7{PM0 zC7QQq`OFj(McjE*s=oO|fHT&F-bj=l;5tdF9fYlvdC^xg3{ zY3Z1ow`aBW1eu}~&mR+re^yGgHC5|ND`+thF2Lo-Yb{DX8Xn{_S1{F3(N;x2dYX%) z@VL0Ssi~>&7?{r{|LT%cTs@RBsi?ajjAsJ{b?_EG_m4k-sh9k>Z_nd~Pycgl0Pic~ zO&&dPXaFer0_d2V-vOcEe)-+8Oi*YkJJipggjvWm(%SkcL%*~ss11W+&^Cw?4+nQ2 zs8e2;*Ptneu{j~)4EQc9%O5x%7PhZ#IKKA1{V|b|MZe}Db2&qYG=quoS6E^Go&AdY zk=~bpw_rdiLM51Xd~2<&d^u9*5PB;DfxK2yN!Y3QOnO0aLO7&G1Y0yc1?TaQz=-*G=E&#@LYMMO1>qbZgEvQ4^4ss{%9x*-67x_F!n-=Ig zegNt73cbJGUrY`ZAErhpEbQ0SoJSlT0IWy7b;j+jdv=T4r|@XtZur;rcWm{3{N`7| z=NoxNC1cQfEv)Z#3A2KN@BY}c&#ZOf!hbrN)z-GAAn(pi$TE$}qv^tdifsNej|)_o z5a86uFf`QSsfj|s&Dp51K3&Xs0+4ELZMp4N+4iyZ*bIjZF337DaKf60E;;y}avosx1JF8q=Yz!pobn5IAQi@;`1 zW5)uhJV2c69+=`n=HJy84b}j29yg#T_5&MLt#w*-CPqAZY1H51!MRtO<`bx$%rN@1 z=nv$KibPlQZZ`rjI_dVtqHDc-O3v}~-P=8ot4K)9;PbdiL(Aw%0JZoiJk|?8TI>7G zjoa%YA|ZaMonEM9ettUX`>GAA-|QwUi@-9S8d(8keeMI^Oov(hAEUs>*>4ieT!Kg4 z)d$KJf&&F(ZgutJ&O=8F%6>l9?~wkvj%yinM@IW3(zVL5dZV(7KF5EG5yUs;Q6siip0F*f){uOS=Q0_+e zoebTJW>yRQE|I{2WactZ(@uN12J_o+^?y4{Q9w#x7??&WfB&ktPqBi7i;Wd9wFw4; zrOEtm9xQX7QG5TA<-bj$3;T&<-}@_>3fQy$`e*vK@uv)Y0r-bi6~W%ZR+;zwe>YGz zTzV92AKRDo+zH8o0oa~S;xL;!&pd4)mS(=0h!+%_{EjJf2b7zKkA|J9&y zR{;ncqF$`A;%r7WQD5_Pu6AYhYc19cru=V@O)f6(#nk-Ao05`!eJ=G~GGj^RfoKAU z(Fh#e)N;MH>D*0EiORZwBhxtH`9I?PYo!jWO_KMIrtDOhQJ8o|XE9;Bky8;Eza;Et z5(QX^5w)vi$?djxFLq2Ed;v*K246RZT{Jtpy489)E^@zQ@v1#8X3$l5$gz={Lz7;a*snE43_XVf$_ta`1Jn5)X5rHBM! z1e&U9X6-qq0ed@CW3y;2wT0xGnwWfX$Ogkqt#-L zs1%e@`AqEmaGA*CuP})?D%>a&vFVtTF}AkYM4?#?45SDXB>pgLgmEK7UC~xdPbd5i ze99pLYNK~edzB3h9zZ*cmKY#d!PZnW*$ySrC^w)|VOFKiSLn9@7LoDTwR>yDyyjiz{Oi0z8!5QSR| z4mbKKb$hO_xAZWw@#g>7ho|`L^rTerM20J&!Dc%3XGSQkW%Fb!1WwIvg)~;m>8;$^ zG^2-tejuCQU0#OIAH$>LVH+vONX&@64;Y>uzr8-=VPl^HjZ5cK)xfF>Ou2KB*&8~! zCYQ&`0eCX$=WDXT50?utV*@0s*HouS_^Fq1l#o#V>L!JhzVy9>fC?*AtM7^h-&Gu6 z{fv8ZKr39jhAP&rZ8hFUET%`XT8nPqre{?Zq4!mqdF&P;STq4!nV#McdM?s$*8R~0 zfcpHF>hp%dmqP0A3lWzQ#|{0oySL|z*hRH@{L!iNG(0raMd0)dM5l3aaIT`+4)=j| zeg&ulvjIPde%J>ecH&%}klFmkxE7EJIYB`81}~b}_JJrVdOI{6@;)|nP$ZqlD@0LqY!8{7%ONv59CVa3Sp{Tj8rTiA2GyYjSVS4_n$8Rr>ml3%+}RtsiZHeV!(E&Brhi~s~{_yzDD14)D)dfO^@dZ84G z#!iY4EeOk&^rVy&E(|1-2Pllblh@{^=oUzqe<; z^p$|&&k&_g0=64dp=D*omQ2#Yayr3L&LK9=p1A7B@c2J7c7A>;OoX2q-QW^t?I(qS z@oWh>&KP>`~D=Tmg=@h(j5-=zbfsEb=RQAhKFMOT~3HAayVYirTjGC8?!?YDr+ z(_I)EOVbi<_r>tN$YCIZ5rBrv8HH+1;h}6d^5WkXcD}8h5CUq-9m&x<*|BNaHoS7U zfrX&vVFF0vjOQf**t}EV24lI$i{>#wwF$fwWt#f#IC1IAmmGk_qf>4(xkU#9tA6t113Rck^IXMn=yN$?!sj_ z)V|hjs+sA8E8*C6e_=GA717e8&IOW46_NT(KKEl_I-Qu9$VIP6YLnslEZvf|g$Mbp1{i-Y4r&C~H4VMHz zkR#~Jebb4=(qyzZoMO}sq6U)ExKV8Y-o^`;{D}XDfE{zG10-y)t@94{;HGBB)7-|} z9Z$!``>N{%q&$E}&D`+#_XGJwNzxUz*7aU-DuW~lX2I1o_P@WeOe*qRk(?AmE;>HV zMMIN>Ghe3lCoW;3+vI!$4_j#OlR0xqKOvK`&$6S4UJqr^dYSm>fX}qsd^`(3c@`Xc zX*f7|FyYQ|TK8JNdK)bIL;Dyr@R$4<%qWu8_wlUHB!WC6?gGZfNYoM32Q_L8n2)rB z4sizA=GrM-YlTL`Qkmt*y4Y%>oO-1sBao z$QY?$%O5b@CWM6Ua%Jf^j5C;l5s_Rz&$9>{j#>@{j`@1s-=U%8=>JjCBQOjVLqBPi z{0II*xZGG5Cmx4zWKGa;0uu5*oP5gz&(@h~=T`+S9l;E@+#idoKtZOnqd$5lvkW^^ zOMV-XsV&%Yx1GPR29xmFftW;o=Ub7-*Cm3NnC>{6r3iG?$n{ppE_LfNzwLVC#$~uq z^FzU-vdsrU!~c^H;st@h!7@Nfs?j?ZKs!rU0Q(>?>Np$#P*0urU&CRW0L?VO#pR3t zIbddU_zdL2-Azm==ZWwImAM|cYyb!@Sy3Nd?hmMBWb4^7P1Z|?2;xt4IYh9q#2o<5 zP@qo)kb8xm4hsS5Ttl|^emeSBpnO=X$%&%mzV&*%CrTNoV*4Y35EBBhIC(fZ-A?9F zCds%0sWufmYs3kVltdwzD=N7+o^rV_{zD|uNJ%_CxmR15-sgt@ z%VDSd{uc?if{}4U{jYELSIbmQJ8!>X{t|N(GJ#2F#WV`6nALVae(cf)`;~S8K}MI4 z1QUTpZaSGKnxz?xJ!KTtZP1)2m4`(N`59mt_UaiLqG?6vHgy1APjVglq%^2ighx)> zd8SXLB_$<*qcX%KXe6I5%XR!K5L=;{$Iw8jlglI1gSr)KdwIE@uw4|`cFsEZ^;8uh za`%961sBTWb!k-IgjE25RoGoNnvfN6m#G=@CKL&t(zCHzES__+H3J+Pd&jji$|HGR z%Km|;u?P4ETTQc}-)@rudVtji2Z6yAGGWx-bnXX?3`6ifhKx-Fg?#=mUh**O!3n*F zd9s)3A;Bv0>>_j)WSU*NY`36Vpp&&6O$!S}tJRBrJWuQ;o7i zEKJsV1JD8{>DW19lL@Y`RZf;;UzjEY|q7fMtQ6qy~M_1j$wL_{n+(IwYD)aa;2| zJH7g$GtCADwt5Mp4Ogvwtc04>rdaFn^cG@~Qi;Efk`#m})vvBBdyd8!g$%{q`L;iW z7=o_l2>V(Jk=GI_!JyeX0d-CN%x<2j#BP(7d=E7DM9WWqv1i6=KsB9&*l9~RuuBKu z)ciyycyGK0ZEYaA$j}n+*^1ec?V`4gqk?VuA5N-!zswp=smi>U8W9zgZqw1|!hf0f z^AGBuoK`z4h@t}_N+xoXWE#~-CP0#C2-D#GHs=h>S`u9OLWLm9kZ8lcVELUhHkOG$ zQ84JLYB$+3aWDGkEM)l4*>r#jr7;WG=UG}#Yd+waj|aJN0fI27UJqO0jxA^0dV5zs z&UBkllMvyABKYy=ATuDI@L?*t!uo=iA|j8AjSXQVA_7G}(7`4KY6UhKAQH<3(*s0C z$k{%+p3BXE5cZ2lcPN4vh8aFYTLC?~uh&bq=X%#pe0w0apzmEMOz>tejT%zIW80Q3 zd6R1k4R#6WE??E#RGw485%p&xCf08z{RW>izN}R(1H}zF0ahC3TBQln--cfPQa(?k zf06v%`WJD^=Ai_Y1P_!cVC=7wpl>Y2kiDdAZjEI0I? zC}<TP1D3yzr(?!VYXQ`pu-r7(!^h5=P38xL&4_%VWF^>f0JT#HB$fw&-epk`KztQl z$y*6LgCK^^fqE_U-ux;8vika2!X88*LU1tEiYD{`frO<@B;gMr4EzD=+NL|i(!(!p zxYxi`C(=OR>uKwOl=*~Cz?0_^4gD0A1W-i8O98KW;Iaq=Ik$j6y)FB}?&MgW*Cq}= zqCod`&yM@#jT77&aIh$F@&`brFDp9<Jr zm5&uAAwRd&MX@n|a}=hgqtKmT`6T!GThbW=|^GlHj%4fW{gCmPd69NFIq-FwhE8rR};Qu zqnV`DG?Im_K77EMoP&`>0O^nuR>MpUFdb8R@;iVW*Q*P^7d^XIeYZktHvF9epm55e zZltMbZ{TVkh9e< zt_Bk>EL30Gmofo!`l89%NfkSI;UQ)n3K9_&DB^vOZ(Mz9CIx5qy_c;lXzZa;y}v`H^D*wHINq~QIx(n@4-2`m;?R21S$BQs>ZrrpxN?Ojbk0EOEi{H{D* z@i%{+GrB0$$U*VWG~kga&qGneLe8ZIe5C!?n{Idqwid4V@FN;U>j}`nKhRH`!hg8S zjm!Z0%`*VbOgF#J(TyObkLrsOs)G%lo|fIo8wL z!%FQZ{Nr%9Hxn^3lm=oTR~|Jv;p957X#INjy+4nU1{!6Yax@7&j{T+z3EX*QLnJB5gJ6=SlN=)5C)>Rs5!A-VD3ezcoEL_ zX2K#+U$*f&M93~<^BR9ACl{r`w!&Trt}tzedUbda-*~rTw+f-N#j_}X!Y9Y4HQhgv z625YCi~cdO$16aX!v5ouF{{^*?|WTH5-^3pX9WkbS$*c6#Ca=#TJwxrdAHYa&3Gd6 z+&aQ{>868O{4^t$9l=(eMtpMz5QzjZbVM?h1KAgWes=63!3#Vg5;gX0gDQoP z2>yh?b@al?&jMJTCqzDi;B=7_akPA5v43J`!erL(hciHiYfBq=aw?l;Hi(LNax$%?FZ|rtq-SdL1vg7L6vF+{nK}B4bzZqFS6X-C= zCH0~2J9@cm>`2c{VjkF@5N@oiD|ecDMB31P^IywvE(0|96AzM;(@$PMQbNKz9_Lrm zwU25o#*Xj2vOT`3s*aqu!E3bLZ%qXFJ%0-bkgOq0*%J>UBO=lfn;HbE^z?K&3E*Z6 zd!}|B4#c|Gb2c_|lC+r(71TwmAs2Nl_+EB)6`tA8(8?RMyKqgOY$LIXXnLM5dk@hZ zID(gg=lK1ew7G~HB=#P(lj!E`mVXRwUjsNL%Zaa+Z#zma-m@SZ&?UdeskJS?&)(ec z(vnhi#;THZ)Un>4cn)UV+!g_qf7ig-(Q?LG%a!MhC&U+jJ$qfSz}KFcEMwB~2%vmY0Ah z`_Eii=x0gbFXasK*b+BZUYqyJZET|t7+xD9?cS}oRpo5@I2mO3BjYa;6D$Wr5XmPPA?IZk$P*G( zPT=yv^bT0o>Cu$?*eM3QsnOgVEZ}&hRBOp#AQnH}I90_0u@O;@{N?8ksrXm3Vd77+ zSZKjdFp57N@|=yVz(P}HMxKls()aQ`GW{C8`H342OAF{=fP4x0m_NHhsQ@yAW|99?IQ(tL%{L)yW^!8 z3_gZwwA#7*u&l1}`_+aA_d{d{`Lnr~4n72@R<{0=%mf<#T6OifK7{kO$L#@lM`M3e^IeC>?!$jI1hUew-%%oHqEW}Az1 z{?>qv{~G=1cAa%kk5(ES+m1?3sJ4_Vh1vE_Yjuuw_gQqbYCUFR|MR6)%+fGOA0v7p zd%wI~=nz@(a=xnQ%QSx)terSrQs1qc1;$9eilx?Sgts16LKl zFn}|>!|ZNFNy4Z}7eLvDn&zt1)$s_e>Di$>k_ofa{Wd5ML}Q8MOMLJpgO`NPkL179 zV1hIw+6eKNJ1dp0N7uO(D}2* zjaWf%CzIjMxFW9`;8+hU0-b+**@FiCO{G_)amJr! zKg%3Q?^OeJ_Yqo?FV2UEk*h#nOEd;Jw7}k?w!-K^9lm5={D40YRxAJo!ovFWXAm~+ zq&^-ft^&~#h$o|4<4A|5t+1e6TPH3$I(VcrI^jnI($`|@Wmt)~^Cbc-QX5$Qw-fV6 zBMZFu|NK^}2OCR^SP7DX%PQyLE_C9V%lb=y*P~Nn zao|D~`N1{3M&Ke+RUpX|?nx7L@})#{fTx4JfT~v4)C8vYe%ty30T}y_fW;WgeR^ib zvGuBdha4zYdsG+|UZ;9P{o%P8^MTj`dX)ot9hU4-xX?VXg4&Mp@WPiI7kkGmXI-*yjSlhLb@gg=LzIJQfZDw>LZ0>;~&;?afI(%q9|P%Bsnt$A^osR+l;7$!hVE;{NTf zo&TcQ)s|PQ*0`tD)gn=X1YGeaqhRQPl{7|OO^wu^7GvYd#|I^5MtyF_wer=;K4XUc z$F7XzuLQLPuv}I?j$nm#jS0j%@@IPQt4F*J_iOd6ibdwckv-i161xt0ew;j6=J@!W zKBz=@&d&q2AnZ-G|8Z3G+G`LI4h;`#HP}fSt2me&I}hT`&hqwr9$1dmh{0c9UJjEw zMYS9xOO}|rxD79)h?`hhb}4Ub6U0RI6>L}L<@N7=C=T#*xHA7%t^ruK)_SDXwe#q( zXvDct+K891tuvfqF&)#T8(|GPY4kNeb<^{NZZj~u^k$9jl;-vBP}BWA0*R&(UGdIDSjLECF?a=0 zd1X4y7^Y*PHt`O?HK4SxD1y6ykrS|{6f z7u=wz&rcnDza-$PckO=&d|{sw97AU`>w!hMd!xZ;y+2<=V#ujlR5_taVL^a|bNi%@ z$TzJi+E3j}N~15QgHTXB`zaCnY>Rb{jbU#GGkYu4I-CzQsUA3-EWrRtN1xtrEXhBM z6DmYgZGfpsadznPP*bi=Ie}Q>NvxGsKmh#VQilp0|F$TRWr@A^U^f_?Kh=TH+y4#k zOSa&!2)$Hqr!^N}c=DWbN%G~nf;_`Gbw)SS6i=|H=o+0eisn&2S_bDEmcrj;NLv5t ze$(EIc*)F7l^e-PI6#k`m1UFHD_)KE&&$&s8c?0l&mrVLLf(W}E8(G~4}B}nH9xFV z=F8vU6Pi-a_@p1n_bc8QK_j}Oj?V4NANa2%*esHi31*y!@2ar92z#xCg&{R~U}+UK zl9fr9x>^hM_PX0Myd;IE#xH+cpTRZ zxJ-)H`aRw6_=v}A{+W!rlB2ISyZf5&8I|>X0g>lQ^c(#JvJSX-cqM$<(wQrt!!>!c zVzYs2$+K7$DsY42N_uuSvutW>>nYG29ZkXmZfsmQbFnNc7Jq>c$-l|ENh~Os55raB z<@BF^*nbM^7L{!68G^3p?-sa9416&O(!p^e4J$&V^R7n;;X9~ zVx9*tVw|U+N^XOh5sIJhZQ{`{{2~m7KyDGO@4FOGl8yRneI1mpUIB;4UQC4(vux z_qupR8%^g|;v8{KIDNx<98cXxzV{L&Iq#o9$xEWqZkIv2fkqRkay$BdeD&zeg0THE zSLt65?6ziV%Vx5vz(-Yc{y*J&fP$R`4kokg+j4pUCR#1P2K=Tw`&OrQUwHpjTMbT{AR`!w?&2(ANEdV*^=^ompkUUdLCNMxjiXD< z=ZJm((Yr%fXHH*5iS(Me^OaM+fh414!;N`7jo?;mJCZ!n&r85IEGHhnrcH^q0x4Ra z>A(G2nJ@NX$Kxf;VWFLQkRsRyRSa+lJH}5G%J9NDy-mv4IX&WTqUdwHlMIepQ@qZ_ zY+5LI)S@N|j-O22Xsf>>tHdWa$?@p?;9Q_C3X>Xp&PvBvBPS(;;Y2FP`%Z`M@+;3? zZEw_iQq~ULAeNDsH4Wq7lL+0EzPpM1KCFt0nj*O&e8fgQCQpwrbaNCw+E6=-w7kwP zw||qr)=@=4a21^p?_SB#kg+<=cpJ8^H69yohDsHjW^eak(h5I7{|j%p-*HG9HdDcOcMBnJ$y-|Ef#|@fvH9=rL}2iA|tiooeJX)cRtHiH;_5SfxK;z&q(Z^sO}welLT|a zF;nhT1I{DtuPlQjjphDR{WYOfif4xl?4Ra3>wA^i#ClUd&Gc@K=3$3RjmL*bt!{Nh=tj~uW$s}V*2<= z4`-U*f6iZVX^$fdGV{e`%0`D4CFzxY-Yd_)`g!_Nk62ct#YRa|8dNfpMg<*m)VdHe z{4o^O#C1E;tNkJMQ}sA5jcF+H;{zdEC>{3(2uL>s_H!?0F)6lfc=4QHy3%Z|cvN)`Afqrfqb|yl*1iX-q^{_-N#^frVm}BG5mM*;O!a z@CRyeJNY;?zL2vb$QfrC$ar5s2~v71RraIGgMR!CFv zPNHBM>nudrr^0u0Uo{^2FeEg%g)|^Ne3lJt1(Unj2uQ_u5a47)C;79GTPV-&5MOf8TDPdj#79@frcoU2A|8TJJgzNi3GUsALG+?zG!iJOrLztDOfi^D#}9>*;S(Rd zE^*g6;G@T`MXUJ2!`tjRIT_K?sQ812Iz*yof9(7kze(PX#?8>L zZqY{aSIzq@BfcfGN1N33^VpInsuS*c^x|}L{+8y-zsJM1mg8T~+PSHZIR5_)p&5#N zM}inbpM|Z%Lmc8O!`3XRZwVRLmyUDE^%7ObM-_xwpO?P{j*n2V3{ju zU!&M9>57li^%Q-?5<+oV zGJ@b(ugfK$4vwE?D=)%eMb_X7(S&{~Lv+K!{FD(|IitQ_myVT(k`m~TNq1)#1tx4w zABks2*%qL~%*9feBES^a;o@;TY?e=C#G*q{(A=i3%EnySA#|XCEYVJxqS^Ma$EU-W z2q|o-PW<-zF5q)+XK!O?d%-(DKfkg9e`8lI(+uWZSfHV!``_=PJ-%u3L~g6o8{w^{ z&lPx#ZS>tp|HW`>m~o_qFD{1AVDIE?x;sb~YoNGfkxn6P)6=)LjYS{w26}$4tsOL$ zj;rbvw~00kW9>5U#D=Quz!uDk4pVXiOBHN2psrD8Th4{O2Q8YORIX8~$}uaFJF??K zKs+F)p*>^5&P=H8%hw#E#l#^vM)V1S+NU+&ijy>o!LN<9^Cst9C~CKN!-^O*b@^OK z>N#oS`PpJ{f1TyGd^bBsn_z{E1O^RJEI(iKC@yCu-Qg{qbS!>SYHGEor@f^19y3pX zC9rDawv3T|^0@PItBFx^{reL5FeP)kIOUN}Ew7@ju3>sL-*4gAu~J8W0z<&)qpPdi z84fwJB{I5p0#|T;;`y_{IX3CZM?g@YMRFc(j@!+{ySociQiJcyM69`;Q8uxI7A9G? zws!G_$~&SQ!Y{RrfLGD}X2sc^q2XeM$~2Kq zx)CgiiC_UA3-5s%3L}Y$%s)V#b*qGWxHm&W{bMuk;-Sk~sPY%d(z~~c`rMui3Nq62 z`g%@AMuuD3TO^*ON-Q@|@PiE+37==z%XH%QqpO8x9H|0jLE2T$f4MbfRN9IT);2aS zeA%){PqM5IjVm_w{;&iXYf95gO9WkF&xx4gwBKs7aA`0I_F**jx@A3g!rT>NP~Ll0 z%g5=Hq%BcCD=aKIX*NzS;TWC}2kH3(wUMOEt*3#R)1DdutTo94qaIhnStL;bj zM{lCHAN;+&se#gDN9gx?Rm15f!1pO=iawD0@Ag6_=p^oGtI;M#o?Ht-qSLDGyV zhl(y=3mBwaqL>#XZJbGXdOBeYLnZikr+X1i%;j=l(H4%zMJ(X7xaTo9$Ayi9g7`BS zH)?imZfSDz(AAe~3{>d|q-0lX{Tes$bXpuj$`&{*)Z8^aHNia$E-$X%a!I&McJ~!l z!chP0qV!PetQ$UV9kLhxW;Hy&hPgXkN2*v^TS`e!t*>`*VMKr!uaMA!i;i(%K`ZD96_W-ZJMswqVw_fF|N#S-{g)@ z2>F)zBcqVk4X?%r!5Ebt|7_-}BJbDrQ<5|{-FOjL_h7cuy00b~mvwZJzWAyV+mL4! z^yOs^f*nM576aHiUA>-r!%)7Ybr>0Yd&4@W2JwG-?@?9>06&Um4}(PKaL7EzR%dYi}H+XmRSxg1UD z+he@EoLJm%)9E^R|7~NH2^JRqJLEo;#4PMr%YiLS;eB-FWo6b3z)d--L08v+QS=q6 zA)Qp{$HRvf^5Fe@MDlU;qiN~3-ewLAlz#$^xVs#kkZ}KCTa%xje4LhhCo(b!**u7u zbQ?~A!e&1JVf20#7cj}mk~a+$sZkTeGH8z%6|md+OFGEq2-5JZZJS9ZJg}r~#o*{C z{AOxDd@Ri+5b!$895FzciBZ=zGqkZ}-aCyI)rDNziuGUN zerv$At@EbgOt2H@=4!H8jsqSq!`WFEd35_uPw~&gu&827`tg}qsLx_SX>+uY@COI` zfbhFru-#g{17_`b`Z6|&oVb#2^7zjaZVyibg^VDow^hi|((?D(IX$tOK)_q;mby52 zKttbY>nly4cXP?&8E)nnpV^_Zxas;2)5po-t^;O19$dt5DC1TCqeL1xrnljd%97%e zgtT#&mlw$c4^NK*I+1dyE8+S3bHPm~-VX0Gu5@jJ8Otm#-@DQEbAL;@V{fF1>@VIf z{-UCQ#m5x%hoK>B6ghK@646A*0?zIC@v(DAvu-)2930b}>RchhSYr`^IzYLt##h|Z<3zCr zmd{xVN@K2z*?)RtmN!ALPcSH-sU@W{sQ7VX*8B(Vg>zw_fEjAbN73ncmbSh=$|h1e zyZf=4x_Z|SiVMi#gRxA{o5QKu*;y{z`DDOF+1Ti~(tz?Cs9pqEcUpj;x3q*Hm6e^H z4G8OriDD=PEgolL^^eyFI|qP+&*=ha@cU1L@v|d95%2@_!cS?iF%ZGYDJi1gp@Pu} zIX^W*RPOHX<~ef$g8V!%D-6WpjDRN0#Tj5`X71~wj;8!9E_4Q%`S#a7yhAO`%~l3i z93iZ&Rco$!{BWTF|&f?ba>;Ja7UaKQZ|1P}C?kMG~>N z5dQ%MFG0RR0BwU8Vz^ho^jR~zMZ@j=@!vKA%^hBYFJ0;~na;xdy<@7jgommsh;roL zV#(V<1m?Q;>EyCY*RDj04ay4#Ku2yAJyq_vhs7tdHjKI&HDFRiG92 zlBcS$yqsy2JJvaIhqsk*lo^$+n9eU6sK;Gta;8ed$5y$47Zkl;b+`v+6k&}Bsh`E2070uJk{u}D4*?q$7DC`Uxup2;@DO50va%CcAb^rq ziQj;;{B|pxj77V~?4y?2$;m7J5a?SLpP8leJfR&)Hx9Cpnf$Snb&Z(qF_)?{xoqQB z2Y)c;V)J4qUPVxCW$f>7f0nZ?+Q&rYE8sV0DlPre1w0%mMC^7w!)X1{WNoeo9*XF0 z8W(3rsNum-mFMe!EmxbS$jS{Y>z7=w|3$*%2t1E+03q7gjkBe{j=9EGM@QCV{+C@5 z7~(Q=w)z)Ghd)mK+XH`w#?nM3DJZ+TOD(*0m$pN-R57kt+3Q9Wbh~s4Fboj)drfB8 ziHbx5mhbwJc7NlQ>V`4h#w#ex#ubhKbbz@czARL1@+xWWjLk-sG1bIB1%)xvIM30v zEr3{L>lo6spsN#mcXy|*j-p#@Q8<(S-E)_1Nr=8onh;efi}R{p^Y`D?956VaQTSnU z@0RzwU0Pl1s=#G+cL9{q{s8G~tpnSd{#sJ0qUb%r8c2A$%Jm?}2;y@fQLBl>T-7T) z>MReZjzqi1`S!8AIQeGN>GhLj8BRpNMSB~!uZ~_Updb?id;N`-uBrGp8yg)JYYgWt zat9v1E0_JnyZzK|CQUH&dEBC#qN~M{S6Tfns68Mc&ZvsdvH1snKY~u)m_jJsCaRd} zznPomUrE3w2&2VDOZWu>ha3(lFF%oTb)E!LZ6&?Ervr8*5HFX2C%o1F z)d%E6{xz^vi^&6MT6gpG)Mdg|CUjcQ7Z0aZ$dKUB2sDJ43(^4S13rNLl!u!esvY1> zK`X=kgT9>ztg|-@vea1@#s;1IQYv$MpwbD`chYJy{yWcg+ZCcebqtj`GYqNK(XjIT zt1%=+7(mEr8v1e7Awf(69oB9D;ZtPVh_y2Ss43QZhWIg=%Wi7K1u?0$u=&{t8}MOQ zHac#grUrXUagCn+dDwlMA3C#TmMvfRpVZks7TNT|e@V zC`BkiaqG%Ie-OfZfcoygfKrCbY5<%%++E|o!nuI`d?ycnBH^_IewYtyNlEy@_e#u+ z1bD$NPLt-zm=%YXGr&5h6sDt;$^nWeCgC0;K=FF5;0}n(H9Ittkm9(}%Hm44>UABf z|6LQ!TLWebRwqEyweRlx&9q&=*cXK-7yK5*@4!FpWac&ZNmHBRSK+7&(*D?>+{D8j zPo{43TH7mto~+i|JuYhxGan-aTYzZxB!Bzu*m}dlz8b`l z#??^I=(V<%OPJkssfXZv6|xy+lSPMgWi|LAG(=)i&nn>AQ1WNt$Ms3Ra_^Q*O3Edg z<8wuq4fQ}0AuH2xV`TT}lIp7gdG%DVm(Yj}km$1v=wM{zZ)M>%N9kT5>{qABT>lb}_CdevROF#{bG?WED7I?uH!=X6EKS z=WhroOAY<|ishKb#Z{`7O^_dF=_7)r#Io^eDQRK}jSMuTGX@vc>V*%l=~XzO;D5;} zY&&InVJJG`QKlUM8Y%@T1Zw9E!3-p{$Rj{9$mhoIzljN>#^-S$jtvLgxhz>pNjolo zfkl4~xLl>D{~8XQf_C*3Ez$=D8%Gfud0tV0hg8eP|WhQ9i+T_vkAVR`XTm1y-*E{zzby|MPPygc-S_ z4T-?oH)C+styWPF-2};g`Czq{_kQEW?M=A)q%Ip+TcXC_JM}SoH66$x15`^QVgHwZ zdm|G<&vx;Q_(6;1-HHH08I zl-^UY7&a+x2_hzlz5QnTo9n?itfS>xQp`NnZYG*?4w)|gMOs^}|*72u(3%50k#+u+FiS2QN#n+i?W93toVEYw zoDbb!`|F-Hv)=c4?)$p>Z>l$}mt8=bXtX&;u(%t>>JXes0--~PRH6qEB~7tPrrw4QSk1t*#-bpLM;=%!6mY|gOKtqf; zKOW_pKG7uMyyXvgW(4jw-gLZqdvD~OpI4&;m+tkgZ$Kn9UW-xH$*SEhz&M%VL*wM+ zgyK@ijURlL#;9AWl#do6;+*66stbnw1YBu_3UD|&E-x?l8Y}!$HcsxCS%oUPMeN{sD}K5;pp%k1LF$|>c3V&k;ho6PDy+@uYbdZ zVcdhLQ|qYjXq&i$eM6ga{F7lpf_|eRc)o!XHmFCL=MtkxD~E zfztu4$@o_%TJ^OQd*@{_8bJvxa+V({&wqX5m@xT|39g%$$gek~U5pytr%*yh5*J9| zq>`L4jwKqeS@~k~|G-eM5C6rW=V_^+|n$j-#2IZ|CuRr;n^ z`u;F1Ot;6si59WC&`>=GVg>COrF4`qE7L0bAjLyqgw&l!H*=el29gu@*1k3 zL4H>XD}>oPCMh>Ny6p3Jlnu&}tKY`uWG`;#wpVUbx*X|X3s&|ikqRSE68YRcnA|Qg z2ygZ}ib(z-(M0tKS&r&q$Y+hfrrVr>thnFng)_Q!sX|apgf;Ba<%PIN9hkJZq>i?3n{N=_#_}Sb{LNi&OF+Mc~J30JqJt7%6 z{dogteqg=_!3G@5aso+J${{3UmVhgkikg~Oq6_f+2Sk15h|U6R#ge(eu^hNk1}+|p zT@MArqyLT-vXJlJ)_7~G{|bP|7Bd>0Bs?!-TmRmMTD)s&YU-$Kswk@ufG3U^|D%5v zNkjujJr4IvJq!aeF)=7EGcz;5zZl^MU1%zQ!a^D!j0Rb#Oke@L7lF|T{Q^vlF~#|2 zcV}JQl}q@XW}<1_+h1u%6E&WBK*d&YTYLZajqEN*nBH}tT(?z8Tc7N8LUQ?4>G}`Z zn8q<=t|Uu7a5R>G0rEnQpa5teL_OMm+GFs%OHV)xaEXKNu(PuhY=;JR#b-dQhtu!% z5vZsz0ynUkJV6)W;G`L-a6Kz-VEikWR%m1bU|BKy6*&N43~|nNWGhH&&&R(;vmJ%?D8)*8cX+9%{=4%Q>zAIziwh|lg8ym;*tc1*@1MUE89sQ4 zp-k*qsLwt7qM{LEg?Ym7f9R8CcUR5+E!d+P%DmG^##Sdv_9Q9}G3Nf6j(YkE| zviedeorW~s+v^2zN4vY%(VtDl)=Z&JX&nnG71Yf*9OsN)oL#sTvL^isH31u(U!Ed{ zn?Oj_8pO}+J$QCSRocX~?EjXV=J)Rz1fCI=`>3j3F4tcnNkP?{mz#0Z25C(mf?y;A z7VKNE*Y1tCBFmFYX9*M5x`~aoMdv}zWjN^f0-D|17coEouNGe|yvr)fFa6Mqzt?WQ zYpJ_E+)F_vzF!J~e#h;2dv_H6k~2+X*<(iqvhX&3A-uRaxk;4dpkkD-8ybMAVY zgK#7UDh<+&h7v@4pRdKOcs(^Oow1A zH-vxp-OgPAmDc72bXyXFc*Z!8 z(ID)kvSCxNu@?VYjuDZASlk8h;DLIG_#P1ae0qBFVB$+VhPWVYRxR!aXv+Xbvg5N2 zP*MX3pZF?ZkZ5xqS{2{5@lJc8XPW$|#i~cOrmn`~7;w^+&tv1{W)^l(Hu93x1i|D> zL+S_CS1e-DLSiE)LK8wa80iOW2ddPYisX~;0ZFt1AN9GJIx{g#A+6zU4H z3oAi(cKX~6KhCKUeO8J=`tJ(9PTD55St;6?CjDcjDg1Hm2cCm!BX~ftyZ~S55OoqJ zns5SlJ_uHh0AVG1bPo_+lbUpRLI{doi{WZ=Zj8L{NNzxxT0#2|?Et&Mt*LV6wi^D4C8`C-C9jw51+0SDZ6+hFY7 zWMgMD!~~FYiFv<+dFWTzN|CWNR8_z1uuTK1_1MdL(T|f9>U7UWAJ&5fd&&jRTvps~ ze3e~n%#1Y6RZ17;fI4x}wtZ5);^tL&^TQ#F1xHi}1ohCd}NRm_Hw_GqikGl@K91;?EI8eQvL*Wgqe`fzpI znpKsA^j_DqX>5x>pX++E5&?ha#Np4MlNW35MmQ#}WOf(U;t=0xqj(4u|{LU5~pq62m{Tx$Ftm1U2W4HN#2f+ug6;8*h-jt?9uFoBEx= zHe|U*o8MA-?lUtZ)~E1NdZOj!5UPfhyO_g~Lrd}&i<$Sr!LW~mxl!tT&V$ghbWKeZ z&xebHP}rxJhs%kXskJ8O^hIMEdp^*eM2r3U{%zYIo%m2FBZXM0qH9OmWAqT>q#q4E zo*RE$i>ssr-;R25_m6h;|ET50^efXqC4K0b#B*|?|2lPAp^Dnxw)64Ql+{}l6k6L9 z%q>NSmO1dV$Mk4(_}CqG59S7yoTB9{i*x2>VR z!YU$i^v8@Fq>&h^YXIQ7?lQ0N6RnSp4E=K{ZGe%$#!s{KLF)=q!QJhCDgvttlaW|1 zm(|O9!Wb?ekM@mUSF6qJG~77l(GroN@0X+y_Au}@z{esJ=%Qq1etBv2{TO^$R?OrTO*tnV$C%Fc zgBjYl#+U#4HNKPp7`A2h{N3zseu9Nn3?IDuw|dScimevVrnO|=^i@+mgvwrBaYlY- zYU3q^~?vBKDYQurXs zcokIAVhC#uPDKi|?`+J0{hB{p-6e`ytEMys{krC<|1eBHeD&4hwOyGcVE2EE=I?z8 zz5-Z5&QN@NE~O0iyw%rsMQUtZrc=om zcx^S6%7wVOnek)XTp#dJY#Mao(8Dpt8JfQQ= zgFxP8^0is*&`Rqt5_=*v?3lm@37*JQCL|>MuSf}Qk^3)$^#KxPhcNsH-x8vykDC?? zM>+I0jmGciqdypQa(;>eOLZZ&w$S!_YyQl&JvJsIdyMZB=Deom-0&$=yb_){-L&bjfH-B&x2iD}RkhzvgcpId|&gvrR-Y(3JBL z{SDXAjgL%1r5E|y%8Vc3(&Fx=Csa4HwKl)HvDSJ1&+O^RbJOo3j%2N__=<@togPT`oPR^-XwsF*yoWt^ zv*93ZT|1wPVBjTAl;-&?Xg5(hl}sIrCyw?wc`4s})cUaHvDcbgffi@M!HcA2r8LDN zN{5FON@9g31Aci8dZ(R!!NX&aghjK>vL-L_L!YeaJ zKoLMi`bu?Siubc{!`|SN8`4_y=4wi47&qWM5#kmS;&+TBA&dtZvQ*PXS_@PXA(0ZH z!7ylwa&Lq?kxt#_SZ44O6EqSfrgO%M`D%<-Yct?x+Dfx)?V3*RxP2mK*V3;??2vT9 z2rT23<&gkDftc}0PJM~he~wu!s4t82#^ID&2BVyoduFnSZuJ&(A zo2{(b)r;n}E|Dd^KOaryjAeF%jc6{rKLUB3k0Q-^r=$0_K#NjN1s}T596uZOjfDo0 zq(u|W-2EMF-PevHfn6Kqk+PF@F}^oIH<- zt8HQBrE*a)fuUgMj}=dXrN<4Gm*bP6R4DLS&oqWC1(M!EGNvfJwFx9<#AWoAqLf2) z;1&2)G>Z`gqsVw>du)#ai&*gm3|MlF0>xm7J($8TacdW#{-q8E1OdaJmBXlFC2a}w zsFDF$hL#-z*L;z5MOD+-@Qxj#1JhOvHXRvFgox8{3lj3NJ*Y@nCkLm%PF|e zkx}%}RL}y>8Sn*9Jmv-seAPp81bevh#f^n*pukBEa|Gr@3?hym#1x9O5Pnw6OH=s> zL(Dneo9B!S!fPl(=lh>p3lSrfH101634KZ*I4iPDH|*6Sfi`vm>&wd@9~*CLTIa=6 z@93K!FQ-n)s`K;7_-*T-ab9 zWyuBN(4#=c^RW7)FB}X-guY-|Ql@L)^#rF8yGK@h8e_O9vkf0?-v%24BhRfhWsHLg z>WgcRqAYG|to@!8I)_i_JSm?tdZUTVn)jKIyw2*n*)m|d(l|ldGfRY=qYjg}I=Nup zI>$mn1F|}#Zz+&DFdKGr0OJTdmXoS;2SEf^B%ca1hck(6fF<|@l8znfItYVDB9a~M zx|R#YfChho+^8~AEQCOF(U5`ba|0Oqz&XaW=}R4pdMwQp2waymPO-OujboQkus}G= zAQw+=Ckv0U!v5|?28j;^7ZzzIa zOHiqZEuK1ut$pZ(s)IIjc6bEKft~IYkE9SV&lqGGpYU~@t%zy#jRvzj$*_1t7)v1+ zhEq?>;EsV`^k1MjpW8kIj+J!KXsX^jR_#FMB&)>3E_<{iDt^EQ~G z7@dbp@neG^|LycdgRn0Tj_o<>&XfFBz#iewTD4d)jXhoo`bQ|Gk!0MeYE3{fHP$Yq z3_*dOKBQ%{G^n7yl0Jx*0PGM_07b@LWSuZC0RK9QnN#ztk7|3bh?YJKDx@FU0;_xw zT6@F{M681iV`wAy2ql_} zz}WprM%)_7t{Af)8$wns??@fOp1qtsfv>Wd z@diZ{%%i`o!6t)bUj;%bQDG(x zD?kzlSmXkx!@sMs#kb{4^YhN;(3Ti_(o5XUewBN%45LMuNgV?h*8-1YCarIGc>Vw_ zkp;gb>G#HR^6Q}^C9>(q`y0o`w$lXUP-HT&ta!P<5Ci!5ErK7fw;LN9`EVtc<+=0r zaJH1*CEqrEIDR#d1@0;9`5Q+A5v_L%x5q6Zd^VXB?G23gK|ZA>UCElPwBwK3hiaRs zoF!8{1eiF$r+(cm#vUfZuix#(DQ;_FL9?>_pGAvGd>Jp<6ea{duL}T)JZ$;<10@l{ z#Cc$>g&RMbT+jy~k?OP>cCkMd0Garw#wepA6~#v9AVzFuDhh&GwfQ|Q+^$SGiG8~^ z`SplMx|KU=O&lm%iCYvZmLYNl2H3usAVdKLH!GvMk`lN7hXjWDwKKF zCUIXQQZXbg)DnH)7F_X;Cz9WolNgmJX5fw)!LqbHcB8k_ybJVL!cXIJ*2p2-L-Bs& zAdgghI1OX^Lj*VOL`K48L>FpHocatCMooUlGLf#vNVClSm^t2{NSQB^suA(*4$omW zLratFvydK^NXK#lk};EA;^*@nvR>vGcvkb4iVTa@V7@-a2sKv7rRrM3N-NEDPK^Zd zH1?W@AuJuUg18}^T`;PLB8yPuC1ix$tG>8Qz{nQ^2<3)UlNte<9WDp~iD0xrEMeSW zC!TYt86@yTu_apmj&@{;hG;lyAj$-JU#>>17Ht3lHQJxBii#ZUJgXf1L15n24FuNv zw4A?xrv?2e#ir*14Fu=GP{9dFFF@kiTU*O6C_$VBw$A61g=0r1KO&zC0aH0Z7WA9z zKP$8}I4L(*A)mAUX0#Iq5f3d~_=3(t1k&2-IC)ff_IY=6yKIq_TZ7izL*8V37;n?- zwsm=VVcqGdj?p~CA*v?%;5OBg;~5B=RqM7giEc6&SqnJc0^tp6nWQQ6V#JaEmRUe| zya-S#`u*UIQ+As84@cV6!&fo(K6vA0=Q!R(Qg?y=7h5#@+pQ`?&iogl`|h^o_@;*% z*SgAh2K>`8Hv|&D1BW#qXZSU;js1-|(ifB|-s_9wHrV&b$^Gcoz01|6BLE#)G*^Jt zJ)q+atmDVZG(-!WQoh#e@%q2NX0luSW@Lbd|H5BaNzZbIXLuJn{o1+phL)nOt`0L| z->mbW7Oq}R#OF^wP>6i6Z|$tCq_0q+naDKv=MoGGWDnfVQIxZeg4R-`*&HPsmb9_5 z0>FQ2-rnE9mA-A?-!?bkcb#xfKZVfzWqMB}u?K4q%lrUwyANC!khYYv4-o#nwA5@qH4+zRzfCTpJveLGhbWL`O99qTK-|{Qs6PI8#*F|{vYi}? z*GdRvhzy3a-|;A7gMc8aCNJ#F0>Nrt@%;I{wN`W1Xhe$}NizsZco-=ff+Zo5>}f#L zVyiNQwN(8q5<3x->6iGZh)-tb2#yM)WyP%${hb8OA_g_|DKI7_9c6Q1xOnGb%a`nJ z7y;)lb>$j%#BfndIlVXwG9o`z%SmEwwfyeET34P6(vLL?&NhWTU~0JnDF9 zhg9kanl!xFW}a_=Ym8$kBJ9z(1` z$k&@q;1b@YZ2kkJ$OUNo@T=9-2|q^=EV0O&=k1}WM~(~`t!O~F*WO2)*Q{=7#TEM}p-zAu zS%LGBF8nq=wbffwQ%uE8bF}}X`?Xig?P~Ks9u%2?2Zk4+bT`ns972&jySf76OOKi9 z>C4N@txkKAvlqb6KPWNczWI-pi*Fq3k?b#gdk?w!R5!Mzxij@nb$(44-;aN@(!Ejqo9CGU z_`3}w099_{*RT2!)^0#p#udlXawe%cwxg8BAC!xO@-K}HW&!F#U*FEC)x z-uJ7o?k{P79H3}Jz92FD2~^$u(t`)kJ!7VU|4GBMA9J1{y_-z(!eO$)-d+lOYnQnBJ-T$upMyU+e5}e9rJIB=}y4UMQue6l28qqFA75RpBb3T zoisom1vbV7nkb6oyzP*VX2zhU*~%fAsLBwr9iSp7pX~)y<9nQdXb72*>I@r{lc70c z%P=LU$u|~Chfe`nA@vXfhg3TV@o15nr%Yvp=8kB=BiFM6d5#96dDY_5lHY@hY9Mx3 zd95&cM=Nh1zkI?Bxj~Z+7vAY>f-B*#zzi@mKy7fzo zWah4f7RRvmhl&oTaas!{<+m91O>jtD1+AJ)B(IFzr-m(=6yib0(h};-v!jssj?LLw z5v%61GWchra!l(rz!n(rR4*dZ3Q(lVCIE-n6!%sRrw#695blv951{SlWk?1xH0jiQ za5iF`348&|R;C}&6CI8v&i>22_(`G>jn*sT_nXa^554-tN&jLQ6NMeF17(>>Pr5wR zW#yM70)5TPK2GZVt;H?m&+S?_;538x0Bfp?n;XFPL#rai@&=HrhGAQNFLs?9apV5y z4Wk87!3{n}azxC?0#nw_LAU>LDS;Jmpya~lCK?M;9^5*L8~n)1!a|A}ZkIc`Ik9}6 z0Puq|W$5m)2OQ=KkQU%^AjQO*1{@VPFhtbJ#|YTX#fw!&Ug=?d9q!Lp0jA)OI57@r zUd8{C&$?UBxY)hPK>)fsYvR6SKl7y#sTl@U-p}v%*7R4$GzM_mz{%V0`y#^B7%MML zIAZ7*)bKx%i=bK6g^0w|y{zmk=UX@uAs=8GJrRT5c`;??FqPkHI1?v0=ixDOR{@b4 ze!uM6@?Z+g5zT&}R}g-0tG%?8o7grA&PL}>PsJe-T8FsYSpUuDu(=Klsp3g(I&Xf} z!3S-&nAq{VFXH0hEt2xEm@gb>5EF6tbX-{KI=C+{SYZ(n>clmZlJMJ~PhY(B!N+kd zG3x*@ma?+C8BYHlD8+;k_4q@Ni*o`s7cpUFZ)s4qV7IEibQj!0qQWe-@LG{lgK>{4H+t$+5dT>^Qu*7MEHNcbxja3fu

    ws|8&gPg5$+ma4l=uRpD|UR4tcR{ktOu=Lmq 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

    U}93rc}n;T`tI_p=G@C&r)`#&)FgHL_f_53sm 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 M07*qoM6N<$f&<*rvj6}9 literal 0 HcmV?d00001 diff --git a/docs/static/img/openjs_foundation-logo-horizontal-black.png b/docs/static/img/openjs_foundation-logo-horizontal-black.png new file mode 100644 index 0000000000000000000000000000000000000000..5e7756cdaebd84eb1e80964b1472605fa1132258 GIT binary patch literal 64541 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

    BH}*&9eSTXStES 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

  1. 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 literal 0 HcmV?d00001 diff --git a/docs/static/img/openjs_foundation-logo-horizontal-white.png b/docs/static/img/openjs_foundation-logo-horizontal-white.png new file mode 100644 index 0000000000000000000000000000000000000000..5807bf05763e16ad4e33d1e11c3c6b3b30639d7a GIT binary patch literal 57903 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≫$}&3 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
  2. b{l&8iM?Y!On&t^CMbj_BVJ#gCCWxAH9SKHAkN=KDdEqw4KKN)T4i)`0gal6#4jwX?8YH1jVyk4 z|0^=gcYHho*>)$%#E@5;!=krWTj${>vCL?t+&32y7Y`q;dWKTOH*kdrXy-t+(klnEv^Oid9E5l9ByaL zj9!3#n>T>(w8c-!#+eSER0cyq3yZu1Pw{oHz+qt@I)DAYN9wP!LW+U$VcM1kFAYd= z$-sZ=Ya!wZeYR$1t=zH`zxQ>t&K^&X4%kDT z5|EYRC)G@Ckx|47m*%E?cirVq=7BC4ROo)|NkQX*Eh!#Nlj)kI)pX)Af8oYz+k$&o zD0zDqjze)eWSE;?R@2ZtQ+2ud&ojh!Ve~J%xN>2fe^`K(4d~*yCNtpb7{3aY{WbfG z1UX~bWo`5TLzB`(_-(!T=|BIeS5X5UwJOn`x*2sJc;c((lkHgJyHhKS7eo97R^b5p zRd&-mjYz?%*G8#@UO(SxqyDK%oK4_q6EgUAt|fD`(ll^T<`T++GVd z%f*LHvumOeKOL_>MN+#}Da%AuqB32HR@*F}wE(#YncA@=&%!l|A_M zDlCSaZ54oi{M%kkoPaL+3^~qXmYgUFVCuu|xqsf)xo~WZ8*)5Pa#9>tpb7O{JSbnL zH~s7efhPUYYyFGr0mLx%tAzp4Y2r4mv&yDDx8qHI)E4glVOm>Q95SF;1qW+anHR2gW^;#hVm0QP2N_7cMqWu+85P=)yxqV}q8}LiKi`?MrwuziJ>_U`hM4UuJZ>RfAeCV z+I#I4_qx|wz9QcNK1@8?8qeRW@~D8Tv>(41t5c0PerK-m<;MD+@IexCEOSHt74H~Z zKWNlZ=CUREN4!`^;e$9^ifL6eLY!MkbBmjQBPZY3nScW5KoZ)A&0f-_p1eQ@3iXfg zo=?f^|1di#50}}U;|?uv9#J;;>OP+>G*~aSNkx`MNX06{R?uI?DRl4Oyh0) znFLra(c*AqBqwsGi4)^2WYI+7>#2M}>MEE{AuZ-A4yJ7QA234oMjaH8UZUoo!vCS# zg@P!!Ww_k*s>SqH(tY$1&HtXA41pbl|A`_qvGCqkEK@M>zPcB+-GJ)_(Exg4u3_T> z9&Z<;^EkkeD>x{J_Lo5DKDiSZao!`?E%#2_a|(E&wxyHfTvMUyKfRLh5=f;f>nJYP z^~Zs?@E?QDlnV@yn*4CuKMgUjJe&zuW!^4VUjW=EWIoz|BPpM20@svoHhY$OTropi zfSGJ&p1*#3UW~Db!^UJ{_Nf);FL={??V^jaz5L(*174UDfiC#%7k2_(@X9cPBYcEMr* zPk2K;!kAA!O~ZI%V6_y;9W%AK)lFSqnX`y_{^#1g!fVDHi-?5640$T`X_bQ!>VVuC zjy_4vk5|$eS+t25PqCz*I&rF1sS~01%-i`VFuNL+fGSN%*nyd{o-ce zChp|(g&m@qro@${lon@?$5l1c`Q0)U*6wJp`w!0CeHn*xQm6o)j^`=!Hwz+cyvHVo zHnUvR$RyT(7xX@UWOmGw6hCJWkiG3)@h_tK8UoBysa44JOGLP5io|gCcwlVwr_#EU zMbMS`hKo>pUQ0-Q*r$s|Y>s2@^pv)u_g_>iF8}vDO=px7odUHVt?AL3t9G;tmS|bjrhQ_B*R1#{@1ZZSlI&4GVqaiD%EvZ9wn^)-xu0jd@1N7_cT@$K z_hT6P-S0A7lo{8$xsl&5JU7_m_M~dpjC&7`gz^C`EB`Gb6@raO*3sV;`K`5s;9g{< zCOP7eX?;r?DcBtQ+1oyQsFzB#cZUWRZ#m*KS!F zCx?&;+n@Yy#9sd?e1)+kkkrA|SygK21x=AS%d@^DNY<{PD}9sKze@EZ6{|3;;p5Xc?lw+~ z@s9=UQeW2}2#s@J@Ns7T-J}&CV7!$!aXo*~k8q_oLNdb9G9u8 z1uKU8Pa^ZVc!|Lw=E`nkCQ)*yV4G#Q4{eaMgAR1P;HAmTzq4bu@lbAAnPr_9|MBx9 z<${af;EJ#n^T!GGnto%71j{Mm}j*hjkcXU7}^*D<& zk4bu(CTF;-zxk8Q_(1AKc^g0Hoeq_sf6UFi%*!H85*CVek>h_*``H`wzo*YdQ~#NP zj{fA`Fc9d1{g(mVBn#ds;Dp#svyaa~(q6sn6Ab@IyIOGXN4x7+vQ*xo*4sL#K=F1-TG)qPAEuRDKXYJ3+57%D&br(xfJ zkwE*rGAMvF^Hj4{os!vi>)-{Jci<549ue4%dj%;s%s_Zq+R#1rL4QrK(zC8_;eQ}4 zwl*jXAhWi3uTf3KmT0{1hnxbA4hvzo*lcRpJh6iUT0VrET6oB9ly`VdmmX@ zI7N?InOu@I>LhcSro#MdO4_cyBM2~)JMV&P(R2R+eHDLl=@@nu3&h(*b?|d0_)(eD zBJU)@FBSP6X7+aQCKbSy=Bu?{IvcpALYhAxS+*6Q+7KZVjnIGUEoei;5&toG0-fznM_CIE;T9y3+2v+OGE@LVhkHX(3o^^0>2WG-Y=E zE1-~RUX!MH5{x`O12aIjI<3j2f|wd zDR?bURgQ_+Mow2AP*q9~H4(O{3(>U24FNk`#6z-pN1%V-Z5YVi+qUB7H3IBW%8;-t zg^Ku{CSHs)_j(n59N%=KDX#VMku|~X7YeK2A`laa08(xts6D1>`F~?yCsV+@@j^S> z`8toWm4+^uRP?utdwBeypvEohD>xvzh{(>tc+lX-7|WW%ldfW_*ck5-sTPR8RvkIm zgW6Qn2ES27JLTtpfHLrHiXdt5sgE9yYxG~-OXA+w{#clbUV-~JW_t%GhV<$b>>N~w z&zJ*f$mq(8!O_P?Wu>3{PXs&i7Sa^EV!4ucP!c@=xX$#!%x}r-i)kw(YdhQVW zZpr*3qC7oa3;>T(>iqTRE|8MuPWDN*dq>BkMVq%$rQF0AW(1NGoAep{8j$P?iKPxd zD&$Axr%`3Txlxxjk@4j}0$sNE_B3Hsm9)=sF18VoV7E+%Xn(p&zOQ*jZ}dE~QwGMB3%doFnaT>8TaBp#=}-!tpA1P zz`D*C3sEnEF(z8^jvMfw|0*TjSDk#Rku}oz2`oJ{ljfjUiLqhbs+++Z72#UN~VI|v49!>)82=s1?HIfexC!u>4 zBI5wyF{eric9hWsWJ`1PsZ^xKP*r0+sQSzHy=t z*@k@rLl~4BQ|1i?LyA9*K<>`~V?|M`TFym#aL1r@@a^HVQ_pdN0MkgXrieA)0ZV=E zoKwJ6D(Z_2eV#dr`M#2pjT-FgwC6hwP)ZB|jJY4tA?WiO|Ko$dE6<=AFDcFcj0{n> zfQHb1Qgok+6Co z)l&HBuqRS~=dMnu=s26j`#vs{acfP^xL0*;U zg*Ikb7Xtv*c1Fn!+$WnnOHnz$NH0pEA3W%t-1v&`Q%RJ5=%~i;r)D-)grH3 z1p2Z9{}}UmF~(-dd~*vd4rIdC@^e%RUX4r0iJ#0T>uS1mKpC3G72SnzkMn1$- zgAka|`0lbR^GiA2Y54lnP|Hu$*5j(kai@*s3&h{I87sf5JHKj+=G=&W5El+1*AZU& z?2+e2UK{RXmW+d`ob+-D^69WP0J$ilo*sOEE1ELy}GyN!08&l z50b&JK_JbR$ZySq?tlMSbDGPBCBoXnZ(XQ8rt|Is1V*VF=y4$CMCFh7CXg z8sE$8Jep3HF{&zDCcbXpK`)NZ2}rx1CzrX$ z!=2q>*cmxJm^VyemKL&`L<%kvCHnq3%&~7$rLXs$NdoqN`TX*uaE+I7?4mfLggZia z{_PPp^1x8$Efvk_fr%i)4M1BtrwR#wjoJS_@H3Qj#w}el)QYV`Muj5q2*zQ1Ke_GRmVxq{ZTwIcmDLh<6Qnvxz`)%o@Xd{7ss!;2vaY-ySY zn6kzbH8-ph-6|4);efmp5u7{xQp^Y>(H$IGzQGkDX;y(I!pAgU@R7BW>|B07Q zc53Tn$yp|l9pnT4oA8Z-#;?A(m^oTjNSNi z(1$nm3jvXqT4Z+Co%?_sYlc1+GoaIW9xy5r2V7Uk0wikJ(nrMq9>zQhU%B#r+#~^n7g7JZ;P?&DVPiHoTIEBNW0%VzC7qR0(!WFkqR(r(wsXB-2k3^9Vb)g-}@pYn&1Muag1j zmKl_jZYRfS^Lq(yEMXF2XkXlnlwiz~5cRdqKoE)$=PgDx-0ocU_LE<;?3^A3FjH*# z{@qNugNhql*m5^Bs?MasRF7Yp)<7Q-^5woVyUhDL1g(nfR z?mPK4QM@Zd>&VIrkgj-SPZ85Vb=IzTO_yD)+k~}iP)EF1uwN(jS6jBDTcdIhHdira z8mV}hk4bFsFL$QD;D~yiELmmk(bC_TruFpd@KM42jzrEVqMIhO>U4_Gy?Bzs?^H{TK=SBD})6)b9nL}Pg`kCti zQ;KR#((E+WfY`~Hs+2=kk8CSt?$JZ!o=@9u^M0^?oSMS|`w?{`XNA4HgNbcy&Vr~O z8%i3iY&FO|kfF3j=dudDf82xZi^rZF*1^x|#zkmN)vx*+`CmPi2LoYE(}M>!#63O( zyhvmE+=b#=F+O)-qvs5IMFnb`Jl?w|dvm#an3Q=V4xYVI1Ks$5(TrW11%p&cxPa`% zzZn579`AX!a&(&_-8GVw{D7HyLvaOAc3En!X3oZE@L5I=^~8$2d~)Bo*XK{e8m6J9XfA-fPE15EY0>X%7ZA;O))r-bwNv|vI_a4afQ7-v1_@uO_M`*5%Xerf_Z@RfQ1`pTS z!t}U2u~Q^VJDgiYMg5*;9CLhFfB|z^whY+WDXHZ~L#MJo7B;hX>FRh^>xQG^yq#Z1 zk6Sb|nTnj%*=iQ(zu8|ka&6hUNeMdmBij&eoyE*{6q?%V(J;nP{q=##+qUad6KUpi z`qA8BmV8OpNM}ZpuH$L3XsrGrV1nxmA$jf^$`5tIF@x{AN!ttpp~d)J$4?z&{3F-Q zdX`=5x;n8=V2V;zWIHcMY0BW$I86wZRqq*6h^NI?w&v-1H#9aoYeI7cT`R?6P!AhokoH=DhZ$G#T&D_8IQT&8> zGYJ6J9P z_fk(6Y0k{hriw7y80(?_W;!G6u>RTYt{36DF!b(V%5i^vAfT3q<^(1l^KK)TDA)rA zREXF+{yHE^a4xh6FnO^Yj2qSSBbP_b%5TKpUNcMW?asSDH!ZQS*bR3}T|n~n8eZT3${>oH23Z^WJt5tKV%?9hBI zeLY0-Sk(0)4oXt@{La=*3x1)hxgK*HOMsRmFdKkBnmesbiU1CdNOToX`;=VoTJZS+ z7_sP@i0FbVIqa2_@i=zWt-N3;)EoLVt01SIodQj1X_`IE`uh@;9_(*#KiO9(y6EO^NVHYdVb6tfvv;iMfFjZrutFEh=ZA4_f?_ZfEFi|lc)AT zah}4|cTLII;VfoZ#(ExNj8;N0g;zKK_(9VIF1JacE?%YxU>A)6V>~8Om>T;-tq+Qj93Cj1!yfYrd&( z7vCCJx^^dEI-2woJDFh*g#Bwi9dnSlR)R?%H|SmxTz_T7pr!tHoT8 zPX(yy75HoPQ*hh|v*#bgVdO<6d$Z2LZN77wA@GLS?(PcNG7pvgpq%WXs1!%A8_YX` zg!qD?_ajrC9brDD#?gB*Yk@%P4(;2kjhonF*o}qbw{Wj7R%g{kWH84K+xlXd zlQtPj=4&ahqQE#wo(h+w`TY6ORa4L0tB81Q3zcZ#Imv8Y-O18)F5>$huLBmE3-n?> z4|N_)+Ip;3nc@O<*`c)kp(0LC4c7b5;cDt|ZVihNmpO$^@J%u|G2ndN;SORr$99 zzu&xDZLj-fMYs1V?36LiFxcI_cj4M}-(I@!;AG(fJXHcrmS=fjNA6T##ZoTnO!2gE zb)l8-M$bCvfjm5jw0cA2M#!dkc3LmxVQqVX;SOOCm<;)4A7oZ5=K#mzeon>R*&6^UBm z0p$}an6g%Uy+u><<+8^UHekA5<1>h+Bxi-SEzWc;)3=LRzoSe~u=oij5>Yf-0VjnU ziMy^M2HI-}Zd&-hz&tw3`K(c`j&?m;!z#0S#m#n)vwCGU_wZ=@Tc*#B_kr4|7AU09 zdyWV9Y>RoiF5=q!V`z@`hMy}Gd0IeyqxSl_8^`|a&Q*6CB(TVrkmI22 zvx)&nk`EWgcq_FW-$I{kGWN6eg_adYvxaE7mf@@$O13%v^qyt}3vUobq~K9yJNE9# z*Sdy={O}P|+OlH|yILKotIsdI48Opy0}P&!XVPkmTIortlT%&W?h___VGV;XtMANL zE-_FLfjC3wHL=ohmx-MpdUf$~Mifcn8h5O3HG2{VA&umpMp+_<=H9#J2nF0M_a1Z{ zv{zdM@SI`uQi~x6*Er`|4rUa}M;5b+7GT_@1c~eQV=p zv5$%sa+w~JZf%1laW?-E-v~%qed*}?(`|$CvXe&YLzX7f&}k7yzb`EzWi6Zg=U=hb z1>0-#Q~eeE8Yfp(R&#rr)vj){p(_`b`Wxv)ICYC+IkuT*UI!N0v^I0N=OS?ql>Ha& zzhSc6^*9(4bFBI8k$Lb-@xvX!Y6!z9$@w2Xi~Kh&Jes)h>Jp8H8=X?8q!m_y51^dSAKEklLMNNxu4koPz_}k@ zpLh9c_>F?PdLEjtqnOm9w^Nt}+N!C1SMam(dNm9B^?c{U>&m)M5+jG%`CFh##_4L4 zE85O<#j7J$={^_Fp9L4gz2!d(F+~n$;Ij-#qYb*~uuQ~{MqjtvjgNIlSr*x4nb+PK zZ5L!V(C8N^Ig&BEst`SsJWMxJ%CqnX`Hdyk6R*s-Ks}x+ zx!gS2@n~lG3r-i2^^T* zzsOy#23Bwi=x29XnpteUV*SvO2net!kMvz?HS)4_^LUQJuW3wz4ZkuZOgCcS^$_xE zQ#+|U^Xk{r#Liz^TLX2inR_abn#Pcq?DkbsYRi$c^@5H8NZR)A)i!Z}fv19Y3Na^Up7eV92%oNKurVp;B_eQUffz_x&4+928r8^mNRl>!lf{5TmezJTD#9pBt41ii(#Wy+L=Gp&xC9 zPiYNfm>uL<=2`b0DqXRXX!FUmIAtcKix&b4%cAO!^yIU%F~%Fcbo0@sIRk5Tl=ml- zRa7c7hg)@G>H&`SvqZ(4{Cp8mX?DK=Qrbka{)(E0z+n7_qFaHuf9YZk>myql^&%*kW!QHtlzeSK~K~bQ*y-^~Y7Zgo!P0m%Bbv{wC3a=q}ij1J}xR+ zp77yH`mCT;M52^((v&UHvd{{ss4*uaoYbs)tX`2G-*G6H`jdIQnIFvYF&1DA?4(RD z3_Mt)zMKW(=717xl*{7Ml?&5YgCC~+v7MdHpzT6NiQOK^kjFubCHRx}6h1%x>ea5E ztDIifaRmLcESyYqpAh17p}5G4y)4<$fv@K9aw^~{%qF3@C=@;;z0Yr?^`?wQl1Slh zN*y^h&jXvS(h*viYDZjQp6SYTObwa>@mX)o7uL(IVo%Dcv|}-wjCzd|!Jxb_Ir8_B z#-~+v+uAm?kvQhYZ_78R(vmoWdF^W!i-rGGb#_KR2z)aBsA4=7mX}KshcTo|^7O=2 z`|JrXHEs?C%~?Q45IGGpw_%}4mS1hBb2}$`hHm*IpUE`)O9@ia&|M)SDL!M1c(TE3 zK31@o)F;NvGr|BNNAYRGUtCRBnJ#uLO5wXh6DgP>slrN^tGIZ(Ee6ARB4}<$Q!|RiG!KBIw2mz-?ATq>$pR=Bq4nhebvdI$O(ipUAKM+$Wx& z-RTjU#Zh~ZI?Mv>p(zHBQ4c6nlz?`Qa-MiH ze${{sCJb-r#d2AtIs8c4%k_IzmfpUy;TQ|> zm=Ev;Jl=v$jo)G3SYMAor%p-UT;8{hM*4PO*QKuZXjL>z?JSeb)?XH#`+2rk0-3C6 z(#nU|e9=*v#}Uz-QSH9_nb}FPW za`oymizgD8?-|+Fubm8JpEHFc`I_+WYY*n!ZRDLCYN^3;RpJM(22+)e6PKT(B3Hgm zk21;c3y!-?0B8|A)hAcNlVP1${O*($pTd0y=FT&xJ}#k+ijfX1I{GDjk7MZG@{Ve( z(Ii?VRV=kr`j4Y(WkuF#F=cZaohDt#G4#~yyuG`VjS{QX-JtLVeDrj##Hg;jYPBhX zPA*Gxu##6;^Q4hmqJ3Qo^dBI;+tOq_^0lDF{(k zOTANXV6ip)Mk-9pajv^>N$sSqX4I*C8`INdIhyayf~?^gTGFq&fZf-GqMzYMOD2^? z9n(Hej~mJQcbfVhwL|Gt4Esb_g)7IW8nqQ8T-qbVltuOGO@^N~;{g@QzP0$qk2hBN zq0ivPkuFwAcAGapH*MCo9WB~6`}$^;`dla903J-lk8VEWVYCyS?3f!GQ^};VmZPy^ zs;Y#ELIP)#EfY}tX!C1%p=?c(u}g0!3h<@5hup6@ppU4sgTIP{zGckdFBuozA@Au@ zVr_A-$Bp?i*OK2Q^vn-vG~vv>_UPKQw>-jBGM01vliYq8d8EiA!uGF1Z_*K7#uwR3 zLQy;GjpW1P=8C=Rk&b@BcT3CA^7;7vSjp=0YM-~xYvgZgeHyc&Qw+sC3-L`Q0!4%G z?R?kpB@{MgY`DLa+Pm##!~Rb}cyQM?k||EUjA?(>zyUGmInGx*Wz^{2L;4=yqT^Pto#e~Tp4OF2LbEHI zTn~O2(-1@AEb6q$IN_df8HtzRiwaf|m;4&Zfz*_8(JC2(a|&f1JWj*Khay96qEYB| z3<2ksvjC+!?7R2U*(l~n*<8ra0Pn^kGUYzH9+@C>$)~2RUixk(`CPrOu2tl6%khBB zC~{MZ8`{t-oFc^HW^B5uqV`djsc(VE<)!F>?>0J$S)tR~@%y88eW#Q<2QSxHpYuF* zoBgSu`lq)xW}Ew(oUCp+O3w!mFy@a5o{u>7dTl0?V~p)EPsg+5?F~y2Yt8eU#a#1P z1li3y-1W$yGwYm+YciehS9BidlHHcA<&f7`v_c#Q~nwV z+;#Zci9DpK$iCXh96E|K_vKoT=!3KT;kCeKR(x9>>h)^L>ie3s=9|(C$5QD>j@qX_ z9z#(F!%n^omA(xAUs(?pnpUsD-dZVF-H|TD)P}-|d!yJX?JpFR>z(LMAm)Lr1BEp{ zz3`5^r80~$qze_rO{>DwUq#tUo(Z2wI@Rjm&c{?b>sM>8m`J_2c|+=C)maZ5qZ#an zv)Ab7#ZVX>C;ZIT?1*lae{K^c^Vr!yI|Y8w5c3Xd5-{q_6U*`WDUwVE=pLS7SbUjv zlWy?9G0u-N?azX)_Qt(W>d(#e@HBt*)s^WLasMvsaoUv~+H{@Xva5BeGk0KoC#@n= zSA05NQY&Bv2x&pEGUEWCLfQEMDwByl_idA&<<+#o>6L?gUg@?(d0E%3hH(v2=@yDU z=5hAU2CQlIM9;S~m26q{${;*&O{${8jj0Oc8~9hz+i`e21Q#QXWA6;1BCnt~jiOql zKTltsBD0RK6v%EhO;!HS9MvrPu~&*7TwEtG^$QlB9jnWE4Mhk(9hQtfwm3NpI9-^76Oyqpfc`+7-Po` zF`5;~`}~{$Dma&hH@p>!V3e-hULogg0TKXtOvodVEh8; zk74C0&J%Z7`eD(5$P1duUZXCssLP`K@rqX-&Ro;?TkkZdP6;r=)Yhd{98Bz^jEdO1 z&vyO{-Tf7YeYS*bDqxvf-Rrw;C`^Yf*Y)QF-hKD}aG_Jk^P6|E2h(I(5(DH|Hdq_2 zfwiLSG|_LBpz8P*Y`@j& zu(Pu#f2|det3&v;LZ^41DJkWTySYHwTnVFhww$dyv`%~2$5Q{)=ZeR4O(@U7fu>Gz z>c;q>=A!h{@#luutg@vl4VcB|6{as9k7c5)JwRofEHTSIPdJDOgfT^Ks(_32nrbqdttXJ@us3#!vg z@tT$F6*V$8i1F}f&dg}vw*jju0R!5q6c?Cf{@PbscXPqE>7X4Cpgzoi<)lELuRY+R zLwmLF+7HO(5*Cp3M;eHL>cKw0+sK{|0@_y#96;BO?o#bKRn7|@*LPaJUF-|+gnGV3 zc@?C^xo$2tt*IY*M2ns0)$e;G8x?H6Pf$4Mv2^5_IHW*^#ImWswzy|O zO@$dauQmDj53Iac`ty)ZbX4CFBvdpQy;(@nybHz|1)L^W?UHa6S4sys>=G5>^QZNz zgE%YZ(@g!n&q_ibA9a&11qM~em~#bb+)%kYgx{c%g8it`%|!?=3~Em{4h$+ z4gDDrwlX7W$aC~i>;}@1N2rC^4~S?#ypk23FJow&eP&_c1v?qKGu)6seyIgtye=^+L9VC8cC&gr4)Qc^lpEnO_EH8IBl6SJX!7h^9HW%1XKynmy zUVTImm9Qd2jKKc4TAGW}bdw>+S{R(x8f5pe%mF8?~@d$-bbl)Z5)-I6?@we=e zCkrj+u|R__6t<`$ zXN9lV4?EuUz1zorucSSHiV3o#u$^tQmnmC_>au5#M#njEb7YD1D=Mu+d<%yRX$dwn zv=b0Z0V`BY<*Ck4NP$>xA41&Fs)1I-6a7XRKHI&==K!weW;y_kJi4pe? z$>F*s7&sRCI)f72g#Xc1E9YP@zIT zhs-lhLEzqFYy0qV4P=oB!&R(gGJ>toLMdzU%(%?Yal&`XwLvk=>*nmga&~4IY z<|M&tI5gsUF>xRo%#XyiSF**@D(8&4k%cf+X>=D{TyHJZSY`&ctRHj(eXBAdu6s0} zf(5M5@Icw)yHsL5UzBS{bI8SJu|XMQeN19kpF?0cpw%Ly2#z>4!rqk+s5lV)Bq3T= zQ9InOaT=cwMZU7WB|1|qkbFWk4?{j{PWd;OH8}nRLK91D?yw8SaU?a!t>6LLNT;Ex$eBm)Ko$rq*#l()S8gPDluR zCG~x>PG@%u3t|tFI)0mAgU|9dH`N0bW4m<6+Tg45(W5bfJyOauj{>mt`TuYMw#Lcn z&z_vOakRu>1M6zbN>^ZcL2|5u(A(;u8*tFvcxB8+QiaymNOH03=06sP3Qig!!L!pK zK5rLF{o{+%jVAX}=;`awJNpS{6iT3dRD}KXb5x|g5r-u0QKo7F;rgA(WW2XIN)vD@ z-Rxv*X^9d3=8wCvy=ZGvdS)0Cuf}&Y&k0p;iyxktU~L!4XnxJSq->WvnBmPw?ihE7 zaxA!;byhXHsj&JGfL!z6baF4yVS#S#Eb+_p?emYznmdGvs&6~)s-VyVFj!jc!CL6L zIEYZl)~IqrC2g~IjT^5@C2#GC!z{YF0r)iaTdT+8D)FDvYciT>Jgb%wE8gYLJYH&( zh|wZhaBd8lEZ6DRh2j1wz(n;JbYCN(D_V&$l$C(FJZ_xXy0Ht7EQiR_pME{Lf^>i0 zA0a`gtDH48b~2KqHHT5?5^?nj-&ZRjX+X2c_>NTkfbxcrmRxOO4n{Epa~bVrk;G2z zqMch?rj)rVFgSa_Pc0Z56fBk?hZVe^n4pOVpZ$)h%iWp6bSFu?2?~}I8!B*T9vlCu zL7YK|)8<}WlW(rDka)v{RJiiATog!Yd7eF7_+dG%nlyMp@vmaRA)C?nK$RmFx(a*f zM^-1FTqh2%MFEkzT4^82uWDACb73)RXVh{VsuxA?{1_7_1{#$k3v*Rx?00!T zM|)A*lj)X7HpwqgEj7H(+uyPNOjDBYNI*~048P74w)jikbW2rFl#uD`2{!kge%EY^ zy9;n*?N#!=hf^g6QXzOKnup|_uDr60L0kNCN;+QR zsyA+`t#3`QvjY;S9s`NfzW#Q211$a!@z;^g&s4?Aegm|64 zYzZ)DYGw$aDak}KcgI~>ruWpiw93gET3Uf2{gpZVM^h3SXrRIFT3J(wG%6?lQ8P^~@HJe_l7A}NlL0FM5iRAk;| z5IapUTwz~4=&RB&a6bjqcBzG9Jdhj}X40#cwPVbLM@8R2&#wqw=unN+SN4pdFD$EG zwWsymiLJEH_fx9&R=Mb1aO?78L*DuUgFE*-m=9ef{P8u1sip`(a@8;HP(VMHRiduu ztumYprUNZAz0(kKeL$^ea7==nvK`+aTc&}gbV+CJ^%ef4nfc!Igm79`Ua7kUuCils z&-|nX)7Y8=qh2bGg+~n^X9hn!1_eJAb@TZJ@Y@m^?N=&~V|(%prWtTgKy^IzrRC=p zJN8|m#|z^bk=!qi(U<<WMK%$Z}6ESb1 zn)FZz3&coB3RL0-2h_?992-V*WvNOzF-Klv47h{n9);voKHYKwwJ4*~In51jUI+qO zz=b*S*Vzr?^!%zABnz;@cy}!~CIVy<&{oZHUYft!7iVDBwfZYE9FM%?(}$(Zc^W@{ zz6kmf`HIyQsFV)=oG7PEK|X=+&ShF9Aep%T8$9IoL7TanY8Bomacf1)K6^d3`n!TH zqL(2=Vk^KerRU<^o4+j9^Mtcu1ys7~!%tFGY#M=y=Swl^03GuOJ`Mz8&pOZ=Ava)= ztMw(*dwk+Np@LaBKoaM~-SiaIBk};%v;H7crC`aH#f#lV5N9=G@GXcvjlp z`A|JJ&FlP&uauvWJ$iT9EWMR}_vwc;T%D?_PZ|yi9(?_iwd+pEUV<0Fi584Zl;W0m z#yR=4R@Y`iHKQekA~%DOkM|fMJArqyyo_<) zEMMy8ueF|Y>@xkq2Lw8^uN=xplh(}m9P6ou0{SRNl6gqQAm=U4GFCrukWGe#68wT+ zvx0UG7UO|;ztFgVZ>hcoja|%c+f3}A5ywAqVes-!XTWNa^xv+YE9dhHpCZ8=mA)BH z$XOTe=LcGBJmedZ_%tszNqq7#JV=8B1nSgny2R_;5EOW|{?MH&`5owCe#M_b!_k_p z@j#n@-|@Kz@XAtu+#q0XD~CE84fsDR0w#27>KW{WZu%y#tXkHBBw{2TCUup{VrSVw zptx^~uGV9LHX2`1g1^_nRHny;PM%OB%VJ}t_#5ctdc=lOpC-#`zYY~32DX?ZV^IEt zBV;C!cX5c19=d1~LLQn)pfsz2MyD3QyrEn})rvfxU!h?nXetg05aKshaL+WfHH^0>@?8 zaF&W66{GuR1`&eh3OF#~oGB-7Qx!>7@*aEGC|u3}E@fxhOu6*OpPBEP;WPRJN(B{R zx+RH6bNj~~5M+?HmAr7;j0RW?B`YdCoYh^IN3h-G3*z6PhFUT4p_fA z{HfUg+W8TgaDS94g;K82=_c~=CEnhN6Xg4mpKCtBh~IA%T0ayw<+`I+yaeffEuxh2 z%YeNB6yPI3Z#%ORFW`)S*_#iCNW-Y(6kcL&ugaW4BF-PR%T@UmdoRxXky*lOiC!%B z)$?|UO~(R#!`eegwmhGd8$;*8|+7Gnx)p%^Je*o zo+v+L9!o5&r={d2X=b=lB3RLc2C#)FO~<1xxrpI>EYA@_{Gk64dTEG?%rX?zdCjN@ za7E8QOTQw%)(&HpQy4>Gz!6ZyRY^JXQ9YD#7T7fcKza= zpJh#ijssaAj#25Si!a`cjq#|~#wB@L61R!k9Od)H!xEYmC%8W&E zuT=61^?~zJ0lDkjRcI-h!3s58L%+rEkZzx3nM&qBlLg#7e-09&z6Eyz5F&x~@NF;l zoSi+|vucAqhn7_sT3cMw=@Tl42j@jR&m;{!aUN&AR)UF-P^+NQ$wf^MZZTRg! zxyfo}1dj=>N=Ke~jECr?i(QR*{E z4KX5t4*W_$kEI)UnZWyTAzjuJD>yhU=-^{_Y!9W%>^YHp&c9hB<^ceR!CJZhp>jVy z*UApM9D@#YclyJ9&A?$UMQ9ZI#Gx-&=jA}N_EFRiAZ6MqTAXF$qy0=spd%;}=)iAg zMEYeS$>@7|&8AwqgsSN`YihuYbmXTnrgJW@SsP#h03amIq4R_ey69+v4!elQw;P%Q z53U&SLwlz_PzvoB_Y;a!qLI^HhN27rrP)f%wJEM7i%U+XM4VfT;4*Z)Lk^U`_ z`_Z4RyFOCrpm#BmKdVH7dG+V#2VVY9$kp=gmKV7ceKq)ti$mbD<{j+sD|K3paX|ZCHSVPWNX{GbYtgSalDhy ze|hUX}l5y5G7(_c?rG-vM+h=VIdAX^Yv| zL!lKU(1GKuBslpsk^9lnzPTKlSik`QDcU-8p%Z}amUi!c~AJ-!vK&{phMiFP__68ao2lTYhS0=f%@|eNbI&L*5P(dmCw!%pA*_f0v+&YCe>;WyrD+;a{9D8N*Cm+Og) z&O=x|hxhp*(Y=heW1a+hGxb^KKBX57L}uoYe7#`$LvSxg6bPk08dkH$({-uG^$Uk- zrUwB&j2$Mb@jD$}U3he-{I85s`iCM9B+voBOxM<#vf>SKsW)8Tt5> z@DScH34r%(5>5(im3!+OrXn;#M+e}}WeuFj*%SVaj~v~e`m{N&JZaC6>i|Ba`90$K z2OriBXP$eWi9xv(g1-`*M;Az-17WPC!G-QUNhNm5)#m^JXf0$d)VQbfH<*b- zur~Gk;PD{F)-g+G4P$&kk*eqzdnW`?E%on(;?1*86Pg$#U| zIK<;$n9LE3wh(;Blpb`21UeAbLMkY~aX)%8mNG#g^&m3<0Inv=yEH3F0MJF^*R{iV z?bi&(>uX;>!IsbUk4OY;62BK1Xp?q)Z0(#PZm;?c){h+43r(@vDSNiYt|>Y@isl?a z0TF!B7SWn($@nh%HFJ1wPPkGPT_J%Ev|}mAt2sO)4z)#ol10092QWt97= z{C$$>ZU*u`YR6iRGMcKR?LsH{d)QQhgdf=uL7oXE4&>5W#pIk<{U$Ou7qF!I0l|LxA^%)5wfey59Efvj|4r5W_$Nb2V zl>h*gZ0rztI46nj#5H;Tl)>nB+f3P6cS<+syXt_{-4XwP?3DqXk>gcv&vav|9a9`+ zoI}6zuK5##J>*@&r zUoz{+0stT+m%sx_be%zi(X;P)%QR`M9pN`2DEo)ukpZnYD{#o~Fwo4(LjlTyBzj}B z2BTtg91W|TtVglb$A&T)0>@h^-)F$5wPS%0Npv08~@af8PH$Xb_OHi!hGu<^=Ft{oHwV zct1`^q93g%=lo-n=ee$xYvp&2X)T8}SvT>UChm5@`-cEt9PUG}NTN69nmo_75F802 zfeyrBD~rn)?ne*8+;h&M2LJ%QI)H!c@b}Lps7n&yW3FS{D=8Mtb==+7q44yIU`(U$ z*W96#5x{RF$Lrhb-`5|E*#1zHJI{^lj3?4*;~o==9skA0zN}Ph@|>pw z?w64w!8tGFzHjY*;DY1X2Gke)I5yZ@3rzkHWJi$@7x#Rdrb>?+Z^3O&hM8uf(4Y(!4LH5sq*!{k)PB zA5&pWK{y3%zmwigrog=Cmggv9L;@X%-&zhXmsGTV>OnTOC3Ic^0MIqp>ixo5FIGGK}t$40bQ7SDv&qNuo8b?hssX3H~G7zOxM^t z7S=r%xVJFt=3Rrovyu0Sk;NBOVg8r+a?pP z4!I?kk64jF2hw3G%x@X%{x5a4?-}=;0|3w}A0zN8IdDG~7JM+l|9cNVtHk8F@gn;` zt(@;M9LxEkNpZ%G(d|GhT(3eg%=(#oF_2-p&c92CY7?d2t2%YurCM!^hy#nPcYocT4^o$O?4qd`IAJ?%gR$hcqPf?Rpx8)P}YN{*QXpp@*L44fexg}jN&@;6tGUsPVSEDb3Y0I z0G%`U$oWbH+(;rIr$l;B_HLSyvxhtp!25^!{<(UV&XH6DUZyzXha}UQy5-(;pmjcz z=#?Y@*O84rc4fNUG|A0MitdLElbpFFJ{GcPaB*Q>FPICH2+|fcK9Kl1+De(AJn!&% z_FnSbc;>Uuiq9naiM1AwGFngia!&ZJJZJ3NkvvB#NT360TT8(3QhBFPik@WQE@g}u z005~Nd7hAyLn7TIHC=-L@44lk>OU+zefO^9vr6f`HQ%wH4#gRB0$4z2d?(RO@P3yv#s#aOg%XQ)%=|f4J|MWDjy|Sjl@cV-#kU$6WVD3Eomht`QOX;{DbH5Y- z0MVLAa@Xg6#3a%e33WbQTfv4KkBRg8ef>(n;awBYcM8YeX*cwcIIp+{0Eog!&hkFl zU2wBRlEMS4=wS$s%Q)lMT_%m(kJs&ij z7DW<0FJ`Kh>%r_qo`2Z6kr=lJ6`7 z03anMIN*Ca&V^L+Z{@$?V{Eri*YdjR*Y}s_U_)gk0~oV?n2!!S<8-fcB!(#n1KIDd zC7|Vc9RNuHARf~I1!$E7?{DOKhm|}hW18wRu)58Ecq%%Z%g-fw{->?SBzk4-%hvM! zjj>3>?9KisuT?Xu-Nw4INTTOw9B4hhk^Os&&rFjvfARRd z-1kasA1NY%4&-Mmi~X5&ZKrA{qd5BniU9yf&Psv;zQ*9$Ofujv+4sE2-ly##MBfTD zn54PSZgbmQaffS(wjS>=G)|>+`I<5=Mu!PBuTK^`{Q~ZAIRD{w4Q6B;u0I#b5LmZxs|m}`rgZ&N;1(TtvBy{r+j?3 zs+RX=4@V?OQ;NBDX)Im1TILFYH+fFJH0Wrxzc1Gw_gOm{f3jqrl@n{N^zE}}=Gw!$ zX1@3KIm6`|izZl`kvtOUK>l9j&qOw=Z3KI@mhw7%1iAqLKpdvB7n!wiSU-{f#(V#r zH!EPfJ`il{?RB!Z6igZg8|yiC=$TgSb%eH6WM;g|vyB4$9(rEdXN+FJbI>uK zWDmpIjHHo32g)�QS`52rFMU^7=TG0059DGa0ANWWYWe0}de zDxeLCbGLrkz%{}J>lIH*b6k#gC z+BqNhyXMw?jPPS5_wJSKi2yH&w(UD?RLd(8I=NFD`fzr3+t1~D#lo!rN|*&b6IlwuN{Zk1pX{Y{FQNS}^zzYBX-eCf^C3U1PWnaYj*>>5Z7jrGzSW0q@?l~Y|YpVb)_jPk)t(RQ;7M_U@ zofS0gU@re&rXzMEr6js3PUL$6TRkDBS6D~-T=pPLVnFstpaU@0(iI*ep!mRn7XSc! z!tO}VHE8>41sm8Gb;lHKn4fwZmq3p2f6OaOyb-}=Vag0 zYbw=B-osc6g6z@$z#*n1{WjC$w%y)yljb-Sfdo2$eRe?6wE}cWB`*K~XlNma^8d<6 zywnq%8!09{lVU}J4e_u8U8#Q0><$pVTR@ps8LgYDgFV?!x`=%x z&JDgZ?zXa*iESJtd;Y`l0vUm0%**hENWBPb96oVfYJVNwU*~iFTv;Ln-sI0$?xNg-vgBD z|Nj2_Z$5kgpE1?n7oYt9?PPN~wdw(?R!-A<7!Hk+tjUj;!pb2wd=X({|PgG_C0DumdpwK}6w9z@XktF$w z18(;ER-mh`?^m@K$XZa80l<@S9f z7Oig;YYZgti2hyy)}Tg^sQ%G&-&k9gYG!<9NWtCYhAHs;+Nsx_sYH! zq{;ee_GMe^d$r;_RwTP{wtr-?hWY-itY;I27Kl-A@0?-&w`%ar+*xOd)!uWH?>*1} z33LEjW)WxM1poj5{tR##W@P4j0e<$;000000K}%Ms{aQd5tb6{N`4Rk0000 + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/static/img/openjs_foundation-logo-stacked-white.svg b/docs/static/img/openjs_foundation-logo-stacked-white.svg new file mode 100644 index 0000000..77f9c68 --- /dev/null +++ b/docs/static/img/openjs_foundation-logo-stacked-white.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/static/svg/feature1.svg b/docs/static/svg/feature1.svg new file mode 100644 index 0000000..b8994c6 --- /dev/null +++ b/docs/static/svg/feature1.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/docs/static/svg/feature2.svg b/docs/static/svg/feature2.svg new file mode 100644 index 0000000..462a064 --- /dev/null +++ b/docs/static/svg/feature2.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/docs/static/svg/feature3.svg b/docs/static/svg/feature3.svg new file mode 100644 index 0000000..fa2f5e7 --- /dev/null +++ b/docs/static/svg/feature3.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/static/svg/perspective-logo-dark.svg b/docs/static/svg/perspective-logo-dark.svg new file mode 100644 index 0000000..5e0b1ce --- /dev/null +++ b/docs/static/svg/perspective-logo-dark.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/docs/static/svg/perspective-logo-light.svg b/docs/static/svg/perspective-logo-light.svg new file mode 100644 index 0000000..1febf6a --- /dev/null +++ b/docs/static/svg/perspective-logo-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/static/svg/perspective_logo.svg b/docs/static/svg/perspective_logo.svg new file mode 100644 index 0000000..338eb88 --- /dev/null +++ b/docs/static/svg/perspective_logo.svg @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/template.html b/docs/template.html new file mode 100644 index 0000000..23b5f95 --- /dev/null +++ b/docs/template.html @@ -0,0 +1,42 @@ + + + + + + + + + + + + + diff --git a/docs/test/js/examples.spec.mts b/docs/test/js/examples.spec.mts new file mode 100644 index 0000000..e56ebb6 --- /dev/null +++ b/docs/test/js/examples.spec.mts @@ -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 { + test, + getSvgContentString, + compareSVGContentsToSnapshot, + PageView, +} from "@perspective-dev/test"; + +import EXAMPLES from "../../src/components/ExampleGallery/features"; + +test.describe.configure({ mode: "parallel" }); + +test.describe("Examples", () => { + test.beforeEach(async ({ page }) => { + await page.goto("docs/template.html"); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + }); + + for (const idx in EXAMPLES.default) { + const example = EXAMPLES.default[idx]; + test(`${idx} - ${example.name}`, async ({ page }) => { + const { config } = example; + const new_config = Object.assign( + { + plugin: "Datagrid", + group_by: [], + expressions: {}, + split_by: [], + sort: [], + aggregates: {}, + }, + config, + ); + await page.evaluate(async (config) => { + const viewer = document.querySelector("perspective-viewer"); + viewer?.addEventListener("perspective-config-update", (e) => { + window.__CONFIG_UPDATED__ = true; + console.log(e); + }); + await viewer.reset(); + await viewer.restore(config); + }, new_config); + + if (Object.keys(config).length !== 0) { + await page.evaluate(async () => { + while (!window["__CONFIG_UPDATED__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + } + + let selector = ""; + if (new_config.plugin === "Datagrid") { + selector = "perspective-viewer-datagrid"; + } else { + const plugin = new_config.plugin + .replace(/[-\/\s]/gi, "") + .toLowerCase(); + selector = `perspective-viewer-charts-${plugin}`; + } + + await compareSVGContentsToSnapshot(page, selector, [ + `${idx}-${example.name}.txt`, + ]); + }); + } +}); + +test.beforeEach(async ({ page }) => { + await page.goto("docs/template.html"); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); +}); +test("test svgs", async ({ page }) => { + const viewer = new PageView(page); + await viewer.restore({ plugin: "X/Y Scatter" }); + const contents = await getSvgContentString( + "perspective-viewer-charts-xyscatter", + )(page); + console.log(contents); +}); diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..1c4b187 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,101 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 tseslint from "typescript-eslint"; +// import globals from "globals"; + +export default tseslint.config( + { + ignores: [ + "docs/**", + "tools/**", + "rust/**", + "examples/**", + "packages/cli/**/*", + "packages/jupyterlab/**/*", + // "packages/viewer-charts/**/*", + // "packages/viewer-datagrid/**/*", + "packages/workspace/**/*", + "packages/react/**/*", + + ".emsdk/**", + "**/py_modules/**", + "**/node_modules/**", + "**/dist/**", + "**/build/**", + "**/target/**", + "**/pkg/**", + ], + }, + ...tseslint.configs.recommended, + { + files: ["**/*.{ts,mts,tsx}"], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + // globals: { + // ...globals.browser, + // ...globals.node, + // }, + }, + rules: { + "@typescript-eslint/no-explicit-any": "off", + curly: "warn", + "padding-line-between-statements": [ + "warn", + { blankLine: "always", prev: "block-like", next: "*" }, + ], + "@typescript-eslint/no-unused-vars": [ + "warn", + { + args: "none", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + + // // This is why we can't have nice things. I like this rule, but + // // `prettier` doesn't, so we conform or perish. + // + // "lines-around-comment": [ + // "warn", + // { + // beforeLineComment: true, + // beforeBlockComment: true, + // }, + // ], + "@typescript-eslint/no-empty-object-type": "off", + "@typescript-eslint/no-unused-expressions": "off", + "@typescript-eslint/no-this-alias": "off", + "@typescript-eslint/no-require-imports": "off", + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/no-namespace": "off", + "@typescript-eslint/no-wrapper-object-types": "off", + "@typescript-eslint/no-unsafe-function-type": "off", + "no-empty": "off", + "no-prototype-builtins": "off", + "no-control-regex": "off", + "no-useless-escape": "off", + "no-async-promise-executor": "off", + "no-cond-assign": "off", + "no-misleading-character-class": "off", + }, + }, + { + files: ["examples/**/*.{ts,mts,tsx}", "tools/bench/**/*.{ts,mts,tsx}"], + rules: { + "@typescript-eslint/no-unused-vars": "warn", + }, + }, +); diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..6c4174a --- /dev/null +++ b/examples/README.md @@ -0,0 +1,26 @@ +# Examples + +The projects in the directory are self-contained Perspective examples. However, +they are designed to run within the Perspective source repository tree. This +makes it easy for Perspective OSS developers to test changes and validate that +our Examples continue to work, but it also means these examples will most not +work without properly linking in the Perspective build environment. + +In order to _run_ a project in this directory as written: + +1. Install and build Perspective from source. +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 +Perspective source repository are minor path or metadata corrections. Your +results may vary. diff --git a/examples/blocks/README.md b/examples/blocks/README.md new file mode 100644 index 0000000..c45464e --- /dev/null +++ b/examples/blocks/README.md @@ -0,0 +1,5 @@ +# Bloc.ks + +Examples designed to be hosted on +[GitHub Pages](https://perspective-dev.github.io). See the individual projects +in the `src/` directory for more info. diff --git a/examples/blocks/examples.js b/examples/blocks/examples.js new file mode 100644 index 0000000..791f365 --- /dev/null +++ b/examples/blocks/examples.js @@ -0,0 +1,52 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +const LOCAL_EXAMPLES = [ + "editable", + "file", + "duckdb", + "fractal", + "market", + "raycasting", + "evictions", + "nypd", + "streaming", + "covid", + "webcam", + "movies", + "superstore", + "olympics", + "dataset", +]; + +exports.LOCAL_EXAMPLES = LOCAL_EXAMPLES; + +exports.get_examples = function get_examples( + root = "https://perspective-dev.github.io/", +) { + const standalone = [ + // { + // img: "https://perspective-dev.github.io/img/jupyterlab.png?", + // url: "http://beta.mybinder.org/v2/gh/perspective-dev/perspective/master?urlpath=lab/tree/examples/jupyter-notebooks", + // name: "jupyterlab", + // }, + ]; + + const hashes = LOCAL_EXAMPLES.map((x) => ({ + img: `${root}blocks/${x}/preview.png?`, + url: `${root}block?example=${x}`, + + name: x, + })); + + return hashes.concat(standalone); +}; diff --git a/examples/blocks/index.mjs b/examples/blocks/index.mjs new file mode 100644 index 0000000..647e3a4 --- /dev/null +++ b/examples/blocks/index.mjs @@ -0,0 +1,115 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { get_examples, LOCAL_EXAMPLES } from "./examples.js"; +import * as url from "url"; +import * as path from "node:path"; +import { execSync } from "child_process"; + +const version = JSON.parse(fs.readFileSync("./package.json")).version; +const __dirname = url.fileURLToPath(new URL(".", import.meta.url)).slice(0, -1); + +// TODO jsdelivr has slightly different logic for trailing '/' that causes +// the wasm assets to not load correctly when using aliases, hence we must link +// directly to the assets. +const replacements = { + "/node_modules/": `https://cdn.jsdelivr.net/npm/`, + "@perspective-dev/client/dist/cdn/perspective.js": `@perspective-dev/client@${version}/dist/cdn/perspective.js`, + "@perspective-dev/viewer/dist/cdn/perspective-viewer.js": `@perspective-dev/viewer@${version}/dist/cdn/perspective-viewer.js`, + "@perspective-dev/viewer-datagrid/dist/cdn/perspective-viewer-datagrid.js": `@perspective-dev/viewer-datagrid@${version}/dist/cdn/perspective-viewer-datagrid.js`, + "@perspective-dev/viewer-charts/dist/cdn/perspective-viewer-charts.js": `@perspective-dev/viewer-charts@${version}/dist/cdn/perspective-viewer-charts.js`, + "@perspective-dev/workspace/dist/cdn/perspective-workspace.js": `@perspective-dev/workspace@${version}/dist/cdn/perspective-workspace.js`, + "@perspective-dev/server/dist/cdn/perspective.cpp.wasm": `@perspective-dev/client@${version}/dist/cdn/perspective.cpp.wasm`, + "@perspective-dev/viewer/dist/cdn/perspective.rx.wasm": `@perspective-dev/viewer@${version}/dist/cdn/perspective.rx.wasm`, + "@perspective-dev/client/dist/cdn/perspective.worker.js": `@perspective-dev/client@${version}/dist/cdn/perspective.worker.js`, +}; + +export async function dist_examples( + outpath = `${__dirname}/../../docs/static/blocks`, +) { + execSync(`mkdir -p ${outpath}`, { stdio: "inherit" }); + const readme = generate_readme(); + let existing = fs.readFileSync(`${__dirname}/../../README.md`).toString(); + existing = existing.replace( + /<\!\-\- Examples \-\->([\s\S]+?)<\!\-\- Examples \-\->/gm, + `\n${readme}\n`, + ); + + fs.writeFileSync(`${__dirname}/../../README.md`, existing); + for (const name of LOCAL_EXAMPLES) { + // Copy + if (fs.existsSync(`${__dirname}/src/${name}`)) { + // Copy + for (const filename of fs.readdirSync(`${__dirname}/src/${name}`)) { + execSync(`mkdir -p ${outpath}/${name}`, { stdio: "inherit" }); + if ( + filename.endsWith(".mjs") || + filename.endsWith(".js") || + filename.endsWith(".html") + ) { + let filecontents = fs + .readFileSync(`${__dirname}/src/${name}/${filename}`) + .toString(); + for (const pattern of Object.keys(replacements)) { + filecontents = filecontents.replace( + new RegExp(pattern, "g"), + replacements[pattern], + ); + } + fs.writeFileSync( + `${outpath}/${name}/${filename}`, + filecontents, + ); + } else if (filename !== ".git") { + execSync( + `cp ${__dirname}/src/${name}/${filename} ${outpath}/${name}/${filename}`, + { stdio: "inherit" }, + ); + } + } + + // build + if (fs.existsSync(path.join(outpath, name, "build.mjs"))) { + console.log("Building " + name); + const script = `${outpath}/${name}/build.mjs`; + execSync(`node ${script}`, { stdio: "inherit" }); + } + } + } +} + +function partition(input, spacing) { + let output = []; + for (let i = 0; i < input.length; i += spacing) { + output[output.length] = input.slice(i, i + spacing); + } + + return output; +} + +function generate_readme() { + const all = get_examples(); + return `
${partition(all, 3) + .map( + (row) => + `${row + .map((y) => ``) + .join("")}${row + .map( + (y) => + ``, + ) + .join("")}`, + ) + .join("")}
${y.name}
`; +} diff --git a/examples/blocks/package.json b/examples/blocks/package.json new file mode 100644 index 0000000..fcd5409 --- /dev/null +++ b/examples/blocks/package.json @@ -0,0 +1,26 @@ +{ + "name": "blocks", + "private": true, + "version": "4.5.2", + "description": "A collection of simple client-side Perspective examples for `http://bl.ocks.org`.", + "scripts": { + "start": "mkdir -p dist && node --experimental-modules server.mjs", + "repl": "node --experimental-repl-await" + }, + "main": "index.mjs", + "keywords": [], + "license": "Apache-2.0", + "dependencies": { + "@perspective-dev/client": "workspace:", + "@perspective-dev/server": "workspace:", + "@perspective-dev/viewer": "workspace:", + "@perspective-dev/viewer-charts": "workspace:", + "@perspective-dev/viewer-datagrid": "workspace:", + "@perspective-dev/workspace": "workspace:", + "superstore-arrow": "catalog:" + }, + "devDependencies": { + "@zip.js/zip.js": "catalog:", + "esbuild": "catalog:" + } +} diff --git a/examples/blocks/server.mjs b/examples/blocks/server.mjs new file mode 100644 index 0000000..eb1face --- /dev/null +++ b/examples/blocks/server.mjs @@ -0,0 +1,77 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 fs from "fs"; +import { WebSocketServer } from "@perspective-dev/client"; +import { dist_examples } from "./index.mjs"; + +import { fileURLToPath } from "url"; +import { dirname } from "path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +await dist_examples(`${__dirname}/dist`); + +const template = (body) => ` + + + + + + +
    + ${body} +
+ + +`; + +const prod_template = (name) => ` +
  • + local + dist + ${name} +
  • +`; + +const dev_template = (name) => ` +
  • + local + ${name} +
  • +`; + +const gists = [ + "duckdb", + "fractal", + "raycasting", + "evictions", + "streaming", + "covid", + "superstore", + "editable", + "file", + "webcam", +]; + +const lis = []; +for (const key of fs.readdirSync("src")) { + if (gists.indexOf(key) >= 0) { + lis.push(prod_template(key)); + } else { + lis.push(dev_template(key)); + } +} + +fs.writeFileSync("dist/index.html", template(lis.join("\n"))); + +new WebSocketServer({ assets: [__dirname, "dist", "../../"] }); diff --git a/examples/blocks/src/covid/.block b/examples/blocks/src/covid/.block new file mode 100644 index 0000000..ad08ee8 --- /dev/null +++ b/examples/blocks/src/covid/.block @@ -0,0 +1 @@ +license: apache-2.0 \ No newline at end of file diff --git a/examples/blocks/src/covid/README.md b/examples/blocks/src/covid/README.md new file mode 100644 index 0000000..042a4d4 --- /dev/null +++ b/examples/blocks/src/covid/README.md @@ -0,0 +1,16 @@ +Demo of [Perspective](https://github.com/perspective-dev/perspective), using US +COVID data from [https://covidtracking.com](https://covidtracking.com/). + +The `Group By` column is a good example of using Perspective's ExprTK column +language to clean/reformat a column. The raw "date" column, as imported from +[https://covidtracking.com](https://covidtracking.com/), use the date format +`YYYYMMDD`. Using this expression, we can parse this format into a well-typed +`date()` column, then bucket this by week to get a sensible graph: + +``` +// Parsed \"date\" bucket by week +var year := integer(floor(\"date\" / 10000)); +var month := integer(floor(\"date\" / 100)) - year * 100; +var day := integer(\"date\" % 100); +bucket(date(year, month, day), 'W') +``` diff --git a/examples/blocks/src/covid/index.html b/examples/blocks/src/covid/index.html new file mode 100644 index 0000000..47eace9 --- /dev/null +++ b/examples/blocks/src/covid/index.html @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + diff --git a/examples/blocks/src/covid/preview.png b/examples/blocks/src/covid/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..eec01e5a3c36ab0ecc94e0fb56e8dd4364c2c0d2 GIT binary patch literal 251530 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

    _oym&`y zy<<0l7!nj69SzVmlZjr>!{2wNM3O#k^GR?xG8-S~fWvF^+rSIZnnYaCm*q`qJ7EbG z32gt@7R?m!Faq3t?}vo%1{g4_WqCJ_0Ji4-yMXEbV#n3Z?Y#;Kw8HN$?swv^c9Rt^ z0Y+DJJix94EXF@?+)Fgstu*0nyl=qw_<`^7pb!8iohNl!V z5x}?w$_cUtk7#6MWG8^Q-R*dO%Zm{BJ@4?w!s22`=_Jpi^3t+7>s9}!Q@-0plXv5- z=YMCfj3ri=?=dp4S34i)YMpx#xh6L@Cc+RudjiMqFu*0%)Ymr$aCpg7ikI(t{?1N; zf30A`|EL$3J_;It6Uij+pTUBPs;cM)Z;uNPf1m~a0KqfS!;mBlRB+l^mtbuiOwhNT z7SB$_y02oX6!?SU-^EO|dYS<0UIQrjqf9YtaN1Z_Q`;&{aBV zvB3iQ(PbLCN{s|k_zyF}`y{i|(`mD6`PcjR=WK720){aGWAE@@02KEZyX#)Oeu@4a zm!^yn`mP-74M#?Y3ten=0gDeB{OjhNX5(;~n-{=|_#p4Z2N=yu6*xx%E$4PdF!E(Ehosy=pYCr=Yys31r`qw%bJK%fP2;nukC>k zejV72uKP@85F)=kEw1;i+tUV_f-Hhu=Gwf;${L7Ez+>cMs&iI`$%C1-%9+J zgoEwdRN8{p|7!szUEn`RmXf-%>qHd%2{I^LnpE$+17s=6$B!js~VDAQn{@7@l0(xvtr0otDQGvOz)>P$^2ib%el%whS}+fQp6nw@5`J z7aLGO1<52v?eD;`;HQ?W)8FzxGkLa&PRO@U&MQH-A&tiT9gU%`T2UvPn(IEvU@36P zqHZ3>zA+Qc$0$|-2`A_`z83CqM(W|B|WNV^<0v9^|xdGa2(!>^1A`fHZAOILS zIXl}oRVe8-leOLlY#~N3KV^*8n|Ynqv(BdoG17PC8}|KUcG_xFV`Kg?@9{z{c(z=% z_Q1h;Y&-`cKJGHj;Fa$6p1El3q4i>09A-919cSOhRl3iI!oe9{E?@bt9*&JSpRVDv}l3hU^6uBbpm3J0*3{dX@M0Y1Vg5raEr z74LoZM(tSV)=@x%6>fxMyV{btzP>)!9X&p-07Nv2W?pVJE*&k+F5=j(5H7e+*aSRpl=A%FXdCkxt3Rr10US70UdA-P0z%H4g*sEbhKjM9_uThNRK6&R1_WOFu8fj z#QYt*VG`BcibXh?n3UJ8ARp;6NVdJ8>p<1IKx{HZWr<`+<{K_jMNDM7RL0$QEA5Vn zUwTRZ^)<{685Ifwys)(}!dW+!7&j(qHZuzYZg;sd{UFXUa-rBhx7Yp$3n=7zzd?q+ zZCsGB7z*UCeUyCPP`TnEmP$E#EJJn+G>4;NQh8w~GXJ*tKXRd&J8Z6(7+DT2ofk;& z*$M3f;nQv!R-_t+1lt!U6L@w>tb>N+*}rf(_2M4k-ECOGof$BKd@KD~@MNo*E0TZ8 zt`rsMAYYihW(IUX(ntl-;;Uj|CrY^G8l<}jO1TdG%lLakhcBj_x5P5E$rfLEn<-FE zY5xZ18Lv6N99>&3)n1?>g-|~U=FFI5XZ>za$=ZYRU1MZJA!06hr}3B)wXG`g24RSP|Z`@)zkC8 z*LAo{9;*f>iPY?~4?ArVdnnI8IAv^hFHF?4mV zoaJsj(-L*dy!E;$Dk>Y zJ2*<{V$0Z|HJI=Y|HB|d#+8fLFYzEB*_&z}ma{9&Rkg=4AWby&=A9TC%4sKGj|M$P z8$6SCUD9>+0N3rLTaLkVzrpYBKtMAI@Rf6YFQvzG`JdushHoG-!bQW8E@o8~ovsh2 z#H@kDqkyKjDVM3u$^WQLp>XO?)9L5i)5fQZsG>V{JFVK^ocKsC z0GW?4;MwVaIl6mOl0+TFSI>uv0hxJEt$Br0mXnt3WnXFeUatS6_M4cQg|b)?IH7k0li#5%cbup|Uf1zUQ78)E{bsQ5>s21re`6`W zBmG%fP8}faxvvm`#Cy(>wwxogv$Kykq$Au-!B&sbB}v(WTz>&Yy1lpGS`$b+)$F~3 zy~B+JEV~cp-j8D~t2urLFlaj4cL`-UktTpku+0u%f{i7=B zZx_ZyiI?ctVDm^`JFKs<+MR(F6jM=DFo#H$u>!^{u+|Oa#T`}b*ySxY*8?66d{pQW z{mj95+o#wtNbaO|VzPR=`VA7pZl}xBw?hkMQ-V$ib>Q->;|O#;*KEFiQ?FQG>Nxu0 zYYTTLPv;oPIlZB7Vv6R0dYZ*@98(L)#H1A=DLD+^#X#mvT4iUQd-?NW)}CsU=ibqPxSV; zeDu<2t#-j@W75iM&h245-uG675BFfM;z5~3;vz^Z`nfU-`>zKNFaG#c&$$JEM1-U; zHKDlsnd5%TP&ei{ZV!mgSi_hz(g=lgf09N})r()07Oql<=h!I)T5U8IR^iS6Vhovw zT3u8%Q)4UEc?P%K$pIa~#pkYz&!!DpD&@20?62M;`6%y!CvXk#y(je>92y$HqI(!t zbbh?H0-{o&I4wEDE>nyK)F+;&nO@K`M-_%vryck%s_+5W zz!%3|ug8E!spfd7eiSTl$Kwz9Rpfzled65P#{k$W1t4+&w340w$!!xSz_@sL%P}7| z0^VMpFNoe)fI9TMx3W;`-WYWYxwK`b1W+Nk15~y+L`0Ix82SM(Zk^A2Oohjf`~J!p zkXos@xVT$mTpPRZEy){iKbI*Hm_RFg{x@WN?ms|z;~{6`fgp2?vg;ha>(9^-43KI7 zrWXdnFo8SVfNLr>#y0ZnCi0LeYgJ9n^umH9J=P9zK|jwsykONFUjhlv^xT}=!r4=+ zZrnQpO{{P#_IP6p9gyzW<%n)mE=Xx;U;|!z7N({+(n;w#IpUjWu#&)(B}G^2phIR> zYkzZ+5d0md><8YsUI3ab4*+K0>r_^^DiBS*K3V8 z=v|>~C$}$$aTKKB^&c-&Ojo`d5fgWP&DW0|dU#E}9+a2Q7&JArak)BWmx_aL942jY z7nh~52-hgm&@h7t7{lV)x!oCrh29rtl)-5kxKf75NG4EZzXrw|z>R9t?Qf9P>-?i~ z->nujU_-z(qxwb7HcY|6PfEHe4U+ocvpe>LYO2ZciStTm1wb18#HRi5mfF~ve9&)p zStHsbeZdp}-thGL1NaRKqJ3A_co;Ew%fn$gdVb~a8hZURQUu4#-+`AnglFdwSKk&c z?L}p=f}6<^>;Fuy7u-wd<<8)T{dSE?2}NXb7YV8Ty0%OC##A*yS)Tofy#{$^-09Dzwzgu&lpSvTFYYlwn0)#0Z0>qxzVn-Osw3s=-5}CB7m769~LJ zq1#cxfRvx%jE}Wj;Tl>4q;gZ1KdkV8BHX*T4KPh5kmqpKUh|CDsEs(o&eg2fj$Fea z;@jN%s{1>~Wv0oFQLjymG-cJ99#hNQ+dC{Y6cXsb!iM)ASI0VPY3ZArnWgai+#24; zjM*G5AH%os!(KO5D#i=Ga(BI~bd3R32z~%L4wO3HPYhywBp0Dqe8|F`i|zbVh22<=+$X`-{OyWkUkLw zYh`(6l+QxUtnezGtqIfKGW$uuXjKV1^4z((79!8Ia%Yi z!M!f?RubN~4pu%G4An_=^u7OHbOs!FAjI1Syb!3Q$KjGH;pr)P6DTtNjlwu6>TMSu zF_-251AKn>pD}xC5Hf{lk{-51zQ^B`YjTg$AQD~=e#9>33=>SycL-oNuo!HDd2ESp zdy)uU8@(2{whxqRJ8Kg+=)R1J-+C&{8qXnzbzU*v#a0_o7@hgD`iFU=3!OgXhs%@u zc*pG}7R|3BIuNhPEMu(-p^Aee>x;i&9D%u6u>!4|4`x2#lv>biDNWgf{L5CCJOsWm z%J8uuN)W^1K9IY@eXI)18kLR#dES2CP{Vt=ieor#-v3=tKDR|NHlk5(kczjlXwy7u3|T zE>Kli80`0SM*<5gEp?^{;l}`68SnLMz$tp#mUGy=p>OA+y@uuPyajVk1y?5pwZ6Xq zYo|~g5g7}9)*P%%l{F|vrP$VItACaZXkf|3zhnta8zZ#*F-Z|hrKj+VwC^*j&w*zga7 zYNKsZikUha+Lm=_ayL$%F;+8{#E@N2Bza@~4=wnqcDji4u9zi#A^FvIo)Y+5zO+KL zJIGoY2D3r)!3WVHKj#XoT)2w z@Os2L?w-&1p5W2^N`*xc&@WMLvI@5!Ej~FpF?M~=md=_A{!|p8&cRnn`}b5tmvQ}E zrW|Qt-2=Sz$Y>x?H|BZP>JsC+!Y+FP!l(igO)Z#0Q}8Z28B?h&U>K2A&RU+}%2!f^ z1TTlkJ^%5Y&0B>oZj-X z5Ea88N9WKSo-2d1UmOU#pI5OLeKt#Rr5vcrpdF5$^`mQWu94Rzw+#Gq)0`!#pHCf< zXkuAgs3eQC3-yl+lyoTdhKyTXLP+u0LO&3^f)HQGd!|sv@E6@e2zL+ey24+`=26xF zQ+g`o6{nW#M-nL*g~0|>n~_C2Oa-P6xra-P#0CauDl>^pf|E18Z;Av)#=rAMuqBk# zS4`n8rex7bIHcS1m8`8T-E*u-1F6-iAw|X3aEnqvhyjS$kr~l=dS8;H;zBQvHc%IN zerK?2yC3_?@($##b~4=Ut@ewIBoErP}HGkh7;^P?OsbDxWW|^Z0cW!Zs?dUZ30f6 zQWzw%$B)^!!4qkfAUcsm)qXu~)9>c@-VB0J;yRbQ6{7dtTe;{t7?FxJ1PaX~@IXfS zy(}D4^$mT{`Op8`Jlb4B(7n@7Nq*sSsz7vatRUB%DL9*&}rvv4fyEY=} zCOAFedNOmHcd~?((N`r!G=P;vjAPcGHb9u7n3)RIGC;*F_)ZS)%N2sX63>7~nRZWV zCy9%%?PH#uS87El@NCHm-MwwRxuzAC)2)>?bKm^KmvwWT)N}ACkXko0>`=EG$fL7nSMI0@yv;jo++Yp{it*25YKV)tm`^nG28Pe@^N!yy4aTcBey_7pW^D7Q0 zYdG4rVDhM0#?h<=*JMV8t{}$i%97)K?=VH1LHoU5i6BAWWFfnmVg>O^G+u=PBbv0N zYT2D0I@<9jy0oiZ37i-E_laMhH`XWDJND6C3V-=!^7#3_m2~)8ribnamy9TyRilC~ z_gXAPNpz~9GrumbbG{Ljod}xWaH@}H_iT?oVe%ODnSRxv8gz$=&Hctwdu=c~<~#2D zOAALS!?L|jY?(WKe-M0V?fbzQuAXZwpnmjSGaAORV?4img8m$*@1q_%mk1CQW0-c7RU5^#4&+aLI%(~r;rm6HQ_^X8S4Yoe6bTriQ`M%^8bQ8VP z`^z>OpO`QKX>X(Kx%IyEkAVh5alueaQ_QYPob!7IBp|cVOgH~;-x&Ps<3L5wNi1^M zmadhcM*JDJR0CHrvyZ8N4n95F@UR0IFh1&{FHN8<7r>pp4f1@~3Gn%#wxa%7K)Y|IsWD*c5>xwg*qUU5kTm^=_PJhJrdITHh zBhE99tyE-jTm}vJm`eL7^KYv2D-|tCDvis|YrrT=wqSXGlM{TNm(IZt@=^nn>>I8a z8`hTn(eDx0ZmOr&1P=bfSNHztEzZA7fms>|Ep zzZ5v zWSp%_PlsC5Z_Y|h(~btX$wrBr?Lk@uLyn<*xUlp8#QFzM25F!A`4;-t=nxAbOD$+< z0w*J*b~L^l`jM;{pd%8kI=P;%`vMw!>%@XbVe>*!o zu<-fC_`Yr1`vU5e3rS?c^s$(mo8k}2YGmKShAo;({}C3v-imFTr)t<&dUd{I2%>LB z`W%6ke1fS?4l$udJ+gE+l7@kxtM%~04yBk7k1v$a{Q~k3RESf;>@Z9_O~|!oU#K^2kx>bfzBeTXVrReriCj_a(c; zp~V$F6N0s|v@v*N$}3yZHLfvFX}6>{2u6|GB&U&+Sgf_ee`!!HsY;!)HlaQmlkldE zL!Kd;rmM{Cwd4v}j*c;4XpDETrP4DH@Xpjg65h!}0o#|I2R|6~vWiG;?*iQtvXg&F zE_2D(h);*@-`)=IZi;j%=X0=29e>hRS@Nn!z9_@T)~RdetE&%S)-mCs=K?=0%dmOi zbxUsVbLjfXi32Y2Z>fT%h`2K-5>?Fm>0E%PG5^Qq-b0m3!c0#0+j3>HGWn{(-}hEU zPs9f71+bWDaqfkg2O(D-itC!)=J64BglRJ_ZZ%`Q(~u)IrFq_0Rwkc>#X-b97=H~$ zRPmoGX8Fc(YK3r@C{DK7{&^ve#b)N$C?&n9aq8Mt$na)N47_E$E#hLQr;Mo6rgrv@ zsitTL?O+|Zgm;pZL|J#NUcQ70bTu!j-k82j>*yy3@i4~#pT@Klq<|ZI(k%C9v4wNn z09=*)XYH`Pd6efWPZ2G$MK+7pfL3&HgbMZxfzSpV%HNve+Qh0A7jGC zi$nb;Te?TH#jViBPF?L>Bd8+YY@_lC7we-N%IWrhAS<1m-c&y@mf^NRRBqIi$$6_HtEIFe44c9!G%;MLzSsWl=OM2kJV z;Pgue=$c_5=n`IY1_tc*zXb39^&td|I0Bq1saVWT3qMinT_XAY&qdUyKHyvkz zcG#QZ5lPH75)ETxh7yztj5Zv3o7xmf^>DeR3fgvTP~g)CBGmPE@R~gv<7Q+QLaQ59 zM{|U&he=KnJ&_}Ot!uvZ9pS9Y!<{!VS^W(kp#uz@j4&v`U{*1%ws*V#mVzcII+_P* zoPb9m%b-Fz4=XA`CqrENtHgwV_PnyR)I=gU?yAQ=&MG}mINDN%XnJLw%?KU?s&Mnd2o_NLmgOP&JLOaeCEsD#QXHo69pQUF< zb^8U~)9y$Si7jw!Ru8x@(d@t{w^qNnLJaS;&u`s}oNy-=8Ikb?#y-ijbN1y=C}&yu z7R5-(2X!V;I*LVqDRIzld9*mpCBFT?T>vxB7tcn1=%!HxIxAQwb=&L|#>oUmA>Uzu zBcRPExXkrS`;?@4b#5hx;zE}!ek-&j*a1&nqugQNE9d>429YmGq&#K;fpE*qY^3x) zL)#$rkc3nbJ=}kvt@Ii#qU+mN2OSLEzXk@);_{;7a$w0ZiIGt5G2EO}!Fp3lRfnR1 zWqc3US7s-wE^f2ms{)ocQT)v;J)7`wzefSt0?%-L_rRDNN*s=z#vr;HIZRS}!nNLL z=2*KRhEK)B#5TrRE%lG#f91TA^gG{vN^JTX5`I4R)FbkPVFp6s$k)Db2@h$Ps_tIz zQLfn^(g}w0$Q|bdMH;cY9FF^S4KK{t$AI-&B=`vW@T8TZ3`u2Lzr|wIhM7av)?bd7 zLk9vy<&j`bFw(|Iei@rbwQiZ7JcOGh-m2D-`Hyx)Hvhn^t?kxP{=Lr2yTXq$H^HB- zS1Uhoa_hFbOAg&%qGW$T_juNf^|qN|NVO4-Q+27~c?yVIeuG}Q;Z3Hz^=n7{ zMYLS1tMlU6kH$e8ph2yhEv$(BMaMDU%+$kND<$3uRmSe$OZr^Vt{cSs%_#;a@5V zc%{l7*DCF_uI_c&v&jud8Z3@N;j&ie!@*V}V-CS|-ni+lI_ z(pTeoQ_EGHwZ|#6D(lFAj-C*WCW7NP1Od~0nxxTq@Ivk&1NvG#g?5A^E;_eEz}@dQ zqqUUbaY8S`aWzBs4Y`({2yJ_H$z-=W&EuYQYM+m}-TTH#D=&r~RCg`G-B`wZyKTaU zpO;*$(#XZNnduS`NZ)Yf8KRTS%v&gyOk>i`k&?d$Xy}C}2V6zcc*^X&Qv1g4Op%Ms zs(%^t{z0At)f!o4G1Z1#}K8U7dN%Ao0NLG1ud592zI>0py$hJ?5*N~+6dBHz`uL$5*^T@c9}8O&^e6coK!B*l0hOeWf6?* z+BUu}RbKHw!x2)ix{GCxTwe9uPJ@i_0mK^1)7q{x*8%}=wKGqJ_m}KC-jS=}8Y&7IWZjhB+xRtjI(k@AT zTfL;C+#Ze!d6(QJrIURBKG`rCC7q94S^2sW?YU%Wk_}x~$cZc^tB0*tit|_5(u!yP zIxw0@%_Xn6U+D)lAN}ZccMbvug3+SINLG8K>z-WFRcnJ1_NW%Qo)!Q{ZvOEMYPk8- z(a#~H?WSYqtCE;~@$pj2qX>%Tpuw|7#yQ*OF4RF_p%*h>wQPPQ<4hBvcBsKK>3$)X zu4uQ>goye5wUuY4N*Xo;d%r)Oqz!HM7O zN7tAsPX4j0J5}E}D+*=4f0y!mudp1cgUo>l<0weGC^(P|`evQrL_z+ftd)~&2mrX) zp1r%jB_jW@k8`u)0sL8sCDk4&#?d(uN2Xl`)b_{{%>@GoKGUk8l$xm$olL-3L^j zE&oNdI-5)U8b&_IwZl=&RTo7YcF0ldGnZpDLvz#h^5T7uLH;b?>oAQ^Z(;7dLr#-~o} zMFLElCxVSeyZ`s7Fgv)SX`#|dK}ffWohqkkI$?}!AN|Ai4D+ZF_eh^*KJBN-$Jfp7{h}}8tAvWU#5L0HeMID2vyJyGXTGz z%N14Iis{#}c_7#xy1whOi*ep~#C65@%HwhSl~*O{WNQbj%1AYW;+Xng@D&Ehc;WRf&!Pcl0*6WKIG?i{huAr~{cS`&ELp8qsI*}!sMv7O~7Ajz-M8h8T zGyfS+X4`?_%p0q>s@~=R?={;ecyDi%i2p|hqOW0CESpYFGGhUpD`_+V`7*kxjS}fx zdO@0=(B!T=;#vb8-s*aixo*A9=!k<3nZ`&9|7Iiq-|mfws;!5Q208`M>-)otPaXs- zr(bL$Fk+nYIMC3M5g4LMI%Gm?Dr8!Q)>Y$9vorM_I0nXV0v~@N@7}#GV`^a?x5@3t zNnT7NW!a`LERhOda0V>&Tr`98DDxT)I0Wl@g%iNm+eSujqZcQh?|ZXx_7!HfC8Y27 zKPIN86_7Bo4Wr#R#4`_%i5k*H!+EIQVftHQZbYfTN}()Su#n}u@8DW&JSoFMLr3w| zgPb+evq@B2EJR-xN*VWRq^rnquPj5|M0Taz-276n418{*8}=g)k> zyl;KQY8l5RC`R4A3{f=R6fXp1iGRg#NNVDYf+7$6j)P&o(1e#$nLnZcq&V2S_w&d# zI8h4ox0zV;JEclefk4$tsMtu{sZsKbJ&{9YUe0e}x5nT&p21q%@UkI2DLph(Sr>J~ zxxT-@03cb#{-N6;e7po&+ItAdN>KQbK{Na^(VA;TwsKXPlz;~J0O?;)PBtg=w_6wM z*8S%DlP<~8sWDZ#jZ!uBc6JuK(>x%ziaH5~q#QL6(KTFtjYF`%6n<*XVRv6A@5f+cm3vfHOW8GHveJG7tEgIPyM@LtVF=W~v}y>*7{S??0i27Q zTu@d=7rJ%gmg%U37#AmMi+mlg$hg^jU1sRgjx;;Dg4vdR1T*Mu=>lpQmKYmSXn(r@ zeMg%6pULJ31l@tV;>99%2BGi~&tM^1;_RAJ6%-C~z}1Q+O=DN_65Yu<&sSmKNO!*J zdD(g2ug4ITd!57y5T{%iWN5&X1?AL;0YV?5P1woP4$2P_nEpx~*wao5cb+yP@hZVx zh?sYhdG=+J5lxh`s6P9%Bu4PasKyXw(ggaq+FTUcxMSvH_U5@p9ToXSl}?7Ki$OoH zFtG0<@cFmS#_4-l4ap%BwI1#M0mV_S6dA8j#oaF)E{ipSN+VOJGZ2p}?a<@EV3MZyVb z4XN(5iUoo@y(ZfJn(6)bMqxRgLRFlL?nX2tQj{{Y;**61nY-U^oobt|dEyZ$_Yp!67@?vKbSZsCWST+YA9%LG8DpM~GaHRg10|S8Q zHtgZm-|^lTDecxC4|Ma+9!5$aCybNEABklfvGOYao-rh$F(|y6GSpsEZyu2^FORm? zMK0N?g8Ei5bz(vFPJd1|M3qQTW8iXrA!w{fQqhlOAt~YNMqs|5z4!vQFv*)+#sWio zL?~85vACcbI-aL9go&00Nn55^7u=ir<%=fGB%Q^|Q~*iQpbN@B%Uw<{jkjbA+=sbe zj;eC5*Z(O(H+fH9P9_R@eySTS(|e@^R#P=;rG`VWtxQot%ZdLeNSBCra&~sE;@ou= zw(Uqu#0VF$S%4Kt(9r-u^2|9AKg}Tdn}p{HN)-aTxr89E7jQW&Y#2!tFQH99*Y^Oo zg*``i!tb!4UX8-JvBc?83l;^o3g&D?l*t#;v|+*QX;QI?wTje_`+l$7zlRZe1tNgbH|y26vNKR+M)&n)u2f56=)*= z%^qxKepIqYJ5WaK#-+PjUBT(n`xO74L1Fep0*tiatEyQW!V`KpR(`gxS1H+6<>6k& zxObD3tpbL40|XsMwU*HxMX*a4%e0E-%5!unOyw2n=xS$8e4>Sj!g`E%_e3cVq)tv~ zIR7;Gmu-~nmGMz8@TGMk=Eh;;2#$B3?hI_*gOPQ#{+{i5*(PZXyk<2rp9f>rp_!P+ zlqB;$!h|=}*``CIoKL;AX8tb*tmpjKU*Qu_*M_dHFbcK)Y^E6qCwfnQ{?yYJwE_{7 zC)HMjMc8k&S&$&U77yE93~nb4N8#Wg6er6KIL9#tmBPS8m$gDO(3@6E2+}9YtH0t9 zXcDBxUQBoOfXbjJi5ZZ#LiR2stV2Q0(q=hlDq#C*X1)q{5KokGY{pI>PjAkKV*hb& zR=;6$`w1*O-W1b2`TUEpv{!h?MKSK^Gm}2aT{ls7m;bgabji+MsI8lHPf!L#@-1H* zNt8xpnmdxzjob?nbXHD}fe1npE}q5VEYbZW6#EniZ#ESSH_sIBAE@DoU;Y7N1x-$H zD0a!VzvT)LH=j)7!K9GP7bg2DG??&SNSb}Mi#|9v0K!nA zaC7oE*c4U|IwrfV z?R!q}J2VOY<%8L3aQh^U&L(Q>RjOxeIN6tZ50_P>TH#^?-D0y9_^|D3{rby^}g7qfZN zoEhE4B0!%cpx`*Iam1hsW+q#U=!I+!cz<)iHUfn4|{~6jQAS zv7u{2&bLYG(@P%IiMcA1CQGAsaE9)ni<>`0x2pj1fNVZo5Y?1^#ZE<<(-uA?7hg%1 zGg>e&mfDH0B0Ox~x}kvqIEMvM+{d*Z3<(*KCm{&(`Mw-R;hvbUY_9C>GbfDfy){WC=Eh53Uh&z!<}7 zw&XsjZKUP3nZo>jiQW>6FaC6+Wfyw{)8ok?$C^Wet?|e8bPK22Z#X9IpU$EcyVQYy z(KvOhA8c8KzOiSNUG~gEopFyKelcr#;|a$()EE}-Jdmd=p>PplKYV7Vr)T1CAmhTx7>4;E>BC=9){zKV=kLd=B(t$B0r;!Ygkn&Tw04;8k`9c~$_bM-l z-A6yySZ`I>7}z0Hc3dr<6`%detLBX>j3oA{Tj-zpeom1;9%pkU3Mi0_`ek(YaiG>OAf&&^@c1aF>QCA=DgERW|MqUy9gzQij6RalC+~9Cn}fHPd_VLt(8MSy3;lr1K6ROIvh*qT z>55Gp0A{EotHQ87KVS&FppE>S!0_tkWuGX$2r4$bTcra(9!)9xX~ytiq0V6x7U%#|sc zW1NkZnl}j1wd6qJoo|Vc@yPk8l4eZCQ7>_?c8A`)LnNyw1}~m!=sZOjxVyP|aZN?* z3^ZJ(P2)7zNuN6UWiK*8UQE`kZWvfS?GFu97MMHxpqdF@!ISD_UBCO2vq4grf8=p;ln8RiU ztdO)5mtwf$`#v%W?0WqEV^JQlE2)11u4!2L>F!_`uA2u%4@{)L{N3EBD=b`){8pnLq9p0uPM-`ZbcI$t2HjibNHAiB_JTo0r}(a-E?= zVN+zU%e%&0neOrt%Kfo zvOM}}1Mp`%$WW2fD;#-=v?G4Lh9oxN4iFSfG18|Jo?|=&J>5d_W=@V$@SXLwQdC*P z=4{(jwFk*%xKD>9DJrC4wT+IC^Y8WXb51s59UQys$Ld8<(4bbAbQ*mb(3GW2pAP-i)JaXAY3Zm9t|hhmf>@N%%2O zr=NsE<#WyN+l9x>Btw&Kp3%&Xs&_a|&+l)P0obnNI>adVhGNf$m%A(Jh&G<=hncQ# zYm$v(s~_Qu=^ftpLh@yrG|Uw#uuoZN$Teq^>{Cvg6+feDYbpr%wfissfB(gwqb=6* z{EuuAn)>7$!UYXcW$zAwm~}9S^YPlldd%m#VR1EJXFyJygEqx`i`-L^nA-yRFIXm| z8^hZ%{wwVETz>y;E_&!>jA-+$WaR|?-}>ke>dQP4mM9ep^>`{_nd8p_H`bE5v%SH! ze+A~e+8DarsQby;Ma(R9V7iYu#{Onr#Xx-bU##ZG<%$)`uj}-djttUBGErPS>HhkZ zuIvIZt^E`#-YQUK4`YLC3ThYI*s^U^re+8ync- zt-2s3^rgbZk&Zn5PRz>@s?q7Aaq1*yxuR@$cP9YN}LoZQ!I>b$~ zuR?k@jgQd`Hn}hd5uA)>`kBtweBeZ-g3G!QZC5d2sepKLKqD_%s#MP8`cU*|064Dk zszZFfrdS?kcNB;gsfQDBPn9-GDXSl;^kDS-JPeBb`Nm2N>*gK%rW1VJ{AF9+C*)4icG2Bw)u#s3(Rc_Nz+PISm)`$#UcyYPt%oXM5otI)Ei`2 zjWs!joPNlj$bEYiBzkWKLV>VbBTRx5NQH0|Hnjq{*xQD)wJ`!9fmLhc4lY*SUQ1Xe z=&rb^k|jNI3e8wU_~pQHILW#!g3+{?%v-D1&cOg#zbuS}$hWq-a#+bu;pe);UZC=O z8$&@`#M4#l-1oySarcu>8+^Gv(}~Ek5t?d^xC-ewnTb{Ma4__brXs1jf&$f=l^LMm zbZWZAHcpr}BDPFv|DV~dT;Ng14D0mi7CY;0ce`D)olt(A9hopvQCV<-v4&wMy_Ry* zHY-1JOpel1>`N^6vg;dP)|9YV69m|CXI&a03t;<6f>X-z7(<1 zSmC&P%OVH^1_esi2VYR+MGE6aOIvAsGVOHBz5CjI`c*OkvttFi971}3md?>5z&Xax zdyw|seAw=m%*EH|ubIAccVMl~tPi z3B!p0j){Qx%`gmbwHc64SD$3*K*?X1EJ%sbT}4$#4gjI;>%ERxNKWC-tF~X5RPsp$ z-B(s#zPSo|s97ubYXzw|5-gdgv4_l}K1ds3F%CCP9=w=;#GPAY4HKeI zua=3;@kjYT1|*c@kO5!8mEjwDB{s3W1BazaN8g361U&D=z18x)%rB>;g>+81fJ1?3 zH+9nytxZr?;o$yC+XVv4b28ydL}IY%lEEwM#5QblMMXurua2S1sVVk%r$P#$H1qFP zB41bQqT7G`3*Ur9EKYkpUw^yG)Z$|VVuAi&jP+$A2dur)b$+iE)bm=s=X zat_DL;WyElTn<;%8PgDw0YbiLPFI@f>Ir0@-&E^Y&oxsKSCl2IZQ3d|uCx=&zrP>s z8O@SFZZ_}$Dl0LIL&tutfN@Lve%gif1$Rg8VdZg10N`e0J4P_S{dB&}If}GfI@neL6l#qOLV;a^oAZzD_P8n*(1a3;5 zaGv9*$vrm?H#+UQ{9nf$*zV3VO{!m!8&la>rtFPNIE?g7a|4~AvyWpYDPbw#(RZo~ zJ2Z~{3ao7M{C@=RMG-2GNU2G#1mCCxKl^=8&k22#Q(t^j6DeO<9rv*kQOz{8(~8v9 zfd{=UN2DiV&@On4MPZ7S%R(KDI{))_*$E8KRt~eF186}%=c;259I9GboM_yaQW*)sXO_`v}CH8Y`6ZjctR5{ z6lb|H?0Qn55?u4rS^R&y0B?{*UTAs>hyW#~Ki5O* z0-??qeVWDZa(VA)%H7Z>l*g6e7QGfIlA-Nf9-w!M0|K(Ev6{}c5Hmo{TzmV1i%1qC`RYGvyt7Q2H9;UCIdgQhu@QzS-VS1)wjJ@5pQKV`-Ma3tS20v3j7}vguX?vI$DNwZ0K@91R@= zE?JZ-8FBo~)Vyw`Z~?AgVxs|q%BIF@eGB2Aoyr8*rFHCP+xo*r5&9gG8M6I5iVajx zrqq8J2B)?k*12>JYJ7c%x*^fRUlmoF46!IMO~g_#HJ8jBs$}R222=hi9Xr!*7WXWs zG(IZdT^(geQd>Qr8c7f45OMck#C+$tp#7qS4?|vhq!S`F!nG0tpPX~JBIrA8ZNwIV?&9*TJxcS(M|DpnB}$U z{Tz`CD^l7Y7S%7tv=1TX78sI;CgK)uo#E2h?dC(ONn7?{_;4QEF+?*>8J{L z5^p6zr~gWTUISIj_n5x@Z*N=*f?sS3K=$EsaI4Lx*SUO%7`5j#Pjb_%qXl=Lu@ z$Ggeh4qqn&w6Lep>3$1TCXaU(rz4(8GIy=?_@->KU%8|!>)};Yk_}P2Owg1GWSyR8+PyjcHk<2i zyWZBGXT&XhPX-V#pD3!ufWqy91W0NuAqLrbDpjTBAFAghw8Y7E$o;Ej%+C8JMc64ATbZq$ zmmFSIh!sy-YA??o2%6_Vgex)qmX~Q7(E2ONzycr+*lPxXhG)E<;gXJW-`ce>WXqz@ z+a;{^mn8T;8G9}8tDvgHaU#S4f37L+`w#|TC6sz!{tTEfT3GfNhINnKD?Y!Hz^!Ec7`194vh~ zY-f;O3bh*-X{-H?5hX%0a(VW^c2=x*)VP29o9=S8jZSH-&C97#+Ye%rjOeQFd?pt) zTpPbI>8z4F`oc*SL6Nxvq=o@dMp2P2^C)`(c4gW1F7Q~mC-PQ#c5&t#a#eA`6&74I zkw~B_Dq3B`TAv*4v$qo6j{iJKp`Y-d7xgw*Sq4vm!et;8b8>QWl_f_;2FN5Ma6dv0 z2@Q1zVuaLtefV=fmP*@ze3Uex)gTatg2(L`^q)SMj*jl`KThEmP|r*iv7qVFWRIQ< zfL2hs+Y}q9fv`;c z4K)QRe$yw|p_%K+DTXu7p=*sEMF^YO#qoz2cP;BsEQUNAx{p%+fSY{_<+3a zAvQ&+{U#mKoa~@&6&hWh?}lhGsc%@5qSG#{r$IHRG5@BjP(BVQTR_D!{3=ew!sqQX zTd*hV@hC{F&NO^;35Q^S9HJqVhh(;$zY%X^2c6s{!ru+1)7vb>g(|XWl$omf^wa#@ z4T!UA>-8nV?!nQPnMMzwE1<%?=ffhLT};^#(%%rfZK+|!cg4#b4ps_*cm;`@92?rG z+Ds9QY=;I7hJK=d-LXay;eeZ})`GTP6S8qkc;L(}VYbKIDcKGi#%@BL)wdx8?0)uh z%0lS9>as_V{q%Im{laj$_8q!B^Bp{e2;ea#8=3e74UcJtkt0htG`>JjNnuEob~#-A z8cT*~q1JH-i~$O{h4~1C!kXRJl=)uZEw%{Zh-GST>O#b7C%C)&2G5a*m5A%!*gF6Y zp#B^B@ChdzV9PPM9WKg9_8YZ74CeN-n-|bC8>|8sox!%w|KPZKiZ^xd2wBrOi zon#Z5h=2mBw=am2=9S3}+YKtUm$Fv3$kw;&_6i!Jp=GO!=GD}yF{|0$MASkuDL8oe z`~Q$vUIMeXUpu!t-Q|poj6iH4P_sL9=wNDUs)!a0N#OQFDglTUJltw`0#f;mH7L?$ zb(6(OdVIJM#cWhlHfF)O@W}eifAHo{?oS9|K&|b+{V(noVP;*UodeVeY z5^ZiLNVuHWVoJY@(sIWdX6$vuNrJVxq;zaOWx9jqq?pVK$=fH=N4x1+pJ=*F;U{+xpLC0S4J#k?N zI3|;PIh(^uE;F^%)mK!8IY&`@hdkHFdYz+8SN8_2+PitNiH(FcE+>?6+Qdk6Lm1H* zXm~t&_MYk+=Mvo@l1LF}Okd_+zP_SVZoqouXQ%;KeZRp#d=Xo&m8w_9 zjPpp(M)g$eouOOZ<+z*Ln1Tu54LS+9I4WUzzB-*VrnPRj(WiNOPJ07ybL80YZ>M!K zf}=K870Nn8H=ax^6+YoD%^>{|1^tSE%j+Lss{qydmz}`Jq1|79k9>!si+AtJ!)+ty zN!_FDK~%J8pIfR4kKXD_-}XX%QK>5m8C1(I7Lttv``aH~IIPV{Ce;WMg_w1$NBdn? z0(P;>Cz^VvLKB4HS|#=02_-Y7*yw|8Ji(^roR544kax}0saMr^4^KN!Q9esOVaD=Z zKg?qG705Mw^uuCG;A8%7bb(Y`uXes7=2W?S))_6 z%A&@cuLwZ!deSHQp@AWr@g)kY()ET`xo+!PWHeMUXgoy$sTS~wm=lB}-$=SLT=7L> z>Wz4;Pl=z-J@b5hGAMDXAr+L)?HI8f%a~w(YYv~Ao2+EJ`MEdy9o3!t4tJiDvjT0g zUm6oUPCbHvmq%LuAj{TI>EsR*jqRZl%qYh5E_%*IeJX%~(+FmSU&WyDWFw=~4e7a~ ziFD0FkaM};VfD|mMxtU=90#AYs3QwTG)z%wl3d+F@@bvh5nqmuG(CssvnE|lQ=e-I8f^yul(3)s2e$;-%~f$qYDL%7&-QOK*J|Tj6B8$ZijEi zITOs1(I80B56S-BG2Xf2C+p$96{2pb(8FC?CN61$JTbIzGl`-%#RmHG0{sHwSSg~u zi-eMGJ%`72CsG6;uQygmZ&ohwZ8Ha#K632mpG5CZyE6WV-aWBIPQls?z~~2<(;>S@ z$OROLI8zt_Yg!i9V8_n&+l(o-3oIW<1Bnq9Gtd?^d?=%6>o=H-l0+TGXN+P2f%StT zUDU}5wCT71j3lMN*M`;Idx2;&@=d&%dwGGQ9@PYGDyp{TI8zc;UTMpNy_0_~R-<+5 zkPYqN4ty8QWDZzT`LIC`W78<0ZJc8rsHBfffmV>6Sq!8TSoKkSN+zTNaZ#w&lqA zOfil7UsY8LO7sr%)=08o*t$^;3a>Pq%75;oU3N`YKdBN&AeyG=JV-?Q{~!&vW|ovX z-6yM^y*2AusrdVfGqLgyFDy|Beji}E9|0K(+lyZ*3EaS5kZnQIgH2PmOdJS>hatwx(CQ;*oH|wn0_hzZV3>T9!(>g_B_}8}N5OXY69E zp?hb#6rd<2K{e>N13LAB)Xx2*+93Z{H)}x6xN#Q`t4Fc|MS*#1+o)7DIZ5$tf~=q) zUGF*%poG31(sO^)*gJQ0S(y_eq#2sTpDUxnXp?oS5P@rIQHF!m1v zWB;v4f=Pr%u)3$c+{~X!MOPD$e`~~1v3y}}S`~4BYRHrdlnVa3)_)uc8vdK`9cmol z^wy=biPs5Jq_07Ws!`G8U8yxYDh_;lu^m$MM$15u*T42jktNg6#3Rktz1*&{5cnPc zvpv*+F;?*E-d`PSc-)}cD|$wq(Z}~-ZkZt=@n zz&m6dpLd=OU893_A|n&n^7${}Vgw|6$3)r<-w@_&dk0H_?_Z+9;N zLcO;EOfKJVJ|GM8YP&NPNVBUmn_IKDUu|6z2k%nq-L=wxpO#rGqun z4Ak3ilWqLhG{TJwA}XV?X7Z-_tm zR17~Oa=#Be(wlu^_|O1dHd6^bqdMS!{)(JR$CVtfq!NQAX97&`46Mwd)gSvfPQ!`Q z2)jyAX86F)jI6^QOGeudo32M0EHfz6t|+Hz>R+<*&zedcTA!ed>T77o#VjFnZG1D#XMZ)rbc}@A4|2dI^b34ih=sb@c;N`#{e!5jHH51%)j5lH!d6TL+NC5tH&;jo>uhrM#smljdk?0#L4T!Fxi5@A z&1(gaFtqWS=C5f~755g?Za6LOVG1n<47sFtE?LYd5}8lXT{z6pg9*<=k#Riu84+#w zyBynmK=Q(oQ2HS)b@O-)YnA!WD>GiRg3(VU{g7*IN9R;q6Y;I2e#}p;-y6bPI z(nu z^z1C=%V^2s$o$6L)nE}oyLFz8v`l=hzdN=19s(OxWU#OVDV)*v54Yog-VKt~r10+= zM`2OhN$vJW=2p9(vp*gtC$+uKf4o2>7JhkBn{RhLRom77o0o9dkCuzZW!;~$|BuNvXT;w^L?=C=faU;3NBh10gsNfON+z} z?tTi5~I}yM*`nj6pkUnI+WfGZrE3T#6+r(`i97 zq#akRlhdJSf>axc?+QGuSSLJbh0I`@CNw*Sr(P5^eKtu0L-^BCYo1c)xsz9jcoS*= z!m(AZ6kGDa!%{n~?Ht2^C;K>&5srhZ>dICgI=7H=(pu6(J+*bgS|u2(uZ)^AzJT6A ziP6&pO_IGE>iI=%cm_Pn()yC!{WL6fMof?-A`&5Ql>MzW$-OBp2#4|FrB+}))ka{n>{gsC3426Jjhx&Mffp06uUjTpNplG4$^Wqs!R24p!l z3K8M?Y#vep?IVuf&YPp2l5_g5UDMXnAwqg_o1MS;-F-_)eks~y(Gu5@LBqroL}`Q1 z`=o2nkQ+;ct6*vrk0{pm7m`9-t5L*jhb~c|s&B>C9lqIMufx`FL5xR)TAgQkeTFVs zHLnPH_#!%vgoKu#gA$@3h9Ek&`=YkbnKFF7ap!!Uw3@G6RzEHc;P)Rqv4s+RnQa_l zWw`t2yvT=$C@Ev7uj|KX+Q8Z_kZT0hSvF{97iuD)-a?V6z=T;tD2r`j(FTl)X-=?tHRy{kQ>ZmH!I?yJizr7f5<+2S#% z`7IvKZ3YFgdar3^IvW@tk3K;jIT)%ser($%f#c7y@g)a5E;Uqsd`SyT#7;km$zXzc zqC@;+Fm;OF7 zBLM$OC8^+r{jxS(PYOGSN6xSxzcmQif#`4Fhl*9PTFQAmqA=NJ<+y;0K??!V$ zNeFrjg;lS9<=TNr+{bKwh(JdG&6O#XGIUgnEP54!hD^>E<@}O~q?_!C@8VMXD9OqK zuG~%A!V2dG?j}CFj2Bg@7we23Ki(Qh?_)HZO^$#6`xMhXgy$FGxWqlqQSt*pCL?z< z_U!XK{?~s^er2U~vRZggQ7smE^2>jX983_&kJ8iUa%kldB1RD>SVAwxSgm^)c9})1Oww#8md{Yu2Pmi(sv?;2Dpbq{JEIo2 zT4hMh6@s~MlZ@|1yv_`2X{p5JY1pt~h{3_F96Yc@p-{ge=nDqdlk`SVOD7SYjj#Ly zrrC+-!Q^C~wsbpzc^AFSx*=H2t}dX34J28{bs?8g3C9DsNQSvcN1i3Pbr9+Oric@Q zz`y{OT0lCs|E4{HWksTEW0ILmDxUkQx~iRpg-O)p^)l#CD6}Snc3cDklLAs>f#C92 z=!$Jbe_h2MpwVzRbZCxxJ<9Ou6(GwUo+W2n#I>lczVYzYD(xPwd5 zmL#>sTl^P50^LFj?=>=W^EeXdZh?rcp(+h#^dN>RgXiIvrwGUqR#vAm!r`|+w@VVy z&JJc~F0Fm9$oNfzb}hl&Y@M$7X8PQtbe0|?x%h`z^ZyOs(xYg(L&%j22s;m+eYH|> z`+0R}^Xp`|w)jd!eazS!2&y^4^*RN+MEh!-yt{}kJT~^5d~YOy?MVzIH?h#|63HGz zk-N~ATr@4na&(HMBM|-|haY{EW~0#>Oz%@%Jb998t&HYV3F$M``_E#Zud-=r40Cgl zlktypd3=QL{K=oNc=>YcgthRVz;PU2dhB~t3sw4O7BKD+I4isPO4~LPb_Tg35%tgU z<6sx@>I6wQ#av|^A$Jn%2q7;{;ftw+Klw4jpM03`FJJlkHy2N<6Z)~%4x$SD3?(SVVa(SjTMhxykWkSq29;u1RrClm7lK96Gc_x!inR%qJ0#?j%-z0(JFC z!afU0QfV|C=I0!uRs>^JMv1;Yu=SXmn;?=(BZQz@68J0&S-nM{;<+jOne*tK?^E}F zAuzXW#y55VF*|Y7rv#&ZG+&f#W^FkdO{KUzeI6z1LtpZ)PLGl#VVN2<>(zh zMXu{2%M!I(4cBS$7H{!8!5zoFvo6prwD6u!CXR=Y6lf@@jUt92$eS@jnu73}H1Y*P z{tzQet0=1Wj*b;gw=r?_1nUQPAW1TM$|SjDa3SjP@Z8#QR(q-Cb)8pW9~=LLkcg8EDxna1a9f_ z@1ka(Cl!!xkkC4g%dumN^z;m^d5)|V5DA4sbao7J=-_$w-QSMScWo&pNeZd>UD$Kq zCzyH&5X{UJh{xLrta$j7L4+)BNG8lo53_FHJpg2vWTI*F%@xg!I`Z@XigoXf_qB7s zP!z)V-i`g#*J*y{KjWF9*Tu+!&cPTXuM`PJD)_A+Ln50^Mj@@P=bnDoqX@Tp*EHPa?6lC%C)2hjV9-lS~?@sQLV?A@F zDjRwXzHv0kuFVDfckD9qn3ZaNF;?E_F-O9NhH$D&lTzE4PUJa z0BBkWO$%YZCMb5CI*rB>t~-nC*?6wA7KGR?uIu2sbzHBB>o)Mb29-tyx8wiB=ZAXL z#gDw^Sea-;%QjHDGn6I=2sXm>SDxWtgL~)|CPV!ukB`Rr&wER3N$%ut`^VWkah|2% zI@F1i)VFr9VjLnSCnyGD9Qod3Y}mC6rPWI8eTC)uc^1bfP>0%3mI{pRe25pcHa>ka zPS^U!nVQ;4{9J-9gTtKGce8c;43GWSKjx#K|4;O7+PLN}>v_0W+@cHF9CPF2xVFvW z^aK-!4&yeUSue5iqYv}JU;i~^r8P~oa6J|;&+yVWzD`Ps;`g0nQ%L0-OaGqSJwERD z9z&W_$;Venl=rYPJVoMsFUyU2{Gm?jO$(n_r8@UA)Ax4pQ2&EBy%#&KgX6ilo`dVT z>~)$1_CHHK6-Lg_BGr-vgofM^XIfuivrsYW9{aOl>Vsu=Bzz=%w@z{@l?md_V}v4m zxqjcT$NBRs=vq6GNCe@6G1jEkCS#it(P$LM8RXExi`=)b9ZkCyXe&y9j`l}T6hDsR zF*a6YXx#<^XFaryH#%SEa~Whyzbb#uFKcuR8gH6_kcJNt>p49UU8qMz2+LbzLVK?P6?fnW3Q=vaAyi_OhdXhVM$d zd06)oYKwAkTJWI_cTNQ@%M!JEeeE-q`6!hW z1e^0z)D*S`k^rf(j9yMqtY$Hyk#`&b3n7Sgv~ziCgh(WbkOcmONhdKzCg zNxGS_n{;)dq$qE;&&zvty#sixX3tQmP0$zUWvNo5M;HhJp{Sp!`Ry37^Y|_~8R{tK%d?+8(x%P?{7 zEbF)LXbr6Q(fA&I@kOcug-}zX>a!S#9AsyDC(i^w&ej7-c65G~(@TAX&$P3BaG3K$ z+v%Pdidv<@Gk^CLgc&8VG*0^-!GZZ-=FHY^HUwV5ytIfYY$KFGM_$>fdueis(XEX*rx+SG+W5V&j;&gnEVbyjx>?0_I1 zkK;HthYwG1@4X$Us&rit46wXhM%Q8(bp=EBA>G9JdVYSA@D=%UDK8NSo5;!=S9L`$ zgMQ{Y0uO)aXJ*{!9qm{P$0-aS!ri{_hCR@+jv$#Ci&`Z|B$Z-ja)i#E{peFUs%7&+&dN%KRO-e=LZMLS(7`3Tx;7Ds+$f7qr`tGlW}dFj28O8-wE9UFpJBbRk?CTE z^<91*J6Gca9}y^bK>XsmE~6u(SJxA-x>dJ$i?{fD!`HSP$atRD3V>Vq`GM=YOuTXw zC92{0A(V4zB-@B+Yn$CgJ&UgtrzYy}$N=lL2qlwTeCZW7Z@mv$R?t!=k&8L};WmE! z<9PxBhxYaWv3P(;#D^@uRR{A|-O<;0z@I`Xj*kJWw1S`a2`Z%$qx<($UDrlzWtC#z0Da;Tk=hGp6=PCs5S7$p!p!>Na3JYc17``A`-mAunWyFQOzO0ZgUSSM9j4!OjQ zH1T|nv1$);T%tRm+^wXJWDm*jhF7eJ{chi;+-LKnIfsK5R=(c zIEqW4s!*^!T=i8Mbe#FQOC*!mOT=^88V3(9F)*-|;2TuMWm%@XdmU%bUF7b&(@2s^ zJhXxBm6QC7w3klR&m#{cZ<@4zFCfb@_wCzWuD0;@_B z_ezCWR|SWB10>OD8U^NZQ^edbls9n?#~5)7#KHv3ia0iuZkjHHU1c^=c~K*>5- zcwFomplh~BGwQN5GRFMmL~BsJPcSw-OjWMq_x#ABhMcT(c{s+{NEfljBp;9;$N1;~ z-;DgP+~rqr(w9(29X993Y4-ZKVC?0%wTJJn|5Z+RKaALzrsLrpTXtULk-q(WH1-PZ zxkaoqbwU@K7@ZAP+J+d}{}RuA^V_Zd!oTS7JVuWn=j?$OXmk8XL#wps1%DOz6})wM z_LP2zF|`Zdip}L%jz;NT)r*U*WTJr(!If#Iy>4nDj}E`~=G9-%L0|p>?N;yx_h8$0Id*i3zP^p< zx{h4+h+Ob6Hzg5@gc9)+?oARtZ6h_jwLPqzop@e~6UP^?I1R6%TyB!jsf2VBqo|;S z^c!Bv!u%v!B!HqQxGoe{R6=j!{w$53!;rFQ$>h)65E6<)=-#_fp8N))Qo7;i;e8${`g`cxE8UoQ& zsQWycPMMtI!%}6y#VJl>sZk2~1vLM=WOE?|vG#VRr$^SL7FT)#`<7fJNy6{<)7I9; z(9lM9?b=H`zM0jP2!{?9dFrXloI1J4{9K7bzJB9Zp-!pPpi*g4tJyRf4z4S(v><9@ zjxIwb=QXZo&y8dpaspppfod&)SFMwhcF>W3nFVQ)ns6BEvv}%a98`n7@w*x6o2IQY zPsNVXJyT?0hGPPD$$$d}9X&Mq@Jc!_-*PxJ5ke!@NCJkiM&LdUCc zSfzQ;LO$TfJL}LGU&5JO!5H-jcGXx(Z)Eg^mwEP^-@<9Og5Y0NWM*f1{;PjaQbY*) zFR~$`@-Nl@z{+Ngea$COFLu+p9OR;{V)@27@9!nmm?0%n%vZ*--4uy-6Ytz%P7mmG zM|*F6OZ7a2=OKA6lItS74vOPo6bhU&Ou|xz>Z*f3VB*tCoRgP`YhjFXnHLICw6IIc zufORmp%xC4lIJjup4Y|r9_P+3W13xrLLnsEBYegtIG9B8U-Lsy;{n1Qal*qklD!rL z_xAQut%eysy>vAQo|-C)fD& z!34|8m9=rXROaxZRW@wcj%8VI^pU@2$`uq+G9vgqjO#Bm%7g#t4( zB^<}bz2d_AsuS-ue(mvky~OP|ASROftDW&FTl zmLL8Y$=y1ol*|9+T`NY-I$kR zchn8inOUR6jU~U-1zsEMt{&s7)Kyu~BRr zwO&GLR8Sga6uXAv+Q_m>K5udr>Tv{smGw#7{CcwIE^1O92g`iVwjprX131u zt_E5;%jD_^QF%8jjXE)<%*FO@2F@L)*pdw6GP@tn8}&=3x1?Nhm6}r^V|t?*;RVC zY({FeNcx3<=eoT3o$oMx`W%BRSwi;-L}nd-*YhdDZ5P=veVC!C-OLnnSd}K39fxTa zcT?|GxogUxR9~T~cM$97#GD!CxOJH5_74!2HTE3(F6~l;xGRVXm9QGXGGZ9vPW;J1 z^z>%bwk;^BjqI=N<7CiaTkSB~bO&axMy-2}mEJb)sx(+w=;C0wpYAT1UekY53AEH$ zCb;r9bR_RZmi5;KqjioSFR)?5W)wvsFl!U58uv$)hif{+A`?X-A{xA=R+ zr>Caq>gsH{K)3Mo1;=&~k_HV0+nB*qqsXQ}l?0-`im#NU;#A%>1FY8~*4D=K_&K_| z2N6O-4(bGgI(|2bm9Nvtl&OzasDvHr35iBT!jr^Jdo8?Kpc#oo$z+yDx0floMPwf| zYA!xx6h-2wv5t>A3gf{Hxj7GIXMp5Hjrrsr_Bsa`7HM|JVmv#R;N#ULHYT_6T<-`U z8a=~Ya5u~xK}(03?NsT^+FXiCj6VM&yB>OoXe!+rWIqM0%+E7@;uy|Q6m2hVK#4hT3mkX_*RlEVZ~O*& zz~B0Se%{C5!U7k5@+|Fg2xVxFMBdLI20xA0n_>6L6Le4SWhuLgsOq>o&SLm7EGp|s z+jAtu2F^7{@v57N#(mh&KE>HjcJN>zMtWhAvD{u>;RATL2+%y>r6oVz_s*luCCOH8 z;!2IhsE@EeL~wDO^WH8RL1=q}{lCJk5?KBQ?Xevr#HAqeTiE&L{QvB|XOLvq zd8YYXo0&Jg&#cVKEZd-Pk|c-3k4WcE#Br zqln#@p(F~!r4vbj1f58bglPlQ-kb8?o6Iul{k?a8RI`C5&?G^j8z7W##EJScEA!;J zc~0JQ-t)cZ`!JX_(i}Gi$@*OMh7ZwYac*t6c09sY#!vGn=25(YJ-z< z!&tRAm{n{Y4V)t_sHru!9ZX_xOAwOQD9+Xxt*+2VD|OSwYE33$AHpqdQt5q!Z`B{- z759@2Z9B&5o?R6CJMr}<*x4OoZ(O6#tJ9&RXffuAnG>WFeJrIrI3)jw#jG7i&W_ko zW7$YxjNBl-v`FjrJ`{_k*-;;0@9%}5{Dl0>A~D-6JuNcN75|v=J#HSY{*cgc7vgG_ zeASA-cZeIUD|}+j!+-Z2<4AoPciKYUW<~RM5^5|nUV0wk;86lrFWooK@|Du>5!kg2 zB@jUVCB>3Z@rXBx&+DwM*y*Z_aDID@czhdu<2U$n_M=q0RrZE7EI&^oO&x3cyTlv{ zc6;nCzp-04)7WeYd_Esi#l$~o;_45-ZOWpz^JdB(E6S!pBUhp36%i$oP$+~=SMV*% z*b<(%5b5cuL3FKw%kKvhR%T^<(J)r~+vi>vriXA!IV|DuFKtrEtn%ghtHiq=rX#+S z8B3X$8UtJ_kAP`l7c5u=<({5l9fKJ?#dq~fT=AA^YuiJ+uLFy0MY#PQi)_K_@N#zj zB0Gc_s_@SJDU!{GM1|_~JZ5qm+&(H~nvmbj&mYXG7K$8dEu{;Wxa%Hk-!GbP9h!#AZ`a6$jP)7%icL4`i;}+}uP_ zRYXZd6h#`127)Lw?WWzd?~|QQrHI91Tj`LR1h|P03Q?H2dWpI03OT)pfWAn@+lI8Q zlenfJZ@!3qHc2WoL8zx4QG9pfzX^hXuIo?%pFeWX*W4k)EiN?KhHV{e6Lsv1CeB50 zE`n=Oz`4X$Sr%|E3piE<94i90nFc9)8JAz6rZ1p7cQci%(jsh0{u-u$ZDtdzcLH;! zoyEc!-Mjao%r#iETk+Y3xIq`ab{EIT;IWnrF-hX2K1^TQPukLhn3-p@)rX}tM4%BQ zqX=w_joq`ocf577G!|uTzaDaMqX5CIhsE1ujMwaZ3(-!4`sE z9btT(o0SdP^&pxYW-4zI^{isA57OCvfbFdwx;;9r@+x6{4qxRK_S{c#=AOr0x{OC( zLMf%FZ!QoNM_KN8jOA1Zhm_};&)RY3>5k z(wB)h%CzmMaXIxdUfR8bz3NLuhh>6G4wlL?vBWU1b}jKAvIZwN{~a5x1scQOH>cx@=F4I1Stb+?Eh3TO*D9vI zDQnzShad>pY<8xn=ZLpDk!2gIDB$z%dP`pD`$d4866mJgwBH*EbTbNc6CVf|y3W|S zvnV2%S{Zpwrx8?fsWM>F$SvSh+%&{G;(eOfe9E}FF0$p@u9rjs*=t4X_TxDi z$9o`-=Rgeiff%j>Ex7i#Z2kSYqP2U8G(2o>N@$Hqe6mSedLtc@->opLwBm6H1kF*Z zwRXm5Z{YAcN$8z8HWm?uX>vlHt~NKXP5E&JI%x4K6tkxYIW+1um5ywdn;pk+&n_bQ zBwgKm&EY10$coXoH2~alTsq0h7immUs)jAMIwJ zQRK$rb;OxIuONHlW;Hn4W9Y`jo|9%2Sm`RH~oy8ev)k(*Si%lIY?08zbsHS@OVb`OyxCd#OS|_#~W~yM&8kpil&piKQp~gb}<2>z35;GU^n;r^c z3BBRPEtlEIoI`5gfhx+h%#CwWI7}v9#~w6LgAMAw3hSOCr&3#rLwD~yg* zX>031K)^ZCz~lG5ZP2S<3Q@q;XgZ;H4= z&U-v8xVxf<1h==VNs4c#In{k-t?> z^K|_rXB#7Yv3`ngxn~IX9^#?oVLVpPFZF~*-BD<5bRWwTowK_wKT_R_0 z{kSDjVA9n^s0{h=AQ8dF(7+%KpNT(Z#bz8~f8%uq%nf283(v2F$mm8&3w-x;pJVvarDm?+4-j(e>kPhpoLDuF)#c>xeZLQ}C3Y;GCNa~^2_(+;(>OoE!+aI2QKsv8a#Vj`{dZ!pCRYy?nC6!yhD^*a|6BUBy{0k#YLQpeF907YbI@I3a2 zi-B9OpvxvspBvlC8}}(e5J)827#zq_uj_0vZ1aKr&KivdhH2c1QronfcGJFp_M4+X zHwkbP9~_T`AtKflOhH3Z>ZC1x+^URNUqPw_C{+q*F4uj_pC||dzIcp<`SJU9jiOz_ zS+ycG>|pv($`izF3Tdjh+W_96lgws-<%hZmuAc#O8zbXaum@cv>q)eY91dZEj94X+ zaC2rhKrq~k=7;*~3H&Y}W=*2CnB!*0F+x+Tm_dn3w#4X_E6suSfUr0{MRsEi(Fc}# z79$ACQUWtuL~7Tm7Do6^>`DG^+n@2s+GEV;UgSsBi|Cq#_=gX1xJ74Ub)MO=>m15; zk(&>)w(P@FY@=V^%cJrhj&63+F{NORuP`%ym6OA-av`2zwuL2 zM|N}B^`{&P4^pcy5SY>l$2J*t9s--j@vr?OFMj#UY^JRN`$Qw z45`1vdY{1I+)K2M?P6(TgP5(twcR5;AqfO4+xgGyPg2;S^D%x(ODaODHjf~+;161; z-g=3d-Y(o0C$Xhz71RC9001BWNkl(^6+ zLS1N@hLSb#F3H%s{Qu(lL6AfoeIZ;mE0)Mx$#g!yNv@JYb8P*uS`jGaHJqM%c%5|} zdEg9|mX>?>7ckVA=Oz0xhgu(cM=B>#5OG?ZbOpNb+PuHk2C*Or^o6!F6fim882h#I z1S>utc9)JYw>5uyf*RSh0!TPQe$s0TSY0-h>@OzR+FS#t&4uE#vAMCr+|n%)ef>D2 zex|1eu*6+B=LN+28*Q{$Eckp0CMPx@AobmXEX(K(9Z?XQcGGU!_t|bV8i=BRqBJGY zO?*I5DHbqNeEr5~jz)hHHVKE+A+wE`IO3G;T{tnz(;341?iw z=P7s_xQZHU&KN#*26fp^#BFfdv52E~gxIw34gTRqP z9PYC~YM$Y-b8MgYv2(^xXrM;+(io!y=Quv_GM82sSS!_WtXtTPg=3{#97&&Lc|ApZ z%|>yc6C*XxL&k9y9^TDm=YQm2c!+v!mf&=Q_SSg@;s>c{z08c<;JLr~8-~tbXnKQx zgHg<6xb?zI#H$4?K_A~vZ0A7ZWun6gva4BK2#oK(Nv~aDcfrjUvY%$YSLO-pakRxe z&W4MMYQds;v8ARMi_VdZZbvZ;+~cEsJJiR4fPr`OD@4mL5=i|QT=V}2ww0$*H%=iJ zZrw$S0fcfIOD;sku;DZp8CKSbiZ;ZCo0RMXtAX~j;8$h?OZxjnoo;Nl;LnkJn4H|i z(1ZAVex$02cg)1OE%+91?XP{GmPPEl!-$&lmW_p}sapiwk_flGx#PlT*WTi(DW3h#h`I}!Qr+`TvuC( z6sl}Ib`(eMB#P9-t)cVST3jS6G1Lu%03&RORa)X6E-v_qM|V&U)e#m>;PphHBoL|R zxs^OZd}fVCNTjr$W8%j3=777;$gHKAIDMK*FoafGM~G;sDHn1^$J(xwEe&IMM945a!jW=!Z zuwLfz`Yj#~rr5owLGQ6JEuVgzmfwAfy^akg*QV**^ss)b6V^sKD!$6(!+W^m{Er-n zj#979;2#s|SB5EW^Dtt06j8Hs;@PKp=__Alb2ZiM%-=AWrpe&>3y22zEt8BYpF#_* zk{APVp$2t{jXeW+ye1#o)cI!Vvs{U}IOx4WTXu@z`aX)qHB6zEV9bdzaE5{2AQ4xT z;Mx)+seKgMY-|@M@Vhz*1rHK$`8b{NPtfU$(x%K3tDGc|`deJ{{|Vd5KccRmL@r*V zwrOHlMTog5+HC~0YmArssRcwj9qMiGrHPU~M^IYCh&t~3%Brwwt_v?YXaGI+m`(K?K=Zu{3cp5aZk_ms*&Mo;S3LT zJcdnqZ_|=VqQt@E!+hU1#iahul_0X$gStG7P%0t_;EOoOq(vM)Kg&xa2re1hnt<>& zPGCVdaSb=H_XZIpfywdf$UYT^+jD1b4zwp39l3%UcHmr-kScGGbV-s(Bsv)!SiyXN zZs1=e*SvA#2A3{gq)~5BE|oZU?mQPRT);34DwQ&0W21cg+uyzu;r7k%EtQHq`|LA( z@rz&N`0*2nqPRtT^#+nGQ79Cdo1NvWU;PTzY7N6M?$o2}I+7$(tJTnT9YGXm)VH1& zMS*&~zV%s>M6FuGF!ZLpwrMvd&`mV)UZ7E}gRIb~+mI~_l$AIERYkx=tc+n5f>atg zMEU-vz7_-lf2@W1`7zAb}Z3+}R&QcgjUL#X9x+BKE5S-rgzFRM4$SG929HWapzE+CO!O z;Kx6Vd-o2kZEc87Cx#?oSY)D)>}HpkV>mrd?`DYATiuB1>pUi3VDzCqTy^{j2cn}? z^%1NCCN1-6y0%SoIdqh|WjoX3SNZnm{!fN4UfPO~-o$SN%7p^gpZ_tjY6)A&$uo%^ z?9Ghfp4P~hMWo%66he7Eoi1^b&+=VMm_*A2`-{h^rT5Sy#96J+gVcgoDX~(%fTL{} z3L=r|5q{u2!0r|aPx%$H882g_8%$1Sn4K-Kx+;;&h0q&4Sk+?$98VBd1BCTSjP(?i zbp=xF4=|j4g=eb(OGIs;n?|<8WXU!5{(qCt9=VQ37?)Sy` zQ6-gw?ML~Va-BtE{hiI9l7zLZ4Pkb4Ycn@sC%dL1AfW}ltgSC$wOf(%KgWPguunB` z`8~)kE4kbTqZ8+7>FT~~9>t1qAQ4l$M z_8g~9ok9@6?e_5Ku}4rgM$?0)?^%8l12(Lv! zz@(O0Mm9t&9@hiO?dS3bSXdljeEd44QXbPZ-qrdAQN-r56BK5DVZl$_%vaqjfHW?l;+-TVed>jpm@cUz<7gI#+-6Bl5l=&SIKMEQVvVgq)FZ zb(*BggDiylIS^esWEnDTUi|L zMNXgPamx(`5AEfK^G`UGSR~&zg)+N_`kZbr{EYA+M7xC(-}(oRfBhS5rkdX1 z-+~C~awUwwy0lZNU;>1mcyCLCk$5qObn(;7@^s!4_#A=8P4I1S0L2}t3n;U*os~#2? z9ZXMK85>o&bqj7>w{hcoh}3u=ma2vWlZ;!!r@67OE$~V`j>T&dzJ2~ztfjCkDuQsg zM_1QPu3SlxNc3W{SWpTEo)sBu(sPgCQ|~TxU1xmkBEfj;*5d|jEL#cwf}mUC%qX_@ zR)pJx`OT=9b-rFdO>6rBg7)D1Y8YvX#(|DwJT06hZRFn3du{PJ;=uW>)K9XA=62kX zK`$*&qXaZ8Yj@A7>ZXBvL&1{pV3;~ruN)`d--j$KU_xp^qFxmcC5e`8Jq!(=L-Ja2 zl`P0b1l`7?O1y$X8jUa#|%hcXR>RBw7 za5|kRioz#9`CAMR4^u3a_|(7o6yJI7Idok|y`6)2YYn9{Gc$u>7+kt|k@@*~qR}Wz zOG^ZUK^B)5xqkgRzxByaGB_}ZuIs$`!i((PyBDj~N~Kc0=e0ELru}}gn-b_IK5&b+ zS~*YMtx%R+Sl3J%0TG9yAT;vmC4qV&kLL9}kcmx{Bzg`%#9D2Rldpb_^XGod%JR&+ zcP>}})vpoJ-K?c`>|_YaRhFgNopZn;pM}%vVLFG;)8Yy<_C%D{%oIxt1{;ku?wkd` zc!<98D-4+HBwDO|cPfPGjPW6@mC=?dVvQwqsSD3ciCipBxjKf&27=_}wXb}Y?8bU? z?A-^9UAs=PmceP0QS}OvuYq;06}z#)t>7BY%060JGWZ;E78i3=t6SbxNwU!zIn3|+ zN7x^@z~AloT{`o>$J*vezEe3vRcK&{P@u}FzR2nN5MQmF;Quavldor=^s9eK#V46~O z0o#B;`{FWfJyV=aU-7_?9sewo$2 z?bsC?vAJ)Of|p$X69OY0m&(&+|J{>z9Qj^`H&Uq9=p0a;^29#t?{>Rl40=O`g!Em z4q~3z`*JL~tS)wSJjz#$)8x$3ulzpAZpTvJK*(*}mO(q&Oo=#s0Tveq5ZnsZw0TDc zE!IukBl;cb^X$wp4Yh*H9{`|K5LlYBu{14Vn&1uuSH$jAtWu_Zqi1OZ*&k{?%VHEOjQ(%VR&9S%FX-k@5ka_-y(^7#TT zmkX=aMzvO@UazC;I;LS#tJM%C={7m2VFIGK6Ng>H?yys@*ZA1S zKSnye#w)M9f*=S@scqB#jgml1fAv>?^;gYlzlq;u=(^6SuYHBOs8hCEu`VsK-rYxQ zdlGwn9%*I)Y2AYoQL#Ck52Q_^B;jy-2*l$ER)N{MLB<9yqBjgQ&55ii{~|Ijs1oL4 ziNVSe$u<=6*Q;W)DF}jy zW(yLxRtf0W_>TW5$+d0xaxd{cWt!I-w|KpF2_t)xP;rsqhQh8)KX&U=Ty}kgS0kU~ zROf!Kw(Q{0VuQ21Os97TF^`SxM2d91$Tqc?i7EuO^Ta)2&IPt{-rU8LD_O$Hai&!R z%an+{s1OP+Fyl$Ey52{NeSy&%uTxkrlj!Zc<9TS}{e$6)mzckHjkq{L%Kd33d#pS> zl%bYSbK$@Y|DjaH<~qt>O+Lc%4)}fB52(+|^w=IIQS!1R%(I+bMY8l0XPI046#Ie4 z@rf$F3xoW>Eg$8>ZDm@@r&t(|v45={S4PFYCTx|ofFpIgtO__+1)M7a&Se2JU1oe^ zjZS%%m;1^bRfFVaf}9HUvMs3)w_Cw9uwhg-$UWuHLS`Lp@*G-s z*IhDR1A`Z8*O&=e=#T8UZ+Y$C4QQ%{U~_V1{VF@9R%E1KVR92NF{$Jln9(GvRc2u{ zM=+*SD&}x%ezdBUl1s->1e~J{ynY`_K%-R5apA%bXy3aVNtQ7Tm>#myxu=y(%D}2s zP^|)1hn@M+5t3c~h#NX(rH*df`i`wuD?Xp^fozsbdi#0_hC;mX(-*jQ{Ti|?)7jCN&arFPPOMf7 zsZ@$Yq7|>#!|d!V2M!!yaBz^0j!w>=J%g$!tgo+g`0&HXih|zI@4A+z-L&6N_T20& zt*vonS#A>GCO!ZTV8h_T_kVz(cqn>I>}x5ewjX6jB7{@!=GYL?zFypcKywhjOPIWIlWQkl zpqOwF$`vRly!hw6XsJB&{p(PO;_SAwI~-+fG>hNfPNXHlz~C~5QNiP}A&LU3;vizz zXltD26?ZSi`Y}GdJVNh=hi%mlv+4LauepDVi-{+BrK5-Ss7lnEX18;c52+_PAil;K z=VR?BE78Pu( z7JOn3wWGk9+F@Fa2ATOwtj??u?dZg!*_xgHK0&Qg;m7~&zY#7KiG=g~W8$+MG;iP> zR2l8K#P3=gBy9bBe(@OO() zfZnB)2U}0ed2^Ehw{40~BVj;`O2JlCm_ssn(4qU|Lm61K3 zH$4lN8xx#%XF1q<2uYOQ_v6Z8b&yd^rZ>julVXU!g5F%xG|CswVeCANAW0OnbriLX zYBeaX<%xuoR10Oqx`}UHMeFiom?l>)zl^&ri2ZiptJJ)N#U3UQbzyT_SQyLW3+t#> zE9uo)>=qxMXb62GMzU_SY$q&<-NTMiPo~@tNW7q8L+_{s^&Q5mi+Jz*^JoL~Z`uh4vCfm?7 zjo#i~o_z8n^laW16sI=T4kX z2R%L8NVFw+^wCEE;ZFPCd@W78X}_QBx!F0w;c$}xH}SzCz*m0qGNSCFpoZ~QCYjv# zB>jFnO71e+q=WSUBthH0>^!0F&buZx!+@KwtE|q*lnN4!21qi9(tRMqf`BMWXf7AN zSPPO}Vt#Ri@qr7JOBD+FO{(Q0rfDK8DwuC1k$BsQSP-CLP|BAmXhm$w0H)^{x3X1| zg0|)5u*qClR@ttZM9YJ0cJHFNZ~{khFN>K0dUozZU58aMkE!LU%5CKHR^oDje$C{^ zjIm<^vcZQjHYX{zXc)C3Ju4ciL-^8m& zDOv1z{Ask|2*H*L7qm-MT_54`_8h9{WAI9W152%Fri!~O!s1Gag@siD0UL@UAxRc| zjtJdcVa#gdNA6EEl03}mM3`!@OuK8AJ=ztXR8Mlywo0$7L7Ur6$kT(@)lRVS8YgX! za&V!*Dft?^BKwGx(#)lGM8hO3w=r8c32Cq6RW%af4qo?#xLNM!snrGC-3u(5BGQbA zyC@J1&oZR-B9+|KvjdEuyF^P*FAlf6+4=7S3|+Xu%+<@ZFvF(%lT2*0^3ZUOjr|dJYxy<8adu%)ub|8xaa%r4^u!dcWZjt($&iMFpT$x0PDKG^#c-w zx17^44CAgvzhM|#Vc6gFT3y#MP4n0CoHg-5Bo0-szVs4xw~bsah}+ztxbGp_oi^mP z=h5an$g@tMwe_y?R?3MW1?W4Rz~%E&t7@z*DXh#usQ`u!vI?U3?h;}_5KvSL?mz%f zON3G*L#DXO%El~HQ`Zb(;fIKgmUFKRfI` zZj9A=tVc&*zsdS$ilA#BsiK6F6qB(eU89$=h4zz{v*=4X>XJdCciRIG9P&ZKtW)1@xqHE*)ra*dBzFLKaXpwA=G=8NHTcjK@}u~>Xak`+M^5k&=; z7RJ|jl@r#-*|)gF>*^SLTMyx1pJ8;Zh);0iutZsI$V44eSjtyv^LJAUhWUoAkM;#C zN1QiUjh86RJFuoCB2t#67B}h5ZUU}hhF>~EpuGdP-`^ZR?;+~78bA5`=LwcdL_(YV zf32V8fN%@Ppu$}HMLuJ3aLM|I{6B>#yS7d7Q1OS9Ge_9n=)h%na%J))p0*?_vq9Q+ z#ITK@=X`XW*8XEeH&ZNM5Agb~?L6fir;$$MTJAw9l&Q8f=-juTP%=puz*E-?(pMIL}eeZj;wY3qrEuS<^lk3;7^Mx;bfsv6BdU|@0B#Cc-``bMG z?6Vk#L0elJlarJD!$153moHzYv$GS2!_i#5{42-w;2>icP9wQgva*h9xKa1+By5#2 zX8#%MTpYtMyhWl!e=J6@ElD8RiWYRCOLYphb=LDs%r6Wvc=a_pJNF{X3Zf)pE|!@Q zHVMZ>Dte8)nnW*)IB!QlwWuP?RT)j!=+B;HbK5~yb5{taI+@-aX4|g4s40_mwMfZU zL{gJ1Z>V(J3v83>{6v~&$GnY)p8e!pI&QR*xNZ zqKef}a3n(vkESRT3j_i-BuT_-@zCn>5ckAzIor@|Ar!@lD9X1Ndcy7d@YcP!UN?|s z3m$tAk8zHZmM7S;G{QyeGW!w-@h;7BYcoxp7A%f1!>I-iRm82oj9(FmN4t617h@{Z z!$&v9u`8pcivR#107*naRP?4uRTY?5iRm?}F@wqME|QKp22P*FG$o=P?afWi_Xs1G zE;D)M0#SXAg6E@*ZL@P|D92iHk@om77WLoqmn;3W?Ox@n#hc3QoybPiwRi;WM_yW2x&@mW@;TIkPrF_FJUY}dBi$5I4L z1TX~}b&+aGq+F0F=41*Pg+ks!KBW>{yTB{`ISx2uR3^O~4{m3Bn}rt50;Z0(`7J`; zen4bqCPOykx)ascvZfQrTCufz-;wlrV&Xd4Mv71(d507Uf`BU!pj^u_aqAl4NIR-( zdHXfb+$a^fggY3-G+=&ICEgQ7wcLX=n+DS5Q@9g;Bu#t6ae%>h8W(9#?!h8ke&fcN zD2RkzEgUagBU!WK6@T&CP15X?&z(l!breCCDdp?Pav9ZTP}(RG>)nRpv0?%@Z@i8g zwBo)U_-b=i!Z3q`5}vKDHcb!&Bw551C9>%PE}x0r zdcBS)h)9yO742CRns(D}+V9ZbN_li|3G@TiE$X_?SHAKU{_qd~5P&OJuF%udgD8p= z3I)FOr7!WO;NO8u`#QN`FD^wv)H5_nDtftw&2e|I6VrtJhD0E4d&^F> zBn!Cx8g9Qvyvt9ms*_zWF*mY7=gu5f`~3?U`DUeq#in7gX>V$?$->wutE+P)lD!}b zSORvUn?7<`9gFQUQSULPs1jxCx&&Z*o1L$n@8iD&FVU7>;?ko>aojw^YWWc+mj>xj zdWh8f=qbIxYxceDN``o0BFLZb?qmDfX;vHOXmK22EE}bDZIz*xr-*EPm7b-Ka6P&X z69HcR)-!zOfB9dL6{R^K-yuxH;L6L#$*4J0Pb<>mI@PuoA{#j@**eQTtAtD4&`~1f zN-#a0BT)?FYV~1=CZcH~+7%qTBPjDZEQ6b9J^f7Omw5Hnc@7>3<8oW>3JUb*-+^hG zl*xb(MYU zLp<3>0C zX<!Tbu|ZR~bsCNQOU-yucY-r&mlolVIEBe@AkjTy}%u(bH@@ za`-KTNDu@3ONDU;=<-o?>z^UOQ=gj z$UC37>-iNUL&mM)P~Y24f59~HhK-G>fsdEBc4D|0q_5BcwqUt#Dn zv~9z*y9mZyjGT8+DP^&0R$TEQQlxpqtQT=#5+-HWm!g&BsMnI zsFW+1ripIocg}k^?WWzdf0=EdDhk0+=#AghdmRP3Ua#||FMWwpsl<&NH~7$pK6J+e zJUcr}KA-22M;^g6OUHh3AXNgUEnaoVnwQDD~6_K=(V$%+GwUv=&h26d)oqCCvrHADv?_V)N`|L#a}lT154G2)8DH=9qjZCB3-Bt6%*RYe_flMFmA! zp%L@r9(Ci>YMk?3#c({u(T)x5YMAR6HrQM1M(gr%;^nVl7$zQX2vHP~Tox=&4Ry4P z(`rWuc({IZ4plX9I@Q}_-=tWqv$9-Zaw@~%K$hiMkXBuUH^TIll5`s}hNqSh13GTE z3$N9V#Tek0_zAqJmzlDg?1}ba8J*`!WffOVrc-al83;4Gp;E|H@fatFS{g)KdiZB= zJ8P?Le6)0(YTGg!D-m4l4ZQw5x6~d2W}U+Fb>>Ir3AMFj)wJf=ctB zQgVNo;a(?)hI6D!3-m=tSgQOFd@B;{d7(b^HTZ{_WfbDSAR-+#z|jp8`Jq4xZAgpT@TT*J3+WTKq%oQ zm~azlapDa-aQQTxUMrf*g4M2INlmk?Ut_bgl|!i%Kg=9qqsPueK|7KlpcHNqP^zek z_y1?_J%cPe&pW^0iTB)`bL!kZsV8G1h6DpdfLboK)>@{ttnzx-YgtwL;9A=sT=v%5 z@>*50Wr?!Hl_*NoN=p$U0s#&{q(PY6lT%Mmhv}U2O?~4D{?LQL3^5oCC<-8{eyY0a zxwp@`_dVyn=Y8M*^Zeic!})XbG&gTWRaG#-)34)8hLGHk9$Yc*P@lt{S?mvCn%Nu< z>^?WwFT8`t9mVPN-2LCu;uJHv5kjdHmIYHo3bB?rHv5MIN8xraE?WgP_&#IF#Nr*j z7iX#kkInNbMd~bELSuJe*n6H z+0=y~3aAc+xzQ57kdEEsVeo1Xnd}y9njNc9$Jr9R8+3l}@4cYMPdfwrKow|7Vs&K& zz0p9rqta;_#)iM)Z}^YouLpJ&MMbmQ@1sCB>J0(`KawPUvKin~sl@W~GEYAFB&KQd z$AA3C{QmF%KC&!RsZ{O)S-ahiuIsF+wcHBb^`YQ8a=_IbAYcM~dap{sU=v+5?=Qhr_?2=ZTdbdt=SWp(vkqV2mF z&4&q(=E%1NIbYn&b*)l<-+=SI!A>c?bHnu`MAHtRPF*7aI?U8x>P%GBS2?pk88Ox2Zt+KbSH-C3uNhBwXw81%=5qZyL|3n|0dB) zdShSkCz1Vq@kJs!1Osb)E4rIqr7P$&8mp~+e6g{e|Gw18m%4AjY=NNfvm~Z8M32n5 zzL#;Qwvk)%)3U>lQCLNO<*(@9S|*U#fmp4;;2b~7H1RdtD%RXM+R{PvQUNQhusG`= z+T=lytOwLcx89>C-2GGLrdV;AcvJ`W)e-unFiyLLY3S4q6RmU^#h<27s8FkEh@wQj z-oQC);uhUlZu!1B;NjDX#qGf>=(akV%R{~1zyflM5-zWaqTZh+RO}8iJG;4X^<_Gm z50Gu%f&ldQUm}o9&}itCOCpBhLUo8A-VW2ie)S~M-gfGZd*&;u^r*8OvF)H+TGstf8UVXoE~3#Wi9~{0twya@!?G-N zT_+q4GdMU%qtRe)ZjLQmw&3^sSy))0UavDSFu=Zj`?zr70+mXIxw$!7T3Q~uKyo=9 z8!Mb&a5D^p@hj(1WC_(dk0q{=cSmuk3aUAa+6aPe8BMd@r`EY#k@5RAv}gV=*t`ex z;FrMdg^y6D45tH>^Hsu$PbUj(Sr+bCh*DvZXuJt5FoJGkl_0gMh(o{k-F++}(kOze>8Gh#izRWKCF`0TK z$N7sdvbpndGTAOfn+mNOwy+EP;1a%ACo|3>s;tmy*RU^{*rz0Heg|sYiM&}`e?IQ% z{_6G=2QQnPI(-%U7i@&Xadt`q4f>cd{uPbFx9E*L!S2tu;XikcF};bi6IaQW{j{xZ zC6U;{sGMeU;1;3S-|%>R4{f_X&o}(P&Q~s<fy0 zKmH+4{L6ns+m0O@TU`DDZSe9H)(XqS8nZ0hzKEDCk-niarH-*rTjf;YH(6`1(@{Q; zT{%pf9>u3FgLZUTm%p1hnZ-a#FnP%80^6n>Ok23%j=~Q0PLNl zW(9=Xm$<&=AjZcm$&|LG7(?gPv8(*5dH|URKj(UT6OFkstgfAelRj2wS4p%a=sor} zip$QXgNG0$3B3V}6As$G5q&Rirq_r zzUwzWVG6V)N!V;QzVn^$aN@)Xe(l$O4Tr%abMu3x{7-EQaUr=LcWB%XctS$cYU`1;qsewRYMVgE=Iz^2KWXP-k*0@Umh zp6V>~dp}Qy*N(Js6lEjqfTrX%FaYxrs^ zQe>W9+a8+6e}voCMnRTX9_S;`oFy2GZ*BBaV zpwv8!p*xXVqU=pXn4hmB+;q_1+|Fcfn0&oPZn=roc^S7~A=(mUV!W66$q7QC1d3`y z)MRW?7q%r6_oPK|R>5aE@k9bRTZ5<(7m`y!5QV$zWYaX!8+9Z_#u0MipH;ayF^)f> z;PLnf+v+Tf3sj4rVW#o|n!kyb_U!~%!9TskY{SVw{w99QPE$EXFq&qu7G-5-fT;L3 zEvZes71_-4bQ8ZMo@6DoL}e;LXsya>SYn};An944@5Ff`**5(5Ji8nA)9rt6eEZLF z7mCCKQ+zM+HFnCku-}weXgSMXeJB5$@dQt`pTo82W=n7jfk~MOV~CParc`LBd0QHX zT|_+g93}4*&vn(=+j;~|l*wK_$N%ekl0%&;!KG)YjBF!Xu#pcHsFa(CH%D>wyo`44 zDBAEv?71-S$JZJ`a;hIIsCwq~OT;!cV|Tglya%Iw8f`l>c*9;sZ&cA7 zIyU?LAXpS7{Lv`$3!_}`IYxX_2QIIVT18-a)7>u{*dxWlBQAa?ZfKaiDFln8YmJ@>Qt&llAT@H z><%moW=3SZkr=-49leiw4f*JI@bBM_q}dT|8lqmNHn)P6ZbMcj8g+qkaSgW*6uZXU z$QbFiO~@g~-zRSH$o4P_bg5Wer*ul|rG_^A4S&P`c)s0X{{RL0Jzo@sPZj_Rf7a;1)zupglMOEXg9poQ|wie(H+Qk7|ldW32Zot?j!JMG;Yz zv8F5ZRj0``sni8GR~l}bO()_#v$!BYz^m}eZ3j<_SI`4)Mv^|#=N1_*RcPvL##wc+ zYTabW86ay^BkQA z_M>XrMhAU}^U@>Nb*{8R9J=W=2fb@;ZsOAf{5M_u>g$t{^U#7eNwH0uJm!C-x@)13|B4 znbc}!CMO2DaqV5MUV9U#Gk`A`!4Y)hnUXm-Ge#(`;c@$kC?%$p62i(ZM(QupP(3t7 zI`CyegjQ~&uGr|Slv&Zoh}T1;^e~QSE7J=yTKNS!qX7ofTX}1B8~XYV0V9!nZ`J~L!+F^ z|2Ir;HrQJ_MYH!1$=f!Ho+85w*D={atR;=tZ$mtJl!|$iXS*BhXg`8Obr8revUu6U z<(=C&;^-$gGEdu5H#4PvJk9M?D^U`*BCgRh_>UaG-ju->i=qaD$UYyE(}^I<2%@+? z#|r|BR)L$&B8PHij;-uszRSyjhzm&orEr0WLjjx&4pc}bGdL#D16dFL5CJGS1(RvIzp>GvCC{Y{U&-VrQ|_R(ItmKO-gPSP_Yq(;u;&n;tb z)G=fU{f_$fkI!?qdX>)MD{L9Qh9|uPA=w3r?ZXyz2<{-B;wrYKaTbFg69fwaVON+- zwK1IK`UA@&35r6a{|0*Vb_C5qrK}?eWg_Vi+MTU%<$}QSypQbW$of69EX3<)@Tf(! zbms0gDt~;M-YU&RMJQN-Us;r4PB>N zt)eIj@`oH>t<|`G{W`O=vpAhj6h--aeeZ_9;lJlgih^dh-=_loo-YXECtn6h5CrUY zJDR55-OtrmPIX8QYud%%8F$ya^*cV%<*mI=i3^~n)X6t1{?jWwXsV-I76E!*CxJsfohqn0% zs)h&hBlrZF;bay|ze(q`#(Y}Am^Y~zYcy@yvhj?5h&AeUUjD;BU?rpyH|$7Kfof-z z(1e9B;$p3N7~QfVHz#;36=Hcwr*=(cQ%ffk%Y&3m6GLwz<18S1Jp_|Jblt|ptvcSY zop2_|K>s-kODlxJNhDcDP^2FNy%vpnmFel*^j<&3t-({&ZFPd#B$4J825y|i;|${o z266gaxW+}!&yNvJIB>gzMCB!hH3{GH4l0FL=~0%*+EWBFt@r~L!SMl#l`uC}ZX>Bx zGUW(SEzXh=qr7yCPTwlDZ0E1ct?Zr9>4?3_cq4>+)`Q(uV#*f9BhE8&p_lf(dp80q zj~W!TEG*N+G)*kiq+YEuH88;R@F?>W6U>f}a`yS>=zZrHfd<5UW4sXmb+)Sm*n35m zThFq&a**F~9^lJ~O>Y|bSS+ya+}-$M25#T`l$PPmXRyiH^EEYIyW*xYdhkIjQ% zS+w+D_&P@Vd- zR+oHaHihqwXR;y@h`E^?H>nisxP9gaK(O8AMifQrb%D8Y7n!ceL)#)u4B^)2u*Ktd z?}v%SYx*@pnNFOl^O3=Cc6yku`6rW3`nM4T1Wy=mehGDDlBHncV}szBC(c`?>!j;;0zXzB zY5ks?R7)0CJdGdr6ACM+$Nn?E1CJrxvz`=05hdWK zdgcsf*DgdwMp6`(ri*w(7Iu$|iJ^X4cJIC~2=NaIfE86mltje4V0XO+axS;Jo+lSY z48vGoFF(Yu*Xun0{PSe9S%SeJcDo&$rakmr(=_>`Z~PImER#qiIDg>+v$L}#lF3K@ z-Us;>@b0_s^3qE$arSHvjYflPHhY(zdhhXiLuYzsn%>@C6h$GKO8vy&yWwy6_xcWp z6H!`U_uTt@z2|@80QdtLHAz}mYTU35`w49N_7HuijwAXs47EbppCZ`VhOaS!GO|cz zsX?eSbKgF~fPKwq=Za9;4IAgrq51(tR6I{N{RcVPfshyIe1xvCpXJ@c` z3JiGn5S@DypO|9J=47$ImrQpzZohA%lRf~ToPYgwh6gU8row2e)2w9okrc1txZZ+W z%yG847uohChuW6#yAoWxw8)mVcATw#E?)W(Xx+qv4m_`Y6JdM^5=}Ux0X$(3)3;4( z6^nF7lu~_}TbEDc^G9(w*6EzOUT1b@gua_+>Fs-mid4p*i4tjQK@dYM&DxnBvJq(t zG2DNGU?7Dn;K${5p^XV#S{^5sbmDS_iHTDTIP8pzPqSlolvjBNMe&jJX7IFT2<>(FH1{vDwiVg5a*M6Dz4)p&E=|6T8rY1aWJtFMkZzvG z9DRcq_d!$pA^Z+M0FGK2t7m~1yAJV%&UeElONj#l3I?M_cPd z0hWb@fyt`1#uK8opA8+xZfZ1lCqGcPSCmBjQ5S_}fu)%$o`Ctl-81tOGTvwum+v6~ z9Lq#He+*w!1o56=ZO&TbCG9p_;@yuNOiV6Lv2FAw(x%V6zh74nKoCK-Be;VIp)6{& z6OUpiQYerezd`2qSrWR8+Wt8B;}6{m^gs~ojo@9GLn_R(9Eg1^5G)EJ5m<)y^8f%K z07*naR8O4OR?o8CilhCQeuZSWQ@L;+bIU$dyTa69kzk^ZD8l@tgv}Wx6u*<{H%-J7 zNAb5sQ38Pvt~u>?$jwt`s-%sXCgYiqm65^SFq&cYb1ZfPY#P=whivE|=rL z{{6T;9%M=S$a8gF=lJpCT)%OH{rmT`yu6GgibSJP^0^%S{R1p6E)tG}SzKJCP$*y+ z29-($z0qKHb`HPak7b(oeg4YoDo2kV<+pz8w>W(G5YIjTJe#^UVYh1x4h=CfJc1-k zc-(FzNg|ucu(Z64X0wq_rLZiEo4q#)g+d5|K;O+i{5~JU!y`;hPBK3~&)nP`Yo#^3 zJ`Y1fw^>|RWMp^*m)nJ^s>mBO;0+4&&k_ZC!#3=vZIeSojNH6VMaklBCP zU%QPqRA;rgOthu>?qp_}Ff*c%WSM1kfZ5J0OCFKF!Yx|F2zEh5kR)u;D4N%XdE!SH zYc;G$5@h8r02M@m;z|*>-~3drTd}E3_4m=-(v2X9h>Cs!1-a*=WXb z9}Rkau_54gsa&7(@cHr&$+ztwR~^JQ9jERtlQNSmITY;L5YtjSe!+=Qtgzc}@Q&3- z^W{9g1D|DS(E;)*-cJI z%`U?E1{H4vXEu-bW*T$0hOlFaV$DKoO0h2$BA+wKT@cvPx|y-XTa-l!y`CkNzs6|O zBH>gL`JLA>B@H=|B9Qb_t*A^5)d?kBc;g;=uN-GBUt(%%kZaf8WQ_veWQb@>8;UK= z>Y{_`+jdMVKqwI<))Zu7pg}m}X6VMd#G)-Y{9as|6L|uzu1t_fxp2E8#Kj3DX@a+d zPt&~=X1Vk-my`k-HHJ5u!k2OrUKqq&_0d~c#4uK9Z)CAYTbP(D5HwzMO@!T+d_cB z{8e;U0K;gdd21ATv=6Snz^exYLLHA0c7@l+Ml71f`Z>0^o6w#l&83%E9gNbPkFw-n zq*`qu)e^;V>IHn;I}m>?t8ZapVo|ax^qbS1({J;A^8zQd8Prgcy}HQkP%|f+GCY=c z;F2ZeQZHe(hNAenaXn8{q?zD?f|l_>w2QCc+_LHYxe7R^4?-eA&=Gv3>j5l1cMS24YO^psf@AO`uI_CQ9xDU^H58H=I`m|}7`2BwVz!UA^g*nuDj96WFkr_0Il6DPQJ>lWcqi07Vtj*iX_yk0N5ZZI=5 zgVX6G913As7Ju^1KVkpA{m7EUfBe%wW5{R&1Pxn4UQgtnZt(;VVdR!4S0hB{ZI<@MmT-Le!jD5CWuI^i7})_ld=}VE((bC zF@%~EM<{ULB1qQ-1T^HCL8ik3315m`iQPC`x9|t%F@}vfEG!TNlwgp+7e0%xF@gNd zHxQ}B)8&z|SYGrL5)T$D0!GTb7B3J?J)lrfjkx8rk!1IG% zg(+`}LH+Y|jNPQRXFH+VJhumiG3+{-MwU=+gehi-$5dVzi=YSE_&hFpHy2S)9i?SU z1WW9u+9(hoHmSwJTyOKyGQNTlgW`CJbFaUKX_^}y^}Q^eX>$3^W7O^ z(RCO26muf=s*iBW^AWgDAd>Qu>_~I_x}CMc``^1fD-ufkk>rOjwS>`r?5$1r(QX@- z&O72TXq7UB|S{yDGKs`G#p=TGn0K@JIQpxf}=rk|Z%PF~P}`Cz+X^;q>V< z4?R~91P&cO#H+8qMn0cM5Cuj@N3q-OG#U+p!2mty&f)d?=)G~1zP>&t#>XiZ3WP%; z4AcC;XPTCUs;F3&wZ4@=6fjMbo}M1Q_{A@>ckf;f9X`a!$S9^|AxR>QMuU2zv99QS zXJI8t;ywO)W^dcJZG=K0y1TpY94``$MmX}BBOEz$gw>T54AXq%`?lf#;`;BYKtJgD z(sg|U0B_jOJD||foi;=*k71Qq^Mw(xuu8*Jb9EeE_kEGvQeMF0SCQuWxawIW>RuO8 zOL~*+?Rt{`WbftO#!YmLyOC$-rcHzoZo~H4cM$R`>w~c(;*I*qFMT?XkRXcq5;5lH z#@|cpVvD#)H2f5cZfw;{>KORy`<5i=;nI zu~0`JR){qx7#X{PGn~a}c<@eN<#KzDf6?&p+xY@-a*XTUc6`k))aSo}ICLF{OQ!3w z6rvnw__`CjtBGnQ%R)1KqZ3vC3ZhSn*;yN*K5Jl0^|Q9}6lYo- zY$;zLqiw^rBG5Z?4&)s)cc-xB=a7zlhZ6_DY~Mn{n_M4HEQ{3aIF8ec{5#=S*w${x zQ#wy+K1-lxryQtKSoM?0dQdLBfphaFECDX)Lwv({gWtEGaxj*KYjKKmYKEvwLKFl9tA^7YN0#l(&#w{i1<)2Pl;ES6Ks59Q7tX&% zwtMT{l1F+2rf+LxHb)+953vB=kb~A8872nYihQ40%=w4qEB}~IWyaQ?<<_ka}pC=KEBZ(r14;^B4b(QB|c%G-8`qH{6*aOZr z3~K3E_6d?|{&0f0B`=NbGOOy0bPDoZ%j zMPz3ckI#u{>D2Q@go<%@FV|fFoD=b^El`fjG(3J}@qNCU+ves-+vm9&G5DMMNeb^} zPXz(l=fk~YGs5+=ceB8el$XUR1?y8knH}>} zt3}CfX=clz4Dn_^HcdvTRS^5HAiwaZxK4hTkRwmz3;WTIKZCozM0RVE<+&ubXozz? zub>+`j`kp%-OczGqFlZ-hpwAQlAT!OAbSH1zUq9Q-tAkN4gSxh<_mng@OSj-lL$^b z;U^BUr>#cYib>DdWtN79=$ea=^n8WI{xUoAf5BHbt?|9xhw)1-h|(Czg$5)3E-a7E z>wo@dREouqoq3}7YvPjR2 zqMpt3yZl#(dmMCoboBWi;!Ew!tPSIdv?D4$Tq|QZydsj*$-J586~_eIwm!+@t)F94 zY%?);3`J7zENXuYu;?`kIWL8b#E*ZeViK z9H1rC_Q29W_nKC#u=~m}TuleT75Lfcn4*N(zMsye1zINh9~lG-f(y0{P-Q8^2yoqA5h-T9s*tSR{%F4I(CCiLsCR?iw$h{3aagszkW$km>le- zrDZ3QBqJyyOqChX=SgK{)=U-EnZd3I^p(ma1nmQhDW6N`wFxIrH2M%SK8g!h(My{U zTxH^Bj0Md=wl7kWo6)NpA!=;)O1M^U@^;ce=b3(-n|D&E1<{Qh!Br?`6c$B+&dC*4 zvL=Srf>3rbdhRW5o<5H3aubZl?y9W*W3l1uH@N(xmyw$MX!!-^+qRJ@O(Wd&)0{5z zYVb0)%2)7o6xbUJQLW01pQ^I6wVT`1*U@cG>g6P<DY=A2(mOKV)N!%m>4CVY{eFG;a^r+S*z1GJ%i7$qG>9c7QknB z6R*BO$)DgY&o8lmUSgv95`(f%T8ZM0CJ2NioFnt}mujq)=V{Y3xY8|5&esX)FO!O; zc+S|)=PH-U=Va^}EGq$Q^OF>cWtz5bc_dM+>e?EoU-}`ZzWW_ica>HpjZ;-n-6a;6 zM(96#maDJ7L$OpuZ|Hb~fx8t7ewNL$EUuk6&ebs+6| zfirxN=Cq3R%D1_>$6$C{ zWz=Ke#kac~;hynu!OU~qxj;vx>!EE>^*Xz+zJ))w6#|K$tqv-P2>v9&$(xvsDmicH zk@f^T6bI)PFK|F?<-T2df`BATlqVOkn!0d$wfFC>X(FBa5x%S+H57XECMpV&CQ}<6 z!D`ur!=sU3XwbAR^s&^#e^UGVsz5h%o$J@HGchs2!Gi~Jd)8IEr)Q?w_t;}JH#f7e zuz)`hK(*OE@Hq$qPM4EdEQZVFqOGlsSUg4~5~01low>O=6h&cER~M?yMk0|Q8ja%f z`)O})ClZOQ@3sB0wke82Yilcui;L9j4W4}RNnCC>nM@kPG$|DF95`@*R4RquXfQQ3 zh2QTd5C~AK)rrSrw70i2Gc${(**J9YAhCE1m&;8w5+)D~5RFDjCKJTtae~1hvDo^W zA`lD`kH;UVIKAO-s6hWrsX%YohW*3~K-1*XE3YFcF6J#ChQotv`zC^oNtEFwiYq0; zZSM6ryXzdbetxi%5Ac_)#EKpru!%D1w zuB1vV&lK%j5G|Xe1*S50F8RXn8ozHBQNL5Xi8zc0G zZDdak;ePA@%PU^0r9nb9gW*gQ_TnY7%UP^sm13ZPU^HQs9E@Ffi{8^GvH5)XBawd; zK)6w_^YS145oN!OuWqmuN)U?7;J%a~P|wr9Z65b>n8A+id?h}D$Cc&Q&1ITavUsvS zuJ*nK>NXNCk=pp*kx$KWB>NPC)A!!08!?n2ZnBD|g2!IYH1TUN-FMzFg?6qm)q(g*{md${_>Iv6$@ z0O>#$zdD{^a2;g-41(dg@p0bz)}IoQB;t+*z8m{DY)xF`Na1aER4=o=dYSF@b8Oeo z(ruh!i+P$YoMwx7n$6N_x~+4BdlQU{!|Zde@eTQF?9E&wTa44W6ye6wd1~TT+P7!0 zzw@_@c9po;6<~XE=N<5C(lXwUejff0^`Ft$E%Akni>yOLocv4d{ky2<7f`bqOp?SK zlempJv{Z^wbCJ`&Je?l_@a~RNmPvAIlne9iOl=CXKjKEjz_I#$BK}QGO%{=aF!4nP zo>UM)`RFmF(WrCk#B-#!bzyg|GllfJz}PJZE#1kF%-lY-z2dq!mi#dAYgvfb&fp7x zat~;;usE*wp(UH}**{!GUos4KUO!I6n}JBv&txA(6cGYxf>VP?YpahWj3tU9^R*n= zstvDjzxbGFw^KWR24l;CyP}i;!u%xm8^>_(+WxVF7P8la-hUO{9YyemiM4p~MIApE zD$t@R5($TCZE3;fc4Ha_vLq1-g}_>;f5sDu4^@P=Op{bHiK?ntrb#3cx!WGU&qpSc zAsUV1bUN1oh24(V>qWEM3HbdFRa$mBog@+oT3VX1+wGW!fubnH<8hM76yZ?lu2@l1 zHcKjab9B%x3Uo6SZv8by|6(y0`ZEE5ieP*s&!JcebO>(?8P ze?XOb!{1PW-cW(wuwSqYu)*cm-avPYtl12VXoOg2JD&0YwjrIGU=U0t?@na7B?+|( z?)(LgZ8GVKZF}e>XOnHD{8_G;GmI98X%QnhJbtRD-of1T7$_>LCNnc!#uu*hY2Y|P z6e!HEl1Q|yN56pDN|{AU3)xU}M6Z@v$qJxF!5 zhiao6*;yefMp=-{7>*)_(nPLcBP|z+r317su5e8rrRs7Jy|sj6=U!Hp9Z0z`0#1|5 zp)G`$UZQ2#iC0Wu#w)CaN{D(EqiAQe=MDPKpFz6=gnv=hqD|A}*7@@c^_<3$@KCQ6 zSnEiVzAa#mHE`^#GTP|pM({WI<@9NqBi&T1CW9x7>}}b};N(>-r-ynuL29vwvzc2w zAU1Tf~K{ zAQP>+nW^dwj?dup+pybhI2;Lla*agw7|Y>qjysNUc+OzB`VvEmLB^gW(A-RhWON*v&9@V}$cy2ybW3hCKSBC9Qg3oS$norLn81ae*Y zS32>nbmCp?z_ZwacdC`*OdhrA429MIf{R;y?9Sa_r@n*Y$}D5WD#@;0gy*g>t6t@J zx0l_S$B;w`(K2Zry-DSQ!oRcsQ-nPtPiH+O92#=5m)yW0Eel(jb4<`MI!QH$aUTCW ze7m+{C?elA&d|}k7fpF^rdF^lWYfe~SRyksO55#=c$Nx$Q~wf?E*otg2U25+VC@Pn zXO`aH6_TN5LUT6k>5o=_wk(U@>!;A|HNwd>+);g=ysZ#UCvf}ifB&)hqsr%&a%iWX z!?SDqeS%FDtHF20%XFvrJdg?wAetuKeP>9>E`-$PpH1+4Zzed9!oN6yZDpFJPy$PM zh(OhytY>j~nl>qRA8SbvMXW}RnrY|%XYW0OB+1Y7&R@F9%&IK!ZF@H}Ju^LKXJ`Ea z3r7G1MN%Q^c+!z4-6<}Pad8p%O%Zow=ydl%x(MlXqC%u75Ll7`VF+Lic6Zi{-ZyVpYg&LGi`tOQm?lwuC2N#gmf|I|s>TTBz%*2r-4b)}v(n z*Klp#BtCE85F@mrb;|x-B3#QU%w2hti5pkZU2Z(V;0L;Qe%y%f=4z5lFTB957hj;& z?n2L3*lg>kqqT@|$3r|+;0^z6%*sEat#_3Zkr0YvXL>S2bR)rBgUSE^AOJ~3K~#=2 z?&9v)X#{1Eu+5~B{1%1C8c(!+_J`HqAK5;l*|7M#*}s*eT%F`)IYYO90Dn&(dQT@U zkApEW#rK*wxM6IPu!?-KD)aR66z$ls2YZp*j?=j~$~8SpRddmywxJpZ(pYE z;2~@d$Nleod2))E{@uT0ePtMjA(It)m>E1qNK0dz%%Cn<5!Xz_bQW8&h|ROZQozov z{}^R6LZVusqc(-rB~Z0#OpcDRd}j<_q79qf{tK5zHyaI3|IOFPuPqa+Cs}oWkvsbw zJT<#Xb*{>CHqCl2!`e=fWVS$ZGedGC&BkV$%x0S0_Bxe(mcmY!Ro4Q?v4!|4F(6P0Dwk=R1$8oalOHH*;>b=$gID&UG9AyYWS|<04;5xbSNV zQe_1(`Pax32We!skrHuCAx5aOfJ4lnMk9i&cD@6I)~-6Y?W`8!yMYbo`p--uR;m0#4>;>vYH=+!hD&Pe5KbHdYv&JV)oD zq5Jon+?GV8uzMzJ8p6kFEmzVI*f=w{Z&A|wv1hWZ z_Z^}m8bC;XkNU6!BjG|f?DsatkeXJA?Tj!H&tU7^hs|R9i8I6w&B2b)LfXmE=4_{Y z@eHj4Cqa_YtP0DMdAyM1mn;#keyL_m&W*> zzJ<@0AZQBI#wM{19j1_1kYqcn%a^d*ycl*DlGTFhH!#uye$mBhwa&~!61Q8$YPDiF z5*Up+qKygCk>gy_9_87!B9qmZm{m+V-CYC|Ufjt=X0rnM?KJ(aK~jwpJ??S7;rIkk z?JQErZ{uue%)8^*n`@-Trsz3-oLafe#qWNPx4-^8iqW8Dx*3ZD#964&u;!WcyO?zkA&X%m)hteJgN>%h$m?e)7b^tX+E6T( zU)Z^I>4g`VzJ8hnX(Z19UKkkS+4X6Z35E601ltGKC`VHWu32RJ6sB_?(~&~AmT@Xg zJfe)Fq0(rH*gCGVmivb+9gsPcNpZN)$K1wUw#^Ru4t1cPeV*5k=IGe}EQV?zwp#Q} zUMF?a!~fy>4SbJ?d@1h6rHCjySFmk-jpR&}&h-w``Vw`spLn|u+qtjfI(m?r3|}!X zaj@egilm^{Dugyx=vbPhcj6+w=@kN!fy38^k~o6kOJLeOGz67v%U-TH3?5I|&_w}t z`xGHZg!EdKR>MOyW8jE*5PytxxmqoA;oLW98yZB_cgb?SCa^qVrQ=`(S^4{~^b46Z z*1NA`4DNpq(Q44*>&nEO} z%dKE8oI+iW;_3_DA8S#o2o$$;0t>J4{XsxGUwO1?5bEoHa{0>_w$UDK|;OHh|NjvT0T+R9d*- z)epQ|1y)#DR(Mpo#&pbwZ%Sjnx=gR$P1);2umaXiYYe59L{^ zr??sTU4AEikwjn+L6DfA%i>E#aYbB=PG7*34iK?4D5bwkCA`Mt9iKz6I%&29{^m{? zT?V(c{(d4X2nY^8P9w(QA>QGwCuyOGRxN17FWLecdRdtW3{WyX-C-J-$AUkf^(vblC;ujmGHKm z;xq9IeZDlZ{|Z%oj=Wq!&bSe>E%b~7f2M)E*q~ymGVTvjX!WtbJW9i5VRdPqv9~Va z425y|eIL16VQF%b3xD%fe2PiXkoj`g=XtDp2j7UDtbL3py3-uDI_U96iACc$+T#eF z5o)~&3f(R?x@Bg%3f$>RGM|{@lUok{eegFqX}Q9|>H(B=iIJ@&v4KYku3llneVfgp z!zA1ZD|vX(@MhY{pBq2eq~ zt%xNY;!0zhT-Zaz5kYCxIC=RywADmhjv#8{C_=ao908h&jci3_c+JJRc^`i<9%a1g zgN_D|_zfhphI8jdT#h~_C$spS2?8k%Yn$r>tAM82;?|8bC|(PmQ25?suso#@Xpdt2 zgyvZv@MUkR_Ro&3XEz=zC?*f&gZVO0k77H;yZ;V+jNvp75Z~|5JS#!8GZV zf)91o7DN%nh2c*8MDDRa z8mg?}40JM{US+Vmi^7>VsrL8pL@?O0BqkGadY*ni+8q9~zwbOH-5 z-dS0ouMh0@HiGrrB)88~b3elokDXJMEbfZsJtWxcRCwFo&u5Zv;5ZUOH6IN%vkXeipBHv zZ}ic=tq`-{Wi{qvtolE(XrHHZ=M@f5-K0Hr6knu+WIW5x6kLA!Wj^tHzxOl!M!%5r zu+?I7ZH?P!PBVS>9D=A&ibjwU4dTrT!iDjK^am zbo+Y|1({;8PI9=)C)+z2pSp@O+(RK_VBdI?H+xF_L#+?dwYzbj&suq9J;>SZ2+z4w z{N_L!Lv6fo!-*@1{m^gorG;By@gnplK-GUT>7*x2uzG_3K6QyN8)x|!J)g!gJjc$> zv$PLA!|ZSiZ`)&Rl&4rcHO!HtpCK4bU>uq%nLQ)hbg~+nDa`BWhd6KA*(4E>S6f5WdFa!4wwPmv|w67{A^k z>e=LR&n%AOFpH7XY!$1lRzsB5Ir@}JUu z;u^eRiM@c zvkK)c%yNc0RjP_c#pbkz!3|X5GfZmciSb1{I4PzgNW@QhB6HVn?Vm z>$o@PvAlpy3({;tHY?C$QBi$1_%Y7CspLFs*%|sDJ^4ctvMwRYUR(j&M{>UqQj1ut zDHQLc@8!jqTy4(K<$JiXTify!YUlvSmXGhkEeH_oz$+;nzwtUZhMuFOz5l!S=pN2m zHu#NJ9qmWV8?{&r@#UAXSVgqYe)fdM}N+OcH+rKEwGO{eA>oyGgZfi%Q-auC5J-mna@K1;pMFv5*e@(sn`jTXcMtx5L zy%!%>^4n@La_It@^-UzXNlkE~4jskUSVEo2QLNPQB@*{4q_Dm$6<@FA#vg1UuEg&a{+NB%({o&dV8u(mAoh<=w@pN40}%TjfLZc7(= zuN_^y!ic<&sAwbLwlGuFnO%T`9Rg9=Nv1Tyq@zK{SPp&YB!!BLx^<2G)+~uioanZJ zvpI(lu`=a+nn`z%lJyedxlvrT2syun^zbOL!9g5u_b)&3O{I1#zJK{uGRrFz;vv|t z5VMvsCS{V_%UB#!2(AI@36;=Y8Amq9@S!CvnMcU<2l--j1G^EWUN^aRA?15!C}v-z5lr)F`)7Bnzox*~?u2=}L!z%kBU|(G%CMD$wT9PLdp{45EePH? zg44gddHw*x)-nYu4GCE^cQ@jO2Y0oJv$V>K;wDGK$C0LoX*499`+G<)fgsrNci5R4 zzeFazj@uJNHFPXNJ6b`+y`>=e+}xQ=0yVr|2M&81N@WzIGKD94lncH?gmY2C8*lO* zX@;o3o33V!(S>;ejSz~b7i;T9W}*j>H)D93*KqBGSPdA6C6#&i0mObMvHU85X$f)7 zq^hRrX+O(B-#*Sa|2zJ5;{aDGyvh zlbTUCcI^Z!h(nx#!#;I{vt(2B2d;Et8_&OE(?3v?St`SSUP2mukT!Ni77s*tNKkw@0MC~faOM_%m^Q=}J z^dIa-zxY*NK2c_1;1k$nm3<@caQ#jn|2F<4$BwIfGVH)8-p0B0SLC;%Y%N51q~4Do z2NUJni0%lInjp9{iNjt)3HUkP7(t1(&CShlc(mU_a+J78Dln`Z+YFq|(C74-YEFkO?lv|;1v{AD%< z!Z@#wk(T?gD>Z!nD4FsYS-YKWJa`2QewT&0vd+}Jz@Z)seN}-w;slLip76E>xp#- zvhz9~%L)^Qi@49jxj7G8>mu#$5}ML_f92%^qTUpl*s$}$Scv~H94DqWh&ao;dVua+mAk1$+FKoz^(aB@ z4Ne80rgyW3n4iX8bFkLyC2VAf%+_!&OO%S+cw+BxBvfa*^@sf5Jk5=6gJ%Xx{H|+^ zlb&t%1RO<{;;`pG7P75v+O`w2xA7&9JpQGo1yUX0Iah$;rI~yCU&djm4xWwAhB4cl#=khnc$<-HM=H{!X7H1}+FJ0v38)q52aE?N$NN#JJ@=gxJ<=Q31_pa?$v&q?Sev9PjO%kk;^E}OHx6YBV z8at^CqK79rrgiXV+23J479cucV2jyNBX$~I2L-p2EyG1p_cE{gnXyHgb98XY8==iT z%;AiKNLuIS`Y7RnrwFgS&CSRN^}~-4aYl%ym)U+t;7{5=#Z$+1p7N;}TQA@&Tw!&o z3(IQsn(HKgJa!y0 z5~sbxOCqvMcx{@5C<>T@NfVQ-StCV-X`#p=pAAhkv2A|~zq60|xgG3Ul+cbaA+^|ObfjGMH(PS;fxiO65JbHWE`>w~UjcHop5I&p#fzRz+ znk1z85M04uorPRCP^~@!qi>RPg(++Ab#b=H78chh7?cw4l`abcmQV=U?)bp_+Kt8> zxsZpjBfLBE(4sSw8E2mqLwtx7TD03gQUd%i7idw~b+WD3>jXvyU;j&XLjMbKo0GJ=#$2{YmtY{Ut03&{c5?pCCwXl99Jhu{ z!c#KSTjM;IjAO))@kHxouGzy}D>gVHxEN@+F}WDy&#nplkH_HmX7c=L`z>s@zeg|> z!*ybaos6IKf)~ZxK|xxfyfHz0K1?EO!|z;TD`scZ_644N`y#gJAo=-C-g)g+9{=KR z{=!{%9~2v_Nybi}rrhhp60Q>7*`{?zV%@rmJs_eO&v7==PrG}KXX~d>lNB0wLij?P zoU_eQDE|?kj@_iw-w*8e{VJn&`iAzAujkk*tYYB_qVhJI(l8-;i(u??AnqDpotpL` zv{(3)Cy7&T(vutIjP*H$eQnHbwec@6Ir-;(D;$b!BZ=k*R(rQA3b$6=ytL#2rxSNv zA>32rPek_C3xghhOLG)@9yo8GP!*F&uz@wb< z{STbD73OB)MV>nTB?iwW8C|)~A^ma28RT=Z*ZA7LFYrerNgC@r?3-7yRjlN*S!(`8 zj(QeIH-4KxuAgA3Tj7ydl}Dw!SU2AwoAx5fG9I~1yR*(?hD6O9BkSv6%e0X)1Dq*$ z^7V~QT-lTSud8P`-1Q2#xAtKl?c`V}$6Qy7;nF|i)2U0;)Fo1N3#b0y{|g#T1WlxA z(~z|WdaHqi4Vz&@k!#o}(I^R+O$AvoDXIdsn#tVF?~^wbF^77=R!i>;GadOLVxVTtBa3HSb+JZ=f`)%@>p#pfpAAH^_JAl&QbmIdmvNL>@D zBT+}BfkXuf!-VdoCHhJynB2UI#dC}{HN#f^G*8IEzBWun=B?TY18qY{qC_~o zgr_0#<dlE=27?O zANW*DqefzR7~$X-fAtn}hYxG-vmCwsI=2sgf(`c%3BE&fa9uA^YE&?U_qu!kXa)U! z)nBv5YvKeay}xn)v4lIp)qIM(*#!>Eo%}MaEDw@EOOiw;vq_~~!8A>pttQgFs{bC| z!@nN5g+)>D2Lcb5F@G4mCxPCJk87B%7B^mfou&;0wLvWsC6P$L@^@h}fV(GnU*?uw zm(XXHNQXyg>>I>q_&?NpTV#c$dWKM0BH##9x^)|~?+Ah<(P~1aSit7^XbG?+NhBAh zXiFSGQ7j0uNGn;Q>6bB8jiu!kA`t^w*3o1O#jWc!ZG!}?R_<2v*y}R-Jz1DlmAJ5B z@X7o+QUfu(H|JRu+i(j_oZ%>Q=@kZybC^b)t2LRBpb>UztkiWzW+aa8bFe?V$u|O< zbX=c7D+p)-AD)gNy2C{=@4^(rG>j^x;w-_8K`3ibma8nfTJ$gFDF*GV-5Q~7=rG2Q zj0yQ=sQzAh@ddU@DZH)<(qxT7V+GTvQVxHX8{MD68_9A^coom$I_|Mv%y9*4WsAE9 z7ieyOlKMV_&qsDJY+*JxiY%U~@>th#)R4ign{T1}qm+02L>FJ@eE%w6(&O0gRe#e3 zjX#-*@LcaOPlR0WY`eRfP^$8Qs!+&t|XQ6q(#GcwsEW zU(U6&#Ww*tX{hsf(<+Y+Rf4(5X}j*((T=gyf0RyEMgF%PNB8rajtEL5j9cw%m9RSPTAO+p=htX_rT8>cDd3wV5C^q><>P%suujEJAHg$;_O0{(ytr@alS zbPKaMOV~fia%dmji+L`$-l9J-gl#IxT%ka}agdC;PB@t2b)Mmg;wcJiHj1QZZ@WQ2 z?&fd#=X|}hkHBD)-|>vGf9Goy=CZWvPtn%)IBwTIM4^LXAx3)DMLyj`FRkJ(&(g;z zk6Ev9!nHuaSK}|8Coofoct*QM&b~l?#UP%wBRSW(6yJ}v(oSFLCZ@ZN=By*@Duyf( zk?Vxb41Q^ifWC>}TgMl)(&iIMc&iu@E2?D!!`?uUM8x$bKDmYJHK{5F#kC2hl6Of? zPvZp=)(YS0{tQR9XVJ%62%Xn>&Ti*B^?%BDtr5cUNgmmF6MJqOM=^t=l)+if;3;Pa zR5l4!H;Gm^Xsd0|)mSI8y+$IxA30fIvQVLae;@ig&-21#O%C>d0$o(tcjpp+xA8f& zr`kBF&*92^8M)TR%pDg)`F;|vFbmCTW;brpd1MGdaIiA1)7EBST>ci$BZo+mWk(Ud7_}gU`DKbi)5ow2_t%kz^5x2r&pN)MED^Z(E3$i;nDy;U7EAx-WiT4lM`*HM51f zQzRn(bS*L0nlt1>zW0nf6a)dk5#ZJAWd>V8Y=ZU+uK+*H1v;C_(5TmuL=m{>aJz^1 z@UIQtZT(Rc%R`-G>Dj{9J%lEIxQxGmfT7wM*|^OSy%%$3p1Ly%4mYZ3_wwEM^+_Nk-)7UBltXJ(J=xP7}AOJ~3K~(4H(7R~HA}r2Vh)J&# za44K@Iq{e(QK!yY!^)i*3n%+s^kirFZl^+9qlIz(GAzuaIvu#Wqu6~ODrFbddI;0i zqFh}dkkwcXTT$}2@Cp$Mb%E`zG(AU;{!$rYZKhIO_^anp+zO4#Je6Rat=Q*T>^(}G zZ;_$Ot0?nT9AgL37ObqMx6q0$Du-6tXc*jyev;phzfHuqpF*L|t+%##!g&lwkDrP0 ztL!v4sa87(H*(}^r;vSVj<?~gd}2n|B^D!Hn{r3Ekl@phaW)i}hwi0vk`&OC10CMVRpJjII)$P;Mf zne()Bb=*UbRv}=oAjlRJXN2C>8U9LJB`QaWb_^gUCuv-{1pWv{G=RhJV0ETRv#!v7 zAjWnf$?&zaXjT{Qa0uONpf5D=7;e^^CeyR4_`Fu!?if~~Kz;L7Z1#R814lS8x5`_V z+Z>M$(j1v0U9Qt*J3@gw)ZG|}-TWu` zWWG)~c!tG-j3ebCSSyk2tZ>D6mZ6M@qi#oQMX?BN)TBO=${>^4QEqETxvU)Kv~Y;m z#e=*d4RNQ|jiuO$vtSTxZ7~olp(jKXX&c>IMHEU%sXBp*NX)s!oAG~4f;l{QEEGd` z`Lw6OIq47h&t^NZ?m3R7&*0adA{;n~)6t8==*6&hW7Rs*)DBdo9YsnY3Nb{YXhi8q zI=Gpv^q>J_g5o|NpaQYNPuZwTi-=V$Z2sTBhckT{$G0MN4f1Hj3F5b~H z^ad>K^I19KG3YjQ0=fmeA|v1Hix%FS`Mfv&D9p-o8C41rP1~?`Ja~3*tJUP4w_n2B z9m9UlQ=pU=NY2^m9twXneZPQEDq?x_uW=n8LimxkjwUT$Zrmc$wjZlvecyAHMxBEr zXRvfV3evBzXirg*MV02xIvd^}5`9h4m{`Bd5vl#54$eOsWwXXtg{vHDKXJdJ^n;K^ z8K=$1JDXP+lG@)tXZV*y0$nKNk?zkf74OSI_wXM6b;6P&qv`tJiv)U)0Pn@eGUi4` znYnq5ys;l^W|jQOXJ}W~k>{5vZ-cijcHaOiJ*5yypW@UpiT=o;pL+b!gF=;5rYdW+ zS9H8~KjpDW%_)9++_!_W7Jt-QPvC2J31@3D~y zRM>Rv(5ubxta6bf(iJ)#8bQwi>_!Kk<`j~dVcyfm1Tx}UzaDgMl| z%z)I6ClE*X8niCIPBWWFi6!vFy}Q-m$vXa+3xAuB*_oSctSsY?B(Oz2XglD_$cO3r6ZOo{qDHtPsDzwR%_J{nNav#xwWuDl2jd1HR29qKDDGTm(1?Rei zb6v!>DdXB!aPL@f7ganJ9Z%hgFRQXNKSD<9V85%vM(#y!p6Fp;co3sfByjNt|7Y-b z_`*PeSm||=%Y9fT{T#CnQ1>^uI`#^_-Z(Z_g2izi%?Q%AFNoSGqF(qGjw6SdHrKf0 zsnO-@##z{A|I|%hYyJba+zFDNB1b}2It?qDBq9pJkL4VKS;e{Y5>9(BBg5Nt=pA?j zI~JeyfmPS(soQLomx*-mIx9EoFgK#paWIBvRerV`v9Oy4{b@3n;*E1S0}2+O?|pwB zX)JNq+n~$a{Xi1mzB)rhvLkq+zxoTiW<#BQo8{O5t$RsNS(F*c&ok5rq670(^E5A+dsMt(jSeL=vg+L+lK}e`u6o(Pya(;(|sUssI2m*?xP*{>U=vyGQJ&Q#=My51NRF7jub^L)4`MjT< z)kTIJml%`1WKBCg4jUP)T%9#|dViP`rp7>Skri=M&PcznvsKHA{YZjnZtI+Vlp>QVD&bj&-Glw$i|oZlGpc=p_LMI__AMOxA^#8RJ-Z zn%9H-Ik)~KUo>}c_%5-ut>Rj9<5Rl&W~z`11NT9R-qS+Flzu`M`I7d^-e0i?wc z@?scm*@L!L#$8&Xy*W(3auuIHO+KbF5_R#W=NK;wPjTBE!1Ql1a95((xXn}XMK*2! zlt0c6;ybX$Gr5=X)gNUr8%6R-RDBIvZUNjPqDMh?D@b-3i&ep*T2K`Wx~P)h$#8Rd zp8nnev^&r9ohN0E_CAXy$n3jwk+0>xK;Vfm2g+Y#Wgk|7qPoRqmhepnmGoIwnQ-ehMRr2;KLvH&| z)#ERfXYr9nFug1*IuOi*fAto#x!ARLctr2;PU?rBafO5 z#J9c;nN0+z8x-{?B1ytd2JO;!a2!4SzS*(`v%-HBE_0;gag+zl!l>F$Ye4Ui$xp`$Ngh=PtnG`oQp}LUI#(A zk3~`3RmHVh_h0kfufK=)@ZT$Dw|(pfkN@C2|8dXNt@@q>dN20k-H~2eW^QU6O>a?@ z-55iM@KtV8A1$DJ+}K>ZV~(O&8o06nZp}tJWvK*E)}rJ4(txk4<1Bu zYX}oX8a@fhW@Bz{nP|+8BuQBHAeGG*Y3hSmECx%38UaB=xK{-}leY7j;+xF(MF~$7 znQt0IL=8tlqh%Md`(5aIjMarYee#=RHH}HrNx#EEL9}scN+%w35o+tBM-B6ME>EjA z!Flf{a~)2M>ISYG*ASPNa76rURNXi?)2t=zST}FsQX}Mxbt=^o9fO0va4GcIm8;B; z-9q#>vBqsQSAB>haome4ne`1UnG6Sw0=hk(0} zndxoJn>G#~93t1qaQ)`XbRRv+R!YaLI|)s_$l3jC`~$TO+r8>i~Ly2w-V+Z>TD(_vQ$ zdIr(dAh~Umsi`a@!vz|3531^8Vq%r9&Q3z&1T9J|xD#Bw?c$)Lgx6LB!;QBBu11!x zsH;@!WulT7tH+NX3BcWpG#8gq;%#`^{Zz^pR;KE>0#vivG@QG??}fr-^+B4!&++0TIOn*_3>R5#iP z_?{&Z=)#p#uuj$xhchH6CKy@1#cH93u(Ly>RHe{rvSqeNRw~SGZZNws$IRL|tLZ6n zo2wL7*QqRS;m_LeCPGNg7+Z_m9JZaM(&pjMtB(=OcJp-bEGeN#F{#qXnbdL>YS{vn zY>rwsOD&V3md#MlWTPrP$C;TO zWp-|a+4)gs7Dt&`9$|KMn3>cti|IwY@h3?vpX2PlS^S4SiO1?8u{y)tEiY$JKEiYM zyVO>f@h%L}-P_5n`EwM6459ABtjrk5mLQ#jAvCLku)T@=%3tBI*U-DWxz$=A6)=do zVno+hNNkt*v(%>u^ai+N%kga3iS|>uyqj2dt`pED(rYH7=_9^w$J+7!KHg@d&ZUdr zBebs@t9|#Lzm<|`G<-z6JwLs%p4mdYb_Q#D7+oyDg;#0i@?bSUH{fCSC9{Qi=@jmE zFY=Fg8MH8Yt#KPQ(S`pZz7+A)JTWu`Z|tY5I)7|KKu}bK)Fi1;$9)p2D>}C~Zt;lN z`B3-f-7^Hmu?i3y5@B9&@|t*tFqR#qq!@`$2@rfK(& z|AEA`RwUlzQuu&`^;swkMit(!OTd3OydKUe~-e4qqc5=C})b|@AK=(Z5gh=lfh%Cc6s#;w)Dgrcy4C!t*VCP>$l`>J_5(3o zej9?YJM*`YtD`7oESmX%?E~aDa)iSj_cwWhB%;_=%*h>ub{n}ugJLm@&u2xFEa(;& zrOngm?i1W7l?k;pEcdFwlB!T%78wXGv9U6WVt9i- zQ@o7bmf|uYdhJeRn}gRD++3V9u-aVs+Y=;wy&S7)#CK+y(Uy6m9aKZWb>|B9U_Yx{ zCLXiKRKi2&_yUbcoYj#LeC=&`L!n>Tc~`I1c;)~556Etr?Vy2sTfsBxCcmCV-P&N# zRiNjQ81(}|-cTnP&YeeE9pX;?4|uD)m*0<`VKCgo)>fURb5$N|KTh4K^Y-O$(|Pm= z`E7$j&O@iZ!T-5M$IVS*vK_UnynWQL*v@DylW!-z7>e+gJ z<>%+BE0^u->-XxqEZ=)ATNdRY%aVgcQ6eRZltc;;iA)e-01Pmgh{<`TCw7`n=bZOP z4~GB&NTfv4vc*-^HM^#F@4e4H`<%7cUf=rGLhdML=K{MbA1Zl&f#ah-p0gVH)$%K> z&xkl262wTtalIz2}GPl%5@21`B^GArwA941T-Bk)rKK$ zKwB-6%x!ReZ5U77h+pTySsi12-GMuKo`%>wUw3veKDVC_80T>XPvG>A5%7=V4~-KD zPvR|~!&^RwyL=9Jbn6qD#T}i+6P!YF&XRI3vL-B(P3F<3Sq9T#!y^`3rA50dckN0n+qiICPP(NS-^bl zC9JK@w;0_s>IyGdW@r!ZxUE4hsS2%q$1zsy=C&~}|4~I}#5CQ*Y@mX|)^*FG%=qR4 zjd|y7iK|}s{$}}Cv77et*S)`jrcpUPNM3KGpflW5UoYvAj5dar`)J-ujr5ZWftDnR zeS7!f4+LmwX`!jH0bYxf7ezKVH~HM>{)WkkN%Dmp`Fx(`DyS8L4lmgQoHHGtc1j z`xzV@q^723+fWxok$gVSU;Wi*xc|O;U(cofhQ8L<*BKffCKw3Z^xL=E*K73wzW3sb zn9XMVe*cej?ZZREtgfvP3YGoP`b9-yVqyYKRWTS0cdW0eDu4aiCpd7|eq>qxiG(x{ z4h~T$6!81~3=I$S*(W}S+wCSC4&SmS-z)?=l}z#Mv(NIx6Hjpb*l~^?Jw|g&6Bdj0 zJqYxB@*h)FMd9Ty{Ue%UWkIqbMqQNGR>I_S7$(BF8_EzR2~~yoAlM5hxxOn()K&4e zr_?d)&74mT&{p(gHQC5c%%N4(BM2fUs~&gI&gzmxd^(TWroO3tjHbczoJ4%qN$rj> zX1n1AqZ>p?xMRLyz0t@-Uk^34HxoyR1{oO9u%E{>15*Dk9)v8C9&B$7YOg4wX zR>4RzOGq%4yuc=jqw`kYpZPxH)j|A|0t<44x%e5}RS`S}FZ#hvLRJ`}Z#{MJ+>H_)gLvOBR(x;Vg*z&h2Jr;)4fr!W_% z;4u=nB&b|bNSYl?UhbxT|335vo-#@*h{2Www>t9Eu236G?BI@aycif3odG^ zOvtCd#<8Y2KdX;miIte2HwzN~<8qj~hH>t;=ea%%l@-;utmhjIVVljzT>SDBg;Fc?BaqcwQFUQ8y_P3;+tMhpODL7Kh>8w34QR8>_H;tDwt zW}Ic5xa44$JB`y^L~xa1F?(sAo1jXRIBbg3o0}mly0P26SYi>leu-k=HRMPI?#cj) zVq$8bfIH~G9yHN=@hF-q;fs}Ha#%2piYymB@)>6rEAh=^Z2!&tk4< zATpU`D3hRt2HbHStAk^V&Rn5?c?y&wWf^Ez#;B~Z;oli1UhkyGxyqcQ$eLRu5ipXC zxe@B3SQ=ya8^ZW1E%?F^2rHD?3z%n9^kvt{tSk~v`tU@8h_)!{nFV$@&N2~=@a2vD z)NR&a5t>oyMA5n^u$O|mmje4pt9Oyq?qXdz$f|NTYwF!t3_Gc^2iarI(PCV{7gp}ix<%6uYW_d(=4*Nz8JG6SF^(?1{PGJomq|ueZ zosZ+x5;#cVkh3_AMO-!kw_A@VXu%V4;w!V_H)bewf01YIGSXFZ4+cS`@zN>2n0buK zgAr;o&$2vjp?amB;ic1*weDnfu?z$s)$Jk7HUnZVjd=P6taF!e9@vBIaG(irx;Q`~ z9427(Q$KnIgHXx;n%GNCtBnauihVvi;#+(di^Wyk*=`K_DA$Mc)GB2-ye_0S#PSvj zInJGXj;ftIO5TJYGwxSdTSE8UFW^43A8{+QfFO#<_7ddZ9+lb7pI67L?Pl@;UbtNZg0)@;5D=z=` zdrcDr3TsPLi2gfze_t&Qp@hqB@~E5go9wvs9Xu;bEQG32-vEoY8Ejlv;}}w#ghV&e zPbdUBpU?BvuYMJW!$EiVMJ6XFsj03;Q`Kz)^3>_m?Ax~&kH@p63v~E=UJ3;Thr>a6 zdHF36=tMF}BC)}*{Fh&$Yj-Dq|M!2-{{8zYnTir5`?5*rD6dwYp5#gSzlR;#7ts2CmN`t|GRbvjH&Qz=`;+#G`ggDfm8u$jrw z)YODdmT!T-78Ql%c${c7f~Km3!(jwLV0~?k-rio4sT7wkUFLo7djM5c>F@7nVtfKw zmN1*ltgNhX;lc&J_u`9KEmrgfJtmV0O;s5f7-VW{60^lpx~@C)^+q_ZD2nu4xk5gl zXJustNffb|%_Nfv;!AO?RvW2Qip7NmY&IK;qA)Npz|`avCX)$^#lp<=4EcPX^73-7 zU%yVVP{3xllTN3()_aZR_%bGw38T@-(9jS^j(ne~sVNY^WHe#k($hE1RiVK5zyA{9 zaG0sdDU3!VCbNlwfdLG9116J+OeRC$wLXHu;4Nz)v$@IQ;vzp z`skyC!(l%C>A&EvyAEK`8{UIJzbF3zJ;18Uh3CJ6V6c!hNe~MY378NkyHS@dxGGC8 zuXI90y)r{Q+Jn}z1DDzL4(MtDoor-jb(}_94f%^_P^#KX9sxjyiVMvIlHp(4e6 z5wlI@PL60*f$8fy@;MJR9U*iE`8Gzb%t8g)p0MhI_vj4_wd+ zm?Lg_dnX8mjOg_edcBkE+EH}&-CWBncnW$9f`ljthz1>nX{aunAu)6nec6L#ML+Wk zmq~0c5Nxc)mNK9l$zpO_G5ca{%%-UlPtvV3Gb0#q%X%ViBb8w*Gm3*}hh1Epv|_Qj z2v)?X@YGY2$#U2MAoaEB5)z*lcAa*D`Dtb5t}pzw5byi-iJ*|Kanf zHb}b7cylt*IhFPU5vVWYq<)l_5-*`FRI$jfajA9}LQReb9cOvS@O>J*0gQ$+&Yxaj zN4gnLV~|(RJ&&g@iXiw{n6y({Z^v}(t1LAOn1nudR+PNI0B0sVd{uAeS7N;+rorv5 z!eX{ynlFMRpvuBcdt^Zn(CKuz-CKt6M2f+Ic_t?dpm_;}stJY4uv)F9)7Lx9r_pFc zueUMKKS^~}HD%f*GLpcIql}Xm9qjU^aGHu>FeCb^@nB%*+&Ge1;z?ogaEDSO|GKeo6!xr&kSX6PT7W!6Jkn}~$LRA>j zr`d2t&{pc0RK82Jyb1fjG?&udbphH;aKq+r={y$&`;BEUS-CuQXLBu zTUcN@V`gZ50NjQHV>z4E-u)u z^f*miahbT{)^o+9=ZGQ1H?uXIUhiT>w-<}0kqV=a7K4e_{1P?#453Olu4slB)%^L! zM;U2Wc}#j9+iHXkLks%3P0mc7Lhsy5b8#Gb@nvLr9cykCeP#|_VhY{z2)e0Wbfewq z`p=;2Ifm}?3Ftq`kv$nAtqqjS;FT zBUI@`k_3dsY2(wW$$7S28ZwwKV@K_pmFv2Uj{l(`X-0ryfO$lrd~ivOj3W zrh6-KAsV1HBihti7h*@RNV@Ho|+nBQ%d6s1h(UTz0oq9-nSn2E95qjzVk1* zcQv4Mxo&H$i6#@qvNFuEGUTOE)MMX4OC}K#YgkqWvDDYyWK3z4NSfw@Hp=gi_DE6{ z+Io*;s^|pS@KdMb1%n0s*sIJ$8ql_srCDd@T)dloVl^VSD$ul+<;&6~+M``J9sbr# z^x=2JFzT(C7ROlfMbU1!AA%s@v-o)_(M?m)i{0+~iG)ClqR80T7+$ZBVzG$XWF{Pm zY{QjRRppge&d|}(j@fKNuh(PPGWt@<6n2N5NH}~8M0P12XJmAg-JQGG*hp~U!UgWV z_g=V@0)-pXkO`%YrbJwohLZD??rlPU}r_)JU zD1@pgD5}aoeBldNtu_*g1pWO3JaGR5T)uLd@v(8-ZZ}{4%2(L6YZrw=k(K3Trl+TA zZZ3HR?RFbaf8!gZQz>+M9nU}iJe|9CVK5kO=}WjR1iHY$zyOIv0>97CSDyR|?d@$W zEG%;N+&Mbh+nJc0s{R6bLw%oD?(&;o$ef25I%0m3pKmRiw9UUA#a+tQ( zwh|2a$AmyHudHzK;ze@#9I;poQ53m!=`sfn?B~G#{pfVMw+w+!BoaLF#1nk-lm8X7 z*@CQ-357x^ipt#FJdKSF6bi*32!WP)Pu`P%msYThB4zL@3JdlSVs;TjD#)fy+TP5k z)&wlMIM;$EDs1l@bT(uUanyL3gT)-ymIiXy&ZF&rXj_2_0$5!}8oHt^PHk|#Cr-E~ zhsCbmq+<)Y64{l*A0QZW-Z4L=D2dqpc9N?ZoIdpygWTcwv#>CQ$6I#GX3bpZ!PzrM z!KSCOvVrak1Kf46q!hi*22$%M>2yEHwb=xF@E`zTpMh`dck(~xzQ=f1h*)Zdbj2Q~ z1{^G>rZ{%$Ycx0Rr@W~S(_{iuLBU$p$=HUUyHiKl^x5f=J1J00ljtHGFc9_|NTlt2 zecH$8t|fUiy2_rQi~ZR}hT=!47Aok=*r-|1=xc1}z_}?hXE_%xAI2JWp)>fI8Mac>=)!p7YY24~j@t(LM4=YJSz5E%qQt+Bdf2gZ zhB8T_cST1_vkP5H#XT(}EQ%z|dD1Zn#dxbn*km$MQ&WqgR3UC{q~3}+olc;Uvu6j{ zyLTrKXx$VviT<{B{@d9~{&e3MQRikU4$>FHfAxeuJJNmD*w?9C%t{&f%OxHSe500W~n?$1r zAby5Rjh(o!KFmw<_j&)_`)GY7iP55BV;oa!6eZZjbzzC_!bRk@VIr9zjne&`2(_Zy z=|hqwlA8+Iw1zH~!j@adqi#^0%M)48V%nTXF3z*ApXMvBs~kLZh>n3YGb0+8c|!nYrkh_6{0o9lkE~>o)EuI!A}Js!pePc>qJ*MoD2j@vsaQM^_f;{j zmUE;~!_%wPIM(X%r}G?iB&hWys4-09$R_x!#Y0@G7x@MKbJ&)Bv{_m)jpsN!`wEh| zlcvpKlG3YusWVMlw}Om_Nzh?bMNEo_4Lx>@*fk>#+!zFzC2fO^poto507q&A`QjY^ zRQGHAd_=^&_)TWUs%g-i4CJn1Fz=vYPdR#{3`zm<${9@8&)_()1IhOKLEEO5=fy$~ zjg>pm3nGm}=NY#=z}Zw8VV^?4DdIKW0E5vKKts?92ucz`Nh2tm2x=BV$s?#a1Z4w3 z%b+ieWAe2#JeVRNg)!Pqw~$Jys>0~-MS`tWKnY1B8@zZzKLYoIsvx}bB94d^z0dbU zy_TZMgr%_&b6p*|*;zLGU&6Mh>lQ<5O-u2DeuTUI58pAZPar;v!yEvE<)>4>>x@_p zUR)b-mR+Uype)JA&H(cpYeYrwEk`65iX+6LEyy>JZWszVqO$|g{wRVd5gt56Vrr1F zSmRAbuujs^5!=VpL+AK}R*UIfJYOvqi=`M=xwP?@Wf@79P*ep`5VyU%k|go)!w&-x zjh3=pyav;iB#CS`i^*i7p`oF4h$4w-fTAcA3PqGH15(tq?KathmR{_>$lB@}27`f* zfBfU<^?Dw9@Ij`grpRWqjE;?!#wJPVbUGe;>@k8{;Yfu-0khdmHk+latc=GWe;i2? zId<$gfnWep6tOuRjE;>VNfP__?`O~6Jvd!X&Ye4l`iAk`c)dEEZo4c>5{vWmY-TpO z@BaHJ6pI`^dJIXHsIRLB1XN8aW&T)Pq`tnM&CLw`0|T_Tx1#~MT#ggRPw>%?J`TX@ z>Kb$N^Hfw++|t(@$MafQQ#HD}x@c-_B%Mh!IyR2OZhxJuZ3~v3FBG_XR;pA>Rgpg_vx z!4Y4@>~~d_UKT0mCc$9wTf-dUc!Bccekf;r|a(?1E?+dq}f8ix^?fcQJ zj#~`ZP^Al(-@?@3GCKV_p^73py+$S}Fgai%(olxOYx$vv&)$%Yxsf;yuX;;ehu6o{ zgOuy)!_Q#V z5_RhtG8Pvvf9Z>S?2rEhS^w^^qN<9*wNodMJO(lbGp>|?E0yG_x*l8$br@~GPfv3I zon?s!#TRHc&Ea*`V=&xw{_$w;$7wlwu*GagGWTFzP z{`S!5A61-A7n;_h5e7=u5H8StYGH)GWkuJ@(u@s<7b(h8)fIt z`|%q6bj?6_-zch*B37}Vo$<4rY3!h?w~OyuukiDS_T#kqnbIfdEsisveu37MLPKsp zJ^l}HxUQOdugK4cvzYUTQH(Vde9fqm6_o%PMIl*Gh!+*63JM#A0;`1rN;1pO4StV> z!V3&FH1YmG49A59hKTXX*b&-OyQwG-V6VD|skLeHYk4HGbYd=w(w28!M3zJh(hXif zO=EK-jWj(+yY)PK?JtoCg_sUjGbq&Zt&|&O&5v!aj$|UoYJGuUuznlIl8t8j4s62( zE-b!6QEa6xHAKR3k!N?Axv%E^nBNu` z&hmuuBi!BL#*;iua@k8{xty7eK}2H*vF0Fpql~bzf^_0r*zF4DLkG5V+N!9GDvLa& zUBDY@pxzlFyfBGHb@D%_o2hHFa#_F1hg|M$lTXkJm=?Z>Nf}2L^~jPLS+*fbdPGr& ztkWS$1|-RhB>549Ac`We98b_-Hlg=gZ$SoctS^%jG8iou(g}gpMH}_Iy*~o!m4?`V z5leCm)80LA$r~t062|g!jFHGKob6;DpXGNAXMG;u6QATGF`v| zVz8R%%m^#a0Gc2mAP{jy>06y4B6@B*JV*ky9D9IobAwx36}gYVkZ%d(78RJQYzD@CQWNf%4xO|mS5B$3bM`LjR! z6pugt3tYT-5m6NRrC<7`5@d9XNT{^VwQU3JHE406P(af(BuPS5l1%AGDr5{goSTU#ua?RI2Y=KlNdXMJsrtG(Aaefl)N z@$0`%Hk-w4He)at*uQTtkw}Dv`FX5X3#y`)u5ruIktON%@!UAJH%{ce>- zQAE|WQp~X^A|Rlt8hX7BG`6orl1iR?P1VrJGODH#31qB2EBn`D8$g<5PSFT zp;#<#dF&0yIvv0K%OAUG&WlR%_V;dkZR8Myt+k}87z`znE{ny=&Ye3rapDBoY>rq( z#Wtk*#&#r8BpeBI?AUQ0d+brZ``z#Ii@*4bh@yz5XvL?G-p@ci{o{@8*tse1>JyM7i=R~1vk z4mMV%dHL8ksIP0Mu5BmAl|1GV5x=*MIa4JQ6GL=b43+Fgi z*G9`gk}o@6MN%|cXQruLZO2w$ zLEbCAxo}MZl%Rk@4XJdBwY3$B^K(?1uF+&YMbU0y*4oT)tcGOK&4-o4IF@uYxVvx; zC|ru4C0(eeE!EFjxR3Advaz@N&~4*MB_wo`4lCB%3`3)G>>6jLcaV8s1vThO0$B@rbGjfq* z!hc2zdYLk(=<-@HZWB;x=r&#@XcBNb9s-1|&HE3Yyw#O7cB>oniilike1pxZF+Sdh zKUPk$ATl*%Dv@5vKLY91;tYmMFJQm_&@Eo(H!ZjM-H5WlluKss9rDux_*WNj>YZS; z{&XvPM*!d0E9i@z)NRENfm1kw^frw6sJvn?+GdhJ&z`TIoji=`ViiOFVGj{nXUfaOu(|ibaLH?>@-r z*cgH!5|77;M8f#|ex82j8+g55x-VWtDHhSaVLU&m?Dcxd=X0DnbB1EENHUqCP$-be zWUyMTEH5uJJw3AxqjkER%+Jl!d#w+r(?K*6Vc)*JEG{n4P~X7S-mBEs*8b3bUn}SH zd1OiC+O=yGiwdVso#I!2?N^ay9Rq`djE#+Q{(Lw2d>*sOL~C0s-+$?4Dl1}q``h2) zxBsu-24Mez{j{{S@W21}U*Pq5DUU`81_G?FZ{T*j7#ka-wY3#N5U|;7j9kBt#bO~6 ziC{LFZacPYHplT3C-8batgNi!^ZC&0b#!#>;E(?BkND*8e-cg8Zdq5amFw#2n3$NL zy1JUR)iolK2)TTo!$*z~jYR0}y+&`lo-&```ZvPN$Re=g(m_nQ3ipy>0!Q%98YN z69YUvJk0O?-tY0i0}tTXN)1pf7Wv%gK8L1hoIH6Fhr>a!SmZOG`3!!)pD%y;%XD>h z@ys*Nu(Y&9Utb^DYu%m`-mISGp( zBMJhlUXL_Wq&1pjcJEzWvQ8qM`Z~Ua9NwlHjDZNr4L?hZlgvz>#~m%hRaJ&5C*znB z2v~y@_2n$h7Ktk7X|`NpQI@%&=@B#$uR)K=Vx(uv#|PZAysDk2F%e}xXTdANV8lpW z{{s0k4^tN}p&E1qW3hMi5KL87Uj5E@SY4kbZ?j=F$OOj~Ua1)-SxKd@oVYm8#L`tF4Q)&gn2A&earV53&br1oqthJ9 zEwbBGk65#3d-HsC-ofAH9sH-Nt0X5CLcv;0dLy>}Jl7X4lThpNL>tKz=jp$49KGF! zzcz*?qeC~GMMI@%5#fjJ;WZGpK2lCYPHAWc;TC(uWHKR&7KVpssHv_YqP#*}GO^}$ zBj~+6cg;yYB@%IDG05tcA#6eN*AUXobS+IYsbA$;Z495}A{eQ|sHG_!eF4oCzz_`J z2|HL{mPoD`aD{aY^`692R*oy=$G2c$bZMQeZj)%F8Xs3Uqif~vi8U&-4V>|Pn9B`^ zut&1&wV$J1x*5{OnvG-!~7iUQtG?QpC5SFr&9Kxq20U&WS(kz`7#5;UnJiu`CIg#tY~& z8b-YVyWd417{Ow#CZCOxm^Wcdk5N}VN0;sh;-Z&2??HS6aAolk!W$pdVd{Rg9;n_?xB=@ld@L$MQe2G!dd^xr&jE9t6`~ zYC6K`yI;aOa{<@hPGq|s1OWw=p5i!P5YFI;HPRYt!zddOG>!U^9u%#K&(80nyvanu zmZih}dd^=lw}3nGP5j=S2!iycj9-AUu?-xB0A;w*2X6KN7xGyyUVM>AQxh}S_4q3y z*xhgay)UFTke~lN?gO2-kXQXUvZ>|yN9{E2Rr}H30g)uCDlL7lU@7kaz4518(F-C{ zK_y$vvTpZm>$demhCmwJVhPrs)HeBw+(Ucx<}42tv!j%WRs>Jk&1*|%KsI}E4V_{k zRE=^2WLgje%3U!c_Q<=kuY4;k& zVYk}|27}vd!TtPS+o_P=%orqD#_e*mxU|TD0|%&x#jsc{1cL#V zmscn+k5N@sh1ch!wzigyM1o8v!#(%hgTvv#Xfk58+VFV2w6(ObwziHh;G?6XgQcY< zHa9nEZEeNraGJf^!E00^5jWsYiqIF?d;#b zpU;2(^E5Rz@%68Nolk%I(=043@YjF+*L>AP3`f85Bqo!UiC`-o zA3BKX${(RS*FxE$W<*J1c2vZC;snn=I?AE;k8GzzcqgTx6gbuY9e$q&k(bt49#5b= z{IPBL)!QxEOo6d0qtxxl-Ll8cX41s_r@8x{U$~=}RLv_Sdgh3?z+!%lwY6dP9|)r; zDaMxine~2xW79eA6}*TD6cvs0XQuf7Y=6e}u0t$2yv%LPlDvF^-LEF`*M0=0?I81U zg~hQclxzyo!mY1i$U+Wo*b!Q&S7Z zRTZ&2&Z()3l-b&_RF|*MA-tA^Bae?7{EyvY;cyb;L zOFHH!*7;b~0{dbs7~~t`)C*bEsjJKI6?4Q3Ln zn@sl2;SaB{GSx#{^L>Qe6(p}M(5(z(Ys*nn>tt@_GT&Nh#-%G#uNxtxPhhu3Fq_Jd zBm>1FEH0**oJ^2T8F0Bgq*6%~WrK=J3uR?i^m>JKdWQAX2x+N~f^82)b4`iW7Xb~3 zXLAe|u0c5reTr*o3Ab2pWQ*Zv9$)&996gl)m@+ zA_Od9+y*~lP9SH|$oWL_9*Lq(BVR1Awzk6h`YM&)3W9?YebcY9kPgzC7-6O9I;VE} z=&Cq?S%bR4ZjzVvEEmi0m`Cxtm%(l&968CbPz$3(9l>?H+Zht&6?LiTd4ge&$BDB zN@wqRo>>1iij@`gde(U;>cFAj($i`I>%!kt;fY}|IN$WMs3;mQe}9HO#+{VegXsM? z8>i!A*O*+jblo@r03ZNKL_t*T!xw2_a==N$-YBB@7LAx<0qKP=1E{M>pZI44ZxAY z73N|tD&1ADAMh$g?mF`gY)ub=!F-z=qQSxl^T|0bb=-RsGqm3lWOvz~cN{fVI?x%z z?eU;#8aka0Ra3Vi&@~TIo>twKiXao8Gakswk?u4deYm^`#s8y7B)~N$=cPUs04d)#e@hxx_eGEkfo$l89Z?>;H&f(U*(B8zoyKxLli%T3na+v!1I(F^syzSWD^!0Ad z?T#IlxvXjGy9B=I zePgt|yu6(c%IozqGc$uM%N#s-5K$DVtgNKJzaO{TjU-9<{eITg)_#yfgLFEBhW4Hk z`Hv(Tq>}5%Ix`zN4TDog%+4dF9b}C;a)kmE6gK8{7|cW1oYfQxMG7eI%HX{2YP#n~ z=yFtJGR%{_aE#oJyWt&BrkbdsY9^A2)GaxIHBlm&TVZ~Fa*GV>_M0hPr4xH^oJ^aA z`J9chu~i~bD-NAZD%FGAQqR;zlAv1hM!2IcURiydhkBl2&f`O>Xk&fvW1OrU#B=Fu z?EczeLc2bKwXKDj2{)5-qfAUck3}z0Rkw>!Z6&Uh8QYwWa-*B!wHULDqf`W+C+ZFI zg7y)1hXWj4sp3CNzsz#OXQ}Fbo)!NGsF_*iQdK==7hWY=euyP6AlYn3VqhiFdlHur%MMU?{P^Vwz`gMiLi8z8_Bj^aT z&+fv$BxCSckQz;_CgY5pdznOTnMhL$Q$q%9E;r7h69}8bJlB$9cXo6?U8t3_Yafv;09lN{QF^|uZdFrpp-~00z0uf@ZA*T9d%@xiXfp1B8n)ZNIDcjM_v(`TUus3JC7_~ zz-c#9=B>e%bm5+`Q;;>XUeLKbc*@*(uc`D+oMkTKrFmnRnYKaBx0kV_tP4wWgO)32 zIk|KfpDN$U3?elgB$}77Om5ONGDoAbjzd_W%61;BBS+5ZK`v%FzOWzpfe1mCFs{GE zxo0r!Y9d!u$QQGm*G4#En4~$ji#CfNK^4j7@(7xWFS$y~ z@Odut2*Z*Whb>KwUBZAuHeWgfFegsq(t)O#n48<6SX40>WlScy#4auHdJgj&jKD-9 zhlmy9vVamQ_4ztdLt$v}JpSfN=BL0JwvsOt-r@tVV(31GQBHuj{HFGQY}68KoH8$Q zz}iIa4(*DXO2x<}ilKJ2eBnJ^43&r%PGB?z$sxg=?xQP;Z+(HP;Y;*Yv~IIaZ%SEG>C78Ezyc50)Sx-B@RfO;?npZFcB411Sn>Q|avN zq^_>+Z5zWoF>gOA1p1b;^d{=u0D*p24dBViNsb>sj-n`BxNrfJ$;2l<@riA=w|G2` zrfKAIIXoT@R;!gvrWCZE$zYR9tTE)fnq^GTwG(OJVBW|ib1b`*Tz@pujAPAi|i5VaJIFgoj!vQ zu0o5xGpxn_awn|s5YXK7O;yP$CNZ~u<6mS*-o z`Y6GQ*pD3_oZZYY`0~q`?K)Cnk>I$UXlHsS;hwqOVa*&H9o(tVn;_6JzD*RU|_V^f>w?771P zyg^!8@4{n>f+V1!Q7q=!*jOf!SRgUK!t&A->GV42%&3}&Y`TEYGr-=42apGHthY!g zhMP|nLct(Fud{PkB{lX4&ebE-N>!XT96&QV2xrUq(wK{i$whv?d4aHF6H(B> z5CYc+>0BJ6Ztfy4yTX@>^el#o+wj8X7m;V^Xt82(i?Pi z+{>O3J(sTZGp40zY;Hji1ai3|vok3sC(;Ol9lyVd_BQ8j*MiZY$7Zuq5sQ&ZrI?yn z;M&zC%F9he%Xi_oKsqzU>hclN=KIMz_G2*G*k2julIs@=j>&^r2hp5561X8mw~O&KME==xc_eE<@c&50Pw^RP=tnc)ppsP78gObw22EBX1cKQf?kk z{v_VuJ*=-6=((K6{r|J~-och#*Lmmf*Qi!YSXLcTG%*Yjk4M4nkHW=racUT2{yc$44&M~_x))|* z^ONRf_O>0sxJwW7NN$Cgs3ZC#AM5O|1LUd-KHWqTK7##KYJkp_87iUJjoq9=GSbcL z@)~pYI{oQAxb!y}2$F5lJ$o6Y?=g6{dw=hP^j>?Bsz1tnqV2}Ipy75I?@;Leuqe^* zRR#J3De>O-KaqYSksy&sU|H5(m6kuw{+(2yKd|34KiV2#kH^EKk3LFYUmw|Qmd}3n zvv@onE?&GycXv16``-6xZEfY$sZ;FUy_rB3zzg zXy+0g-3O6G=_7l8k|a@dn&_npDLIT5i_&=IIqc3Ipu6t*^;BIUJ6a%|G7-gFpOX~@ z!|&tz#nZH8`j8d%w&#$&8sbs|OLcJk1`7)dWHJF{!J$zrQ8rqsRviL@iYN-C<1SuW z@G-QOpuZ(zfVlmua@6A59@Anu!bC$`Ar;)<~_=2RyY+moajv4+8 z1LhK8r<(yYMSMXc7zv|y_*p8z~rWz|6?t}f7yM7`fQU-rXO9_@Lg$eW$7f2)5mu6EW!Od z5fueB62aA;MqL?0zj~Z-M*=C@O}UbxSgJE|{V2uCCR3A_xp@94Lqo4KGq=b>wn3q+ zVaXkc+IC!_K|+~sg2^rxrpv4q&ePG>k2ck$5fX9KTfZZhYfHKJ^5rbDB#_DU5R`JH z>n|a?Vw@3V+QKeWSD3#U_ERYeBz<)ZWlL;D@P^UDUG%Nz=&tAaslLp0u|QYnE<8&! zm>13?WIFM;L~$_4uX(VQH5R6?(XwM3Ng=}0NS?XI8rg*squ1-mVw_B-n|M5d%jH58 zw$wUr`S-TpTGKQ_p%AfHno33H`p5>^Y#zf1kxKOAvS)ENp2D$2&|-w#9t!DphRP|v zG<5+p8RGk{IdTD!@lciVaE- zu!2meWjl$0h4#{4;XAaS7oBVT&^^QUu7_v~wjqmK>awz9(~%u#_sB^iu|8_?o&43c zPDm!14wZN?;l-zJy;j7;z53^5!u^23>#r}<)wzRMEKWEaA(2RuNTi9!Tkv{gh@zi* z-9@peu)1PXC^+H*nZCavE=X`xa&QEo;HY>97&G zv=HQ9o$SDA$n;0cTNN80=5=*9`uK6?S_ZIhC>?8x#>M4xTr&#n$vkk&8lPCp(ot<7 z#Jk@8+@gpWND&@-iH&fIn(o4@ddc`Qx4m!NYunu1#O?8HQC=k(K@@H%?n;!4C9E6j zzk(p#q}|^4@B5!5-*CCm4P&b`;4O3HgZiQqx*?zLcDo4%gLHIs0Py|qf1e{q zj<9p*PA*=&NNZ~=4?Xk{$z&3M@$qq8M6o_41u70YZ@!y-}IW@KMx813T71 zBt6JbtCunHBJPpngwJ0l7z+~a??7}Vs1y?jYM9O90+VwmDGEg*S{QvTjj6s$)#s+5 z#PPd=tjwi|$Bv^;PEb~N5vxPCH^Jrf4mR{Df(s`J&&as^ag?NrX}MV$ooDEkpHi;Z z2*ndMlw`5B z%IEZPD)}hMWC~-+rZ_RjRIx_uMv;#FVU)m|HI#xZqei2+S`#SQVf5isM7q-`i5@Do zR@PTbl!|Yw0MNpf3N18+qhUxM|uwVEQb|8+cHH{JQvO2FLy6Q|{KS`>umsVez z=Jh5a;&i&wM4BGlIRSm$Mq9JdR&DfE8-2yPc_^i=+GoK6q&GM4q9o$+c!|)@;NiI%y@c-@pJP+?kFc|b83m{mPH?xOtfIC4z5e2-8Ohi}7xOE16 z27AJ8d_ob=>Ni=MZl`;_m9gSwMAt#OcO_9@`w_vu6!YE&&v>#N>3R&8>e>n*I1Z8A zDtj)!ObZ%v*Mm&eGW`Cj4hq2(dJ9Mai+;cRMjp*U%^f4DY~Xgca`nn8nwB9Pj=UpS zEs7$_jsA`vj|acsPdFSQ9*Gl}7Vx%*5#EyBZ8mCLxbPEFeFMyo8MO2#-k0*TP_H8W z_|FLK>%V0_y*=xB#hl?M{v|d;I?tJx8LLfWI~E2is-Pg=rtTu|RQR@Zmc8u{-J($Z zb_*2O>04byO!a?kv%hsyFvH0YpbVj;gQ>!D|8FDmL1d^ zHkZaqv^19a+V&aZxeJunh8Z0=!a_7n$*eK6K1Duzjoo9L^o{#admh6Xcz}ZFXE|G< zyqUvrZ&2MFWE>Om0QR9rP1Pd2zcWq_IoL zm!9UeLlUjcVIKADzAg9#n9CXbgNyC_=7ID0r#ESD+lN~<2%f5PYU(*mdpCzlM~Q#_ zk-Jhg?OKh>_ypyZ3iUmoCg;u|s}hDsMQW50OF4u}8R}K2mJ!w$5jK|*D>Kc-ev54huJR&NS{8-_vJ4!x3`an zk^$VZgkaZE@~2QY{}LP@nBwi?1#6F8C^5N`l10UR5BrWcAKK5;-~T8+-3NfHR;G9SBB^o_seM0nEy%N@G5$82V&Ap^ zZlB1D(kf531~G1oPqDs8c>Vt&lX#5f37CIkK3(DGY?Y?dKl>jKUVB<0*j6j~}f zBu8buJL0#1#L0;(%;$!22m2}J;~krjU^6@Uqu`^^8Nz}t23;wjWKiNx7n9@5aA;d!Seb2sG53{4*f=*vDq|{ zWa*|aEAoB+zW<5x6-5R?xOHCKEB~XWK;Ow79Hijef({DXo!&$ z$7lq*DCz-HJ3CS5Uq;-F;BQG_nj#x#R#<5tCcfsR^G9~82-zB@8!SU*b!CZ6 zOAM=g5!KzpiKXmzmOD zBwp;JT5ix?P7y4CY^<}^GQenZCk11cK=yUQO9pZ#hUitX3OX~FE^_VIQBXC4@z@q~ zYwd9%e4In}LjmH6gRh>g4pyPiVTEWCjNifuSLcj6DqhD7V?I_(d{k#Dq+yJKccmQiCdT*)x<^kviwM{!+0fqwO6w7E;@g*lA+ z3T}H7kJiAO@Zs%k!`<77yQc%|+$)HtO>p0SR+lv<$A3!O_I^CGB2DE+dnMu4ai(b+ z(P*6Ir8<*Si^O74VxbJ)>Pgy!HOig@XYB@(0sUz&bM*j!b;*ZW5s7;m7^;I1h+za; z*uJz#ffb&U*V);A5ZC4s&DT#LwssQiNKmV~s8v18&Ag1}av`~7YEG4^U8Z7{D4Rvf z%>t!Ho??BILVc4$ZG(JmgPK#taS5nV4|>Xnp7h}kd2vey?uLdtFX5hdFs4n^rhqRU z#vhF{KVM;VbOoO`Mkd*V-WbN&_%R+MK&kIAi@p0;?%mB|?=G@EyO`_U$!yOKrn|Q@ z)xC|e?rjWr4RXG7J3sOsU^pKmGHuc^I>i3Q8aL5b)u-{5&oFx>Nk<{XbaMq)Xb){&UX*9PMd08rPD)ecQ(-cJ43cfrF*nMt zYp)WH_93+HVzv_I4^Fi)rL}NyM*!6jI7f~hF`bycMM)9t2CmgVBNOh&G&N4YzCmyA zcJ#VL%HxK1SmKazRqjx>V6oP;<`E3RY@h}3egtoAQWk1 zRNxUaTTf(@htStxvsffnOMT2V2qVL1Z1j`zFc8JAj3ymB7y4|Y#7Nus< zvDr3!0&iyL?UkS3&0Kz5WP!Q@SSg?#afw7c60F2491xsU~Qyn8SH0sMWp!LHI6=ci6h&- z_>r_SxRce*Iwwb83peT8*`%CultR&v5P~2zFp*WEGEZi9qDeSuo2q89jZP z{ky+FBGG>P4Ns-X#?>sVdnK-nUSZcR4S%4{L`ub)2xMCSx9!U7E00-1akEP)2ri%Gb8L8E7Q5!hPeB)lw&;%U0Oz}9A|In zAeVi^2-~N4R{I^c*IJo+eV$)4{tb<3reZ71M3MKw+Bz!w;~I7M~&RUUinix{g48?h#p7WuZ>Hpg)&l}b!a zjndX8(bXNG(O70FH%?J|h&id7s}+NKMW(}YQC)(G$pT+Wt?@+nDq&Xx!8WnSPI9g` z&eKMa|4M$G;6{_&i8C}0{{~jiPBvC6WJl%@xs0ULw(9mI2|?Tvk9p&BIyb(9w}N%E z;}Isb001BWNkl#B?@w# z`Rp8(>I#Dc0pbaTQfZ9!@)~vHAeNrS(%ZK(u7dcERHkziTsUkrOqR=art%e<3waJ~ zOtE`ol7wd*E0bg`5~tt`(K>vEv4s>r-u-DF?1&K5WCXK-w)rAKYlui_58!5gzHp;9 zTfys9(KPACPrQ|WZZ`8Y8aWz`BK5{POW8KUmpWO@U87>`qwiopzR?o|s#8?@Q~aTF zf=7FQg+y_K{^8>U+zEu19W+FP7sjG|cQ(b|Z9zhQnI)>67YpnO8}#|yH^7jCy7@9` zh6wn#bL!L*0e?5)K$zh9CgIKmvR{AW2YRD}6i&m$u9?_%3%hP&*P1w03)f&2L47mh zx4yQ(nae*W)w`d?=?K01Q|~D_F4U`tFMN-H&&1W&b4ON=iN&{@$8dLVBjHWFhiT@} zuo^5D7g;DQurs+6kLtPO>)q8751n}iea9C-xp#ek^EHKUpU*I*CK0ev3Y+{|*DAYG zd342kPYH6<5_xts#t&CA?B5>1t>2z)V%rX{4;47#n&HdaKEzD$;y7bsgORRXw|zR4 zU*o~y(}>%i_}N)jwvAIMBOLyR_q<%`P8r}(IE*aIH&tpyLA=^Gm-2igwSgQ{1evJT*!CVDqj?gnG5Y`0HnR zdUFw!GneV`{I4X3yHE$8WLb)_oXw(?7D(tGY5|#v*eWy8C{E2LGk1#2 z>;%%aQ>e`i)UFoFNgt!~8s~&5!cvom3Y+Y7 z_1zWx{?)}a&zVW=-Zg&RJc^xbqpPzU_q55{g(1eO4LX(zZ2y!CEt&Xe2C!{Y8673J z?ofZ^%UmB`AwPSR#~%L`yqhjIy*0|6@9Y(PGw5PjEG%Rxm9p&K6T$D7sa6*$lxE0T zK33Iz44EyAR}@+sZpgbx<@L%dBJ8D%fTXhX%G) z!VpaQ`wtM+lQgnr%1WJbn?ymYGdn*^qp`-oV32rRquE@eR@BkOa-t3w=%KgQ<%UF?kbkp&B-cnNRuX<~t1 zR5iiuY?0CHC4#{uilWkJ)TmS{G@Dfv#l+*0@%a^8F69O|R;W~(jQqGlIzm38tosEG@d|?CiolYZ0q?F}gzUov!RSw*+h)$KmXm=MjPq zh0P4XWE;V^^#;Bz_^p%>pZhC5mP(LlyYlx+4wY+2=C+a^K1xz&^UHkP{q zpy!PwJGwnc?~>ZJY=_sc6?oh;!>H`Q_x=0+C&RZ<6@_SQOa1+qk^+6- z?%Vqc0#8pdKQ_&};zH^lAmv)5a%GWlx*bK;Sr~E1xqgaw_x_u7wV$UMia}MCncWSuf`&bTqe*Kst_*>LX}5q!uPg zO`gWrwD9?p#4|g{wYG2~uuSjCa|HShlU!{o#<~@L5}4$=K1+9Fk-t|gak%LsmDrEivg3~6_b)D{IqIaadN%m7@gl`( zm5%m7R9nWlROa&fX;i0=ZSp0&I|o0y5)eT^35QX|I_Ak&3G91-n%Ks*b1#q_NE593 zaMo)yLL#^0KZ>G=-|xpTf(%_-!ZhoI!%=+RRzfZ>0dtg=`ipd{6>6Hpm{-A_)EFxV z_`yX#amAsf%}+%1(!VyrpK5bNQmz4M)^LkhhTe z8*1#LKtPpAdkp&G9=t6HCR#f=(XyB1Ko{M97fN#(*Xnmkh%-b(dstnOIeT^;%Zkv| z*+`h!HHG2*clk#HJa4b!ZwiR^VXL&7j|d_7-s} z!EWGM`BT#20m@~WE0-(u_70#{AUrAJ9*81H!uuMFZ?#6F%Ee315$_scW!^_iU-Vt) zg;2~PJ@ais9RZBCwmYuB4sg|)<(NOmuJoRtiAnTsfH|`~OILmqp=H}WxewTmz@>!% z|L#&J{o4~H!s<=2V?`E-231l?FC*0e-@EFglvfD3nz%LV=G1i@nA-60Z%%dMYD?48 z;<`zBzMF}{Rw>MDS%YT5qBrs((4j>MxtJrTX_Q^JvXK1cJOguMh^@Om_;;y!A=L7b zh>_h*1$uL11GCvg+LGDcQpdgT-}gT;KA6oWilY87q!dC82CzpjVXA(d zQ0zTt;;>AF+#2Zacifx>LBKM%+7`LIcaOfShJm06Ob=hZp$sj(<6N>&!yYLhb^Dl@ zS|%LPaBDS02ZqH6)rP&*7U>R~JV_ErhBa)jkCQ6_zI!!5u3#X0eS|Ve^msR+rb%Xg zl;rG1VxoaJ*~Kw^jO~{eP`f|H!bSm&4U+Zkl%!J_L0EQWSgiH%g+iG~be>l_@+`WR zNfc-3T$se~jgeEsZ074^)+O3=HjbEMp{;}Y^d54a626tw1m-2Qv!%rd z?NxGX>y(PiR6}m!H4l07I&+Er^duy-88?0P1XtG2qgp-mW}m{?n`W}l;HSY^&Uxl( zF&Fquwa%w24(*}sxY`dOrU$_rzU_vpX^DL2LWWnw6phX`zT$b2i0P-B5;p*k;nWIFzBSho~(nOV}U%*yx zKOR(7B^r&B&%?;bI_ql%L`fkSOc3;S5>#rWtqb&9$ML8Z>j9NQLg)Bwi1Ly`Z-++I z9bw1PB!8<+BM5Fk6#}ORo&4afeZ2d3X@C5-u6p>^RMU&B83NEjT z$FJiJ8u-I*!YMDoL>OC$Q!E5n$c{3TeH}gNBbrL%UKMf8nS^3tj983PMP+r>W^6Ri zhqC2abgxZUGlVl872A?LId? zsfo7v5|Q#Vq=H?Pieb*3+oVl{x?B|cqv~$jC z;o#07HFuNa;s&X(%;A_Hzbe0Ncsr=MS4qn2=z1HkpIWA)V;hF56S&gE-xWu4eK387 zndu>#T9bM$hBq9?8@M}#Sjep*{pe2!ZOfpi({}|p3)VV+u3hF}=iyuW9pAISXuZPj z%P*q#9s&7-_o+86k*7!Ee0QOPL%YLxUAK>dN}@o}tI(Qqvn&L7YBa#af{IVI@aZPU zrX&2(NGH2?#fSx!_k{u90Kv!R44TCc0R#&If~+DePp}k!Gk7654*g^22t>QV?f>A{ z`G^7VZB?L~O-iLAlDI_y6+{t9lJ5KW{ZE`PDKffY{Bo*5-?#hrUd=L1zVR>qJ5U2y z1~h)_AJRGVzoDMpMe4x;@*6Vi&kk|^D|77c{M5%vD6MHVd2Q@P{z+pmX)%mlue1Kr zaq55XpWYq>I}X?uLVg`#aSoe~A-&r>bbJ++ga;1UN#A-E1Xg{$f_+I%0+3*s?=6Rmc(|JRRgbJf*%%RIkIUN zMaPSGWsdHdV|Z6%*zHj&hD34Q#wwO@1yl;%Zo1C7INS3Yo9+LIy_G4pFYTt7pJS%# zp?h(f>-)|!+2Q9v(`082Qq>x2bO0gR3WoPxDRd1>D%IH@M7w6F3N*9){8an7Et zFgUOm!&Y#=y2**(I(GQ8vQAM;W3FyyLdv`iBM}$9va3hR&Y!cr`g15nqWIODqjU0LBbIw97(~E zRB(I28^ZC0LDNB2a8wm^V=KVq^5Ce(7Cl##0D(r$WMw|j>dX?&@)(LZPv5`++LL{- zW>VcOQTK!C7HAsKbV13kQ7V@xS4$L%c?6+GFenp_Xt-Q5h9RIRRZO#lY1L_(c^b_; zbrei7fu*#AUw!KY6B9nFp}@;{#0 zO=o``Pe|knc_fd;fv^v+BHwoIi}fs_)&E2$`Dw0Q-NZDLq>?FIb0)Eh2ctU-A7FX5 zZHwoh{ZrDr2bsPWrvJfCWaZA(7h!D?`G;R8bZ94PG`!bmd#K#*i>> zALk7^^b?*Lp1jv|XxpNB;Rn2O=t-(Kgswc5A`hQ^7J2s{lEP^>OKv@5%B!>SBYKwkI2~_q_*|3 zFd?!u^bCdHYNg}n)c)vhmapX*zWxgTjrar}L8rN~!P=#1%wPSlx9Wjy8=+c4SeSvu zX{7Oss4fvLokWd*lAPkFYbote|G@<{bJ777i=t273Way;-mB&=OOR73GkygMl?2VQPcr4L?~mh-~OcK3I)yvXX%{DT=@|1_t76AAAsVrbON@Q|OoPm<_%a$kII%Rzr?l8cJ!<8&%3f5PE2EsJjeIFCJoy7l6nBuk=`6& zyeTyG*2iwSZmot>t6|q_*o_8u)5J1OoO&I*T*j`{uxkx0Tfp3Q7-#zd9B*K&B}2nv zZK1&ORKom<@R+UC0#MoE`yBVazmnOKb;Lgh^QG+#enThEOl*}TM z+=?hED7u59Imn6$SlG6WW82t{jbmF_%_e5UqEg<&z@c8;myQw?ms#%$@h#Ui9`1aE zog-(+hJX%CfE&Ji+@CWxSd?iVCb4ffBztA1)^8L zzc-B_e=u;dwwmSKwPy*Y9-v-J($aV5c_A##qCEBg5;}4aB@(`SCKZc6YaT=I>?d`X z)=K+92;V_QlpeS4IwJg&@mSyJWX0dK; z*lowbG)=BwzfLBTp@kcBe(-W7Oq3V zc!BWl1E}|aR(7vVmvtQ7#pwDqwn-Ue!-Z^Dsa+dE*jPb6_IK!`$8ieea zgpZL*kROeQxVGS7XP1|rxzkJz>|lBPSq8LD^4cJCt0&p+8ek=F&=z`uOGX!YStS$E zX-S6|lG}M^^#HNT4j!pnH0bC3Y11$ZlSzXt&d`e1_9|C-|yw5YM(RAcRw( zx!?1Ksn(SEle4W{@+2s=uJHF=&(WeKICr*2PtOiK9uIQGLAtcT_39{ky_ErD2+u(8 zM?%Yc*Ks3B=$SO)tOrY;j2WU;i<5Gt@Ht`ZvYT>AV|86%daBO!RFmvH6jt4I30-vO zB1Fq>RIkEI1mLow$N&5aTrJQOboV<$Q!nDMX_l#UX+y$54#~ zmYpV$ND-Eb=x2U}YyWP(RJw%%4e*U`e1lLZ#F;Z^n4ixwFff2D%b2DKz{yjmxIQ|@)bun9i&@6TCzzU= zW^!@@RZ;Q#{qKo-DhL8suU;b(iQGK@`=PBinplhgzWwsq{MdD#`ggxiy<-QPsSFS7l@VS#MmVttS0KXhQ>%>a|1rA`fB9om z|9iX5+l7Vm99lJaQ0_&r9jY@kNV<*^i6BVQyHZ@68yl<+En_|TJMYLiHtQDGPmj~T zzxJN@1NCZ^i8JRo^6+or_rEQZ;y`s`gH^rAVxYpp;#CemoTOG?WGy#D(RiGu{}4G# z;lx@IcSU8J;6p`xpWh3tY&K|2&+)s1XQ*Y5@n?xRozGk()%m+zSrO5jXW1EjkPGh9 z1a_{n?t6kUtA)9$LE3hcatx|@hw0fe?u{j$P%feRp67(Vj87lrfYm{Ky2|GDJo8c- z(;GtenB?M{R9aS9F*<4HY`QjcSQEos+qQ$279WXG7d!GXPOV)gT+Lv4ze&8i&VxJt z5rWJ2zCLHQDe?6)t&9ZXJaFg$9q9q81p|+u5fVJa!~mk@&{$lgG(1W@ z+K0XSFitoDqKIufoqDN=S*s#CBC71d?G6zLCUEH< z+>#r&=0~&y?7D?hwXhl{O-!1qgRMFkISD=Drm9z&UK}C2G(<4dimbM>zU09h4wCE$ zq8ZA~fP>>WH`T9kz-(GHY9`IPMYCaHnHI5>8};dL5gCkgKCr>rgw5_q9}k^=nnwK7 z{L70E;M?yalyuk~^1d^c;n)av1-UYYTsn_o4&!sR;rDOj%$ZgEz8*rM5U$yKLwyFQ z)yh2o{GX87@i6Plak_RT--%Tm2XSf`_2^#^dh9Tg@7?F=Rdbf7d~-b5ari^Ie+Z7l zj-ivZsR2m#{LJSuZGj(OkMrDSEBglgXsY_-t^xGQ@rJRIe6@cl1yxC{(Y{vGysML@@onTULBz z*)u^P>WT4E@e-X)F9Fem5)2}Fy<6b-Jw%-)UB{Xqr|C(6|JJOAD2Ws{nrKD^^*wf3 zk`)D4(9hV#v$VAKBT4dG-Z02M4Mt1Y36*@UNwZlb9#7!+v}2gl*c;EHNPgNwE!1S2 z6V*I^N5=gw+2FTlAl!z;x??c4sB<7~(z!m*bAu_uC!QnOeS~bzOSL(|fV7X%+y;96 z9DUMD^lP(-N`rCPVNro}%pjJGaari(<;92CU+Jc$xy+>f3a`X#M7lEcs5QLv6I4nX zQYDIiHH52H!0(>Gf7xI%bDez4R#J|5ZMxBqBL zFo>p_aO@dEJv|WL%eC_tDV8V6?C8T&(-5aBG=0K*qC~%Sp19p^6vfBTP?k)ljfA#B z#u{Ta)WVF*&5M`A#KTD*l#BES)K1>TvF5 zteGkB1Q0x446jZo3quv17-XpP#3{uMc5aO&}-)FTeaU?|=XM2?YE+ z@tyCmXU`rIi3G($5kog1itE3ZZZaS(YgkOPo1-hV)t*yTgvl>15*cX^2)0E?lHgD9{pbp=Gng-s|L? zx7oK;pttPD$5IPR%v?H4yaXhaQhoUt*6{9YBF4?K7ZSO(0{*ad+s952$&DDr^&0-#B-^wX z=+-V#mCKyBN#L_F&>0}@?&NrR7sa)G?8w`wOE2(LcnNv1m0p)lWbqQUf{Ij$;8}~Z z!D$w|4$v9UX<3aB&MQona7+)(-BBi+nJV&qy^`K0sP zB-9orP8kdjZ^z|o3cARKz;mw7g{7mIt`T~cPt$r=KeE&L_H4|OgyQkw>}rSeFX1k4 z;NN*4^Yc2>7oH^A+fF#vf_9;bC7T!?`3*$??RGm}Z-}#JmkEc%B>a%5zlaj(Vliar z@LY&urGx!(nKn%&;0ogncHwLp!`3p6lGuh6AAx8ugxV4QIKm%?U>qsZfzmRFJwAdv zIzYe~q(yPlBRq`hcJ>vE+?CG~ayfazxz5?z91+<^z!S#R*@4q7Bb_~rIe8XbKJfaH zBni!~;PTn=hh2nQy+k@f_(DM>*-O3dW+UZfJ?WxW_M+IrxO^>y+d7H$b>NQr(WMH7 z+8V3tb6lQ1&ZX(2l*kk5>&D^jXKgV8N`$V_Rw8XaG@F7XOr-P*@|h#BGLM{HL&~L* z%0)05NQ&AV9Ig~a2Bb<6?TNp@cW@8ilqQJwY^TGp*mmg{hm{ZVoY~EKXO(v+e7IGq z33@A&*f*ZSU-&9fB~RQJB^(^XxNcn+xG1W8Ry@ym-@SP3H&2-FW{0@%6`@r_*`mzx4)a4k58(r)e@ZMH=qPNKqfy<%Lm2hIZy?ptCkA+nm z2~Pu`!+3Kz)8WB${<~;xdw=k{zdvFCe7h8AS(cfZnIRI1Vi*Q4r;E1scGOMEr~qDi z=_U5<-N(x63TMuo!C|-4+SbNuY87Eww6?WfM={N=XZfQ)`tS7h_c1p=$Nc;Pg98J6 z>+#141p{a{8(;Y17rFbMdstdr;_F}gIz2r-Jn`M{5DtY21Oq(w*kjmjb}V7>{qKLD z?c?K2O-&JvMfu+MpQcu;vVDBKNq?4QjvYJ3@bEB!KmbKiSX*0TWp#xUCr_|@*DfUB z5B}?arKhKdKmF|I=}@Qa7pv12En|H2p0RE>t-;Dr}nU~Fs~Gt<*lYgKBsI=PJv z;;|UTVu|%khQ-B2#>Pf*yFK^={w+H6j|m03P$=++fB1*|#&7&OE|(isQ%NKen5IQC zxx&cEFuHEslme}?Wm|TKm?ca!)l1eFz#Rx&SJ<{~Cre99EG#auV@HHY#7QVL zj#n?Uk^d%kBgp$g@8Oc$&y(wEB9?|51qTXtWSpqDBn2nRRV*ZJ#_S~tw)eSt;!HPj z_ucFjPw-KPov+BCoe~iIwf}C@RWszrPeho`X)9yRPXz)2U zln|HM#$t4gP&i1y9N=uapG@*S?3$aP?s=N;`7hvjWQg&#G_7Z*SxZMazk7{Z^e1qy z%rQ_OW~y)$n>>t5|2%3-mEO*u1og(@byG-Kmc)8hV`0O^O5VxgRWFr}5ZS~spR_+t zOm1c3l*#b$cAT5wRx4TrE?DI2Im&JoXHL+PsG$1&Z~OKvI~)WK?8mXRg!;^%;kx@H zOlKeB$!|Z&_|AUDw%v_wp+r7iq?S-<1Z6B$x-Bs5bUGOt8t2^E3-tE6=}rt_TmLJf zv5)b%J<2olQ6|pZ%XpxQg@j=>)ol$SVG1ycE&HcKZtFd0wJ)0dp!bS%M_0q_wB(`E>pSiB$a3Kn86lAFb19w z*lpl&AzU65j~{!)1yM(H&jJinP$@L1mg;2J>XdUegjvVo_2Tjgyb+67mxX4tDCJa^ zrffKU0s3|Yaky`}?DyNGuNkX{P{O%wUq zZ{Xj*^R^a^3ZlaQ)SsuVcQ=7sESv_4S$4nt4Jw`}b3HpqhZC5$F~CYfFfcuVH!=*a zH{X{prG0#9xr2N6c)3Hc?`sx*Ar{nlaBqmzhKGN9BE-9VY2H7yM%-IRmfiq0T2_$y z-VL#?JAPhc7zVN;qo~SNil(9}n3jnwZRW6UM#bLuzV`vp(b3+FkXaUzBq1vbdcBSi zpeQnHYiaiG*~6K$XW71E$0k)+H0jNXa&@053Ny1a?B2bL{rmSbK0Z!zC5a^jE|-f3 z9()j+rt!=(&yvk%nK(T`G#aH=uMv;MSWYhE3;0Q=GyMF||2#qn5-lyLY7=BU_Sj$2 z-`~eae(J-`++v{7Xk5+ly>eb-+s1HvJzTtW33(F$X`05qefxOw$*0)6cMqpePq3C= zQWr84<^epBp7(9#m8TCH$?a*`c8b~Gyj2yxB1Z~1S3 zUscp=`o$Gr*BhI(XXiDa2Lb_7sZ~tVx~@-M?;f0k+9@e(w86Bl^_}f?=1E9Xrl@&$C8}U)hRo_q)Sdt_$$(gE)Ub!ejgdI!@M*|vULbw zON`~ly_o5{QCDUOrVnu`@HDA+d-3PLIPiZiuJl&DWL1mIg9s8iHEg#3)$Q*Gvj!lWNVtMuVtYr_-a;6Ie9=GBkJ+t-MJl)X<}6>Sd|K9Z3e5{z-;K4g(6n5j9IH= zNgAfpj|j$4+WN2uBLrI8pwowtBurCKEjFl>8kF)iR_E%}D-D7z9(s3#n!s0BNQErY z#ZzdLhj4YqaqjI$al2o?tq1`^Y~}(Bgw;GwQPVJ@0lv!9Y;S#l?v+`51o^$0og_xw ztozE0_=9MLmuZn^2q*4Gl9X4C$%1^oMsm5taXfs!xkU9<^D3Fz-RSmY3bfcd*J4Z&%r{LJtrQ= zIs7PY)kb(}lKkbDneX1gN~{w@x$#sgoJ-S{Ny7H`zUgyMm+k!LQ=RPF9Yj^6JNj8J z%a91$hy}wWGhTl4NQC!BQ@m#|O~~1}{oyV#aOaGTH!)RJuPW1C@l{ntk(H~e&L|3v zdV}Bn-T%lZKJjsmA3u&P%Y5onpSr4=t0>CVyx;crHiE$*t*x!dvW%|lSE~><&7?#zXH)xuQX<67b4MmozR;xH14s3QiLqo%~w8TLOR8>XZ6weBV znzU$P33_^Zn4O&?pU+{_qF0YoY>r8 zN7Xbw{pp`U)0zyrq2Zw>(A`uImL=(G4z(mn$g+Ihxo`Pze;>r__K`p!h|}rf+`04Y z+_e)`RjF3096fr3M;>_u%d*fE?REcF(=;?q-2%W{c4wHdEEZ3nK`0(1Q^#v$QHmby zZWooJj8f$i6T)o{Yg+I$9I$a+I_1Xa`M+CI*aBj4nK2Ywox{go(+q$zm% zBGfLeQ5aKkxm=8nZex9Yo#&pNW@y+=XQvOlJxC&U7lGOu8@bO>lYAJKz>>UJasZ(Q zurvkBZNris7_yV1aFDEPyqIlpw-mvqO6=Jd;{QF>$G_e6F#8w2L2+<5&y=3y-5UXD zyPIlxoyoa5&Q)KavtyJl&k(+hjc`?^+!CP_7G#SXtSqb{$vJjMmblM9&6;U2E%kG` zy&qd^E7jVkaiu>*_u5IS(lgW|op@67^w;B@Egi;bjbJ!G%dpoX(sMV+3YL&KwH#z3 z<>6$(LtSwb323<7Ho`+H_t_m zYwlGh&mAV*KTh#jop@UV)#tn8`l{mb5WN3h?3bocPJRjBo)2(wZiB!7>R)m9{SVXC zJA$(>gtuy-XDjGuE7XiS^{7NWtf2elCK!HAyHym0!NDQs=jS+iBF)ZScVR0W!ez@a z*?uoItJ*A`AOscyOEf`K(-#O~A*?#4rL!pPR80k8G#LpVp%O#ECTuwHVzXpwHHo7o zlP{jEu*1I2`vyV`MEZDe=_KQ3gBM$({89Nuc2tAhuZ%+x%F)(dG)DGBW zk62h&hJa00g>)q^;uW@qq9FTi2*2Q2{S4905yFN=-{liLrTwq0hoWSHWgdAZQ z%iku{avzeUU|E7fp-wVcWNERArUi&ZIv5=ZUd`JAkSy?BFz^hZ=ue1Ba<2Z*O0dG@? zPVV1NuwEvRyh#7$mznR{$x>?%jn`Bk&@z>-MRt zD)D%nFMs(fv?dZD=ZbDFBDo|RbsP^q= zb-6@oeH~d|#;niacLwO|-bqM_(#TdR2TUpnnVM#h$*z;lrto@e#NrlCPnmSB$fa5r z7ezOw*-53=V4MC7W3w?LDU;L1BK_$kr}sb3FSOo`bMPLJB+jS&{I{t=b`JS*I+}T{ zRSeS9Nm8Sr)aX>RbeOZWah8}?#%hdk;ey7<$T)VpeUtKRVh=evxBN0?X@f-H2n*-! zbWePr-bV*-Dc~azZ(G4Pguuw<$Q?aFdFVmrlvWle594uV>FM82TYE2&Xj^m9$`I(; zD*AebdZ9|qXVHi%)M5%kxjquOva&+4ILF?7F?4-~m8y)XwIf+YB&!5g30YK-siBAl z3RaUgx|suO0S%$hkit~u4oZY6VxuB?smNinmV=yO(J&-h3@84)gQeLTQ9a8?2GfkY zW|0?n*Z3duVfOUBo3QKV z%Ie{KhFvF~*aW|!*WY8KhFF=Rnz_hQ$2R8M2dLYc%#Pv9C+VwONZs3cGhuL3NPJg5XL$U=_Q~4ieWZ?h3)|{5;9!WfVna ze0&>Dr<3EykF#^g91b%)G=!`uRLW&ePD~(#VB6Rj zp-_m)3m1q*ql7~t&YU|(C=j5%qXXSAn4O*`k!Z!`b|Fg=6Q@s8saCP6Dh*wyzrUZg zREm+&Q6?rP7#$g5^1?;7Z5yLjt1&S#fn{0r_xF=XB)E9-BBfG^M!kU~OLTX4lSm}c z4THJaS*E9_>F(}kXlMvYlD5>9e@s-M4c#E0&+*brM<|y|3=R%5K0c1vT-M|45Q5p$6Z9r_(c8EEx=F6k zP3p-al~jRJo45*u%at;-bC-$542Fin*zLC;u`talORI-C#6M!O+RHvEgp5QoT}N47 z;Gd5ir!n^wU+Cz;^UN`}=e8s7dW3q*0Bh+w>&sbejto{~8mm&Izke@n{%)j#pkmgj z#${?@iHcDpy`H8}NE3@eOF~1l7g?|6nJ&37&s)4Jx0@47&!g1`QJnvS{vpARo&Ojd z9yY2PzjbJc;axGxcAYiqWYjtlyP!i}qt#jb zbJZZQsL%)*96$dZ!u=gM+^sBLa*{A}beCSjzjyadqZ|enPc%*tmb?t8tpp`E3U5CK zgfMhUr_WNTyUC3{%vwRPzC1^*u!zUCL3iI6ZSB28W9`VB6kD@Ur?Fb1R4P&!Rnfij zb?q~o&9by~fddDlIGt52tAV1}kR;pY`>WfY^t#~1vJ4EPh_08=jT*X9qS46FFzeJM z4~EjgvbvoUjW)E3gJ{`Fa=w9@&++lT6uTW~QC41LuBC@V!2s))N!6%P(o0nJ5~fkd zVSvX}iQpzIc?jahCN)8FMDh`k-N>8pSVn@)sEt`Dpl7q_g(9|42zy&AC<>-zv1a92 zFf*JI^DH>(#Qj}FeKDf0D6%ADSOyinLd~dBGwK+Ij$t&NWl)=4xV2kci@Q4%DemqT zytuo&Yq8)^+}(=1yIb+%?(Pl&zP#T#XC{-G{K+$uY`OQ^>%JCl(gQ12m$Y7uQp@T3 z+8ZiGDEUBY$x9ELP#7yV6+z8)4z^JBL(S|7tx-l5p*IzXMlf&KXq2qh#uisZ z1a)Y~vmXHWCJxF}b<_ON6lq-nYKqhrn@NDd`qX2}f?Boz!ck;7=L(}x(Oz5nr(}f5{fY#_u3<$oDdt55o(pXmnBnS1RK=1h7< zJD~43)!*Y&*5Y6Y+NXwa40dyZ!E%Sp`j>T0cJ)X#^*c-*s0DRsRjeX5Je4x7S6JOv zU2QfJsg&QQzs;-~6n3zBUQ{sQ$*dWQ^|!TdloxHf&XXXdMUAb?L>;_Ra!Z(Yb>m6T2!kzXP6`$Y2@Tn0IzHo>0NI;g7fcR|HazjrVQp1UKlqO2fw61 ztbx8edBs?o!1!D+3;yiiyBt2T9b#5f*ZBT-IyIhBNI<}kn-cjdDR9a0cO!Odr*GEO zjZ-<5P&(ER0jF!C5AsiM=O_Bq?wd{Fz&gV9UtQtbrr^0RuW05v)s9jr?T#2U?(d) zv|BpnD;Zj!GWa}&hD+)>Jl9qaIn7a@w~A_E<4Q&tTLroTEJVPO^)c?1k-^OwQGXLe zSdx!Si0BFb#TH;H#G^FMIC_??5vrYYmb=qng@u(*$_!O1vy`jF@Vcp%uKtOys8TIE zxMqi{N{LgTi#BjZ`>iW(NdvtSIkXEqugbL-tE5I&CN9VAX~jgO|xx& zQSwsrvg~n+e8~$!CUyW3jzE}Hx~6(LgylM3Al;lMdJ6b>e!Tqj_G#Yhig6aLXM)RO zf%6zv4xS=t4jmej%j$k+hxBe!!lNKox{dD@0kde|v&%?BdBhT7V;uKH%<5 z+?J}pTPF0$pui#jZXzTyJ#67rwGqs=KG2V$%J43q)7L!fF+(ZhXnw%{rpoz^7BXo% zhXQ9K5Lf8AeU0lB1~CyYdKe}oKjc zN-nT`=8oTnkJTf>QLW^nWxLP0tf^yhAHN98mt||?pLwOps+InFLDR8pn?8j+< z{0ElS&IDz{yx`(WcAPwRoSVr*VqDw%`$gkbW+f@-M7aq?e*Uzj3JjPV#IAKQzmdRy zM2P4{OKA9p$s=9b9{IN40(ZU@ir?BsZZ|i6y8$w|>FMw=atUK;xKoR~lV34XY0`A1 zT&8}SFKwJ7hvk%0em|y=?q{E!x3Zp)vA&bJ2hVD~!u_l&(?x=P^+fjZiB?^!RX!g) zyp6DtokGssLQPsF+-^a9-7+<>(Q)u#)mAmr?)>}A8kC}jP^ng~nmQ$B+Y8eK;HLFZ z6e;t2F?#v;+11m&lSW!h3uz5{2CFA$-Kzri=Gm%_F&aP$4uWqhheQouj4r)N%Xn)s zg1$5NUpMhWX+73IHtzULLdc506GjGrT+(9)14)#!lF6c7(#87uDnAk{%>FDzal49a zco9)n>!S8c9Vj*f~NDdtooZly=QM0usxaj57N*qdZVtAAs(V}fTZ*5a^deV$ zs^w;GT?%GQW1n>nDZf+$Ag3kM6ntJTOa#u?@W}Rks9tjzaS8d-cR#(B*zLMc*C4)Z zF+qDrVRsxjDCaqdTKN}#U_*lmQJ8!##}YZOYdwQ=pk=|=FJkj?CTz_nxOTd{PwZ2Q zPpCJ8o61W?M*qD0Z=L*O!fx0x=eE3Jho?cj+`3$F1#tCctP?mYjrVHsLz8RcbaFo! zjjJ=)-KG`wGf_b8U!?LZ#??~pFb80gIiV*WUk~i{`R;LneW8d)xOlVSEu#!2`n&LF z6tpq^FjOQdRGwDG!3M|Og9>0f#jVWZhDgdPEYFcMbDKZj6$q)r94b2*ZTaKNkRex@ zX|hUnIG5U2dKr$M5F*$OFumz*H9ks-e(8@mw92IM!=M$@5}~U-Kj7tZ-q-PXessq@ z0h;FXa2d64!<~F8k$>MaR?}1{Pnq_Zfe~9%i{<9c1l%>8rC^b`Yzh^!;ePF`AMGBZ`SGKL9u2pC{hETEmYy3_$d*VVn^~t!p<3%5hK#7 zQWu}XCEki{vQh1zvo!dtS@<^&prSKc0zYknZ?f|n#n<6XV@0;8|9n$ItWL6G_%-N4~)AqOaKj;Dl zjGSk`qex~ytAD1&8sZ#Hu2e|nSZu0yI~kH@J;p1Te2PM$e)j^~ue1LWx#ZJ7ucT>0HF5OdNBCaba>qGG z^%ZXAV9g_1@mts`9R&}Lgt9MpdZ*w7pB|ok>fS9C95n5bkex$$|L} z%0?|94{m7Qppm04s^XWJR~^h_#txJ7d&Dr$$BL~ybR_hEKdHxMI1$5FMpok!_4iAz zzSqGmS?eI~RbBVknf31^wHL*_UV&LYs5VcNKa~hLf?vkiH3?jia4p@PLA&&%o|c+q#Hm`keEltd<96p@ zj}!UXU88UmO`n(em}W5;gjs^6Csnpu|H>MtMaARNSZgh6Q1dXWnt1XAa=sGZiwmu^ z2mh6Yo~PY`PwJIb(O2B{7&);fAv+IO)gHZmAG3%^4WbPDNKkaQA85G{_y>sj^!yVy zESw~27#U|1AjaTHQc;17jZ;o5`^%C&cI(Pv(ViW;xw1Eh)9EKWdrx*-zr^99!aPTJ zw)3nM^+|rq&!M+luIrW#r-#Q)R%27LXZLs9u9tyxf;XfDKF30WnZGiBtBeoTwU~5G z7e{OVz#S6mTbIXu5kz-R`a{^u$OAuW-w)2@D0&|V+5<4#nl6TVYx=B41SIPnUnK$ z-vw)U1Nt$vb1{Jd0@sd^eJcJHc0vrhGlDhIaI(fQ2|wLw0$j zjed59PO=gD5fl7B0Z$|1?ymWvH8>6Bg?9_X%B)o)l6xjC@^KM0HT8FYpXy?|k6_D} z-XNPe$ZnoPaknb&>nuj!Oc=f0I#M=gWB^sy^luj7?PpwF?ldWopr6&%6kYn|a!Fs^3#GTxaO5r!$Q;bv{x)6|Ok|10-<99?Sog zaF0{$WDGf<9X$uT^B9uZM{BszEq74ln*Ga@zNewIuan z0U@I>XhOC3U!vO5(jP1v+y%QqYXj;|i{xNFf8q(O-LoSd&m3Y_Jpx~~7dc*mjZWWx zc}-2Jj@&^de@3yF7a0lh5H%Kn0{i0dyrU^9;7fj_iDHZ^JaQg8sf1uxzItwhS5`1> zJ_F-<&9WzT5{ue7V8B9qD=q=Ib0c%6$YgOsvlv;tDw?)N^#_esU8Jp2sd2JaGf%pc zCmpSOXGxS%ag+j&Y$^H>-OBGwbby0j02d%S1{qw($yQ*^L87oU*eg%u8jj?!0K=Zh z7oZNx+?c+m)UivIB$37t8|d+lCg*0i_Y6Sl=}Z{i;_r zu0(6+AjP+!uLNYd2=%0K3P4^ud?@9J9nuhFEon6y>=WH!-ReHc>hbW3-fqi?NU~@1 zY8&rVL&VjEyV0t*zJqNP74l!H`Lfbl-J_7J z&aZNP4o&VJAj8oy?muOFRuV>>+rK1y0|Rj%CRqy8nOUv&i{@3QX`#H5`LQ`Q%BgPn zA`#9lk!CjEJX4tfb89s5(s1TW)Xya^%Gxpd+Eh4Az@g$71WEI3Mdae;*$gSwO7r~& zZSf-WS+>MV7#*G+4>oWVtsO%xpVe%GHBA;W=_`tob4z@@*gh-PdLOTMQ?$h{`PhcR zF5AYHzHe?0 zbAEnA8N^vYL?oS%8;Ym^4zMzw__3$Y7Zb2WY+8EP%%rEU=2>~)0&*$F`Wy`wT8092 zl0Sy%2RnbO&=ysT3g2ZtkWA4k?jtwTcZaT(LmN&a=1rz0a)tt^e2QR}P8!Gem(63^ z=`jG^YiqgXXw%F;`@-O%bu206Fza~`f?0v8qV*XZmQ-Ige^FJxY6#&#%rhDBP!3X$ zwxUt&JXESz)YUSF73|}#VGm!mP=v^|C5UsKYMMjNGZ<-d$Qv1xn!xeFD1@93B)r2> z{j!gXPg&J0>+>BNRM(I;q#-Bd4w3J+t&4Y1VmN8d`cBpplcxxcC!6`pBb1kFvS^@q zZnqY#wj#+baX~xy#Tn87({yhghP5*Po!2JR${oGBZ3-be0W{{q{g91MMk$h-ys115QPxK+GxA`7l z;mEk#Jwu)>OrmFHUv(5`p9gEmJ6lo*s2TcJRdRf-lP_mq0V3C{a}Y5Lygn%WkrM~+ zJK`IRfy@l|@}Zz{-ZmDjfsM)qkCQp*_I7#gH7K@|JKqfut$-vKV*{TwSKiVtMp>bZkXh}sx6C!jdM?)*v698RFK49%G?>Xe zm)?j3Nefv-ixc4>d~<(rgg9&JSc7mfMm4H`ag1_PraO;lCZS|vQcEn8oz}kunOho~ zu?NB4Mm+K!_n^8PCdn2eaSqIKHiB^Aithc28iCZRFHTLzx8OhNxFvy1nQ02}^Ux(? z{q=IV-{2=!phj!JgcVfGD2cOaqEBB?k9f(DEOng#4-RZqDTj{bdP`tKochtqUdECHEP3 z4Iz2PX_e{(4C<$*71&Pm3P^ltW3fyOIT~yr)66iX*M^XVFEp_eNZ&38589pH66CM- z=im`grUIGg0ONPZ-q`~`e330GP$oBrZyQqs1umAeg?`m0S{3-7vGi{Uo=2qEWFiDP zoYAB?JTRk^v{_{~;vv6UD9krChx57$ad!#TvviizgEsT^ef=&5MLC<#AM6UjM!9qR zPwQ#+>^=k_)y;wnd=8^WeZar4TbRPblwW%|k%Tjc&(F^Ykqn#In8y{E0GZdFIl&eX ztTKmn+MF&%&!w(3VX> zLkueobV_-ofLD0p*3Z9B;>dEQmWN$B% z?+5A}idDTK_UZnQqGjB8ODI$Mc~k3C@(UsuJ3H+8^aYfHm0-BdssY| z_2mYokO_KJ2A4VVwLKH&*1vNUv}Y$%p}dI;DI4JGMB&V{YRXJGdYwYKwaHgDU^s9G zQH~`cvX0>)m(oawnLr-h8Bn%55b7|vzdgZlmnbT)KjRCne9G)AtqIxgp&PT6wgN{> zSrDymUnb-UZ2rn}Oz{*F){>a^aE6O{pHW|_cPX5l^F$J$msR_u>rd{^m~(qh4-D>R z*Ff;?u6_f_{6fc zj~^@2(#|L@U5cG3&Of7$X_qOsR;j}McZOY3W~jwlZY}n+X;Ru5x}!^yyI{{9XNK@h z&1uf@vh?!UJQ+LTXYfk7h?1(M$7(o_-Sba8E$bA1Ye^FuX-VQS2GVqn5bXZOrl2C- z>_LOCgWyoDauti_Im0gdTg#}hoYe+#d>rloK}nr6+`vrQ-xKY<9tt$a$h*h4TaCz> zC4KR!#8S72ggN#prC`@(humA>YM`$b#B^xsH3`4t##1gA&P%;TtdI9qhtX6GCDhFW z7LdlE7lTODnh5Ges^?q@5vuw;xd~QhU2NP!^)F8#acqu5m^&IG7`f+7r=WLg-a{%)sH&7Yy zMt4f`LKPwe0y-*~w+Xvb$xJhn_mvpcb|;v+Sw~9OX6DDgi<4Kis6}*F!OQ$C^%Rt7 zo+!XptqmDEm`Js>tD0Z5XGu;!usIC7!Bd8zL;;ghg*^-ZxR{rZ{z}>@m7I4C8>dZZ zeWYZ>xTPC>MUOeBn(M~J(PO1iOFrl~`>4_WJ^F~^;>t6gTlJ_)M0ZE_;9#kt!|gLX z{B;@^o-I6C=`-MQlb^r(jQ z;AK8XB;@1`lFIq(%6H+)nzQWWGpx00Cq$&9J`u~H<7_&y%B5073%`!@`%5?xZQf&f zM;2QECS3fkLIgV10xb4To#)wN*HMnkmDlu>|JBIxv41Sl8laU#wjk%c@U7h?F}z_bp*vX0;SbC{vk$`93W+aUH$}V5Qt#L>k?ma=XHBLFXCa?Xjyk)`0Eg(n%ka7tdRi z&sal*hbTRgdwN{C@x075F1~s`k#-eAu2Z}{eXAGjT~6UqEJo^%7{v7|OWq7LmFx{5dJ^id%FB{;5cFQ^*92=@_Ax~0_+ zdQWHHXRh$Dj#bJA!FRk$vUUFN-r%3Z>eJ(RhRnDQS5Hq5jw~%b-N@KDXme8^+&H&j zoh%mi%}5K|&(H4&=j6{?$0q;uUYNnllYBc?zZUYlYA}1(EBZ+aipzbf3B!kdeD(0A zx7gQeS5FYwg*)37GxJQ27)pA(7^knoIKCuu4JBZc?IIbZ81u{Q$UL3b8!8uprPZFG z%C779H8biD?MPv3K&*VHY}rJwnDhNHT1JL!qlfG;_BW^H)~M9;Z^rJwzoJn`N8|w~ z4^s$%C`ZfZxksBE4UNO&#~I!OS$UFa@rC_k2w!nQ8RKiYPI^PyQ*pB=P_IoX)Sfxw%!h|kbzr~c)@V6KPn}uJA9p(n zw<0hyhO|D!Li6ja4Q@kt9*KQJoDIo!Kzu%i&9Z&YjqZpo$pw-703t|&p+cB@pJH=H zv>-p-kyey4ffQS@&<20*(a}6gtpqU%f-@OA+dB9o1Zs}AObj~m z7hvJn#Hz@dy8}Pm~i)+unSD2CHDo<4SIrNC*C)u&*F`kq(79>G=~*g_8; zpH9;PPrv1{=EI1Fnp8veFOV z0zLv%tZXD*2zdq9L#~RwAa)3rJv?bnwl2x`QI-g-SE%bTET!VE30`ANNdh9Bm)u4f zTY|54&t$$syI^qex=y#>Nd;*LQcw-)rkup1ofjhnpEb{}G{H>V_L&8d9?*?TVQXSS z-$KIVC`CsTQY~l}wJecm@(ubHkF)GyyYp^nE}+_kqhhEd!^sW(3!2O+^vW2!30#Dz zQ1S9kq;du1XXE1u!UO4{ZRw}B_BeC0Jmwsp0@Jcm4e(r?Q-rA<5ld_?-J2xHZePLC!PBWwU*V#y~}eeFqf*9$kiH zr&fmB7A+zTPPtemy;x;Zsajsi=~fN#Y?R@ig}D~yA_mb6A-aIlv!5b~Edr@%hN2sh zDV&I4WneZUo88@RcW~!m)Zv%`k!wyYnk3ZuYyL?dL6eOIZqw=_&})1mXazoKjfB)` zjj{A#N)8i+uOrPes-CwC?Ee>&LeyHk3&Itja5isuB;DdEwV1WPqejaSQ!ibSvX?LR! z_!cl0bF#lu-|?Y81#3XHT*f8k{gH5$sDcc z=8>4@eG7`;R;9UIaE?gd8yN#k%?UYD5M@J#4BSiV#VYTq>pR0LC8GwD%Zh4RhT~T^QO)CJwuK_J6{B-ex!78MXP!x; zIrLC37?AB7fX#SLpBF3hNtk}SDr8>^&N(wQY0g#(2(s|tmu{q&5R*fc74erbx&0$i zlpmNt`zqSQ0u)bn)p6;0t zCf`ctNU!r(kfD0UPg^!i9;QY~xQ%HpQF>a8hpAjeM7`oqWK5%75{>e)XCUqOg;-l# zF(}bLj!?$P)1}^l|K|nxV+hrhrFX_1sZu?Dz6^m__*U1*^16gnU+(ZnGF4K{i{qyG zR4u2za`y$Zzp9LqLPQlJKgH%deJ>;#mb4=Xf$y7w?m(;+$InxjKgtHZW7{(`ri$&;xr zv!y^v0jFYhFXqP3G^2h)28+wOiL9?7o+R_opPRHbleW3;&QRd#KBjTxy?<5I!*C;_KeI-#lmRs(l3_N ziMT-hisB2eG@$Z4#GiSavyYBQhz((G{4R3L;}$F)d|)5R`z#3y7uU#B&!-6e#0AYPVZYlv;Rc)7l3{w(kQ4Br)XtCtup#glkh49bx(07a z*51xF^KTBooyEe~I@1PsT|>&hZ)P$(=cj2bJGA^u#zytu%=AbNM@c$`yOS8Tb% zHR;P-*{LqEromM+O3>7VaVcS4 zH3vfXDxt)Wjl5ff;A3Vben7GXcEB)~SoXS!U9l8D=A*Ep%tU)4DF+l_vC$h7g9Iza>%AZTl=w2T39@^cDa* zpNxMM_Ll?h;Rb3z;nka%e(wZU(oF@$ASis$vu(|A&k~d<4h~c_ZHipr&uaxWCtFxP z1b1Dfm|qzDJ|WLBgV0|#zD|Cfgq}tjpNo(v5HFlfcF~YE`T@CDc7nbjoBXq4RRg<} z14pzWat7*o;A>?yt@jMn*Xny~v*M}t4^li5q255T>BP$`D*H+^*MPK5z+~D7e#hCr zA+)_w=EJF6j4pFjwx$zgwRyIhH7fu$)aAIR%HQ~-+y{;2#aEG{(%+uH$dh#pEk9jC z!~nII6s_&>n|wk*%v!(B_yFRo{SsuOq+9MLuhYk=%rlXN<}GGlXkdP!jfQ#M2Kyvd zWQkSAa&s4}g?@gpH9m^qGbzc(b=XQzfKbZ8-hA%Izj01cIQ@0-MPOSHD#Qe9t)0h< z`w?ra`R~}N*!jm7$p9y-2zhET2gPZgmNra-uW~C4JC4r@LLIG*gExC4v+OZ$HcJqd zwqQf9vh2>&+mDxJ!jYkeb$k;US)}>SN*7n%)QAXp^a$>J!(%|p&kdolD=!No3L3wG znKAPQ?pOeS4)+`X1$3`09WX4+YdaA3dHqOm1RS!A;J3mKI6OSGMk}&T1_ELF zcbJP+;EOTAZeH-G$IFe5dH^MQ+xFAK#fA;y{QSNJ4)dCJU9i7#H@`YZEQRl%etvJc z$fYKwGxxcIZd~$>;mZ>fU5k4oN_5K0*DnhLQlp!nfO9^j;3#Na5|-3E&||kf6%y0+w>VC%$O4x{sOq);W>Q8 zs$#Tb=W6;rVde$4cBIq@YiQ6A#rPFfVBGKLn*Z}){NaLJld{KNrcLzk4|6zNqPQ9j z9x|8%(SL{Dnc?Pj8#1{ZBxfo;TO%YzfG-srvV8i8eqm{NH5#QBO=zcQLwCSF{4L}} zdr6+wIFDo%V0bhgq1%A%@(#$eDz!p0nCp*A;NLyp-a8v!d=z&%G2{XffZweuh-plKiZrv2AM61>o z&0uthp*Xpv|6+x5`P(N7So{`D07X-|1BCXU4-?uEqe{#JH&pzP7R5-AUZh+m6eB?h%3q*eJqqYe zm}I(Mtx(JxVdv$vHTEA@q$4m|XqtQ;I6TYMK&a!6ds&|jm(cYkS&n3mxr>W;+03BdY0OwA>IGw7(hSl{o2sx}{mgP5?n_Hc!OhCB-~;Q?rnx!HS~J*( zE_lS5QO(A}(&y3hruT)HkMHHLg<#C|k^FENrKDCGEKoY!FO1ik?Vy51 za=cIJ!1Hxl@QuOXbuRV4=kj@6n)>=?t=V^6bdqcO8+7GtcC3I!SVuo<@1Z??b8HFA65;i(CRvwX?=N@U_P*{Gfj#i-Fkq-$@ zD-NMS@dv|fNG-F&y&=y5jI@&m;A$Kb-NGq+)|M+_q`6A)riqQZJ+r!PTDF&YX}-Ol zto@T5oG5q)k@bJD?x6U6nLP4QT>=y=@5RP5To~6rsUGhvcl59gtFTK54t}O-joj3g z;<@SXC=VF+=N9OI?P(6hT5>Z5RLmh$YhNOM*b0&`q(UMf-(%>2>2bG|2S*>Fy)#wp z->OQv?^#Ml4!x|7Y&z@jc+_h)gqbmi?)dqq2kIu4-JwUSjn~WwJ0crE4s?3jysuVQ z$E9U$h!eL9;#I!XgOD@C6h&%P?5fq;a(JAL_%luHA^E}jkY&3MqzktOP3;4o=r!Bh z$+n%)1Xrzq1s6vM1TN}phU-b${v4D59V8P{&&`bPQpHufZzP+OHxU|^dIHjIzYT2z zehpW{w8Io01Km%DE#EwL3H-B`56S+T-U3wPsS*rUeBj)^{f;fsxb>$PV$k`<9~>}A zgc`2TAtwCQhh`6yI{VXh~#$`h-5o+?5E7 zZR?l-vW6iX~H9`&QURrkX#=Vyg_yfoz~gDJQ7 zJ#C5A+{Olg0ede$jCglG_XVlK+sEs=FZcSiVxW@vm?m>_ZgEkePK~mmR#-|3Hd*$d z@hXxAopRTy*=}RpWm+IE2^|1egxdK!UIC`}s-qy03Pji+6AHS9kB|feSyJ}T0Lj`r z+QE3JLb7CX>=^P8!_V&b#zywL-Z=liI6coa&+q=1J)i#ICQd6^E~o8Y|J@tz#6(qa z0{G0_s`IcHRU$mU;q)~zm@SWphVu_v+|(-H&uSX2xV3eMFde>!^m+>M>A;o0w){Q>3vt-qtEb4A!05*^mo}M~V-~NhD{KC!)vd$M>j>RhykacMT+N z4hR23iIb)cLBX&%f7FmN=H45DwyXDa?M)^~qZdeJ7Me9zg-aO}#tfwQR=4J{g}{l{ zj02P5>2#sZzvbiB-Vjx$zIcy>-jUstI!fcFrppC>d7*>P_Mqu?MPn0^zHY(6%o%be zTGsO3=?gu1^_)X4(af8snmqwkOf|Sh!)ViaUT=J^`jxFQSkttWamwVm3PQTBLB$0@ zU>DtOy}Z`)b7%xrG*>u&7e*Mba$sL;_`NE6V_w#t?YvnR&_cJeR6h7<|@!|F+%@{*;Vbb6gSZ|^h z)nOVjJv}z6ctpss^e@CdS!hbOZ*gcZfHO6Wum>6XL14^uB!cam~yk`&Rg7_O*>Rs-JjbYTdvVHR|Z3XO#Evd zx5>zgm4dG^)r}9$>)tpxIPg%x^o{a>dFJ~Ja@RT~VFT=NtPEg@RdjUpZ&{ArcBVez z-(a*6QWy4$N-uaKkGknpn^=2C?(7(W1WBsw?Bu8A3yX@Hs*wG>eDbZpEvM^XdE?1r z%QUYi_vXflaKvc71mOGG{RRhPjky;_;vaHW)01PI-@E@0Qxr}+BU5H@FRf%u2s9uB zp765N4ZRZ`9`iyo(~2aobh#h*rKrEEtqA$tkp`HT!CU^4?fU4=tTK3Ww5nclolM_y z8+b?q23~M?HiFTwTn^PgqtSxWPw>FoDG#_7n0b9@&5^59+J3%KhUg=;t70s|SB;L1 zz1fXgTWNKIl!_%jNbT{BetdMt#yJ@$Um)Pb)>r1 zd1=sDlS%y{Y-pyNMM%-68|&bImkCInmEDauWDgzC0eYzfpKY#6%Nn9wrD&@6 zm<8KY#b6B#9XA*oo2qzic&H}l;Hv-_t^!%|covn?R0bt;aLJNHYzy-3EP!q$@Dhp{ zO93xcFj#>645=p`OkIVBLA%3!*Q$@V#Hi}Vx-6L!n!YfYx~f&or7Eko0;ZS1*mn{G zxbW~et!y2rc{<{*pT8A&Y~6rkV1<=`diC-2(w}k&sx#E}9SHT^ZujeE!rJc0V*Eg&|w{NtFH{GxdTTSf&+UeeS7iANvk0^<4`SAB=TBt0z9}j z=g^BO6qWpMd-il%Vio}ezh_~l{zy08zHoKV$`^OW4z)qt?alMwox_O=0$g08FE`!T zTih`aB(@S(S83u5;7~;{7%iR4$z z^m_xYwTyK*jR*G}R*a{j|2p@yg=O+x>U+VYRS%SCa4b!On3mjZwxyO*p}RVhO}fqJ zIgWu-eZjCcqLM%n3k@7dNIyyp-A&9j%A9`xV5o8r3?u9;7(!G&IRIyYN&*=~H=yWwVIoA;bJ|5X8m@LQDertCkQ%+%;T zUlG0_*s6fsdg=xc6ZPF>reVd@t5dCYL^RJK9$(sx&XIe|)o)7U1apN93wv|O#Zz|+ zJff2@v@F~Pg~p=AC3fCHCVe9&|4a;S)d{F7!St&>K4XP@gq6oGxr-+GjvA>R!R0NE zjlBBAs7#a$|tf`X5y^$L0sc|+7i?kSyAm_aHyvtRRsSwwNye0-TEQtdJI z5Bskk7V$=lFDP-&#iDJx)VVdx>P*Vt50sXleSk2KKt{lG$Q&DsVq^TpQ(ZxAi z)o7?`g&q|>4^D?JdhZ;`Cy#AvD3)iXAUg8&?RnuAd8zs zu#La{61IGG?#>04;2pP%Fk@CNaAO*`sb3$YC!m*M#;O|m`zI{v`(+86&}3XuBZN|J zoFzVsIfN(4M2!A5%Kutf@l4xtGDFm+BHGboPGQKa_bTJYN6}5 zZCiQ!)2MG+k9Sq-S&bFxamD|iA2@)(>Q&Il=krg&x0Nl!rD{FPjNv1}r?z?88Jau) zwY4>AO7zBNFu$v*y)@@6I~l?bA7;l9?qn?aeK~gEKS9#I?EqU_Tbmi+a?l_+KbYuj zCX{n_yYcy|_umo=Ry_3~t21Iz@xjZtsTd^q9jJ!TvsE7P=|v2zE`oz{D?WkxkTTg>!4Ot%2Ttw?&@rv(@o&F}~aMrPdn&$t4j zN^`yNPrSh5<-8lXGu&(Xj8Z^X3G&KXi>rTz9Sp+p?}@Wy?-zJl4^}yCG^tk2*wRLD z<qKOP;E_kB+z6ulE3Khl+yTwY})c)@yjf%khp>Jk_zBDWIP<4Dg9v zzk5=SeIT#t!N3ym$;m-QwLwCcgN|nfkm-=|X_$I9{yO-9q39(}Kne7EF=g(aqd-rQ!=1)rUThQ6d7iZ~JP( z_QSb`C^nfEy|lX1Ji@sgM3Abh7x?{z)>xA@H2o`Ge~^T?)SBN@T~5FMQgfvD&?bO~ z|NQCJ+0TCC;jwph-bq2M}llAJTPH z!&7LBM~|>a&2dH#QZ;qc@-)XuLD(fivDlkI7e!WQifEB93M*j{BO(Ms(ajco{tkho zO=s3kHPbjR{d?hDF=0|^Uz8c8HJ?*C`V~KFAwX0Kxg9?+PBj5yf3Oc0l1#6+<=AzU zEl+o3QxRN>hli|f6wDz(VW~cC{;Cu^!g>`2_D9W8%7KqB4E|4dxikxD1$5cf4*>u% z3}XrC)fwdOr>?67Y@jOb%TMWbs0({Ar9v9I zyQjz&2OW87`U|xiU9Zn8f;j-;;sxU5MhHXX3+0pN>kMFRk&cR_^Nh*=W!dEZ;o+{r z;63t(?+zj^aO?vT78j3a91N$dvm+SbfActqqKYLFnwr}sr=Xxnn!sxB=-}pM?Z3|H z0z*!mo=1&V*VZD$$-%7gSVBR0TFip3u53fn-Waa+-dx|CDA3du=v&IPR6E^d`Ohx? z+q>Kb_ep%Pq$%a&!w0mg{IZ&Ou_xm-?B$iS|9V!l_W}l6Iypt`>@4BpYb)|EjxH~A zuQl6K88E1PPhnnMO3tcjM7ZyHPYf8)=)T3eY&%*CAK3$=eZdbMotc@DC=>3fa{G0r z-V#-}5`nS)8Sw67dD`nlh|mW$8)s`enGZ`#_(_`nPhqabu3R{>*!5!EJhjRmE0ztjg>>f$;2@!P)C?DrB6m~VJ$7AFl^vDnUbie$a_M# zLHLK=M{v!h)~^saqjwL#2_H&cG#<(Eb(qQxIYk7)Uzk(Q{=E!qE|#*m99p!yrt z{XsaFPig~u1QTjm$r zq+p?tCUFckX88Jr0jzUmJ#>^OBfz2%l`}@_V1tPtU|q$@@YDY`6hagZ{A{pSCq$Ht zaP4r2i0@iAHHLsILAIDSMHVz^@QEA%>iKjKFW@kjFH_rXTGB7nu1ZiS)mm5z%Rd9d z9y6P4mcZhrdiBuC)I_u0H)8(LIh7PgZgC9_oR~7qk&)rFtTbV`zU^(&n**NyG56}X z)T5C-cu-%jY}IR`2~@A!|6}ScpsIYns9{Awx=W!If@D-68^@7S51P;b*3!xM2gnaeGAc{4GMAgi)tDUV6) zUX9g1uEcs?Axk?NX0d9eYof5(P0F3PiLF&QXu zT$nzW@gJduvG1EFqX!;Bc*5FLmtba+TiI0g2->fL;|JTn^h#$-3idmB7S3@GQPQK@ z%@sphYe5Bed$#hvq>!~e1ryB4x1?k&oigPNUHHCO^wuXD=_2JGR)&R|aV$@RTiFkc z!nsGIvq|caXuAdCjdp62OUW0w$(5#e2Fc@_8ZHRmqE+!iai!8#zD9;P+#(J>z)}do zkdp?~ggAU0Giv;$B#7^13TuCY<`Xr9fO@%QYb&lV60b_}?&9}d$g((Fin-eynJ7m~ z9jn%Tq2?&QW+wx6iNq4U8NY`9R59&*Q1SgM$j}cuakIBGZyhUkQ;oIg2V9XqQ{v944{T67{U#1XBXEO(+;1yoSxgNI5_ynW@oV~-oh(?8nj}$ zG{8SR9J^OhtxKLb-vQTv)8TK{yd958TywUC_k^aL4cF?bCuc*gyu1Gzb>-SwlM%&K zDW@sLCd@q&5*x~T<=89Uq$Ik%(r>O96Ik5kfBri){O>{gbFeIR+`otC{`ecLzb^M9 zM>aw0T2z!VSA(DHzlhOWBG(_0u1-p=iiSojR;txebchA?+Va_Y z#M?kvaZHK?*i@ENpc6iwN;^NcYod5}{8Ix+^vr*JH|3I^L1K~b6ZrQ^Tz!mh=sM(J z<)_%`U2c3dKkVnv%$_w!Az|j4mb}b+KgSS9g~HmAdAWaAXs@o2&EGwZT=?4Ze{gUQ zO^bVu%sn9KCTU4}U3lz<*XNCjqE(?@QsOj*+fmH4riwO|;Ms-1uoU=G$UupKQqap5 z&YdTJfupf+F1?j0#53}zr860YxEZ$)H?Du5)_Sdhb zU zR-xIRn*BPr?5R8mv03DPk8kGkv*NrS&qxKUfS|m|^?x@X7i$bp`mqK_`xkSvD4Sh) z=KmflXE{Pb3iTbTvJ;Vpu7^J)k4)mzGvgsjluNw-IAb!W&e-$qVmnXQA!+ZkdK84A zn8LkSwo$zxacWb8h>22!DwBfHw%3UvIf{35m#9fi@-uf%{C;Z0=7q`K8v-Fx=u|;% z=bNc{UOu8c@0yt5p1ttjXqc^*+q~%G;;Ne5Lpvx^yG&u@yjTGQhvn4p<%9>t^nnLg z;Z0mxuDwrPJv~(lmJhfj;{tI=_M3fOAA&>tdDEZ&QBJ5OGN5Vb+wB@N;^W69<55$< zSZhhmZ$`mC(?YZqnU%dlVCwV-8?zT**L^q>LsU1DvIor?f>J*oM zz#ykCNDmUjQ(+cs40JZ=-(p%ARt&80Ms#(X|6^d(FE$-Ge!=L0eov{f&}=9!{)rb| zhrYL1AUjE_OxF(&D`rOwu?U*aT(j;e)aO<$6ySfVcp6fr?ZT4G^yH*PI}nQZk3aD0 zs+{jt7sabvQ?PitcB|EDc~jyxUFzM60aYvWmhHJdiL|8u*eHGl)^d(*J)gd{lh4@i z^H+R|Xs54wU*fc4Z1CUlw;a7fI(6iutfeRp;hxTAngzmRf*%f&qZf3f{E~jR7?TP& zVkf%E!oyi*L9Xn4`r{fS7P33PqgqnfyYi%1e`6vr1Y(Am7>J?Os{Vj6Y?SeB)bC|Z z!3YhoxMTI{(tWD=kNT;Bh~DtE0!j4I+1%`$qUk0^k8d|wm;K?p1ZMm=nbzUbuV(4k zIDve=pTD>@Nl9^w*Xj01Us7x-V8{hIyt?wc*Mt#c;Tf_mR5~?F8Q@Hk-X(LIE()Tz zf0!lTX@xBci#2cch@X3uzk6>zqkAy3B7MN7+ek&e>Fp7)#R0F5O2v3Ue!=zSD3#H3 zm;J8~e6@BDS72$Vrn=dCb=mNNcM{A&e{p8qro}`XtYHNSM0ij+yU_&+#Nq~+WoLBq zwNw_+DbQ#y}jL1 zrB>3r@OZ-u#|Qb3u$8R%PW6!wuFXL!#j9$}qwA9TJm6I-W5yo>^JA$KnglP{i#r<{ zG`HR`OX=&Yh4+U@Fq=NqQ(YOIr6Ke}<&LH?YfUi07OnCJ!z$&4v^8gxTK2(EcXb!3 zn7}fdv=5Sn=Jw}?^t{Q=+`D2ay{d2}`aMYh1s7oXOJhaTF+H=>{r)zPEZe*PI!e1O zM)EhJz}Xa)WA{6gY?8+8#3&ynAvuAy@jTo5wt|@!*TC=;394nxv){U|5 znXxp~BgMR*rEcO?;XkjC!EQT-i^*5lUhFVkU$ z++QV+6io}al6K+Sj6xj&e<)uF_R{&QTNI=QGc{&h`W<90zI~a-JqxzVHlv$$^e&ss z{w+iwKcj3IgXWtig}B)B??zA54D*qP%R55*ks6p*P0nzwg{03E?jn7AbpG3-s|lOi1|ag9UkG46aZ47De^Y%WH8{q=TwD z>D?A|IgyWKQ31fxh?K+qDho$W1Y;5*TzU#e^%UQG{vpk%O{D*pYP+7sfxL!s<7c4$ z;CCd>8A6ZK#~nS+UtNvRkKUsa{{2~-LU%abdK#7I59zhc4W5J)2Ryi#)hqlnGZx=C zep+<&!qg>O<+JvfTLjuzCA@)(t}XWF%}BMcNvEwzOW&@)|8o05o9QAx+WUeiRjq?g zk~}d_a1G+!K`hriT`cw4B=PTzAk_qc?qO!;)?J7T(Z@w-OZezSA)eAkvgPU38d6y- zqqC5P)?R?%2LY{*+yC5ZKX*A2zv}+*zxvHm6;PO9bcQ-tdhhn^yPE}lJ;vMPhUB`s zHg|oFt#o=hw3{DuFzC0U2Mj_JuIYyJS! z&Hnp?IlW07V%;j9eM|PA8IYqgo1*~=!y63t)qrW&(>vt=vfu)Y*nG_&b~>0xt=7tw zB5jGzGx@3@NZZTuW_>I}`gt3aD57n8XS?y!1x`_8<3E^oikdN)gA9a;-Wrq#6YMdo zE#&n6K$3-^b*RbM$MMk6MJ3Du+rLhJ)J7ZrBTEggzhGjrwbTQe98RQ5-cqYn#W0O; zGbwUpu1+C(hiX0eoojLgfohh|nTnkWr}qdLj5tl_1S$1I$w^1-(1?Cro5;p#9EaZN zk5`x17zt27IC}z)2U4_}d ziVV!fO9u(R5)x*>C`Y^rE*MKPbb@+L@qVe*t&;JnLy>RXG1lfwUDt+hh1dKpGS`TU zbS0bXYM4s=@*YR>eVq|b95I( zR8RXK`REY>Z7n3)O&5Ljb0$0{IT=%6fh0X1qj1OJE9Vbpy5uT2>KTX`Cf#4nbA{y? z)@1mWCD`L7unGB)6c~C3sIiv3+7yo{iB2RruL5@4KMqBx2i6XdJ=<8}S1fSOU-%xn zYbfVw(PfHtG&W4V?01WZ(cHlbZH z8+{V9vC1x1*<7)veZq<{saYq#3neh(lWeed>D-5E$<0AaSX0eR@PCdRMmBwMHZZUK zqQyX2p&_s$3Sv>KK%H{$1y0U82?(Kb1Sdm+#j#iUdq=Y>e*N>-&A|4!%S?&4LK`^t6*FmwJTW9FxUw zEZ6)Olg2Ab@10&K)Z>&Jw;e$6&O%L@8CroLM=FC^tkNG5LFQ+Ii6ulqArcgBif~LE zLy`;IiUd*7NKfJgRIHML4T_hJp>vKW@gS2~XwM1BLd<&Y3M9CbSVt>>k3<{q+wJ_i9c9{V5Pb_cDuaJr&297@IdWg7hPB>&j& z^MRS(gt64hCk?t1!u@Z@*Ox{b#63n@k=)UDiM+{(S*$V1mie@pR2rhFZima!er^0H ziv8NZWzA4)f=X4@tzsOqP#9_`)#*SOKBdjsdn~M zTH(xjkyEIPvIkVSamlr3xz00-itNhk#uK4zCUEaKd~XGWR|_)S1tDU=)(coA%H>nv zAih{F3sulw6%x5}X;F<&pU#w}smmUj#DA$JPo=z)Y9+e)%}b7_!zsw*A6z61`HoLv z4j29ph;)$=!WhVG-9fNgAq8@C63F$SCRDc)6*E?p3NBL=l`FjB))~<H4AZoOPT6h1xT`Bf7B%t?uXDH-oo*+AuZR^6wWq68@(;~v zbu<;|hF>o?q{*2`Y_;Ga)^n($Z(lfO_^Q1?^XsA*&eCO0-aKwbrRisZ8#XpMC#;YO z77m{1!>!`NJH!02g@-W)VV85fCViL)H3yr~CY7e3nsJcyQ-TRVE?s3V6UISXb!uiMcnwm6i8?YbcmT1RqKKjqlPT7i+K>e&c#5bQ%^xfjn4j zj;f7jhAX5>PF}%eA4^?$^&~xrth0%&8ZevS_Wlax3H9|lXShDlW+C!8>Y9|k&9iegE!uXCBfRh+c zEkdXR1B>tz?QiGU$J7rxopGFn8Ld}0L*x2#b#``C_?!2Bl~$Ab>#a8ztv7R%yeCt< zqD`FUwY-T6CINB1`sgG#5sc*T!rC&HSnNsbkDkYTc;u1i5ckrtpwVr6*6Y;!9to?26RuUkk!{DNGnO`^R_FdQj~+V|}64O!Q1Rd1(^e=_e9^i#u2Z+ntrHYVXAlFGj} z&Y|gOkvGy+we`Q+y(_QRFg?!vG=xqXa9Q2NxFKH5QfF?T7U;eQspNea+xldWYP$U0 zoB&>x@3w@J|GbqdOw2?;=!8CBgg?uW=<6n2B*b9%HA*+Bk|WmQrI`K|3%UD5iPR)* z)i+Ap8GWfcV%WgW>UqD+gKeoU-M}wy@C9w*fzsb`$>QgPLzMsI&Y$pq`B+L71N%+) zYo9I_=mGWTSYqD6^dnDdPJabrumU=q*cYF=Q)CC2z_54>GNxO5YMtBCoE_H;hG=-h z8Lji(zXaHk_!QAO&XNKKq4d-5JZignhnL%!WU-fU@c+>fo@_4X1=Hm$)yVobtLvqP zl<%p{f2*e~pK;fw##8u|LpuoZazd)#ibfouKds0hUeZn#(Z`p$yi-HP)WDxX!JNaP zLmQw+qDg>UY~z2G#!$=VihmP8Btb)wd4Z4xziG{%RFJK0ps;@ygb*%WhyF_x)&p3^oOH5xk#GG-A-depg*J-lJ zc&C$UL8a0=%=D8Q(O{9Dg~e(-ovq)LO+v-|N2v<81zvIQi87`-Nl1KD8CbF=&T{ag~nW5yb4nEbt)I+8hcT74$?$tFbw zg8koI&fA{4c=?N;$jl~mi||X+@ou;y;5XEf=>$~4M;o&DG>IAiuuV~ziW zLld5U94j#)&%dYGoTB`DHhp+Kdx(m01J6c5TTA5$a6&cKpWCz?h=`4j`E!yM6&-~TLZGjC@6?LXZvB3uGNC$=4&T=oI7 zdjIAVS`jOK6A$t56gSa7-|Pls&sCF8$oFxjqW2iKEi}2Jn!*)1p|i_nm{HW2ur(N% z5KJ9BWvbT@Os6pz2WrUD1l{7XAfG1Fs78pO{Md;50o)2nQl!4ghgI#dpn1`@bL-t@$ulnRHzMS~ z@BdKWhYw*R1b!K#E-e2%n*MP=S)Rq@sHL_fxe0WuPSMbi|H_J~lR(T+O_}SA)bPsH z<0j9B0cenq)e#{z1SJGy);)GS?IbDxhj!O%N<;WQ-}&`7RG|W`6t>TUc3JSQ@TqFU zp)!8nf&-`;JB=hFoC+BK7-A_>>55=55n(L(6ym7E4?@bnkcF4M;>-|qxn65cW_k~w zh$zh*|BL=4b_ZTY2F?r%0xKgs=KM!Z+8@84GB-%GC)k`tlfCnqbuA)gUxF)%%A5z$ zC_Pv>tz}5=*D*D>7>;@VRE-5x!<>;=hYvasUaqqBkw`;sw$R-ygQg(<93|>Ya5x=8 znUV;WgoJ28UVFfyUKXNKJ9FdW89~%hI!%2yb$EGHctHrAc9~$E&1f|f$sKcvaABqd z6Fs(;1*Zb^1M}Q8;~Y-f0V~cH6j>+r%$xe$Wff<){j9AhPT@;Vw5q(3i)QY3`6dqM z?~W*a1=IyI3pqvkTs>4C!DCWV8R?6CKaXG&tx&94%95z0wg|~bJm`JB1%6VKG0~9GuO0Qs58t4m0%oB4Tc4&Wn9{Uz< zI;x%d&JmqNK-$odq~3CZuef2}oPFf^VG7-IzlSuC>_2rwDWPJVeQTv;o^_ujk2SBi zX$2cs|8D>J8Won766iS5O`}czfvX+ZtaE1a#}KWfL(rr+N|*NbNFToAr{nWqc&FkA znWJJ{9!U@Vh?F&YW*H|%myU$ZLX0+l++uXEf)$1w@QX4dU{>^VM{39-C;7V&2^>bu zupGt8{`}-XJ8GF>Bt(z!U}4%_(QD6xD@?}_@O1I+{E zNRJB-ewFgpyZu z(tSkHIfg7?(LqCDq=yme@O!dDJbM(%$b{eIWx+HW2yv65)zc(OIaCQ-97#JCHO^+H zTIlS<6F@^u0v(F37eB93IHZLaNw?r&DpiG7OBeNjSeC8&@~K08h>|ey)vS!BJcdXr zT%@xB&Td;e8?T5!MqitI%HlPx%w>NIyKwK12SbS-v3Uzu1BrQwY7nS|IPi8LB) zE{lRpc`^L6VVb2#$*M~B z6C|QoPp@uO^L8SN5(rF67%*PEG|nHX(~E8Ayg6i?Vhy??^37` zAu8zt7VN1u=c5(Xb*=}hplV_!r}X%WP#PSJWIzXL{u~~j-TU6Yc|92WPQ0=?4HW!s zbvk`65FF1Dd_Twf-=~dNwc63fns{m@ijYLAHa`Bc%F6z|o0S}6*nl@0Z>?<|w>!?Z zN5;maRETt|Dbdk^s6X|Z&xHaXAD`#HgObzFokuHLqkZVYP!R9UZ8KBe%$5x&I{(AlOAVoYS5jJ>Iumx2@Sz0*xuo~2 zo@&m|%U(fjPP%WCGe#xY1f|=E@9ddBkRl5B_{7a6*djl6`KK#>AiDqPUbNeAh%A99 ziWVd+&=QDJ=2H?um%1D^j|GqQO_r^yT$%5!8bNWa#=#6?Dy!AqdV9AMlU#X4<~7;J~7iR{hG?&JJmDibA+ zn8ZFWB9~4e2!B>2!)b!0M4%n2s4UAmTFf?Bt+q3F+UC~bke#PTc{GpU?Mg*nOcxcK z=rfw11@m_KO@~f`RBOUeLxgD6xaL<^Ni<0+97rvFW@JHLdHFXYtNpyl0t^&*Hk2e| z)^Lh3dU%~om~1D8{Y*tQ7wiP-;PX0m=1k%4^@fF;%sQsIU$edS!rrOHJRdCfstl;G zWS$<8pC!%AD24yc#I@`iIgkqrlYTirM{06d9qsVKKQ9q}ir^P=dAmIrv)#9nn%k9R2&jJYOoTh*MDM zc6vFd1Zw6I%p-VR_CEEY%KdC{!sc0ZHS4~JOQarA`9l1amxzH;Jm#jrd^a(BUEXJm zlMmZTfM<8>23{#@mN5$+&i*R8s+y|09jtN;8`q?2U*q`0luB;F)|G;#%N$cZF6O`%5W7U*3s`3>p(4;7=H*hB!UnzN0>OM}U zz#&6qKP+27hE5iSugiDtI3FSd$i@&}8!-?oeulyQfIJZE$0;(#p)j39Kd_{%MsIBV zX|#!sm^UXuSb|*ez~dnS|0l&fu@LwC?;5tuq5YZHG?V9f(!GfIRxQMLXz&6?uc>5= z)nj{0zs6ZIB}oLtqmXyQ(n-C~tEB8%=Ikq=b*Q7XiC0&+Gz{|W&7;lvhw~u@hIg_k zZ?K;Uw_A#OM~(iJ7SZc6d4};D5&y(ip(uyMHK)bdVx-UVDzALj#@;y-+|cHs@qa@p zb05cN+$3Zq5~;Ha=tr^C)Gc6f9GpO>ZleF=q6Q$+k z81W~vhH^T+Gq}9D6RAux@Ip<#P%SM=B$k$yZO+QEnDx-&#S>3jZimLCr-qOrjAwFX z9ai1rBr<`fj!7l*gRHKf3sl55F$7lyK|LGRNsP-Cmsa5cAEQ>4n27Ow`8-Z6g9HvF zaA}FCw2FpB*ngV+zdA}%xm4;_qweA1OeoZ5YRzv*JRf6XLV+OEOFj1wEkBO+TPvhg z8&xe$_$LgUD=fRq*;!1nuKX0`wSF~sqZv*oTg3B~_an9yw7+!1cx;KpHeJ(wc%fwZ zSrn+4JYmak?CjB0i!GDV4hQx;$lKxgKPfda#nU1WXMnPT7FV}EiZVS{TRi)=2&mqE zTSz_>e~==)?h93*!%99dZu6>2~* zv&t&o&T*T5WhKUTq_6%_67ke*YZJm>D{qkzg$0OZR3yT zWn2kdT@R_S2$xj9B6d-!)pI$&k$G^ytv1ED{u3;iIe~{cYyV)5x8?helF~%D-lF(l zwgsYfsHeF+tLnO%S>Tr?+BRd(&8;bo*7PM_`R?yi>bW~L3`AcOo4oj(dU5uPMhoMF z29KMcg!5`_SGkm+v263>_!srKONFAE9*PClCP8&=%k{D?n*uh(`2{+$6SPfk zKHe|gthGzSg#_1UhTFBL-$Fj%D17~|lGjjeCJVK$eTClIk)mYxl}=l1G6yEP70klz z9YLU&k}9--Sp;JHo6`czD|T7-FHk+#0R=)V z_>tg%cU-KO>Ll9pHL8&v3ClMh328zz3RIBOrPB!zw2K1$B5aBcQ&n@z!oR~V!;#M8gLoBPY>FMb0Bfv+Ib<^#5uBc;w)Jy((+U{;OVFB%@O;>iFyX{!GN6 zLV;6|#H>i2dar8YjA3@IUyN98tlZ35o9ZCVDh``GW}kE|L|*PYJH!>cAXBhYrLxEVVfYQL>CWhv9k%{-d; zMNcuNoxV5x3t6Om?&08x6Cj`^HA=YwO>Pu8*3Byes~)QmLqC!bI5_&CF1qWe?@V&+ zEM2#zpF0yGiR^Xo0NAO(H}L(zWuZeOESxL+bf&bkhhJlV@BX7GGBH;6TSI$7bpc-LxWbJSC`5lWEs|GQG9i z_l?E(HO{@aV(hJTVeXU?lZj$D1A;sE;m`=Fn3B#8w_tws?Q4AU;3&3wDKiEkK5U1N z?H!!%-a4xoMdB!`Ur)$c7R41=as5_x%zbZrn@F)ge;2jIu*g3_5+{hg_a7FFGYEg$ zZ2iZuJn8N6sVzp~9XdN+|NM`qZLnq<3MdoO-i09z6zQ5MA>(^93Wjs?oF9s`r4eBuo92?{CfL6nRoqa+Dg3bW=slekh3d%bE3$ zEpW3m$_Ov%kn}Dg>4=8>VtNO`iv8pNrmg7B+Y;OXUUsb<|7%D99wg!X+!<7cWtMMV zw~UO89N=^D>>kXs$z$sz*3D(_4U6 ze*du}tnKv(4dxT5v*i>Mip>63)-h zb8>Sh=H^b_?d3Xpr)AR(#R?Fq-v@xD3mNSxfI8XGDM@6oP`?Zv&r$L)+OGU=7N$xtn zLj)U!U>XyPIEsSk0_cj8UR-XrJ;^oXahQZ9#3V#y8e*c|Qye}i#QqkI$mb|p-Isnj z)TFp$G9ua8p720;j-sqsn>aIPbiMLDO`8e1F-EeR^D1A);~U=_Nj)iwH>Sm=Q!|zK zr*8#z!t`Nbki3@IBbojSg}k-uhVg+ve+64<1~txr`YMJBeP(y2$k+giE7X?b={?uSE3Ne;raZ<^Og^GHleJ7M+yO8%bNLz=TO0Cz7YI-PhrwMMFWrWh!?nz47Q*Zddj>H%kB_zMNbR?oprhX#8TE1(h#>LuO`D*{J z>mlEuYeyr3yM@Iy=2hTLHAhszP|-;F4V8qgImmlge(`B66FRqwoezbkcKp(hjEYK_ zO(<5{^#$1{C5dY|nBZO$3DKiPsZgdk2_#nw3+n2Qb0ov+wD!MkQJ@WnRlm>*o+?zv z+l7N)tE$Lu{v&5Dex38D2ntg=(bVoFJ0(2JinyjNqH|^@#oZYGkEj;p+GWBzi^B&W z$$!j9*0kb%)7$)H3cu=%nQ~4ynkdS5wtZ7v>SmZ5^~)|JPfJz>K074+4KM{Tuni{F zFG|TU$J3_7OZg(CVY5DU=hB5Dhi#KDnm8`ck2h*YMpMA>Z_m@ZqxXZY1v`~Z?Zj`bw(^>#FCuM8}EbOO#TPs^DPO#4{lqb-edb zLI(x@foCKFtM*i%)%nbLT?a~3%A@mpeMth2UNPqe4pAl%$0K@5@Ds!o9`S)$2l{Df zlkRZ0+Bp0F`od=1Ch~jv&UqCBCMn}_ACzS;Mn+RDVk_yU1qB5`k47XUBtQ=zw(z^Z zb54ljZpde*iT-GwBi(!AEa0Doc&lNzRhbdOr`iAlMJQY>sZOc zGU@cdFH;^5m8HbQn3=l#vCBb4wAj`W{Whe^8XDp%$!gqRECCYc^H})O(n4E?kA$EN zz}-QM!K2H~tz^u0pvpEoZ6e~54Y@HQ3>Hj*#ReW16*5zgYzcSWtJfS5he5GCDP>lv zc9BH3kRNOijCVobweM91gwHRg-QI{2WZVy+_VqWYw%$oHa2iNq4PcJUvZ7GpDtv++ zB2?xA-GPq^HSI0@z>Q0vjU=*2@?jB(lQ!h_++qv2Q|#gRw~ z@y|*%Ok~xv!dh24{p|SR`li-L3Uf?40X*#R*Nl9SWzMqi{?^3?XjYZ!PuYn zd{7(bjcF4aB?6u;Ak|`_qG8at6R|C+zj9DPG*9GUJ{!zN>5lgIC^GMm@6M~0vY^8t z!9iW4knCC73p;}9!?)v-9ltuJm4Q z+wC~)xS_k*v@TcusT{H|o_KZ$6Rgo8u4{t{Frpatare?!WW~-_>;*aA@CE7TSy7ap zYA(>@=zc!)j6spNIqk*LsY1aR=10(*LF7DCkIPXizNy6aAGOmU1nSNIzXb=RBvt<1~P)wQ=SN zcHfJy{Qu0Ib(WO?P8W1$=UbNU#|GADLMUy@$5-_-F!C%ACLaO=)g%K*oI1!xXVb(;p7r zwdE*-*O)3!bZdwVvzE2DULPUwyYwHpAtZOVa_h@?x=_0CCME=!2u~P;x!FTjO!38+ zQvh-u5e1rvo58ool;WU~Jc*ar7bTpGkJ%q&Yb|5jrACoPIxF^Wi$Gvs#5b{$C`I&_scRhb)c7Ym zAGXC-9a>EkrO4B~v!3GBQlniv-q#*scgNDZdv z+vs-R#CtuZT4bNOJ6B$NQ*3WY={%rHd0RU;KpPv~rrn)7?$H;*gi#{qFqYOi6~HYJ z8#_Az2k(8or!ET~l*08EZ@!_v{D#eKLI1Hj#gTd~jTCeCi1KprOf#X%V=6^&$?%?) zl&ZtWe=h51oE-26ZGP%Td>LI_L?@INJU@N86y9GNWPiB$`Mt@t^2xIj+&U+&$Pw3x z%K)%|lcUar5Rgqe8G_K4!!i1?M4xdXcJ({6idX173=9k{_kY`86m>jbelBOE;s{|; z`!@E3pLy-=?X4Q!E@F7rHV%!R+d9e0%geW}R~$>q%l{NrHQLP4aNGU-r1_(wsw#HM z=QqdEdiQfMO)oB^rZNazK)}Fb84ojv94q3}UbaV_#TZ^YIJCK(d(nThYS5>Rdb#TF zu-b;X(hVL6fEsB7Iwuzwm`C=SiC=Du26oF0HrmxI^Ya7c8WoDtwNI?#z301QI$b`z zjGC1j8CE&GCIctH=9AOXaxY=dd)|@?6iY^e+<3MBqpja*5b5bw6w7&IwXgkjlPLkb z|NY83ygefThaG_3iTicY25K`68hES@0NVM=+Q!CYsm`KrEwsI@O}o}~pvF*Z(GA>^ zA^G1oWuN$jy(RJd0{oqtDT2`a@o$N>xoW>F#=q?+LWZ83aNt697&@sX_zHo4`-Mb& zt_Xdsp4%}zEkM}L>)~t&$ffpz)d%0*<#R_*n&xV-*LDAQ`|18tCT&{rlxvHWuy^nOsLeGVkZ8J5wZ-N*3G> zr$dld{n*pxxGq#MLx+lr3g(eq=5cCdT8dglUZrfVE{9iW!$-<}r``bmFj*x}${}!u z!?-wG*k8!#ZdB_i884B_>2yQ%QOpHEM3c*Q;nNHRAJqKg;^K1h@FZnrjkP#!msV9-ULP%i z)64RTiVR+t-#Q7VkVQA9v4W;1ytpB8aN^SLaRMzW3hwm2p@^hdU0ogj%A54*zwvzW zup>VXw?9AX3(!W)u?!ZMfPBqfuD?(qSq)f*^Ye2sT^7$zk2rXEiD_w4AS-%zcek^f z{e+_dzS6Qc7_m~fF%k|LZ+c~AY_l(no}NCxwifeCU?5DB<2rCeK)eHR6j?AXm%YhG zd}XkSNW8C)$O(bC{N0|Kq2VNu>Us_e(d48@s}L{u6u$u zn`Zad$3T9dQU`PtjsjYuGjnr61$5)t{AvGJt%Q!58T#$pgaf4-R6f71F43G_23&;! ziQU4gDm1XxfFr&1zy;rOd40`pHi&Y5aZy%XT>}0qmhsAlX6dgs3|$!o|*s)kPsDp9U$@r-_SEaCW(g4{YV=ue1wJ0 z|IR5mfp6T&eSV+?G1u$jH+dw*%aE}mSS?0zBx48%!0oztstAD9CF4j0sp1x2`j7u8 zQ>$f)@)U@*$yC?WR0Y1LRHYDr5(@8|zZPp9USRa#W9b}LXkZYBT2T9yCUO}p(DS^Y zfa1mA`Q4eo9V2jFvQViL#56XZ#e<$U#R>9sdNj2AS~s;Lpz%WQjnnA`v0yuy`;mh{>=Y! zU$K`?><5l<2wZjT15U?tKR-;n)tUOF`&p>oYRdBNd>23}#UW%fhI%-4S>9T(Qt)*$E&f@@ReW_|4^U1KG}k2W}v%@hA;{uyhWvAWWV zif!Ol0j{^4%25E;0H7Yopn+Xx5~s&uISxJz0>}Q}ztEPJbU-grS`|>DRkPR#M(ccn z(HJ5huB!ca+YYPDfcdxRI|w}<_%}{r!Un`-abw2OV!*ymlatiAy1bvG&oJ!$>11|q zpq6T}%+L3tiulmt^cb64YNvxY{}!Fyq!*Sy>`baP7KE-m=jHDnm8Fw)OqdZ8)0`>F zbUvyodBo_aG8@zR;y$N6Z(n}YwY1dq^v3F^grb=iO;Dh zI48UN&C@NTr}sW3B}H9b-BZDHDjIZ|FBI8JT$Tj z&P%Y$gaUOhG+5CMAjt#w?b|o2ozBwo(fgC`smGi3DR4_9VrZL4z%!mU*9Qxt;(7S3 z`9%urg_=5cKXQkfyr|`T#ZwxOFn+i_`!PQccCa|`3TgVzh>-=;KYtF}R(FQ39RCKg zV8mBfR|3xu7r>#ppR6gkxn1cSDrIbYCoR8do# zt6u+FIdr1Va>>4{sga^(%<0wzk`=9_s^~3Lr#)Y2F}xzI_XHyr{sGb)NXfNmKPs`9Ha!^nw0y+2A%* zoI)0Nt?#`%m=7iYf7o?Q+wSdQ0+Tz?n+8Gf=9PNQvD9*@tRH{S`Q5aygO?NMB-~xH z$}R5d$^jO74?rf!&w@M;szdX8;G%&x&4%$&&Lxd5=?bV&FKQw2wu?ul|g&U<*b}j z`zqM1$@FSKv=cc07sa-XtIuA?jf)^?DDDxc-`-96D+-WE&2i@VUhB9TSgp3Xfls7< z(7Oe)%-Y?4f*@o|CQ^}jAF%b>=d{aW5$d|9^A22q9t* z$BzDm9UWi6+^2wi7f41ffp~Yg@QuG*9^@|L%0Q$7e`C^XLiuhfdc4|>-^W^7S-JBJ z|MR(JeX(9}XW!5;c4&Ju1m6%GOOEEa za4BpTgPz`;3LkB4mw;7TSXhAdMr&S~0Pt`h_|hqWeb=9^o545ppLB>zNe#sx0(R4K zyA|~UI9gjXKK&=Jsfh=W5fG=u(Mf#~w9#v_djTFm6pKh^wR81Z8koIo}^QyBvOa4z$p@<}HWTT22rS#t@I)I&IT2 zGgv;{Iz^M_j&80ltZ~x~GywAh)?$|VTdJV^dT{&Uug7y#e2y^jzbeXHW+c}JTChty zMzy;CX>?s2pPU-qJmOznx$S3)2avdfmi90KuL49~fglMe8U}1YTN@vzLSb1MV%pfi z1kd_7n3hoxwMwh2t$G6C^!*>NfF7S;P!3(B&lYwr8A)8d?IJS>=pcpz$N-K?c|MI$ zr_}&`djMbhG_(vt#&!vt+JG+#2T%vh`{dD5JtBP25Yvc4R-FoA0;gS zAKgwpKPZ7>t~pf;rgkeBungc5`isM$NT?C~+5F2H8Q;i<4VWSqfV9Ab|F&$Sf%9CK z1-kB6Lb-7RQTQ*f1JM6>(AYYf3}I(yht>bd2V@kb02l~0sj;4+czLf-)c5Iv-q-K6 zzQjq9jx)8@*VP@lI|CR1!bj{M9;?J9ws^7hv(j|v(o{RU?E(`pR0#060E*Ohl9ylu zBn#YV)Juuendi?&Tdi*2`i#!N_dE_egYO;9X9^$y$21#tvgh*h^4hI;WdqGmybrql z0NK$oFi`ODct~4Ze(n^woEB#?>`1SwV(z;7<_Lt)Uaxs>qXO0lToQOckQdN0aKn8W z^P-{m2QVGcq{2BPNwjl6ez>Z?$AlyR51YzZ)e?xz(lPyXbaX_8f$#~)t=E3q6a!n^ z9xChWD+mN!0N7y~+cJ8-GRokznUyME6iIziK-1Ey_dGLY28R<}=PA{!j%xw{9@?c& z&1)G=fR*8O-Nyh0Kkpq{0zlaLT2a*&Y0&Oo>%1!q8Wg-Nz@i)2FNm-(KG*fR0D=2U zpNJ+>;jtLXgD3!AmR?E0jZ4eWi~8dC#CF{nb0L7#FE0$dRvlQT-&46$U?E}|)L043 z4`z!2zKH`K_7kWv**x=PRY;0rZVL}V#&=$ITYPDlu;%~#Xi}ZTG{(uvsSMmNK-mB* zy?AZ=74E9GKMc&RJ6Kr^g30c{2&zQ=g_GS~1aNS0fZSUzC=BUM(K3{gk%>5-z=nO@ zykb8!F(q(;0%9PT0xB6o?x5R<-jf?_1lZGi&|?G>NWe*fK>Nsy(zgLr4EDewvBlZe z00?vWbdiF4*ThC2#tHz;K*Xj2_sSL9b1f&k&qo;#j*njg!tqKohwr~j@-(AFs{c`l zqn|whmbD($je(2^@W$Vo*G2(Uw<~$|J=Rh#C!tNv{_WB1!-dltw8> z-p^2|4T>(ymST|scqf3FOKld6ndPR0@U_;-g6%go78iJ+)JBcdPGL`E{u&%@5l0=vfuN=GXXo!i40Yg@+ygfh?dUnj++YBS6!8+h0 zf2p;}g$28?@8Rx3r^fg-fau`mCxxGVUQ8bF?LrbT^NV>;ij?`D#Bi z{7Z-004oG1T1vu?oYd6R+`EW1Q>4Ov0$_)AfS?ROzLj>$3uOa<{=1|9z25Kf3^OTm zqDq0Kr6q8Vx_C%U7y}dnUL1X>$4w*FXx$QQZD6V$h9^3ET)40S;3?<7vm9ramTcyfJOJGTd;MiCz@kGiV1G|$k9nVT zN&%&$czP3Y2t~pMkd#j)u9Qr*kWguO12x}oc{de77pa}XzoHCU;g7aW_CB z;bbUqB$*o9hqmv}v|F|{r!07kj*iaDeg?oU6Qn5FKVMo)s1R|Rm4K*wk^3wFH1=RJ zp-c8FtSALDr3%!3fJD9^6JP?jApVb1$s|5@2$CpuTjkm)ffobY7)37|rLU%$3h?ew zW7c6n^>rGoF&Fh+P^0i!2Eopa8?rBqGa&#dB0yxLlvhw~Pc0xa63Ni-&f+Co^5P*t zEz-RM6&6S<&aAFZ02)60VxCsQ_d387-myJRfmePBV*nX!xwj*R^}WO`5Iz&L9J}7h zBmy2#yC_5wH)O~YUSRFWcCf$yy^aYWQE;O5{zWk_gWH}k)naLHtZsa3Aj%8q4z)a- zj{(Hiry5tLR#NA6VY&AC@B?$p22A_o6F~c$k&IOv!1DfDr7NmL7=Y7Wpz#S*X!|%7 zR&L`n=0YP;KDgw4%@4ASeH$c-(nm#rl9-)>deD^3J51ct89r%g}%2xd!oYz2tOPsNl+N{Z+CW__ErO1=nJ#G8?&v>r$egH z`etclLmx;sy}T@|W`+$|LYm>{5i~6RQ^VM!VcHMqc_LOj!2PUw?GXcIrzybc5^zKQ ze|1rj6zIz(?4bMk(QBa$h_dC@lrh+(9ajr_AlowooG(w;8LB=r{tJr%Q~F{SAeZ2M zPQ>FtBz)V0{u2OzGfyI!L^^uwaYF&ny!VkwyxpM{9$9hho&a?hJa%p)%9qR? zbe>2T|F0I{RBWNjVC?c>4hS%XDexTX@=GeK_ z&f?aU0xwDhfDn*I^b#OJ2l=F}hHS~g&0O+vq28C#j-E$nM z-Rm#R9q@bGs#bW2%BiQo&fcC2NJ|-d?cxG0{ufztP~pna*x2~XX^7Y=IFP@F7y#N@ zyCwD))PLRF{lZqqQa`-7qn1?%SWzG$b*U#XmB|(5vF^M0muLOoLi1#)${~0*F7SP? zaPaXt0CFMme{ia+uLl<7&YwUPF~-~;4cJ&B;AqbcXMGV}A zqvPJ$5NQcWeF6rZ^>RD_0BQ2quSB3)QW+=Y?qctyISa@-V$;ZzWyqfc?EMlm0Cka~ z!`T9aRb4{^b!1yrRkar&-9CVSKeexdc<9h^Ci%DP-XX{PFE3b3WIQGj90<4la(^T) zqczBlfQVFFQWCcQ_}Am%zWdp`yY>pC5Qlc1mj;vNyCzFQHsET`gE3gdUaM1t@ zX}Td@2cGr1{ekWl2;qr>WYh~&1)vEmh0{sr4HjCM4#-1{rLoY0^b+_7c951?s5Y9K zEtd28Q&s|CC;#7;H)&%Kz*t^lh=PKG&sBvHH5eJl?9_tfZigWYz#zr;uZUxv;EZRB zWSDg7f=5SXEhjPul>E-IUot-cm*1>AQCrKe%)wBQeb-Gh=GkdjJ!xtx7*ks;*E*9|EC%UZmUlkQ;2D;D(hq)yOq4Z5>~*ATv6ngTE!2u;UK`CZpv zKiy6F{&o4WurNC_(<8^yY3^v$wi)`~PW<{n6p;wM*Y5jU@B9C)^>05qan4T&TPoOSq507({Wll%Ze?cL!e>9trFBaCpSyE*p8U+MeRQ=*Dqp??}o2AItM;R9iPr$XO3WZgr<7&_>Bjc~% zr$VvT75?tbopfDG_ii(a>+t;ixyF(A?jx+TUhpDQF@V|6JYu$>`u}LFU{>>Z6L_$M zKm9>Urn1A5+OclwZMU+rO}T<&ud43GbQNd%k$1RDuX`@eZP$mB!%k)IyyL1h1QT)m zF!ScCGk%KY97rnq(mJBm`L|wsQ9=q7GZXx7=;I^%%JyBFi&R+x<}3#m1?0$A3-%-J z2rtJ^X0E(++s^DINpDDbU-0;QO5OgEQH6ms68t(N;|iHBy(L#1r2(n_pr>7ny35<5 z;?VZ(2Y;M@&9+2F3t$e~y0q^&U6^b)JRhJbaH38$1SR7vp|hu?q(}{lL8!nn#LB!V zeH%(hc)gKXJ&f11SC+ajSk)X%)Hg~(o$Tr9sn6D+>zioy!J&^K`_gVy%^oeH6)7x& zD)uzrD*Uw67qtag%c(XC(*u6b&LADpQ=FkC1i>ufcO4yP#q8A#0Kp zxt&cB=C8}kWl1f3klD@15;{15?2^-+%g?R%iHH!22Tqw338~?!=#Q~8gBtsBKH#l% zf#uFuli@98PpU_Z1^+RVqzH(JwAlBTRa7J>SaLHF^R1nI}m%U z#2uxaye(c%`E*cnHrw0T^{&Y8h(R!(T78FmLjDG|`sZUMuEv6Ds)SC*u8FPJX;V{E zKYjk(cc!;`WMQWH<~SWi_SX2&vu8-OZQB`-6r2=%to!*4MWRof2@=Hj#ULLAT>;j- zd@Ww)qC+u9oDRHPTDdp&Ey8-PKQ@t)4krwyl`^kYTypT2{?;Fyh2@@n-}q0KSFdE= zy}QngjO41kr5A^2qvDUu;*$AdZz@YE?;aR%ZQoW^xFD5%Kvs4ZlakPx+8sC`1l#W_q)`kRv?)3wMg!Y%e8#MHCdU$B6y1p%kmDo{Y>3-Rg-i6mq`7A^^Q?}>*-X}y^W5Ikm zB=YR%Kazpd8#d2E8Q%k6uFJ?|(>aT!eL=ze<__);W|@pv>k9ql<1;JfuaH_W?%tpG ztyy@p@H9F|Fli`kl;=TEu;?xI^SICDT*wota_{(n3O4sg|M0Vw)izx}H>~>lR_# zIIrOIV0Fcy`Ji)W-rdGG)l=Qy7NkO!{pyE{RurYAyXyKhhN|U@{zTe1hw51ddmuA2xl`^gh?^+0qW?4b!==P4j;L#IoT=^)K>Oh7D<#A4py$~Xp;BD!rm=*t!Uel(y`Qi&g#(bSW8Gugupz^X!)zYXyI!0S>K^W;UlHHhPspMJ3!L6J6#l834-?vv(l<+aw;`a0PZB^6QdBj_E^pz~rIvuKq0!uXa zBAa^HD)#z}pj7-Ev6IZa@k-BA|E-7J_SOHnel*NSvDTrIy6D$j5k3-VOs8wiLzklE zb~${D+eu2WGr!$()6E4J7cu@Re{eq^=3ZZL=u-~PH?XkKG4_$&@2j=g?w9@VHp?vD zpUDd?EiIS`j`~dKc3*QQ?kZq4qTv#(HdG}(&X2ZpoW6LT^JY?##K2(IJFgI0>&Csl z=RX((c!=&>asKPLx{TU+mTb)E84JfEXYxs){6=dR_Lmvi0vj_|tJojLx_ z-zJ)3mv^&`5un>`pNmyz-=@)>$oTK;NoCMD*wAT|To%rj+$()RhVP;dU7LO?K{o8^ z<)CNYc$H;+vE^TUNOy<8zoge+EJODImfNz}qYv7eBAl$u$=obd0yw%UWspHP0TklD zzovr;>;If#^WBgVVSrOMf?LA~cte<{;xCl~U`*lva1VER5ABU*IiTfPrmpwhn?90Tw)x1VbT`T&;x zi|qaVaEc07)Y{Zc$Bmc7pO)$2sw{#NRUyuW-}E!M|BqFXTj7jeE2_!L&Fw1ik%81w zFTP2@V68juUEOx;t*jfC`XBy&-u=94hcsa~xVrxHvct`eApgJ_CLJNh-3p-3}h_e8%AAOzg z6CnR}8iOT{W5ZqCuQFenGF6&Zne8Z(1wlbxwOu1a5g6R5WXI+RO!Px-omtG+ug*l3 zHiIPJb0~OWX5d@Mt;WVisS{+JAQyrF5So_&`S1m7wtK)PFa(ZNk)7@krh}hih^k!~ zDT-MCAl8H+kG9S}$-t$zCIlXVfzgGg_FSYO4r%_Mvy*CZ2s$(}U)35ZokTR>Me9Ki z<3}BF=DrwPDgDSoWR>l0TS{)&^~(N|$Gb=@$t2JvwQ>^I--+OG;BP*WE|lhX$ss+M z3~%r|%ChYp(#S9V?U8gG2Z~Z|Y=DbzVxsQmMwmr6$>hFBi1t7$;56$>QYLdvAC;Bq zo11I-`od`1;oUKKQwE@o(55WkCOa zS#69PUzn5}{r2^%eBnp#s0JK$iJ}+&KCFbn!9kUl-&`Uwmvqd_OG@n794i=rJ!~6`@sO%#Vu%{yuj>byCnx#5DqEbZ64TY^+9`7J z*xaCRXn2{FTQABrVSeGlg&?AhC@Mx`w80%F99<)$u0KPA6uJW!1_GD84wxl~nhn~X zB5!(OdPdwzq3%UbHpAtc3|rm_e3H@W({9q1nks7(kPe=@>2t#Iy`sFpWu-nRA=0KJ zyBUv+X;UsABIU@oFe-4KWlwIqDC*LZoE9p!}sppL$a@%B3B0q=@p0o2*jf>R%|gQgMRip zU^HAsJF<}J;HS1+kONxST zEIeVAFH~`+)g@}piBqSdP`E-*&dkfZ_Tt6SGr{vls7F2IQ;D(_VkT%9Rde&ciz&>dP#(C@8mdcvoD90&Wt<+uOGiV?FEF zhJ84Os(OZj0o;3j9%9a}PN1rm4nh%yFB7cShpRwuj_7p(z7d0UWYQ;GJ1u5={%MF{`%5}OS?LM5Aa(Wwv-4YSTWFEdXipVUfy%C3mC{uJ);;e z*!#VEv1PA@PgFD}aJup;JSu26G+Ps3-M6PE5;Pwks{Z(#y-wCn_P5Mb#X6rAxYC=(U^oKcRV5gs=m4 z__5y%ALjpO!sdPam=mt9j?N}Rv(nJjJw|?R#xxZ2+m#R)BO@d83JUBRBDXC=>`^md zbCKOi?QR04d2q@<+jvjCZ7S=Wys%3eZq?Kg^>J{*c6FgG`+Z(*^i7CmUx zPM+L)C+97mJ&ciG{X?sbs28z)?FjV(nPgTk)vkC1t|7d1(&@)H@IoY9`=nuwLYn{A zQBsJCayv9(&|h2`fQU_88Z!XhMIhxigBK|3by*i{0r~3uygz%}iTh6G zlX6;}oA-%qEFqQpOqmng7w|E0%E#LWf4nkNP2KvOqvU;MsPQ)vOWLC6R6xb!yClhT;8uRfLPMi#%8PooXf5ImfRz=VcD!7H3*l zGZY8U9Sk_HnAjH0glkbzBM{3DArpi+36kc`H+3}w1A`jn&MBWC*!f9VxHlnD5xgLJV7f~74kbone%tB2OWN%MpqZ2Z6!f+B=` zxK~A{B-|ybML3>Er2?0n^ihb+5_jnlf1P%SQ5mBLmv(?Y8-$RqxcDjYfauZFas5+J z!(?u^*%SLOkQr=093&T+#nfY2Go*(hiMYEwrmnO$11t(e1{c}>i)T6LX)o1riZgn@ zSSxzmvvqU6@Wwy$rRgkd;RJ=VEs|o*kNsX+BGwN*#nq#qei+tY3*sT3lrpexi8GTa~UENl}=O90(`m!gEdRlXC;8%~eU5Xy()dcm)6DPFgzRYU* zOX0biW9p)B*4cUfys$(VZ4=aoFSDIYNycX=E0X(7*zR#3;R-!sXiHqx6dtWeK~El5~QsxfOByx_}rjSUSZvz33&DtGS`9isZ@uWa`OT_ zfmz6GP~Ym~n%~Ta{P^J?{$s4yEFI>{3DyJw79JiRg^?XVfw*E~V#WgG&W^vmtL{;G znno^F7y9i>VgWHQV1e+A5!W$p3#U7J*CF}tGtw7hr#HXX3`IYNvkC)PO^J0AIh)tg zoRik^<5wRD@$-jEIlQ*2==Yy2e(AR|Po>D5gS5faH-s>f7P91&7P`7Q!1g1xEIbTL)Ox zPq3m55|&`ksXH7|U0Md#yRqzZDFj5rDgM5~Q4`nTl9N%yoGx}covYXl`W_+o?zhZ& zc6VK(y^Z0FFp~uqTd!rmLv>a+st3=osqJQdCGoba>q^`Cmm@PwJ>7X9VgF{$*z%j4 zdMFvxBYxs!hrTVPl|GZ9(jx0~Hufx4Fc#YhR8|;LXmPhK^!$2{s7&7`te|&Zvg^cz$(2F@LG2&FUv=W)J*lDRPB?kEi+d>(^gcWX!6(_aYlS zvLO$D6VxN38nm2vyX~dD+V=K>Kqd(^04x)dtw#I-vxoqeLI}$QYKnG;V`Q@=I_&dM znJ6(O0qng7cb*_?(9Maxbm#GeqmZY7=A4C{d%DV-p|rPktDa&Vd@3Q>_Wsz%WA-dy zp4)-r1t;6XGRq7?y%0)4oZ@dSFg6HE(F=&}SR4*`P|mrwa9 zAlqHWHXz&8H9cl)@;7B}23|@q>P&f2v)fa7Bm0lELbw^fQ1OtqqY2Dnu-B_7I zCWJ#9Y+-kiEUQzTI&vC(;)HbWyZ}NB4Gu#8*erJ_nNiF<~~Fp+A#n`^%?bK{p$Hd)xW` zXrQ{n;JGsxK=boLNDrRdN)|6Y?@E!s;QXeXK#qTM<9$;wl0VLu4{xD$JnWm+k*kXi z3@q}&slyXG=6=P>n=F+&G13m$hIP|DRY#yUgG~yTrPQ=wChyKMO(%x9!}2kGeZY2} z*|9su(;nw#em@^JeT{{`>GkXBie$kSNw65j4(pUq_5g^11A~DW9nBQ6g=bKV-cyc? zsD;Mg?IGA$SSn3zZOC7mo1391cXt~j6UKQQ=sAslAk>vTdSrlF%gn4-C%AIJgUQ$g zviQu*%<1YN3V{7MlsYN84z0Ts;nScBA<%3Q5ymG2@V4%1XloNRBe-brj&(h;U%xbr z%n*hUvMe0_F$(M);J{xA`wD0=usCo8ccT#La70JlaM#;C0@^M!;QG$uT8!w3?eAl@TQC(N+M z$zd>J@X-myeNb|Zk+(|HAp&!VyvTdgk+92E>TF&9$(&6qWGcb z?hig@dfEf~6`cjQmhioNFOWdm{fd=Z=)Z;m z?>|X~>-{=ahH(FhK8L}-@qTmW1|>@*EnZMY+;bEVxQ6fLrqxr@Vg)oiJ3IA_jU&38 zM-gh!!R+*Dt~)srqr+Fhz*u9HMsq8WWzHS8>1GtW5!ikRAG#IEurT5+|5kdNr3nYM z?_vD3wopfs|H)I#A6FKXYU4lg%E%lyXV2L@^H1-KNFAQ{)<3L> zsjug=9sSd68R}}t$XKS#`}jU_izu$vRe$lUY-(+dUN1#GX=ZlNs#ePLlLp42TUDb? zG4{A^D_uNOBd_#k!nf+3lD>s~Da8#{WqA3p&y!cp&0nbwIvt!!A}0R$hpG8^U^?X6L9WffLv8G|w6tu@4cHJ35gse=?bl4;JQN_^z@pHv4{9B8 zevPo0SX@$ETJaea)*ZQeLI)2nmb354((ItYKs|rI23DD^o!!7sqCT?XJ>S1};LDeo z1FOj(Hk63H2><$@bEV4a?JHFSW&(TmK#I1}+& zC=^F?KdQ9E+d9V5v@dfMDMF(8b6YkBndvf1MFQ(9Nzvn|dq6ViczQ~%9#-=BEGKPr zf%Vm3`4db*sfw1*egT?uTMt({8yz=p&Ppvmv*` zzvZRj%Kaq~V%B&nm}QK7)K)|{ocKFrt*VagrC2!kq-KKLwKA=UQFC;c;rV-YXou?3>7aL1Y3GEo@X-+NbAqWA5$ zG0_pl2JS}kjf*c6Uqa1wm+yESFps0i=q+z;(Imq`>ZDQ=1NEjeTn~M(J-S0LRW-r3 z@O0&+{?sc<%o+D8DG;y-r)Opc#uVCmxZByE2wj#t^G#;<$00|)iCL;7Ryu`$qwFX!=hvrb2H zpT6)>uMRWYy-%-kJW{6OhHly zX}a#K?+>DFkS=mjyMO+fTU-H#=RVrJ4f7E@^@hI>KX);$37ef6g|CM6m_6!B>`$M~ zT#DCvT?E-SO4zq@BK^avMZY0jF4ajuP#!azt4K7Iw-#g!QD}pBV%xUuN;`g(v+tyr zmX-y2SUD|DM+>x|C=E*#+DSw47@3aZPK$ciSvMoZ4=&k*HDy^G$JdzJholm-N zf}7jjpBhv6=g;S)x)-@HtEwK5$q_TUOp>i!)qyuZG#`_TVLneo(vH}z`| zUH)hB$5&;2MLN^}{r~*y_Bly=Dr?AHrrjSMf7)kWS@rEWGRSYKoUFa$>#Z5bE&q93 z7<$PXyqX#LJ64rxrm9G9UhRDa7T{8RY5rwZRmK&!92F_b4SI*LY2Ub@HQe;H;uPss Z4%r=#Xm}#B^=R-*Q%(11p{muT{{bB?6jT5J literal 0 HcmV?d00001 diff --git a/examples/blocks/src/covid/thumbnail.png b/examples/blocks/src/covid/thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..5e1830c74ae29dbc156672d17e1c979d9f3d8353 GIT binary patch literal 27479 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 literal 0 HcmV?d00001 diff --git a/examples/blocks/src/dataset/README.md b/examples/blocks/src/dataset/README.md new file mode 100644 index 0000000..fd3dcdd --- /dev/null +++ b/examples/blocks/src/dataset/README.md @@ -0,0 +1,3 @@ +A random dataset generator and system monitor based on the +perspective_client::Client::system_info +method. diff --git a/examples/blocks/src/dataset/index.html b/examples/blocks/src/dataset/index.html new file mode 100644 index 0000000..5043148 --- /dev/null +++ b/examples/blocks/src/dataset/index.html @@ -0,0 +1,230 @@ + + + + + + + + + +

    +
    + + +
    + Rows + + Update batches + + Update delay (ms) + +
    +
    + Float columns + +
    +
    + Integer columns + +
    +
    + String columns + + Dictionary size + +
    +
    + Datetime columns + +
    +
    + Bool columns + +
    +
    + + + +
    + + + + diff --git a/examples/blocks/src/dataset/layout.json b/examples/blocks/src/dataset/layout.json new file mode 100644 index 0000000..af965a0 --- /dev/null +++ b/examples/blocks/src/dataset/layout.json @@ -0,0 +1,103 @@ +{ + "sizes": [1], + "detail": { + "main": { + "type": "split-area", + "orientation": "vertical", + "children": [ + { + "type": "tab-area", + "widgets": ["PERSPECTIVE_GENERATED_ID_0"], + "currentIndex": 0 + }, + { + "type": "split-area", + "orientation": "horizontal", + "children": [ + { + "type": "tab-area", + "widgets": ["PERSPECTIVE_GENERATED_ID_1"], + "currentIndex": 0 + }, + { + "type": "tab-area", + "widgets": ["PERSPECTIVE_GENERATED_ID_2"], + "currentIndex": 0 + } + ], + "sizes": [0.5, 0.5] + } + ], + "sizes": [0.5, 0.5] + } + }, + "viewers": { + "PERSPECTIVE_GENERATED_ID_0": { + "version": "4.5.2", + "plugin": "Datagrid", + "plugin_config": { + "columns": {}, + "edit_mode": "READ_ONLY", + "scroll_lock": false + }, + "columns_config": {}, + "title": "Generated Data", + "group_by": ["String 0", "String 1"], + "split_by": ["String 2"], + "sort": [], + "filter": [], + "expressions": {}, + "columns": [ + "Float 2", + "Float 3", + "Float 4", + "Integer 0", + "Integer 1", + "Datetime 4", + "Boolean 0" + ], + "aggregates": { + "Boolean 0": "dominant", + "Datetime 4": "dominant" + }, + "table": "superstore", + "settings": false + }, + "PERSPECTIVE_GENERATED_ID_1": { + "version": "4.5.2", + "plugin": "Y Area", + "plugin_config": {}, + "columns_config": {}, + "table": "stats", + "title": "Server Memory", + "group_by": ["timestamp"], + "split_by": [], + "sort": [], + "filter": [], + "expressions": { + "Free (mb)": "\"heap_size\" / 1024 / 1024", + "Heap (mb)": "\"used_size\" / 1024 / 1024" + }, + "columns": ["Free (mb)", "Heap (mb)"], + "aggregates": {} + }, + "PERSPECTIVE_GENERATED_ID_2": { + "version": "4.5.2", + "plugin": "Y Line", + "plugin_config": {}, + "columns_config": {}, + "title": "Server CPU", + "group_by": ["timestamp"], + "split_by": [], + "sort": [], + "filter": [], + "expressions": { + "CPU (%)": "\"cpu_time\" / \"cpu_time_epoch\"" + }, + "columns": ["CPU (%)"], + "aggregates": {}, + "table": "stats", + "settings": false + } + } +} diff --git a/examples/blocks/src/dataset/preview.png b/examples/blocks/src/dataset/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..8cc54df5bd13966f71fa81393655a11c0ac33029 GIT binary patch literal 109680 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 z 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 literal 0 HcmV?d00001 diff --git a/examples/blocks/src/dataset/thumbnail.png b/examples/blocks/src/dataset/thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..2ffc25f924f3f799c96a1cc12c4f099acf941063 GIT binary patch literal 20998 zcmZ^}1ymf}vIYtvxJz&+1a|_1TY|g01O|6^2r@%(cM0z9?(XjH?rx9&-1F`|>%RMX z*6v+hRbSQCHC@&FkG!lHG6FsV1Ox=Kg!m7|kNV=Hc)`JZ+*3WyVj&=q_{~H_hO(8$$1gw2N{2YlPj7ZuF$03YC8jA}h)EiDu z+u&U3i>&iS;ly_Vy|;^e;n^=JxQY?`WGDq|!b$IO?Xj)#zWqJ*aXiUkKOR!>lwIuW zkkBmTz?Z!cLy=Dh+tEq~Il8_OSkzGXwi~wBKSmA?;2^9HUR*p}Fx^F(jhSYjOx}II zBNGcmAVOdu+u<`sq##}zKwL0|@P4Ac{^S^Koq1{({T}dcuX%1>t=0iGHoGtkCcVDUsEI_{oEEpF(R$ z99iG;k~m5RH;s@CAGMA0u&4(xcWsSdGIk%<<5MW`f(_tPB=Z35s@x{(^~;M&UAgMe zTl@x^Mp!+`Xg+8cMsH}@vCBshntXWC8A=o(!WsfmS*}01E2ir+Mf7bDbL|y2uWe^d{Ixnj7$W5)rdeN-WDW#z$E7% zARxveJ>K>IG~h#IyesAr$#~2V88!eTM>+<*NeZhgWt1JzJ&Dn2H~{yCo(4sLvzG#u zQuDrkEpp-lf=_~j{+JALHG-?JFz8T!GJEv1kjo;Tx7J)g^FDR&g|gX>XS#XbQ}L2H zq7kE8qjAH#d_xTG5dd;wI|{$*|74i*E`hd&|Gs(wu>xBp0@Q|`hrMf{3+?WC)i>fC zsKRoSDi(g1c(!4C*tDj883;Fu<@Gy}t9x_HDJm^mt_ZG~6POcAw^*`m)#&CE=r?(l z_af#6zn}bJ=t41ISin0WcP1=`bv(hE^R4|wmE-E_#1~&$mT6j9_?GNb^}5QH{hC@u(bhmesEZ9_{KncvL4 z11=HL0zhK2fzt9@0Pd3kDMF9G5i`7T2K;;@t~6A2ho}6fp$_{B7)ywhPL2x16KGf= zJ^;#?@AU?&DJ*9PUxT}Q2jT?k+V62VzeqG%VUZg&!=6u-BB|&wTOpsx(8cMaLvzUS zg3zK!t0SSs@v1NueJy_$ML`WH>>$~r9}1q4>WDL4;XelaDnKX-#LtR9Fpd4qI%&&J zoEBvKS7}nX23IGzOr&^%&y>g=rZsd;kY^I-00M>?C=JWjdBOtI*OgU)MNfoj=un|p zLRj2oUMcFDQErf?PI8A%wZ)zqzTEwVFqmpX+mn_bR?p9-!)4RSmCYOZyzBYW<}+=l zufEs?c?JA79A1D~0ERG^0qiE+CZT7%JKTBDk1UkQ-~-5vUZY>wcHiva?I`R>F4-&n z7i0)Yqr~06@pcS<)BIqN%l#ONp?!oNDAkY=i2A4N>as@ z(ekN0Nvl$6MYawo7!kX{xZ%0+G)8#_u86!zPySU`@T9(sr62Mf;+ACCCEVrT4XdiE zDy+)453Le(OTXgYeI3GyZH^TvGA&vts!;w`Ag&x=bfU~vYA?2n94A?u^Q5Fy@Hmq@ zV}7J@Byt3+l3e6Zs}+~etyU{sD|=gXB=x;2IS^mTDq1sBvvG;6md@Pk$jC{`DV|%* zE8IQsN#R!O2nwGanJSDrOgoGczxg|(Iq&pWGfi_Cvn%$H@AYQsvnP25>}>cUeYp&O zt@6{dZ%xr_OKKK0H8g>m9<@s~Pqp7`b!std^DWd&GbYYvIvweq7Pz^%haFLlR%RaA zyKFx@eD>+tLa(JL3wJH&QJmLHDAy`#mb(X?QSmkDM(WnBA+)-;!ag;$m3oK1vA@bZ z?cY3$nupyG4-#o`Uu_ID!xa236)NeJ_P2s5g@FQzg2+JR-M1hURk?(KhmOBDR+F)Efw88lI%oBljaKJArMvt$}p!{qux?qQzc!cZK`4OI3hV8Y^7qVV(7G< zY<_COH`_Gy)Py_YK`|gIyuVHZ`ZudMSOh zuz$>`oS>i3$cW5Xu6?TgSeBw~(>%G-*TmpxVgGz|w#n#R?sR_3d@p`maMRE`@#_a$ zJTs#!1}_xvzB}>#%faqh+_}@lF_VqQx#jul+IXLO*k;5cf8KKc40yYGHh((dEaj-V zw|;uOCt_P|_kLh~BxAq5`<-SQl+?S@?TNR6ONs)(xJcjaO&;YY3W5fGj)$p(HiM!3 zYz60zYl)wVu8p}uvW3mcw&O@m#?I^b^MIj_EM>UN_#W&QFp6Sk6L&by>dC4KyfJh({!b z788nNQ<6QD3us2LIpkQ9S-m8|1G38GN2y2OoknN8?HXLgh@&}K%E|N)-v;U>ODNmL zn-Dw^X%YCPrBa0q{{Vm%OwWBb~=q=TacrE7b_Uv};a;iO)F4QU- z0yl@wR1DQ-OY_PZbzYrEcfR8c>kM<2=a$5koND7#mKn4@KJ6a2TMn$)W|RN@rI#zT zyESxA;6_kxeYLLKylCsNxF@IeSod+wA#KlEv@&F+CH#c29~c(f-kZ zlzJPPhFP~1m*>T_RG#uP?3Gtfn>DYl*D2T|)FBdl-ja_ukO;4|voxxvPtBSny?jmc zqo>}Nfb+|Q^ew6xs$IFT3{?RKZ|4@yhWgjXhkZV#A58rBaW93JCc9F{3E=mu_u^U~ zZJ%+v%i_*?skMXF#mBi@t7*ZTj&7I6bN+Uw*T44$^J0Dd@p2Lw>E7vgfj7WawUqL( z^{)0+k`W?8EV~}U#c~K3g5-3Rj^i{4`54H*ov(Pn7SDGowjXUKaby=66`m2)Y%UPy zo9`adBrna4&>e55w$ISFv)>@lWS~jcF#j?#c3YX2e_K|Q41>Fe)XHkl+s*s!V|c~4 zkLvdB38Sc@c5(fv=hEh>Gqr!s{Si>a1JxyrWn>^|KWI1zXh;kQs1FMA;|BqW4*~rz z8UjKRlHk8-MM&y@>wJQM0GmO4{ZX0NRV#SbZQJ`Tu9K{tf<5 z;r|By%csWw_+(*YX8%v0{~`Jh=s%s{Rs=fOSUUYPh$_}*j{K~=O#c`Af1@=12gc9B z{^9Fip#S9lZ-n~)iTF?M|3=8!n|%y~!9PvoXZg2>|K$B!pO@*M$^VbV@LwzKU)+zS z#*e_u^xw;tAEAL*JPZOt5JKXIkg_Y}X_`iaQGeVzMRC>Ln%bGUWX_Kva!Q3yVpu0` zk(r$f7ZBU90tD9wuuw2BN3cE!KG5yB%%259k>S|lNzF_V^FSDsj9JN}-#qlp^{T2^ zr{CBAcvO+wtd6vBR=XEcHqlX7|45~jO~rf;(|Ud!_gQm$KCSN_8Id-vUpDyli<~`W z)HfwLnYDfTFhDdL-Pptg%0RBZ=OkaXI1t4k7ZV02ydsw-{U*6r^#Pq1kcpU1i-NmC zZJXVJ%P@%Q4Q^r?n(bvERP94In(oaMRFeg|$^EXjt@p07|6)W#a+?EWLwV&rNxV9k zLv}^bZY3?ViB+50+1Vk^CPm(Mp>W=Y)hM1t=-6Pk>^DRg@gCrzrGMG@tJg=`inLSO zMFmVJ^v8D0R0E08$=_9raRf7!?O_#h@*_+70@L zS&NH{H*XH7<4kj3EG;cRyMopgh+35Lsbo`e;h`m)8yax;_Vz4eIWVFLf6S5}a;w6yX#%{-HAiyTx4=7?Mq#tfkjdH^Uy&OEeTn9T)QvB7rKWFv~Uw9&ff zN3+FyhAl2q=@l9*20u4Ac~Hy-UK%T$t}*yQrQ zcLA=$UDbY_XC4l=h-JY-6H_qp@DTp?;`W72c)fXAM`Pnw$ zH*n#=HFeOS4PGIsXzaX*1M6b*3rvKHb_kc{7he|BTqDt4&R&*}$>QB4n!~$_GURVQ z3k^0_ZvDO1V86O(unJTjbJ+wP+L}RuaFEb?WpN5pu|SU-olq$eENVX?lp(s!JoW9? zD0s9rNIYwTRS20cv}SR|vH~r8R<{4f4M8+ssi|<#gx>|V+;9B#o>+LDSVFp_zKy(M z54W6{8-wHCzvtz!MOKJ5~u)Ky3tFR&TS;$an6XN{=8kTtCFy7Nm;r@;q01 zaj_{9f)BHOC>!#VI<5@ zq3A(T9Xq2;uictR7+()2f7-YY)aUXyIwJ?8Sy(jUIDvh-mz0c*zeaaRz2>!LJz9MbmmxmHs!l|LvXByhuv^t zeB^4>SGG4gnQ`S@G*4o17?4o1CAeW>I-uIScNS6CU!U(wOH@$(UAati&evKAWYc-M zuE=w*_W*c>EKK>@1AW_k>!j<~Ss@2wxl##|!+i$7Md~`@bx0l6MGG#x-(DWO#;noK zAK7)UP_v?e+d+n-m@K@R(48oYs7ema9I<7}z?9>k#; zpBhNPmsH`70`fc~WX?+`gQ7;(b|eRg-F0Mg%S zko#VOb$k-C)p<=~#!Ao>)qH{?cP#@T_nUp%iUYzY&7X|2;3q-i zhi&egp-U_?mE527K1rF)fH+$*AsUEMG!T-)mTO+1Y&X1mA*t)*wVNXiDbPOoLVk|u zv!Sj>$N3EBvSC<5#$uoENo4{_t;n?6=fR@p|x zs19Lf6TNM?D03p5yJ?PeM>#}U?_9^ba$aNmb#B55bXHctFeo+YpiEpq4xFB0k4 zTdioKS%pG9*1!ffSSD951a$ryAY|2NOMe&_c;{`oU-MWsuuYm9yk^fK+^`&uECw7n zz%&Po=ucdk`CT(3!PX?+C)kBgk-1V0%^*|(== zoH#53N?9NHn26vJqu;cl<6-`=8iWGVrtQ`)`<*V@%j|C);`Tb$=(w_J6iw!EdLeHJBf+Tg@g&0|u&uhm} zPzhEaouL=im$l{9Tf6Ml^%ORtPk;ds>`HDuQx&FE_UcOnc1h@E=9fgx6oO+7IP5$6 zK`(=lic-y@Y|}c0K<($md%)@JyZPN^YjNvo=ZEQ`KA>!tQPpAN8`ZX1`Kc<8G(1fG z^e=QF5&Xbx8ZE~cGDyQ;xfO-P71`PWUsex;((y@gbUy(fjkytPgj^#2%< zXl@Of7OH?a3~JAaF69l4HeYgjKmm{}Zt!9Pi%3ESMl#y8(ZSUT_XcoPj!Tl2*)T4S zgfukMNfM=HQe_le@cD+|;IHe!0PV6FR(pmie{WvA5(tG-VVwh5bu?7QU}jS^`9WWF z+CYOSS|?&TLJ?w6Ceo>A+i(kJCp96WgHw!Mb`zKmdAhLBz4G+NRHiNW zGJP6(CgWNf@_rLcvc#LC*u)t&kmze<$b6Ya)kR_pb`a_W(_Qt=$v&LinY-?%By=5#vU$4cIds>aUX0?% zfy;RvsuF;g%kHiMjz%Rs^=+}2vYF}6}Lvzu4i!SH5B-H{B*98S; zo$g#~*E{TWvlR+s!~1&-a?n^hTxAlvGnt&p9znN%Y&Ac9cN1{&UPD^ge)dHU$F@EG zeIx{`zkPor8+pI>`SQ)NSSn#rXaa9jRtdQz)Mx)~b)xCgE%AM_J=94(xUxWj2R~f9 zwGm_O$^9MV$wY@9Z?-9lSCKuan%TUbVsvOxCWr-8I&cP>dV z1b9BKq|zjFex**^*2G2<#PzprCWx>l0s-Fjzq!46+GHKgd zD~Z4D?HLv`_?MaC>(`_Tk@@ajHN_SSMevw?sbSa-91mP{2b5wr!@?}2<%OXv(Mg%p z&lpcF3(N6+4(tC~Q&q~jJ?Z1yOP2ZxmPsosWL!KO%=k!@UNP^z1qIfchw}qP9R?3q zNB;U`A%X<_1ncB%Vn?A3_+CE@Ht~gUbk~oucT3)#Mh%#)aAxN=w=l3$ge5mu(s*^# z_0%TL7nnX^&5e2^+kVP5DbYr!ew4X{kFRolOi8X=)muVHn5E7fR;cSR23BrZ0|I*= z&LoNV<;Eois@x?8?gp|6gWFTc)dLyNYt~ zGTn&{TcmP)e7Tqq)epD6h!}%rdbX#Z#|pov6*mygWE|NIRelby`+S6prv-Nt+jbBj z9+VHgu+1Ajhmvn%UkTVnR7L?$V)XU@uFhq4*g;y-ml$5kz&&qZ_+B=FPld;DwN2R9 zrGu*;%#fGATrH$dkdTdz*ZOPZ7hm3o@5!MSBlG6yx=IYk`$T-w7yVUBdhed>q)nHetd9$fOJ7bNR`` z!>$kQwcKu#NT-i?KgiNCc<}3S$|VaR-$%i^u5h9F$a>}PR~@Lqlrqhpl5&HYnVD0c zX}Y}NvyF}dUtxm0I^9Bz^uBH&#E`gs#A0& zLVMHDt0|&}C_w zqA*guY5;FzQS;2nJ=6)SoDl|2L z7@8=W29qIx?~d=e!e-tf;c-36DI>>h)XqZLyBGo9e$a)#A>46Y5xr?Ryv2Tfpo#O9 z(XBFI$>_<1%&%M<=U$T2ra2emYN<~lB1fjp>}NR{?RArCqOw8VI6JD{AEzV{;Qs|L zFEI0sP2-4Etva_5wb(?=h~2&^dg|V@-`frT2AA0Z;|+$3vrZjVBMt}GF1S06g3+yK z(>~do(AyrvHoI$l+-ehH+i24)G0L52pWwPHn(}Gu2JxCm3?)u50+NlBA^F*zjtgtq z4g|&Uo1CJzL;V@xESLXTlHt*9{LvA6GqIS8?6(4VSe4ohJfEO8P zXujn$CZ{ldVAlGU%}I9y;cYs@w=&#)IlUb#IF80mdD3lM7uxVQE!VrwKN0oy0xVDi zg{w>|Qes#yfFZco(zJ&n(wR72{CnibA7%dGq{kWPWq`4EFrCE>@Ob>vK@WFX@MQH0 zf#yEVPJh34UbnimGhUa$(Yuwf|1CSFV}clWim+#c{fh$`$F3LC#3F0%6?lXxSgP8U z+Z$i@8VP}Cx>M= z5iF3|b|)g@(r$I44qs)9nV8TG5{3$eDL2EXPFlo!Wn%FejE&P)soO;o)=ct*zR1`b zo0lzTwm*G6{*&1(6@00*5G$J8f4zIXca6n&?~f;19F*6OoE0-gH?(i(<)MELTaY-f z=d*g%{&)Gi;y&oaZMr6_A?t=+@1wccfyFxve&hYSm%AB*|JB?#=9l!d$8C@-oPf`{ z!Na`IE%2fK{m|%v>D_>T~+{p9h=JGor1fkZla{Tiz`YVY#Q~bCyCYMX~SNT`wg*bZ4LH~A2@F_0{-09|J=w(QmAMJpRkO%oq;6@)y{r( zW2dxj(mJUH&Y2Iz~H1E za{Q-y{cS(s^t?xslIAH=kH4PD0-R{CdMLwg>`ptw0U5Wa91 zRpG>lGf;tCz6h|DS$=86)rC5PL_(Gfc~E?MrACS{nN5POxk2)3JH!p&1@sqS06t*R zTDsHwijO-Xj_(Szg(>mV7yG=$lOxxamx~4=ik)Jxe3^1Rsoa?^p0Mnne_r&sLH+jG z9g4H&0A2K-T}tSG?EFa6g}1eB!^c7SUw~(UbuZjGmz%k8N5^rv+rf(O7%E84QVElK zyeg>gTd+++B6^+jmrWj#6l8ob_!^(`q|8;Gzt8xi-GA55*sj=rh+%d($ykqFPp=?mmz=?t(E^qQVoq~IQ%2LQo4G1m zD<;3%-V7^YWkg(?cpg~_e-~iG6cC4PrDAyZ2J)X=sxleEwU%6vmqt~-6;=UDIF&!g#nEV@&841Bxu1sfnG|l1Ug+c8NqU?){lmFNr6u=a} z=aAqPh2(S62#L1`?EUo1YXImtn!%sqA7d`s503yJ)$?M~>|g-H)dQBZE3O+M!KE!% z+~DQn=ua;d2M%7B%UCBAZ`yJs%MXkCoJO?6Y5kAa26wG-mWSKs416<(R_MK^?(fP$ zL+1AP`B-vq98>)yK9-!4_6D0cdY+YxXp48?!cy-QpP#14&&xR=Z|fnF6+(2hWg zAOT5s@B|mVij1Qr$H#i56#XE-fP9ZbQrEH-BDt6f?QOg7dFb!s ziKiZ&`avc^X}!W6KzbcqRnbe_nqhScNpj!Oe0wH( z(o~&dPgq0?X)SyCTPBX->w!AzNh$Rkm)HDW^ZKJ~qFxYFc=b;~%1MHLukNA~qKlU# zqtm8B`jamH0?KOaHc7T}KWr-soo46Jx+wLAtrCdh>C`a?FJb=3O9d$f0I=JGWE_=r znf^e}lg$DLr{N>HaS139=+|y;PhUfwyW|SkhGay>>EsE0Mv0k{-UOh}@5r*HU0sWO zFLmZ!P3+>9lU={uzDLTmV4ZSHtC1uUF~57lRlKfkJl%7CsIK?X_mhcG%wq=Agz8fr z#c)d&@_ogUINxVo;VKY48T!%4jYLC>vNUD&Rd%luL8g0bmLCvHUv7Hn&|cBqgld9& zKI%cYxkBRw?T}Y?TC|nsD+K6FXkur&Jgf$eRexBKZFay)OJKk>#&b+v*nJ;NuK43g!Q1_)C^jD{0h7zU zK57P|WEV-YC0Jh;W8#1To$IiI+l$%F%YKD5pXZd zln$}3C14JUY+nHvy~ai`h4L5SPC?ieV~3(Y7)(fbBuHbZ#Ay0)4+&irK9+DL z-Q}bhNnmep?*)++Usafqi=hNTaiIYgswNyE@{bp3q-C*^r+y_A@L{vm$VdaJ-)dOi z9krIO=%dOFJ=+HLnWh)Fbb1?_df3|OZ`78vFb!X70@X~rOBd-_SlpHRsI%4f^6}P@ z#Ul3kg}ik%;$GKg@~F$|-rmEnBaLEfhGcb%nN~8ZZWTqDk2SB4k*!ki@Skx<9|p3` z(Y>;kfdt*BKmFi544aZ71W6gV{P>H?ObOsMY(<9I#(52YW#VXPS_NF2xVIB4s2E%A zqz*2rzEzX4>t*8RqpYao#gAKn;L7YSR2bA0yvKBBYS%|eP`K0-s@J$GNSBLIMYg+L zy2nl}Ko`^mHs2twU6VRLFHckEd_;}7z<#(DG-?(DA3P$RO(XQ~ZiUqD$~80({eBE| z_ojZ;TOw~Nyeie+O1(!N&^I}<+kM(TM5b1lRI(#WdhpzqRSInVS7~gz@FIub9F%^R z$}Sr}4Yq^OVKIO5#b?fIO2&*^VO&O7oZeh6r?JH{)uur#BCM+FHh|vhmtPwu3UF~}T$@l34);L9?UYMz zT+!+Ig&JOL#3eb1h~i0t?RTc#khc?jgOyPGoZddb6hnoYFg2PV_H+XHU2ks`%7U#8$!2TK za=9U62N3CHIrm<4|6<$Skw>Y!{Hmk*t&8YV^bzphni9{#>pRH@vogt{czjmNEQCtm z-=E5|M(*-8T(M|_+Dr?>`1fyv8_crjtRiY6u%tZ97s?sQ5%_~rT&GHvngT0|v#utO zd+UeE2yd)1-Bx_-)wVAjoE+ULHZACkz-4jE`@f-lVL#zfSXP>3p;#ki>s2f*Brkd% z&8^@aJ*pl;XqG_&)-isVNxar#P=1x7^4J(=?5&dm$ww(svYSe{0+iW3WIn+>GV(2u!faf1GwInO02rN^` z_k$Ut_xKf``?bWK80V6c$F6#}h`waY1a2Fk*sYu93FP zA|O*@eQ~7}{l$q>*~_)Va3gBA$4nTlT&fj- zKB%Z-U$aVomSmsIIVFP6NndaE-kly^uE)FcGraNNwwT_?SK7RIR$APqL!XhYC?1!^ zA43+0hQiryMrVo_DI0@s_H2BjXj>-x-0R$9u44MQ z>S|;Yr7a9gfRhK^RccUMcAGhsy0_D4q=OzYbJd@aPb33LLnGCLMwIrz#ifj&yQ=lz zo~$qLn+|Vx(s9z9K?l8R4kC8T_26x$wxw7|$aQw?Bi#>)?5eCp=Wc)63hMIeQ-0TL z#*BD=%s#GF&*c7o?Mu$U%NOOVfVvq|JoFMyV?kH%F5NxymYf7TSqJ$wZ@1vBk5~TC z2*X;o_5A^X>zvB~jh?Pd9y`aWkm)J< z{sV#62T7K@e}zE90tvf#6u|WaE@r0;M*YPg8JQ%j%0&*vSX9;`a}yLOzHJ7>1?<{W zs;#nM&Nz~WO$~5r0DT@{m0d56*)-UQ2MM;Ac4V-%g}#Jk2pIUXc_o^dk~XxaMod4P z&UN8jO{6-P-1MUaDCzO|l5^Wq-g=jO8DqVHsgM_ls~VZl!#8JN%y3w=wKydnZ<%gA z_iPKU%A~L91cobegNRVo#MJ0_D0v; z$#MrCiL^*${`XKyTsEjX){dDp*szPmFc|{T{Y_-MCG8zH!l`{@Awy^JkfnNc|D&}y zJbf&C_Ah@{gYeX*0<|oq@zHJQ6+gatqKc9=u=DpRKkH~ORuNa@V>MDhO+@%yVi8cF zSO2L(0ciGyU|xB+s{oR~e#*^nT^9c6fcdz4#|B z$scgvy#6}IX#9Lvzg-`to?E9Idp+@`>1$r2HeI(Wx(^Ip((;_bx0lMgr~m<3JWoi} z(eMYcLe2HK#ktZ{o>JG7uUBfaQiN-D;nX&Lh?m-q^HcZH^s*GuLZTo4yjf^GpUAIM z^aKu(e@tQHw`D(4WQHpaf)&*1jqG5tZ74PYbQ3dS3_aTipGVlgR-2M5#KBQ?d!}|? z#N_s@QSqNfxq-g+?z$cpd5)IhuX8N@=<-DIxU_ zl0_1ly6X&CC3nWrq8)80%%SZc(E$AOm7C6df`fAC$1#1ht%|P^T=cN_ll4(3*6?@W z>oOEL9ekK(yi^ODJSvrfDZOlsJPUZz@iS4uUwP zkgEq$V(2je&t^iq2GV^3K1~1CPYok~IHDeQn}#hk?!p^?^~3;^=|%J}^OJvF&$MO# z8aXAb;s+_q${%N&oQ7j-#;1e+oa!7o+`Qz?_ zG^QYlQsF=cB}SuB^YC`xZUY9OT*J1>cs^B*>p4a!iq3vT20Ec9)COWPqHdeb2ni(Q zbHaVg*c%V`f9-Q+%g(>a)+CIWs@e)YcM!C%%I6vajP62mj!`^}0C>CDqU0C3Y+Ekv zwPW?O(G6=Vc-CF;yOX3G&Onr0J0?DOD1oqzd@v(FH67S6mqdd@)K>(lL!I9zAFua4 ztaAxN-O(1IB1Mr+pnoj#rS7i)BaOyQ4*5OStbYs&1~$K$ac(*44}6 z;TbKvP1Z}y$N8ifj~B*EOZIzkZwx;* zQ||_|NL+TTY|LqvNru;4T~xvQ$0?LWuE)hiOM*exzD#{~A@IpLLAlx9*H9rxf=^p+ zXAwor&@Z8B+>72(%DfI;J@04L{rC6C3HT63{dE?rD60K06Z$tSp#l{9Njq#2VJ3mU zzJgAGF#BsuE4$a)tJ#LH-+X5o>Z#9|K>+(y4j3qP4HqKdec zHdS*oX{BdbeciF&8%ZL@vZFJq;(!gaA>qW<}rMU4YD;5gv^9&f$QC4>BAYkh%!|ZVbFbZc%09CjY?cqR?*VSw~ zR|6UBga&FaxJqyH9%2^+{E>r~T4751zvPCgG!vnkrHhyk0E+7BRh4_WhMZX9582nmq7;`>yQdUCwM~wO)7>~fb z<_PXf+YbdDeri4QneE^|cstoMd^aAR#M!>;7eI9qr<{O^C!@-%u2=%S49}mFAA65A zRI7oUT#j`tK&w*T%-LO^Kv-Z+=+lvz(Nm&Kt%!0cPwzR@J2i8r=Hc@rJfxY^>}%SQr$j|;`Mry%Yu z-vW(94_Kiouu+8z_Sy@lhw!?Y6uaYywxRI48l^1kcuX5Uxz7AJphLghq?rw)-(aSSIz)W=*H zuwm~K*+jn%K=0#Vp9B-FiFQ#pV1|UOKX#wq5zdMQ3?%&2*b4-t!;4*03j4VC6>kad zGvCccq#y3K@bi_@%U&#|s=zh0)1hYgjBP`&{dUeV4TT$lpRgTvA*6TiU7(j6qqFL$ z`F!{%g)rUA?Z@BjyF?l&)k_RXCAH75XUStuoEm3p$7hNXY$lsE(SNE;cJryyN9$&q zF4NN=dvF>dcY><~>Htcl&opvX&0#lNA!HX+V95P);%_8AxK*p{3h6(3Xr`-5W2Dh7 z!;>~~T?pXWFQ9$GEwG3?{UNKNdjcK#HB(BiI26D6_;^?-K5W2iA1zo(!E<-p#qL}V_Y0vh^I@Oq^<{FfDu7*QFO?cB4cmvNL_{f|`d%nX_i(0{(!|j_L4O zD<1a^9(alHOz!Z3Y09zC1`1`o`7Az9$I+no8{i*yxg_R`*qF^~CVsc_Gj_>M1+&Ea zCf-SnE|3!cdlrHAks^$ANH3=!%SMbB|Ah|d(A!Pk*t+XQuv=&KuvC>AU$*_lf2;GS z91q_je7!1FSJG(n=2o|yhz0q3C5k=yw_0~Ayr-K*+vAh^u7J=6SJj-BCtfo7ZbAoK z+lp=V?Zh;Te2B+SB>rV}AYG&#dY#Pht4NUEB68Vw(Af2W3kJE`Vq?@DgdcPF_fPk~ zs0|g6W^)Gv+1=q(eQeuC7PKK|CmqQWt3}yEPn!H+tlbv{<%r-6PCU{6IPl^s(Ljm* zWoo(b6P2|)*rC2Ijv&_Sb!pYqwF=PlY`|m8NU2jRf*EXB=)fp~u*jK6N<-B{l*(Lt z;dMNs8WD`vW}y}-s;w8+A>qhKbQN(^Pd{+ttvA2Dct@NKaHq&zYL4c7-X~ zG%Q9}m21O4OY@|eS(afl{Wz;JvXoQ+E}U~tRQAj+E7n}*F`gm1^wUtQE9UJ+8{!$K zAfe2R3KbEM8-POczDp0%29b%dDV~F{i^ndcGcn^M1Mh9WC4RMH2KN?TAV=t=_AWm4 zb)nK%*nY`J@T!iN43hDR|4t`x%O?J=)RT*Z3t{tRI#>!l3}ftwLHqR=U(0=d*u_aM z*J`i^;z!*SpamO zX#*`EOi;`fODu_!(mi%IfuZG9%;mBSHJ&X|5M54X|4oBJgY&9iiwrQjiR!@e+ax8I zOck)BAbyn1!$eA=uTzML1X)n0l+={3%(5IF8eAC}c_GcUD0-}6MxuBy1%srZO?!gN z_I6HEW&@f%)ofs%hT}no7wzpd&5{bim-$0H0!m%wbtC?4@jkm$MAfr^TZB!(+*dfi zbp^BN9}NQuHNz2r?-vf*4(v;>lxzzRT>*7#WJ6dz@V8%?sjEM>XL9AD7S1z)m5MV0 zN)MG-_n>cw2n?*=axZK*0~ah%3tNb>rTCb0@>|LnbA9P!n11Cgi)-J#{d2m#+2tJc z&qrR>d;)h7@MC700f%O{*+cx0^GED=ENis4Kf+AS5BkKv;<_xZi8e*9z_Z+($NRE5 zQKQ39UC<_Qp*r97rN#a`Dwu0a744z`k5KwH8&ovBR}V!=x}g61 zwK&pouhcGz4qqr>Ndypr_W6r`7>q*YVdBj7&Y1Z^YGV#apF{d|1sNGMI{N)~WT|dR z1OD2a^vq(x3iENweAlzXOj%SoRCw!vKR(P%P^=XO(Vbd4)kaI!pYFQ5KJvxttM2z* ztM9sOY10Wjwq=%iz5{~j@*SN&`7P~|s_gr({Bv(@cN<+(=R1(6GEc~g%Y*3REjguS z^JDUUD)f2jA#pJ+kpl{DL+X{(-B0UEaeP_tC>)@f$Gp;+VE-!R?AIzFUBIAa{u0BM zQv56s56NRVCaysuYWk80W+*-W_VkR}6*xCqp&&rzKa5NgW}y(BRSm;CX}z#wCx`sC zM`0!YYCcKJ+?xO=G#bgnk36f7?5@fK<4a~Q^k$LMj6SpFQIUbd+G<0(XVKRoj>RQq z9}@5&j}vGs3)$^15Sq@-E<)t|)AlF{`jXAU#`Puq$;+)@BWrinA?Jrtp4I z84l`_HPrqY5H4JunZnX2#%);e_qG_29nE+`8o1H4mkftl+(}q zV;S%`qNINLl*|A6)xGfI+6Q4UXV-6rT5COYCdFr&IjT_ZVgj%D=`?M@tUVbg3Gs-a z42LFH|7$cNS`n|}S~8fndRFGmESAwo5N2Tvq;uO#VPX9<(5uH^QcJHXh34DMru^1r z%Mkz6AZ;cr^=A@`AfM50XcexV(_7{ zX~{6S>7*P(f%|0O)1SWoYn7*bDILJ9NA1JB=S6Z+dXW%4u{Eb;kb_@e7<3roy_c?R zz|Isyh~Ip&qU|46<3L$n%HqkkdG2mZhRY9rtMFpj3a6xaC|xlg8xG3IXt|=|L$kl5 zN2=TWS7XzxH7m}YNy>)1sfc2344TEIlWTtp%}Jm87W0_mHvyZW3#b0KB_tNGSn-Z4 z;^{~u;)50q$u9N?o8t8_dFWy1wi$k}g@w%#thMVSaXy(4$RPn3dUf1E7$?(pbFKM);lCiXkcoaPki=76606cRRO)P7J{_5{=P0Hmvj+uKlQo!*seE1z40pReqoL;m&A0E{mXEbn z%2DX7@S-gqYeqBjXnZPewzVeaNEbiUR3=N)0O)qK8jerA@++f}5rlmG=|3cRYGp(1 zOZR^rW;Q^*4)n8xf4J3M|IyJh>$S=JsoID3w! zCsC2vGYe}3`J>Uo_3)>JrIpQa`lNP0ayWT(p5l-hJ<)MpjX;p&v>LIsjcp4vg#OiU zzX;D?JQ2S0jpyt;OjsEm=-Ni3x&eC+3Q7UAR`<_!(;Mq%Sa}+)Adm4%I<@ujd0;iTnuxX z%r6($8eu^==!wv|MSXs=q44E%C&H?f7+qY|0vJ6ajRid57DNMg`@vc`b8^;T`E1GT z%zU_cf6Z2tmshuRfxwjTX{4e?75&CIXndZ#cv5&9#w&R?8$03d!!_~C9dUFD%=K&G z`t4;a7wK~{KH~Uy{mycD>GCNnyAOqLKh$YPEquWnUm(0KK7R1#i{ZhtK9Z&~@fn{j zor##7(DY-Y9?qVc3$xSp@TVKg;!m|J9$$L)wBcCa+Ae3nZnU3%`f1?rFaPo{t(UvF z`jO^_t6^FS*_gf%wx>>-lEogwWT#@d+UQxqjuf&jW1nx_*Jw`4IeAeqTn(N#StrO=Z&?)+)y+)S-VBd9B>~mY!LrX25 z4bzoIL&IEhesVc9PhFC*z|x;jxv&|&y77VL?=n!$-!a@cFJ@Sr4X%`{fX87q(e;VG_@!HFmFH14&`a2;7Q{$@n_;3GoPdX67vlpZr zSr1E3bM59stu~+0HO5JE6j`(&scY4kmz(zwxB)aEM&x)QJXY^FGq5thyF4{B4kJmPu zqCFGNpPI4W^}?Ciu%`09^1>PAZL7Cl4-eI21G(8~hBsfiqy{9e2V4;D2WqsY)YH>2 zat{{`#KeRSC;j~AKkp!X-X`%%5tbh3@i%YY4EMC>_wDa|Co~$mdPk?1rUgfvPfyWf ztcvr3L|-H&ObR@$7G-nKq0}dMS$v$E1 zHCc0xT(Q^`SDPcR{L*Wf66ho9Y{~3sajBM#EMhKE71P~eG}!2Q71vNjb0-rOiXs=4 zEi$RH2|tY|a4V~8;e-qyWus8j+76d zrSWxpa4Iz`gXi-yF+8<6euM~PUNBu!ykycv3=y(GT#=xoUft1H&CRN8N=J6^YGo9E zE)N4W=-6A5PW?ui#2%hF^u+mM&A`LN&qxluXRv8-x-3wPpwIA5H0+t4uIXI045WmE;f78xv2seqj#FpOg$IjER&FZWDz1o764%u5n7_rvEql$bW{X6- z8KUa$K6{8W=8f$}SXo&P7xb+m^%6X9HaTmNUz|{zn*{nAC2-xpQaLsK)fn zEQCWDlneN~ckd}5jdM$NbHN%v?ns}Wp6byfbp~4 z=FTsW^k;nZv9Tq%4J}YEoUofeDH~->-Q?f=&ENdz=bwMxDw2e}@I5o}An-gfUu>m3 zPd}f$6#CwsI~uyIhcB*Q4{I_kUPZ$o9k0Wa-<4_c;X}b}gj?$UfANc7Soq47D_s}_ zdK1o!eNCxpD8^?C)*m|f#;!ko>fadufq+rq~5xEe(<B@%2j(Xzs8Mt^Cno&c_EAXaMqDP}i19S7{jliij?$739aQXc3n#R$% zP-oY!T?>Ez_y5}(3T!3K0}QUz5ISZZ?-yl4@n8bqTYeW_9=kJ3OVpAenF|r;aGsur z^JfHyLn(m;4ui^c$+LBR@*rS(Bb3+XUZU$pL3}@6K|+NBhcZHoo{1sxAU@#@S@3yI zDxGJHW)K}{F>h_Cf}ul2lmA5SEiv-B1231bqih%lvLViUFO0^@nJIdiA++Nj*N14fyr%ZE(*jdmZK0rz9}NyZ~!jDX6df>40GqcS`_pXSeAVgBrXzj#l7 z+I;8taT@T`CIjjk!@<~S#4*w$=bb`oxM@C@Wg7P=!o36PM?s+gCxyfHD|`Wn^<*Aj z7S8FUa2^MaU;p~o;j1sdtf82;rDV)}GQ?YQ&h}2pxXDlN#p{%=*z~f@xo8y`@>B$YfXrpO?eS zRR-2!$m?+Q#uzPAA%^F#zWQpoqbV5c6AXXpsTom`pHULSTk?<}d<@MQQsZ8E|W@Le!p(Y(o4(beKIe5L|0 zMp|HoEyPh-9Ns64AybJAS8v?7VJ)=d^>R2(e=h5f0_QM!9*SssUQhGqGO!NwD1DoM zqU05wm!k@p!=z>RFl!w?Pxaw4#!Cb5>jW74*S_{Oo64+cjf5dDb5x9&A$NWxXY|CN z(tp1z|-i;g~rePO8#_aQDhaWb#aoDHT_j341gKZ?_lgj03 z<$WB!yxzS0_>@}~XWM*^+w=KzUN+3jf$g^l=WzS!Yo|qQjnMM#ClikwSK1QxQ_eI` z8um(NsExBq&E!|T|9IRbuTC#5dv)l80pa!LWyNPW?amF?^Lc!#JLrYamt99Z-=p-k zEv6%oJ8{L<3-Blj9(3G!8RaYWAeS#+Hp)^IcaY0C3h!m=IF(Zx9)(^NnEFqDqf=3n zAM2PF?aYzujH}4%Q0MhtgZZ7iTeoiYEYNxUvH$c);Qs@F5Ur;8ou3N;0000 + + + + + + + + + + + + + + + + diff --git a/examples/blocks/src/duckdb/index.js b/examples/blocks/src/duckdb/index.js new file mode 100644 index 0000000..6931559 --- /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-charts/dist/cdn/perspective-viewer-charts.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: "memory.data_source_one", + group_by: ["Region", "State", "City"], + columns: ["Sales", "Profit", "Quantity", "Discount"], + plugin: "Datagrid", + theme: "Pro Dark", + settings: true, +}); diff --git a/examples/blocks/src/duckdb/preview.png b/examples/blocks/src/duckdb/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..bb2fe662e6fe6624b21f1516179a805fd5b32449 GIT binary patch literal 421247 zcmZ^}1z23mvIYvlLa+cy2r_sef z=iPVTns0rpySo3bma6LN>IwWQC4%vW;0+uc9EO;vpe!65LOUEBJnS{{3*|gh5cMUa zVi2?{ujM=TV_I_yB6>uk$dTBdJ-wbi-;9`l@aNzFzRrx^-QLVz&D?UN56mWa z3g2*ZIceGelCqnF&94#iIa^hK4}43`q5X6jg4jZezI~QbTK40q6twZk1A|HpDz`m9 zhjs+*F&3u9;PBsq1$ zpFwat1468lfS+u%eb})*-m-LPjW@Ms28B<3Kus(rm{o+@q<3M8i-8I8cTK|@f#;M+ zVzGkftej>&VhHr-XszO~@!HiM7sKi2KZ}$R9EgpyWISO$yTo!A2;e;zc!WG{-0V0^ z)t6aE)DaW-iH-pWZ;r5W@{*6!Qxemcl!W{Gl70<`1djuU@REXm`N6>xz#;vehJ*V7 zPxzm-Ed1wx)p-R6=Whgu_^&!DFW(M!wf&Xd;j?01LC26K-qebdqW@>KD;lNG)cMXo0^j~Cpa?-!6 z*qCsWD@pz&6|k_nsO8STHnqGe>C;8{qmd{_wgIWl`w_F>mXXYkC zogIyxTtBsZyij+dPrAav{ccym_N&m#Bh$mf#^HH|-P-Q@zW&J(C)*7|k_c%4TyM~!2!NtSj2X`E|zG^r=sLs2z_HnaDIB0Gt5 z>=ihDTWu7cQaGi}9Q7hx)qcf&X~@b;P4*6kY|p*u?GFgWR7>7PtJk%eQsD3S+On8e zh_Pi-{r;*TS08xJY5I*E6R=kGo%@PLkEQGW@;LSQ_Z_d(NiZZH6Lhm$ZMV!(R8=T- z>1gJ;@TvxhSPTuO?zVGebg9NXSzJ^q*8SM7cHHZ1SJkXxABiW%XvJOg+9w z0!ojOz`{=3dbvO4zB-1yL%oEK{Lp>cwr(1+(<-W|jl89=>m4m`sYFB+X4I448S2a2 zz6VD6{Xi(FTW>S39j5bscLbegxuBzh`P0prZdmI8B6oYqYAK$1bjQ&|1e2?L+hKw} z14{J48(mM1dK{{+t5hHcrlnZVwx{1AI=XJ^cyN?rcRN*HZDgu|jKC&WeG#HnQ9fOV z$S*FUg$15%aNJ2?f?cF=rs{qWGiFLev?EXq*In?HmYaoF;Ru@ubF(+zi?|{SN5*!Yfmtr4hJJEica9z! ztU@5d8lB7DCO#rl)fL3;ktJ`O&Z3FU`AAhSsPWVyKM=&hcmowIgk3-7*>hSQ^$!lP zgX_u!9%gMfmjmS`o)$@eToS8#oSS^&&SgR0|P$d7~%GLQwbu-yC5}{dW z1l?V1R8m30_d=U4<(_R`vOoJavGG$NRoC$F7vh>@j_;kzGB(2SqYr{1-m*MJXu?L~ zb~xkRY8aT9y+Uu0z&{Aqet+w&8a6WWvF=XWxZEWpY@emGeMc51`DGWak#{Q zZ8ZpowfXLU>DwIphy1)HyRcM~FEvR2n4?pNau1Ac%ZP_E70L&+OzZZ5`g5t0JQsA8 zEkbVkDuS>Ebcc_;H2xa)Z@f@an79V%R{b(b-u=Xxa|@_W^41~ptf#<+W?;g43ypULU0djEGL=`|5nQBht>u2=1GWw^`wdBdcK0NBmCOe z0oxrcGbW<3e((~I>s`ho#SQ6sPUpz@Dw{cX2`Ka6QY0^)8-fv67H$KRBU zSQjUje{^_6Ghs&Zl9$90Qe!*Gm!6GKQu$bCZBci{YRwN=oq4_k@1zN_{;oe}CrCvI z?CFZg5J`FT(FBMks`@$Kb|;xm&bs-;rE1E3i9;sz4*6Ic+`K^y4nq>MDYL5x#V3z-F?bAv!ulWA3|nGDgRd>`cwZ&)L$b)gE|V z955~S%y{30g|HpIW@3aXPnNZ579Pgd>o7lGhNy8p;LboTLN7Wqi`vKV6pKwS|KA|i zX%up%@Y;nR@49`R3NX^r-Q<{@GUrb>(skZtpw_e&|7~pArD}Kq@X&(oQs3!+?OW|S z&{qWgs=s8Q9zu1URhrqOnrWbO7~V4%b39SKBhCYkSoc9b<;?~Cq)2v+ra2_IYi;cH zC^T%mYjTY^Q2^6Dm$#!W1kOFlXjJ@`6TNGtFU4k|10W*nE-%;9G~sWOV)AINzr zr9K7(Bs;QWY=U0CRM`3h#Z4Dy`3;O;rob&ffInyHZ355ZS;0j1eB+@e)3LFU*0N;n zws=bi16%lhh#xI<-kOQM-9SOJ?+j+zZuFUn`j&7uP7y!)egOGx?o9(x*@1)_$v;X{ zE>81P#KS&t-X!BcdX!M(A$)#CSMCUm;SWR3x(6fo$=Fm_th5$rw7Ct~8Bh%bGp}WD zHi>&vY#4ad_Vki`VQaOLS}oIP<0+XXCLWo%?VQZA=o~Em5k`lr#)cb5u|)%-N2PI7 z#n~p$L`^Ksc`qqrW<{ZVi&%$|hrsP&2b0iAb^qZ+REd6lTJ$UQR9guD%(5y954pMmwI;+kwfRLB(hh#F&5~dJ57(=w%o#L7j zAJ|tSHl;{F?(4zATu`=Y5MAG)4~A{Oqq&=#r;DC8f}F?_TK$*UwHO(O+5AF&dA}Xi zOL@ys5dKe#;f{j+mA`by810Y?Z3^>rq{^1I;4eq#Z-qknkk3&KR1G;mZC>kHEw)@1 zN&ca3JxOac*i}S4k8^#Tj0u88R9i;dO~}KB<2A)S4@(?i(=p7wrAydK#$qxsHh*lK zR5oDuyFaVx`dnHGO;m5~UOTCAka%}-eru1E`xK7ZSYQzWo+{C5^|qs77aP03z1vqRs4c@-48+`d*a#<2ToxCF@CI zo!&$u`#8$MG4RB?vTG7KTJzw#w~uN z-cfMV+stSCpRo{YcAJN2=h(GG?^q$w_9;@pP>{=KB(@)1D&m+TRQ-0>+lH85*zu*o zKWj}pIk*1|sR5PXQ##cxLMnY3bY&Qv-1@w_`Okv&?`#%txJ!kHTM_YZhu%)7BB7TFo`oU&%VC$0WI@=)pOx3HIFl1|2J zpT?;D+Z2l1yI41|w6FUg>G*FFKn4%`Ss%f=0En zCKc`$RYX%KRBG}wq%9rCH039+Fw5|||G6@N95k@gsH{v{@he48*!7=1gjWmx4qyFZ zzAx`6XsK3uS9N_J$||g5egwz1kk$Ghr?$5O$${5=uAFR{@!|?L9_~g1u>7;OZE7xS zGum?wh)MNlTuI=*ZYIgCvEseICP_CDe!S%#lL)p3x(prH(%iDO-AdkdkGezKFB7uD zr4-d2_3m~>cqSsAk4{{AiRQK+MqRF|u1=rZ?^jklo-5om#a4dF=G;chqn4jNcmEY&uYWzp$P4|GgD|!>Ghqo2ZJt zIy7S42VN?!)8mjY@YPW%W!F@lNcZ&qg>o=~?3#>kFg&<>-o;MBWtcvFxlnV^Y~J1O z@KZlyVS7S&AEq0nL4(V-0}$+hKQ-|G{j(!S3Kb<6oqWGXhJhOO$qnf{d*sqQVvhzs zDrSmA>Uay3sj#2zGNp!*m6}<+78Q+=_uJ?I$Hwiq4EJ~RaV4sWU!`bI+l6>z7=yv- zkNT3`=~`YnUXkyI*fVSp_+4fG_HW=147P`p4P1fuMmAJyG}n8R)HT)B`5J=&MNXd@ zgrzVoV%m8b&nMn!g+gifxrzSdCqo<{VRS>YOf}@mE8zY zdrg=J1?&Z7xiNNEG*aq42L%_kj7~Y&x%*C}GBH`rmlC#zAhD$_R|__~8UA+uFKb)o z7Ozm^1`H))T!;sBeY(dJZ2k?hIgIQ%WTgrPfXHf&zmI2*;5}cY6_gQ=eYkz!b4Cp@ z#2yu=YZV<|fft&G*qryOy}#$Q$2o9L)zFXHcpBfp&Q1q{yY{FV!u^hY77epn*>D8PM zKG4*?EY6KJk9!jxorLw5oz9NR!V*Oqn8u+FXW208n<5VrA#TSaa%3#4zXX)I+p;Tg z4XV?X2^a@;CfkJp0*#N+oyol`m$=>4Wh_gguV%QXdWoiv#y#x>63tupa=q*?`djZ$ z(pcv?E7dGGAMdZl*_Iu2?i$D|*B)4}<%Z7-XJgDB?=Y@hE^<6S>^yadk zhRIZiv;?Hcg6+~41GXC4^fNz$0u2mPqTlbFxv1qOnx_av)qNV-P~N#sj9r7vGFz88 z;VzJWh&uL;-Ro?79$pmzSWA%025I+*amcDa{jQDoGqI_Es1%>9N5!3lZ1gBQ)4h2& zVTGMRjx_2*)ylz)G!E6{$kjn0=RX+C$e3vX{N(y)?R)QZI_xwdG!eSD2MVN?$ z80;aD2VCA*K=YFym_)E0hOl26?d;#oj$TQydQl{f*TWrOt{ZFy%ITkgAA|U`<_|x5 z?|rekG6d43wj|U$e2eDX;*;@+Sv%Lu{(UtxgIO9dTV@(_n~}Eo1&r`^Rt?k`BHWl? z*0XfCKduRX#|eq1>``P4ocbM}TF|ARvO2E7{o+C4vBFV>^(u-Tjxa~aMQD@U(ZESfi&$wODG)G!%h zJKC-KqwXgr?&A?hS)A&YzcX|9ybJJChqK=f5qr}Of!F)*FsGZlI ziL+>*;OKn5OoQEeMY%ETw0zNaR&CLva^*>G0%KNVC4$Z9#I-o`xBH_l#x05j_ZV`4 z>UY!cZikGfbA2p7M_?^zCvjC1irzc6MiglieE_-VC#TWkGcoy7e`-D9UP*$XYx#M6 zpGjhcGW9PaS#|v%88m-dX7LMTYi{&h8Z&!i*n7m4hc4=5+9Z^DhGvQ_KGU$YBA{Sa z^U=N0A)zu{_=a{%TXMfItV~I`%Z2vwP-F*{kih;(12Tx)w&m5^%~oBdx&$QjjjPG$*x(5 zAzEjg#X06vEr$F6_*ESQ;QZWxa zNj|EnrLA{K)$19tA0&0ZSlUPZQfz9N76siFNY58rT(;(E& zPj7Z&Z8jrC7O+(#NvtwytpGcIO1BxHRVjms_#1u^y-IBkm;BmcvFhRWy6!V`W`mgPdp5mQjgUJw6|8`qD>p)!#M?TcP|QIi`)KUqZ#D%8qY=|jJ2t^m+>WxU?}IL`nc`&J88p>B1?VcA!iGCEJCJ!$mfG8BZ5Ekfn$as7p5t>ry=|0V1d4p*)6IM}y@Q za?^H;>*A`a_27(of1Zj$jY}>FyiRXeO{=x4i~R?enyc1!$4~N?7`MBUJnohn@@K+f zM9RQ+zDbwET7;0(j-a1Vn+=S;erd-a9EJ-8PQ2WvgbqtK7m`WxPtF{>t9z_U`#*9r zG~ac;$-z_k5b{at5)iYIX$EBB$zj^-N8W57ofcao$&9Eqo8Lmm?u^<2mm}H zCH(WNTcA!V?Ds*&{X)Aobcyqb?7Q()0*h9en0^*Y95lY~WN|jDYZwNsygz264UI6v zM;}ggR&+A#X6gbt+(jBApeqndRp~FA-+?bm#z`CWCR#J`b1Ij0v3{luqvEQTR4G+D zcuLCpxE3*0aN6?nyBDo2(Gi^0_EnH1m9`x>!fMLgxdEl2I?$^t`)uE9sL43Y5U;~v zIBB||#umiBwlUd>bn(YN+PvP<=)tFDh-@X@u%lR+`Msrsl9gOi{Vde$1ajPB&(~J+|l)+ItC{V*MO0cYg%b?;8wsqY`>ctP#?nBD0-NK!7qCSsw0 zlZ;a|n;keiQZL+$HKRMWpBe7*NC=JKaU5aa+fKF+lXhN^n`DsGACFr!K1rQMLOJ9a z2J>9@Q!n<2zU;DCXcICjcAQdD`(0l(Y$IeyZO#!N=f-blRL$u=JMC6+N;{r^+4ClO zh*)mg*g|Jyw>h-zP4@1m6j6*9pg=LW`(EZKxgd=VmD8{_Zrlhrx& zWOu)>_{>>z_?>SgT*2Okti#}m8*RcGa*?7vCP-XafQI9W=xC|t`#k`sk<;c_Q$$Z@$Xie&>7CAXliLG3%5a4L_)1%5*M&CK~|uSuEK?4 zu~LTT6wq1vc8INwJ#@l575=Hq3SD{KH)OhTRKmcyV7dnTO_u#NTed|Esz^f4sQY8v za}|8&B;@XwGakOkmR#C{F2{O)R!~mYBn+K;LY!$w$@Muj$wzPR0$YSAgQJmxR=vLc zDOWPhTzs~y{=urt|0=1dwDQ6mMwHcZrJ&l#)P)jC6srKb@#qlQb=?zlKFYbE-8)7? zn{>UVt9Egts;)I&ziwnv8=Ph{or^k7VxN5a%!x~XFU8J#^sqK15)H>}Ff8@$xQ+gj zkcVkG!(f*~s5-HlEb-G2RbzTE&_MOuG+y;a3DXCcb`W9^l%=OD|KkX9<* zU_34R;171S(s|OZ6k^j+@RL-ywUp*2$EEE7u}VhY{dnccw#{u$eC`(%;{4Vh-&*Z> zgPddUS0T=Gx+9A74D6kh4(&}8Uyt7#F3;$ZU^{ly1ra(P0qT5%Q}fx6_e!buJ=}2c zB@|dM0?wqZ-4wS>>3N(ims+iiqd37H5@EzMokzKIK z9r3Xl(Q2QL#L%`EEiJ84%$*XB&eV%XL^~dS+;q~)%J|`Cx>Ass>$v$s>cApTB2l{* z5v$dDdx$a5wC(YV`CeNNMmjOKuwaa9y~?!RvgSuv45;*Bb3iyS&m>YT015f;fSzxS zcE>Wz&M}l@Hdrq`osS#x0{@VSC@#DX+j?>u(KcD=`dt#G@dZF{;5GDNT8dwVO*u_uPyw)$`pEu_LS8H&4@ zC@7F8vmsbc6RX$wuoklB5E(u>t|~L=88n5nrPN~8K)gVm6wT=l1vpXn889i7s6_hh z@c{)JwLYR(%GLwfX-lFfrxn$+1Kvf3wqC*Mh%t_gtqY+|iOwu|pmSuV9?bMN#9Ql# z`^md)mMXRWdJq}ZG8P+*=gx=kFPtipz8~)yiE;0X=-A_ENxoWKd1B=j!bMwoe!9+? zBxW^fT{O>{VC*F_J1J!}8TIh(ce^@@_eR}+ur0e3K|zE$nC%pN z`l|EbLt@-+3*N@Z8A4kkL%Vs4f$C~mtRj@nKB*)YWn8>(lL+GR31;Q+s7{BKJ2xM! ze;X{@HVNJ5Nw9x@e6(nj76G_g5wIKEBsnfh8m&BH{W4&d*J~I`=_KnC&X-&0y}>5S z9oKHt8zg?)7HGx0lkH|k7@N~q5sq^twLU-ND{~*uD-FP&-2mNH3<6(c)rTzBLFYwKb?-cKUGOeq}nYQv66ki2y2=cS8;M2=+FCX%rWz%6nAT7 zrf##V<*$lZ)KvL$LuiGw0_$NjhO6gA@T}NtN65=T+5Giz<^{?{qkm}PTsJN|a}rV1 zb7Xn8v$o5V1_76?4odbWgGQj>tm89R>OAIA7i*`g6sD9(ig2#ZeH(Xn%363SYTvWB z`kf~^^v*_iLk2uL2pGsUFPb??2y8xaJ&_)r3VJnhRgFhjIY}D3zSf`pdR$<@^Kg)^ ziHrNPK)QklOBv=my{VSTiP=7MQk?!4d#PNz0mBFK73HFjLh-XxzwD*1j68XV1ZlWj z@@@%_5E4^flqc%Rj8%47po@N${bXe+^9Znun&nS`n{mBeknqYSRt34ppgWt1eAtsv zo9MIA9U1T-zUjiI`@Ldr)#%-|O+L~5x6PF1{n#=Vl!RF$x`$p6%Y1noxBW-p;}e#< z=!x@*^Q2tbl?{8U;^HdKBBN-F8s?YlG5?y3S&iwZw%q;&1D=b-?YRr}Gu!>>(V0!G zy~ag@+^QO#%OVZeLJS7_mP4ikYPx8-hqWq@5kjT3E37L?f?euPEfVtFypu>vH+!3C z;i{UH-m$b5uaR-x>G$QXYKY2%F77x>q<<~-J9W$57QMKI>w&777;Lz1X}!o^qVjo8 zw*f{qjyc4`!Ne1pqbtBp+>WN|=#CjelNKOPjK{%%t(9L%pD-LPIc-M(4ohq)WaCU7 z$WVW4F&6S`>|mji(!UUKHhhzS(jYFHt-U25zt@oHxH3Tk)5$=t`aO6WlCL9O`OfhH zcf(8lPVR&AiDDv{ki#LM&L}CFx=*E$uzMB#$k~o)l$51>o`Klao6U1U3!i0%Eq^FQ1q`2;K82 zpX|zkHnH;!jf^Tx4ks1X-5yt-A2kjxEY%~tqrI4!%@$ZFZZ(jYR8&YjLh}=)j_Buxx7lH;zEdx?M;feP`<$oJJWc zr$c4%Q}=?j=gu3>L&d@OJH<1%@jTypv$q~@FD66qnOU}ZdiGI!9}Ok9)9GykTbZY? zJ$rTUw)O|d(ssB;SvJ?e#5t(lS2$*H2JEsTxTGu7KjOuwXCDQNY>!*-&A;i4$%l_W zjhVUEEY|DSpxdprYCfYawDV0Q+jv0cnM+e6ub|E+ui6GK#;u4ieR!rOrwZgsxGv6x zm6z`2Q_S|3X;Bj1s9LSKez@(vZMlq7tx4V%dBtD{-TM@+wUgdS~Q>OvkOsh zyvI(@4Co0XKEl|A`d&;1$TaP_j*!(#*ewy`n!c6%Wnbbj3b9osV6qWhFjzr=l>>*~?N{oP+6ZgZA*BSG)6g7gzZ z3Y4>!+_gkgiu>;YhAzkPa@F89uJ3bDqw>rFl+W4{+0nv82%%>9%@n}YcQT=#>%)h4 zC+^SA!&gH!9lapBW`@-BP3pzawqFQQpw9#MRcTLu<|_U~kDSPtImtVDBX@}yIg(%{ z#0K$(&X>4265X%wh7xo4kXuWiK#u$ng@7oRD<#kH9pHFx|j~<4PLzp;16S?T@@aQPs9fpKNq~GEFvA zZbjc|3)df|Dy$+hlR7pP^2SPX^QpnwPezS)%QU!szCkUSp)xfB0#vfK{S;(A&gO z7r%Y!LA&=d+W_;-C5vAg7k+HbJgyhXD=c~5YK~25?@DGhqv89SRxY40d5l$X?FZV4 zeGK+H!S{}3c~(vK8ovl*fNem1gEUt=0+5?Dp=QdpH%a#7_ejr@MVI^u_%HIQ37V3N zgvI^5aSYGbJD@Z-Fw}A3y^|?HDe8!0l|fE0U85*Mn8|Q~u6Hps38=qD{oNoxgXcO361w#U6i8Fvgv2_n@+O{ek_XfQU&sePV`zg5-XE zRc4$zRA)Ybi|tB5aFfF|Xiq`X^z9Llx5!Vpe+0z{+{%1<3qL|1y*e1j{kgYUgX6efXHr5EjywO! zISXoJo(hB#Iout&8)q>>G9GK#5S$H^WNpnV#N*AlCbKV_eCw!ni(5EyeR!pjFQt{UyQ)2VlWn5j`1|aFDqtIP}!fnJ8 z?0!X=u@quyMvxb}IN;pVu6CD!F2TaEtrfczXypa9*-qS!IYn>dE`!|Hx!$_^NEcj!%#LR?;{@R2;!?)23SDR1HKxhDBQm<_@pGAIi^uWo?}#l&6s6!KgC zZW03-C!|O;?TFFkoOT&y71BdUT3fH_FinH^qa}>JI1sEkzz02s}8IDHqtSSoEVusy7dX%TA?KX%X zBd#27njuwclH5%{;CRFnK_st zEkSUnZax@-*#^jciWE)o6H+2uuIDuXMp#0msw@G^A}10hRqzNsTF6VRo;V~p{uu)P z$=3V`nF1%%;%V+~Hi@-mq%z%TgI-G|)YqM{gBLPp z5?4cT9bul;gGUm|i=BMW_whUtQe00;O&I}1&3lp-Q*d_=ux@&{FOM6Y!NrrHUsa(i zwfr~?g)x0AB$4D49%TcN%STh?JBVavSDtu!*kb=YbMwZp({}xad&eR zu~(fiBxm5-dZJUk-CeprQ%~6M`%3#wqUG)TR_7-gT8o3MoL|zFdVN?lEh|)Xg{g?l zHO(Z$p&bZoXc{+}w$Q`n8J-KAY$SLp1Y1)rBEVwB(YB zCOZk@F{$$^X!Ryv$Rjx45^h!e-i4fcKTVwtUx1$=-weLPg`-G5H|%RJWeJZ4c)3hRYe%?5@?L z52IZ=nZ#jjx0hu#E|(<)DI^Ai%`ZU+reG?j{9YNoh8f4_3xSf5MB`}X<@XYVm?%#9 zRLOqEd(X0$fV?!fB%wL?^XRl(`xjqrkCGiA4r}kFS{7scxdnNAS)tO`UGWo4?DGbY zTYW;C^LfaNtC~E6VwGkpow+>re4OQpwdJhTL<6cfZO}haBvd^v0i^Bdjkn z8I9*2HX0B#ai`NRYv%0ba29O#%a8fta0j$s`K|sS4MQ? zA_O|60yS3Cm0HE~+q;$5i#cfw)v#pMO{-6|SRzGcuWeWFsBD%9b6$5wz zUs`BIo4@t~<*6h5JhiNXRtogx?%H~kd-mA!{8jvgF?pt{t=kuh*%%U4VZ!Z{KzQ;V z#(w_`Jk;j)Pgq=3DuGM<{v-m#ET`1v{D`rQ*`q-_C^Gs)c2r;rj5s-o!YecQF3ju} z*aFI^!cC=aQY>;zMj@_4Uq^=llDIr_x$zqgaD+@M1L{ZC92dq8UY>^zy~LX=#tfag zkl51SZAWGnRR-U$d+=(?kZQ9{J!ccG#;k4GeaS408us==oM2d8rZ>d1-pUN&r_N>@7nv8n%_!`$5zfp!?^KS2PN4M>}k`iwt zOsLr}nPPJ@3ZNBfLMKx=cci}W*BJFo$7d_sLkkD&spoW2vDk^prmhy9c5tb~^k!Lb zN&~|(C%d1U+vBit*UtSV=1c4gi_J*Myn62=FBZn+t;3fFm?@?dkX_1j5Rn9oF{1Q@nzXwUeWIr^VeUp5a zcUOKssLn#i`Z^h8Q9HtB6rGhfs5P@q(den%V}Jd?zWs}52dRvaItc&?tdv?RnkLT| zbg#h0-Z9LcYdmL(IppV@MBs`uS_XGbv%+@@{t2DcC>;r~Hv_~oLGvWFKDsc9_nfyB z$USDTm@Rl3!4k%EoA8F**9gP4COr=yXw(a0;rxl*ipiji7gX#M3$Mj5y(oO$#wPR! z5K$J)5-$WnS>_HXkD9by1Q}vDaUA0v7pqUvRE8f@D)HZ8#pm||^qBVzV&l#)0zV#S zT^q;U%B4NU41Z0_f#<#+OJT2izf595ZYHb4=}Qs)9*-JwL%Aq_?S=Ma7}-GYe*10D zan#xTE(2MbCYAzVLn`BO>^^`(ycCT7QHA=5*8eHQW~MDoZ+6u2_`-&SNH6Sb)uus; zn^U8EzxJj7Bk6(g0GsasURA$|!maFAWciibt2((N;s^BC1|!V^*G}S$5qo|Nk){>! z=+!OG*OgoC&E687J)9htgH{%k{)|n}>v(OV-u(6{QlLi`A+M3iuepShraGLVC`^y> z^}8V3^;8cHk+xUMT}dVSUA&O@iHVr%#JeF1?Bzk)3K#t$6ICG*jT%MpO1pv@$h1=S zDJZRIH20iscFqgP?uO)q?SpKni&1nfhq6WfCNQpBeLGq2l{4y;1ogAZcmhJ?fb6_J z_U+}7qOzAifNCI(mWyu^S*LtO@!a_om@_D`G?WaY2e|ZQ>=6j1MXK78zF2^hJkY%n z$LXr~WWQCWh;cu8gvC_m$atjaw=g&GI4{+&IR7FEH z#-9b=%^>-iy8EZ1=_t1!setLj5sugh{jGP(IJSIrMcxjWXwt2GMnppn)6v1sLgV@v z%N4@2N)*bqn(5iL;5ArWEkE}fKkB1_C*e9qF^Mrzz)y3yQA4q{oq{nC82aV%8J%mnkx6dX1`F=zqQe9J1 z6EK7wO7Y6s@6JBcmgJ@#6L3znmUr;)1RiWIZj@1<_HyeT*H+MrSsy;Up%jiI{BpaQtEvFZIt`dCkzsHm0F^F!#E3KyJzofBfm4ZDt2)%N0Nln0KbN&IDyHI zv)&%XZ#9ChM+=v=5^=A;%IKLi3+XAz43(P9?6wfoS{p=pqjVl`P%Y%DsDp@a+Y|>8 z=VRl-0{dP8KGc%Ex-Ao@uhDZxK*lWh#IW_F(Z2-Zc(UF)c~n(9FYt+cJOa@-w=ay8 zYqf41MhRW$(x+j#Y3}h)KfhC}s;aPS`qgj<00TDkFLAgm-5twUtS%(?04FYa`t}ssEN+B$Z4DlvQr3H#o|x4LMm-`dfEnSO_xVgpyxelmHjHF&@qY9 zPitlt{2xwq)HAo-)9zq-TQDAJgqZ8O`x}=$*mtM7lL97^bUnGE_w!58C$H8pw>U$z zle3fpJ1KtTFrq;CcUAM*;8%y4$soq`o#ichmyUSCvsUiw(d*W0wUUuU)9c|d!{_Q$ z{MqIFM*tUr*MP~C2)Ipo;pr32652$W`(v_p!Gq(@Bd_=9Ib> z80*dlr0N~qpMz5^;Q~kAg#o8toarR4g3>}1kMP6HUqtu3i%*#824+DxwhDce6u9L< zMi=@*PE)-|08MdOIH%p;cmuPw7443sAf2lvYiZSy2P{{dJp(6^$9kP`anR)Xw8ryx zUC`lWwP*R!6g}A?G5}Yx;t-#kcuaQOpy0%{id@8cd!*(Xo4x^73G+TlOUC@w6#mCr z?6+t1W*r8Vt(HQkq5jLwISx`NJ6a|BNZMOiX_ zPRE{kFf!GP$fbblGwET>T5&rMk@2SUPxh_%0LkW5E!?mZ$I&J^@dNeRuc2rC9j1P{ z9Pu`fkqlDTIA(7}f}G=Zo%TiD1$jZx|j%vE&| zoaoTy8UC)hA(yK*N_c1d!(3=0=%!bYj-~ht9(4YZS=(FD_!XQpgII@BlqEIpKrv4T z&zRRBcAle!MYwW|a&gwbqwkgJr>7gbtP1&@@;_XFWx6I>l z1OihG$;~Pe%-OT|h{yaYU-gjcT5{Gp+!X-=?h0+Ka)TQi@0nTU9%FiJOG;Yd@wf7! zpv8&0=E+)LWzP#R8P0(DD0(H&%N5()wgA^hMuypMF<36u$gQjPtY_(6WZHY*2&pR& zlaL!NqxBVj$?dDaNT+1jaNhTlyzlkaE{`i^62R%_v<4Ff_vgJeoO;|!zxer`C(}St z8%Nx8+}mE!bK~9P1wXX6MF4Oh@b%(NFmEL)-pNPn=vXYj2lv*4&RHudMmKj%c`V$Fx)YHqL%5;VJD+bBGvp43E2WK0)2%1h13{E4fjx4>uM z*;2Cvkwce;0jFkFv-5tn0J^_kub{DIG4*FJPNS2@VqV}Mm-D@dt}q#YF7>7xV%>2E z9tsw_6gwks%|==|sP%*Tg5ZH0ZGlo;Y1>th&C&PxPprfI{!Pctw1a9z*KIv>n2;j9 zjgOkXTlqRfFPG||KJsDa(pHJ_{n(^L&WX6Uoxy^R*W(E5KWHsnJJ8hgL#-gGt(pDs zt2-$dVlB}wCA1m^#}b1A38M7aTZ{|sL0hXXPlnG3m~g$-=F(>PHlYG=ngi>Ia!d`I^c5|Ezs#|Q2-1KDJ5~tFJGHccJhbCs%-b0 zpwc|*^6za%ON*^2WZSHHITvdLjx-<0*5)dee(KCKOk^In!%0g^W;Q5IJ;}X(wrJeb z=gbQ~R6ln{&+}@9*eusDzfqtGcD-Y^q5u5y7aBOb13_AT1Y0}n=RSXE8c(4Z7qr`X z`Cvtp^A}Uc52oI1)d#}Bbx$#$E!?2Yw(FLY3Qwv_A?_9$5sp3a!b^QutlSJN9LT4U zSTNAjS`5?s4CMp8RGp#FD3MtLf9CQ^7jai#thS1&a^#B3Gn zv;(M>V``4fXp(HBThWV;T&>LL0>pkdDr%d9PnBF(d558C{Y}|Y${i&?d59B z)GE2M_X3-}IHL151b*dtujNX4B7PJ*a#WvrWyT;Xp-)fD89j5Su=sHLv=fg9G@NRy z=yWTtq0MATMLs^0j~Ab{6CWf*g1#IZuLd!n`4Lt0Zd|MDBXTIPjZ)!9py9al7NN{%x?$)?FgkX)kTOdJ#yIbQL+!}Xxf1G#k zdE@=xWAxayYOShXd)7C(O#%3NYv!G_dL;2<=)rIN&L`=YJpBNOF7BgUMX*uP?KsaQK2F z(1!MN=Hq^C2+N}#{kfGx_xA(CICuh8zj_5K$!||zr*%DPw#pNwt7>I?6?(UW} z!MHj+2DC;#^kNehmesfE)>$qYNoj7z6~&mvzGjW~a^LW+TohGOY@5bzIv7yv6nF0u z3|tw}(&_@^)|R(WNgTptLk4`#qzVJu^tvd6IN6fY{=UYy7t)u~ZAScp*~}mGp12io3hHZz~qI7JTFjN2!1Ki^+>aWb(RA_ReJm3m@%- zeRT$LQFQ_}-WODMNqO2Eqkg$l+SyIYd%P1?gUyB-Ix$acQ39_ z{>uU&_9JfccC{LPTv=F$r&;beSchG{Io9)KeXOPes&8-P6X~Km*-5{3TZ8O#Ml8i< zX9PZX$WK%8oSH!AAvZwIcjb6|6*%nQF4=DVyS&U$>a6(W>8`^0H~-Phh5*RHyPei~ z^JR@*lzj!%4ZX=l?lHNSB>9{;{8^HkvF9H6)Mdr*hSda(1l&Hn z{Jfs}{7s3MFcTNScpLKT6=?VKTUlQ9s#hLg3o9pbwxP{a)qGYoapcig4pgo=^H+{8 z;ya|Xtb{b{4oYqJ9=K3nyoRbTF|l_8Ik<*g)g>gZU6YHhn+fBON}o0zQ^0ez5}prr5Zlp`zmL-{%!~@ z2Pa+8Y?48A-kn%hS_ucEhU*Wb(xU0|djC|6Gcqk{I{-auPyR%q`27M%;;ZCE&{RD9 zvE66BCLOTT15V?~cTDE?U+11v@;z z)BK12$(tu=C*Ua`j)m&}c?lGy$2F|L6`$k>Vff*GMM6d4T&p%nA z1F>GJWp$NxM)X66Yei%y0V^|EnUvn;jR!X7pBKCVIKp$2$}|PM{K7C1C_cd8-J8}Z zg#-69`!GADO3q@I0#926iOz}Ft`uYvuw4att(3l)^gAbkE%Myif(?fO7g+1r$_>$H z7S^l4s2~XN;%cGrw+;{Sw4tPApx*1zPD%d#@p1BJQG;7f&ZXH_e~{q|`)e_RKi0H_ zN3dl(qVh7qv|1`GbSbIzagMAB{2*$*zt;SVMEeqKrZY>HEoMux|7IANJQ2_cz-iG2 z#6KpZj9LlG)nwxmFAMn7qakHAZ61KZJ<+010_&3{O5G-pbPj}8KYU}15s1cO$wb$s zYD3u*?4HK^ZUwRlgF4=koJNLTC*YL$;%ZQ`L}2*&-cBlFTzeAkEzwgX#~gc2@_-`^ z%=l)s*`C?9=A7#xtcd>8&T^ejEp#h#;Ms_2?HMQ4Z&;6uKh`q5MFxvm8NC1jOVx24 zUv6-S-vEUnxjg;HdVfDpLeZ+pEbj8%8eMY-H|5!j_oqqe7rm8{rE*A96X2HQl0M%_3Cn<3dsh(8} z-guT7PCHIB9ArJ79odawv^t}|XeLz&d8QvcW%G1LJ)K46uDJxwY3gn73U=8~Nl9Av zF|I%EiqD;%LzF{edmDE6loX<9(;w{t@lr&jMpMq4UuiiZ%_BkJ!6oRuaa+s zuioC}OJ?bP099+`>xX{W#rMGlghI%rA*ADR6YsBwOXt1)BNsui$wZ<|b%j!N{}Z5KS48UdB3&iOeM9kULXT(CE%CqrNG1 zxfS~x`4P-|1&6UN@BV@O7SWU^{cmK}Y&T;2Bj4L2&BNcL?6sK z^$RyjV> zak>2_ZqRPu+U22o=|T5n>p0FiS2N;Yv#G_uX+SxIV?NKU3hvk8 zH~m{~GvQy<8MS1Nb+@+Ok5#ddbE)S)cse;4HT;K&Tk`$(PvE##L%X(z(AH29)78Ri zllxbmP!qWG)jn#*=#gMaIm$dp)5DimN#pK^%Nlw1j4hIapsgHKv%51v9caL9O5g$v zEhZG)&OuD*xNoAL$U*HW3ui>4ADlIx>w#1~im(j2NHdeL zDe6U}b3J&S}?w-)TE3#p3dG^ zNPM>Vr^)I-y}|rip0GORFL@p+(b8DhLW~Tyo}R-e0!AqvXEqhgz^6@SeXgba{yNY| zRWN#}1u-N~R%B$Q=K-m2!~=UvHBfg(YKkIeQ z5EHGoB`;N(gizf>!Bc~N9@Fzoxhydfcv{?^Y3h6ry_r&Ti1`_tSJt#(lf<#UK`JCO zDXTc)pj`W`bI*{(`2N!{+?y<|QH+F>oM&Gfa8~k9k4sEQTd-)6iin4G4!51YWzYFI z`ytJJ^z->qMs-hhE2b+bw|Z>9sve8$qP+Rnm}6RfTjH%6gLIr+~hjGe(I|ybOkF+8SoP$BV>7nv+ z?Po6~l__vdNxim)1{YcJt!lPUMc?5%uz)F8tN6b&WjoV4TgTlj)RVnsV*3*{QbQ>s zJz2I(_qi{IP)*8VJ!zjVetPvK4(vtLw}#=gdaAO*udhSw8H>5ph4JtOO5G$Qumhbv z1<2=u1Y|Z2`f`Z>Bu_EN1hvyMNeA*-N^1!F3E>16FiyA^#0flj6=c82On|JU0S_mH zhFr~|JlSGyt7Rq2JD)gDd(7d9H%G0+{T>QYJ-GBESlW4IlT+bhLyxoKYF>N+^8*_- z`J8ZF<6**X>c(W0jY7BGsD3BJfhd3sA>&Ba-58BURf0txwVl~0zegRh7X+aT=fR9f zBoadQQO0Z|w-{aL?Vca`IX|P$7U89h2cjY>m}r_RC)4qJ3Q&ofh0>qt$!~6J!xjA- zF?CNU*P7y_2^#m_`o{^%Jgb}%x82TK_A@0fxtVF6GcA8+WFJo08(J~T3fJ7Y96_qj z@as{2ZHuLc6I%RX9S>*1^yoLLzfNi8-rBLSsQWhVHMD(;E;8#f!Ed#aA2P1~1D819 z0Tb6hr>LaDp0wd(n5|+Q)GQwfc`6R#q&y}v4WM7=b~2MK!9!gJ+13Sljl{VX$9S- zIZgMA>+YwtB^1$x9d#wOq2hDOuF9~Yz`d-E+_#ss>)8k4S570huM@qGY*ShMR!XfZ zKqh(m$f?qR(3uYbXfX{kUU782RC=uL1MV%GFzd68H*21t!2R>S><+FXRCdDGqdHl; zzr2;YolO4XZ3eaMW5a~xXFq=oyh^Q=z=ys!9SUDqb%DR!5s;_C&NfMJgumVSslg=F zazNJ0K!d}t_aPZ(5`370ePQS_o;sgK7?E^tcgoCw4W2dLT^ak6mA0M?=`I);jHL?&P(AconG70;YwA|@bjlIxq_ zv?z1t7Z{Y6yR#;y)^SN+%=Npg6?3)W{;K2}e(wEuoj+ukT6*Aa72R>TYluTqjU%Ojov-WyC|7z5N@E2v9^kmmwB5k_Zmzr)LO)uGI@gxou) z@v`bNZ5$#sZ`Y^1`4%*h`SNBc8tpDmN@Vg9AD4QwY|HAGI;0f-I+;zvc>y`MT@Fb%NR4 zyN2hxwje;jg`oigsrlWsZ?+qC7HVI5RoR!>g1iGd7j9W~cl;X5tH%V-Oo7 zVSPoew91cY$Lq|@LG;0j@hTubNj`>bGT&_0wQWL@`0*g;*kwW@Z}v#3F7NhKF6hm$CwT-1}xZp_zZx$#G5McvRw z=GLJV7I=JGw^||aZz7a00SJd@>`1dTPnLq*M%o$fFy&i^?aYSDBX*Q4Z=*bJ_S%j_ z&8G}v5i2a!FjMh9HyWHarI4xVYl3!7OlxKf%)d|POneX9N^Z=8esl&Sh+-PiQhU*# zGwD-AL=fefT+vGx_28pmpPH(yO@|gF{C;KJsewsW_Gi=bdf4RlzSj#}q>4s8tRALH z(5kvwQ*p+qsZ;zRI|Yo7@Yk`arLA<(zCCTAxZA!BmqZsHTVJumDHhKocF4xht~*T_ zKwHJ8J+sK$B;>T@UEvLe)YbMXq~ogTrFUycf!pgII;z!m%gCO^b@o|8Y~}A6rSkQT zPeD9=J&tkgaG!`!{`1X6%xo?Sf7zdge`u>i{jQ(k)dg+=O%m;%a^g37qBqhMt$(+Z z2ga%18s6q&+U`_wjoX)0{v<5$hV>mCVtw+!a;rIp1xm+KPu77AYf2u8yZ0n85>m_L zqUwdhXEMIziH}7o&$D5U8=b+tH!l)5%U+e=)bww0meL!4c`T837Aff=x63pfdXtEc z&*K0H7RRHT6xJY&h8V}bB=lfT7o*Q z3{{2vA=E;AOW=Fz*SNT9M-CA`E9019U2-2 zpV+XdG9BVUAF{UH;Cu7Y1pCbanu9*qxJCJvG4`im2`>V7xo_I)oQj&3DcZ@Em1eHr zQmRI}4A(z}fWK&c>dG_{5B!@gl6mXPbuh0>A`sog$spoJ@icnO$fUVXa#BTYjlVMR z)vKp21LPstJ&tmG3@U&JvZ|hE$LlIu2g!VhK9l;@90i;&syH}3!SBehl z$pYp)6EnR&7!xVQ{w-f9O;zI+Pd%fjGXh3y>X;@wqxis zOq1Y%jmPfQv9lE&^)i=;mz8T!tf)d+M1s>7mH;T;mA>uJgvk--$z?)ZtQ~b{5h5C-FgYn8jEWY9%GA~*Y3ik7N%j|e(L?Oah z{91}OWb4_4Wo=3c>I<=V!!&3Br*%WaG*b|WOHLHMhSa8ulb%M+Kh5EK#pnllmg^!Z za`9>@IF!pMByw(|oHnYRLO6{|aJT;h5Ie048ssA_SQOz|E4T~R^Q5=S1Qo&lNX0bS z@jW(cP##EY$WZW{smpz)^xc^AN8twW!}BDkU9Z$doO9Pm$b0$TA66>AwTrbe9ItFe z5px18)xIlAcfYUhcNwGc18j4?5O!D=S?l)@nGUzd<2zwf@mMLKrJ_k)YYq>)>rM3c5yrCH`~6gWL?D zZ=2p83$7r$is?iGeV!!6@IlvXS^SkPbTjbiE@%motIsu1rtHY*;MbZ!L9ww(&{86?`y%TTDT%WNv@Gt()*Jb2{>w~^Gef>mvEYCDsc3hi?tJontk#s68t(3thMdWlw0MX$ z^ie0|m@7jIp3*7{hU^bX&-+EhwFB?&5f)4KXp8GUVO$@&V14{ZUsg`s($LWb6&foS z1sSzRtFRo9?I~%9_Ubw7V+InH^l)Q<3m%cLJI0JIpu{K}ld_nmU5aip=To}?NJXiK zWd{mVh$^0_S%CXZ>iu^H(9AKfgQdZP)5tcc>Dr9|Fx>$0rJB9Y+2=jHW_TOa;aggc z6c*t^GX`8-rAatMe8k-4YDp#GP>3BPRT4i3#C>OIW7vSdbWdpWEUTTE8O<{2uB|%`USTuv&}TPt{JTXA&@jOM0Gm)^mZ#HK zQFWLl75TvE8-|eXa{L!gA4r7gEB#oQr0H%lzG>wVNEMMHa6o1td;a0+1=#79Tx`hw zM6J`PZ-JNHr32IH`(IW37v3J@B;{9Gg2XHFk^nwlKJUGBc$TZ0hO6*R_wfw60}50k z9hHt)nVKcB<}dm|L_#&;z*KZ%eO>)2*b%Gd;mHg8c9jB=|04Aik*A#8xZVGO!L`{g z5qA9>%G_pLuy=)MVZ>e;SX>7Rg9SzKz)3-xRA^iT9ve;ZD_2BMd&1CA@>$g;`De+K z=0lo(nAE#LCx#3vpeBOir7v&xb6V~TiqG|avTowD?gdNQ9-IRJqh{I)(p*qq62Ui| z!~iPa@(trM?HC$oB2=5cY;}kO$MC^@$nz;tROM=9tsr({=a~SdT3toYQ6rU|E|EAB z(KaRMjau@;D95Kfze>^*96-t=re;=%`gmovO+A4cX8YJrLQ7rW8b{6O zKO9fvQoZ~p?~&>9Q;q3{>i<9+{{awREY^wfDOo!bwOdGs+C^-BeSib_@h+0qyo{L8 z+gws-cNRmJxTOYN$L!@zZ@Ow^Gvi|j)A+!*QzY89@^uJ;vLiSnzn-Pzzn@^FObc>d z)#7JY7s&9j=8PNQ-XQycpY~xL!|-mpV_Og}t|BMELp_H)UW;r1#TI#;l=uE( z@+R3mRwhO6=0GNFVL7T{pdxS{kvJ+W+tO#35dBr}@hI=VG)o5Ln>!%RrJL!0fwBKZ zS_;&Pp=>xatjhj2-iDCs6xOQi`!uQJig9(V1yB6d6T#&h2}6H z{p`yjL}6qFS$;}mK&2${!GX;8i!CSI5-(e%Jv_Ws#)`J z3kt46$KAL-xzR$~ui=6>c{n(dvfP76uS}tOb)o{bSdOu7p&Scx*(H^CIK)-W<8@2_ z8y+|0n~JuA9LH-tscu1$5s0 zFRO>KoagHyKVCw_yE`hrIsi&pc8ciEF|yD!pUUJ}LGyrwvKI?Os^!DSFX$01j+(Df z(L#Of`l^X)A{9ZixF>ms`+nlljZhtF1563t$$;gkYsk1T6tD`WIIh8aRO1#nG0>69 zLPK9;hGsd0z5Iii_Rm(Tj%HHnf^LsSF^c{Vqw*tbK((*{=vsYaU^pvLgODp6{slAy zA|9XgyI3`h@I;BxCVulDs46Txk1YfkJmg4Rch{%xU}elh?q>F3A%(;*_Z*&hwp|x- z2f4@i0xeQ$am3MRE{C1!Ju6FI1gQF=x`M$>((p{=Zw5T+lx(n*W#U-zU`yjyjBu{> z6wY-1niYyGIkW@(3GM9}1M=Kno=cI6(cOi|FvEQKh3im(G!0um6n`lzTkaVUhlh*s zE+=9Tqv}kr6E{q$=eGijA;{Ft@7*(I%Zb|r`^J{A7=ag!8fl4C{cMVa9#I^6-(NMr zt&K9(DT;U$B0JHYrAI%)Wb8)3aGm1+x0wX~LBm$n`7^itdm@wC;Ti+}V0L_2baS)% zTBUM_&LqbG8p~z|-;vTO!JlCPWr1ZGuGyE>;#2xNiVJ)EU~3Ijm=27`zM3gfPanoB z*BzFfCt!Qe`eEdV!y%&Cbdk}3Y-Cy9bdl)9H%^1cua)#gJ#|;X@aTRd_Ba!y3lyM{ z_|3BzB=j(*(tK$p69@otLGiv|CmZc>B_r727f@pYY!pt^>V@60n%$Li0uZktZRbGA zJvGBp7)HUSOfaa+!?bwZQO9_dh$D$r;CF@zl@f&Fq?JQz4kcQaLvQ${hAUMsSy*pN zmHy@3t285dxUw<~wOUs}%pNuk_|=GT_8|$+BB*?!A+ox*o73LhJ#`c*P(z$oKNccz z$BKVKyo>zrf9bhx-B39mK^@rl6Yh8iy3zmTYW}m=;>uwC4i9?czuuU4{84vm1Y^eJGO*n;)n|%^L>->qf*xTqLA{1-pCWQ`-_D z5iWOqku;%JPmK@vQ?znDUCd8G5u`l}aRMUuJ1nMXi36XnBEz+Y@mIr-)emmh=Z;Sn^N_^)ciKA}|`_7%HL>l7)B#60IG{ zi-v&(YcwLGXL@#VTd4(ihe$XDhpXMcIX#um z|2dDQ%C-ME5SnD6vE_LD$?cf=&@M0bt^3qD)K+5t=LwaMR=(S;52~4j&-H^eqbPsg z#d~`=+q7k#Y+>B3wBy;`EpGEMu6`Mq{4lJPmXiPy+lWK^Bfy+%)Qh^Z!(YnMttxbk zp+ultoPCP7cMH>B&n?{_G{UnlWAVez6+<f6A)4qSIE1pgur|F0;pyu?|L`4lV~ZXT^}?# z?w)!e zRi&{QX7U`ln&!Bt3vOxm{qy2pFM2_5g`Vf@He>1RD<`athY+&_99c~lxl0ac2&7$> z74lmD{>f3MMrP|iTWeE}m~8c;-9iX;3Uxuf+kPDcbeUFv>lL+i_aei6UEI%UfH zkZ#O=xP!zPdtLN@mD+Z%3QRB}PMLGm$3%U1;0{J;ES$VJwP8G+EBVE+1e@)maZ_sW z*Bw&2t2f}=S>=j^Za@)UYzJ@UkF($0$4GCBKRcvyIMOTC|5MBb>>zzR9iY9l&;V1Q znat*@xPN))D*E6{^I-g`CT&{cf>9K+qd)xCTjqSTmOw~+3E#%d0RG=W82xQQeQ~QJf^ATau%%2{Oe0Zb^4T*~ z1E%(r+iMnH1RAO)u7w=L!hIs?oRp2=&;|8B{uysyySUp&$TF{TzL^;}-sMA1acmuH z|FvM9MLs?08c|WSl;&^|v8bTk$+8(v>zr$Z$nD}hQPst03NeFUni{FRu7<52s%=e0 zp&W-2T!n!XI$(6qVO%*KdcQj%$)Z>sQgj!-=*_tg>3~x zoV%^r2LHb`o3;o(VY6SY?zsaY?DrEh+1OUe&6x}XLszj}X}ja~gXpF;SR(Wrr@4B^ z4U_G`d^8li={NujK5zXS7h8i?lH1vMdT8B}2drTKAHM-Op9>}i9l*qyx6pkDU#z?& zR-&L`p_KzIBWMvncoAtol&tQY-@3C-HA;A)lGIasH>VsB0HHZqhp{uC#vgvhr^RJA zj+C8{3NKoZtNZT4ENc&v3ytNr~^etZVSTU4IYy8zTy&(GEIugdLBG?^Q9c*{^Zxk;0 zYJ-FzDb0l&M$HL6qwJ?16s+TEx^WQ0MZ=6l1Eirq-JYc=r2}y;I2RZ z#&Ktx?frs%6bR`xVz@vip;v!3k{Dii8%F`$^8zh5?q=)ho}x?(H;nZkwKAONCvqbD zcaF{vqTw%qTn^!=_^^G!k0mS$^P>fs5UXS!n4_OCy?rUh(-DvJpZbzuaEMMmW<0Gt zT~om2jyWyu+8A!$?n>S2(7b}~tl2y|bb~J$xmNPaD9vzM+$UiSNMHF*9f#DHSFWa{ zyO2EK9ctz-?WNMxEFx#5u?8Ldd^%Jz#d5;1l}h zZAnBxzz!(-j?@9uF-wV(cS2*#;4 zUP@#weBolF{Vm6n1kX0yz3vVNBit}m%taEryVnL~?t|fVbF!TnIh|AG$KVX{+->WS z(Ac;QF{P?^USS?0GB(-A^vxY9{>=pB?u?w!(+PH~D#3iP-_8BC;iIMhq=%Ufm%91RNae1uHI+|iNMk^eeZD$5lGIqL_>4`_G2?pB8n}H++`LExQoHK z#*HmX|74bsGqbUOH`OXSTRUCr*J?cP_4D{+=)el6X-<9u*j1s-8C3|!X^oykQ%tM7^yrn>3Qk z|BDsyGY3=4o9cW>mreSqSed#DH0U;T{r&gL8turWg;c+mL-3eHz<2q;eJ3#t4o`cM zr_g8?3PCg2%0qmIo-gTD1%e=+WhjffMr*)BQamKOJ@_4E@<0+kBGjt2k8UMO$cs-H z@V*@u_;1cs)&FA~k~06t;#>v!B7d#SGsfrstWEN9e(ZlMZj6ptsGWz6CELW+jx-16 zqYD{rHl%23fZ}1Xxl!+&bC*PoyhzHdhYKk|4LKTrhGLjBGH&dZFgXd@4`aA_boi@MGyexV0E@8><7Y3D_#!zLwV;t zbIoRfKrks_9?_-T!*gp;hSj@-ZFW3RA?|mT2O5KWg8C;Qlx|2qIS;L?(j|%mf#>Hs zh7IpzOxLMs98;RV(9PySDqm)30D{e&$KTbISRkgD#Jx3r_O1SYKU*mAc4H22AM5Vc z(8q|j#UVsULZiv$_&>`B>)>?U^>Bv2g?7mHU9k8vH)pfb=eUZC0hZhm|I{>6U)-rO z-=ypfOe#<1(1@6+k@3b<%JU%8rJy{D4!XFZ$NVmKfqg#QoKK2UmjJ96bUMg)$i??M zPh5zAkHM0>MZ+@}=U^|Ev!1oJD8Vn$pVCYRI$)!&;iGWmJ~OLX2X74^Dv`?=M7Hg_ zB)s2qKHqtEwXM>?uad}q+>iU#atI2xop)s9EG<1mFAKs%UPQ2Ak0kO2)I!_4gx#c! zChaUYArSFZOh;JdLb7Jzd{6NT+#GuHvYsDGI%b1YTm;GM_f^mJjlr_ zG|S$n1{J+c-TY42)c)Nbl|XtK?al`&Tl9mRI#ccHDeguETDSc*VSd&36$sG+{g!d$ zPIkEfTf%t}ym^r`^f&$n<~Dke#>;$xjH?AwiRoO1@4FbFc^%D|UO6N%q!0wVXdPBy z5p)hh0VZoQ1I-TIwqB(5N9K00$gcW3Z&DF?>gk*O`llz?1xghU7?)*LycMxj))y$~ zrfUo-lzkLF$E%T-h>fd~2b|hg4c7lmhME(D#II>}KhiB49RCgJgTzrHCKt?|17*6h z234GIVm3O-3NXR{-XZ`0K550CqC$g|%Q=@0febPL z7lmd!Z1`9PrXU{vy++0V+qK7Qo(j;JG|v{3QO?!n;4Ab6osgL z=3k-+syIUs&eaG$nCNP?YVLSotC=JBv2)q-E?YlgxRi>45A+|B>S z;v++X+2xAI>f@CND6YGtAb~3p{U*mXEC(6xZ7mXT4&ra9GkCra z8w_^qM3Tb$>67N)oj)C&q&1OovobZp10-Y5Go~Xj-j_;_fH9sP<8Bo5I9) z`=?RAcQcNeUSD5V=+6EuN%w%KT0}q+mkG)$J-c|hvY$`l9to4hrio|0^|~6o#F|V- z3<)8lkq^7X;r6qbh3z((u2@vQ-kWR+N5<3n_WC5N7luj3LXGt*SV&_-pvZdplp+U@ zzJLQQDLRAQGwrNx|I0o18)?rU7}gmoV7J(XYVDTWl>aavlGj&7j=2OZKSCK-xpK*1 zs}ezQaYfpF4ODIcT0~}Xc>`Kfe>!Ob74jkE{UY3;VF(_q1gx(2mM5$L16@S%w}|ab zV}%}v2q-M763LhN9W(a(0u6uLXjV#8lGST5Au38JWXd z#8%^(%naDb7w>FJYy5CuSj;f7dI})lV$*49;pcIl!O7xqxBba=iHysl5EjQqnorcL zproYqU3?VhAs7!V7pUqP3Wo|)x32s>BNht9tnWK4<1O>4UYW;qW4D2=VNO{66IQ_0 z)zymt(FA(ScM${!SXkJjq_69&?nlXfq-9RB(^!8*=U*kk+M4SljrXOUux0R>0k^wp zcb6bCoA;R#lk6xZ(k)$H1FSX)3yhP!Nh4#jz=wj<+HIi7I))knq$V?h8YXeJo`1~(3>1S}qeTB|5);`^Kkn=<{ zAl6bMMkRGRi=0>w3&!c?{jzAZo|gC^F+1ZkLK3B* za`WMnt7<|~4Wppgi|S_)&5cPDvFeG9Ny zft}UXK9o;maa}&G+0V_jI;b|`Kt#<1-!9nESORL4vB;CjQOL(Ip6-s%FH7Rfc2Jvl zcjubKjr>&A3YCB#u9wVI0Lka&DT6?o2Y6;Vd+hKc+!)TwWJzBg4Gr_Y?*Wd6tsbr> zh|HA1tqVqh!dBE)6WNEm84CC>bK1G@WzjE$PXp`_uMeuy-jK}*T^s(-iobSx zCk&~ZcUz6WNMI{!1#OHi)%7;xBq5&_keI|kMPocM)e{Jr3V9{qZoB<@PVCU5TF2Z6 z@SNM_rbR6ur)X_L_PAbX8!cBht=kegHWwIrZGM?KP@1`BfcNb!k_t^{d$Q$mu4gpz z=Kz2s=Bo_#v$(tybG+x5cn(&aDy=87VfysuJG5D!Pm=^Eut5(%-DXAdk5@?ZwEh$5@!NN1n4)*VEo;*2epFe^BGH zCDAiDX>u$IcWF=GQP;LG=FB;>aMOaX4BB8@IwZF|p7hFVmpzAwCXw+*7sDhdWr=9M z4xwzT_pMW0o%sH6;Dq1Wham1RZhZ_zPOk>$2-VWOS^&7s_op?-sn?ac_g%h!ag3=9 z(soR5)J*V|tfVk=(iy5M+O!6$%*gCpI>yK>P|8zLDsduw1MWcRlz z-SxT@(!-;3nCWt*oK_syzg!T+WY#^@N(co;+N6xdzLI{p<%CRlKr2@ z7fj^B2X-vYL%&!K0gCVC8}wY->@ z<-`_=xh=D2w-cnv+);NfFpG%4CebR|_g|QJjE?iJI5fQtKX+)Br?R|!a~?>!ix7R8 zhV?rt+Q4JdiN{PYbIm;<$~dl-%o&Cp1f8>X`~_{4=J{K)cD>Yo(BEaCR&ng+D}F8b zKYmJES@YbmD3S)&!_CN1qNtNufRxjB7XFIAv&*vi?N~Qh+0&9L?x7%_Nq&#LXoNHI zY++{UO@y@c3y(9rK(7B~QD-@x>JgZ!Y|Ms+aaXq@zfLwAm#nWT_AI|>8qI7ULE-_> zQ&_l$o*)flq#%BsdA5rmyiG|na#YOZsku8{SvmDwwW+h3Y`p$p48OhRy)QnWcb#*c zto};sxSkLU=n{Vq@K~LeUO8>r*j(NYe7nw@Z*awXj=MrnrhN>@`*wi{@w>S4jD93l z7rrNt!y@WcR^n-p$jKg_#4G=GH$mEf$ZzdKYwUl&!7A>ZKRdyW;^&VK5A1@N38EIM zr<9@qT<#y{iVb2kBbg1mQtt}$Kr<#)`4Ibk#U^Gzp3)FWLOUr2!f14pM>2*}?aE!C z=va_RW~l87^FzrpkWU<9j3!R77ORU?Afg>o*o|RFCDQGndvCEr4eMA z#TqU*M)y#%4UG}OTD?9fmG7|7sgjB`@V2_Y^H+HgarZJ#b%73^$j09<=_j)v@ifn% zYiP*eQ@{|%*Yo1q$;A62DsT#7dBs=iW{W4n?7Ad(`RR0}D>(uQ^H^g4ip!S+e;L6? z3(&yB4~6!)M^ir^rH`)Jcr9Oy0pbtQYkH$8a}k7|F3enRhwn-WmJq=51D4O)GPO|q zMP8rH6ByR}Xli@kB=}_|=Yz{wFkj_BgeVzm z!1lIx{KwGMvp+#vFK@jg1hM?jdAV*#k5o5cJXdg`am|B>_nes1GUoJSNW|kf&#`70 zo9;nRK1>qk_dioG%Q3h-uJcSpOu@I>zUMX{mx9g|lMU^r6-s=YFe?`OIfnQhPnmY@ zLVJBH_HC6g(dZ}9QnLp73g04t;U3+P@Qb;=XPc-_(}gXwbxeJY48O^7f=6!`jGREE zz7_R;(3QVna=RBy2yjH01N|u#TU4f>HT`-+IqI^qm|&amCiyQ&ZMyJll!4G_`|77$$dO^TJ^`^(-X2ic^x`CrRIKYv*favSoTFV{w2(OFieWhUgd zJ#e>bYEmx+HWquye#gx%KtdB&w;_;+thY)33~x!=BT2|?b3YX@jeaRTcN~3Zblf7i z`&XUY@xG=Y6O2zDd!{6KYQAywggm|Xv49Ovgvvsb``NC*<)zW*oGZZ*x6I*KL(jFj z!8oE)t`4ezz(~}m7tYwMRTQ*#(Srp$?z{bo96P6u+s4xm2zq1a(Z>c{tNe1k7vxGo zXH7}KYRqz)ip*_-63)|&6m}^kpl5RC7jxJ<>Kh}^ML^tMv4x2%1hHNRqe!_M97d}t zCQ)dL(MSwi$g$gk=$-u*f^8NuMRtWnG@GCLLkwV1T9R|io{f=Kp)iK}-P zS`Ry>r#7?;SUW784E|n#*h#HFEstX|4WAwc0L?yk|Cq#T_Jz);OXJ2rK)*0Ey*Mn* zn-AM0d-4CeYL$86tCmz2_`I@l^06_7j0b%0K3vXbpY3#T$2o@&zx-1l?f7BUun!vA zq1A5|l#sfmVn{fb(fD^T5NnxF28kT&1<#t-h-RxK;q6vBLxr#Zs?QfmqqEbD8i0jQ zeo*CzQ>Z7*n|&RGLUl4iY`Q--TNAf1aeRMT=vwyXh!%Q{;XI+taN@CN@`wLr%Z$12NoOfeY6~DR%qV1tDI$QQ|xuG1{ zS6?0=r9N*21v57CL7Lm|-GBWcu#@;riBEWuecmKhTubG+UwsdPKK-y4N-|C{ODa|H zXeJSiN>vuMsMK#th3g1M_CgnVh&S}!{WPzeMY z=zM?kJzlCSUGo_oZ`=rYZkclFL**SxcFrX6U3In{)p-7(t>svPgCp-FW-vZV z(?a+e!AyMD-Lf5=!`st=(JAq*=xM;qCCGmUMtxmmq|IEmW)N-62H}$M<)=c9>GNsZ zABWCI+`{J-+gW#y$KAB0@{qRhr^AnM-AKfAI2t0Ivd40A<#c7Wn=C2gy@Enz@^lRXV+_`-2#iqcV6|i zg>SA6vuN2Edp5KDR(Z^^&!CJuiN23BNIr=Me&y>-kSU~!J4N+D!qPrVw0SUGBt$Rj zUvob!405(<>E^YMy=dPge0R|aY7K3~nh@AlX!{?UzQL`sH|#pwHEH5x+b2(UO}1^@ zHCYo*wrv}eIoVCd$u;pizxVy#|6pI&-p{k|weAH&!gbH>z}Z~o&!yQK$EIRB66%6H zu5J0B(+k6}*gCUv(3>rCcr0U-q75$zL0nK82(j<8#opUCRZRY>M+rRJCn*e1XZ}HS zT%gDW(N&XpMmP5#$)+H&s2KK5YP#ZZ4euYnWgErvrD#DNR)G=HknB%-sUna%oS|rDgGV+S`%ZTS%c1DA4$cH@%bk&Hf3;m{A{<)h@9!L)wu?~ z*Dpa@^a0?1L`~81yr~2x6ToYB6MQs0z?1DpaAURC!ynBWg)Hvv1Wb(9B36Xm3imrIX{gPoSFxc|Z(9*KnBqb|70QX5X>VDkQY`85d zP-*2D2l`*It#E_gX9f;4v2?r^qdd`jeQ&o6*|`)_vGp93_e~c3NT?|^3a#QL{t^dM z)gR1VR~DZsWRY{xtOgZio9Wg$#E_!+DJD?O63~lMI^Nk*T#h8Idt$zEa2eN0F9+a1 zPTQgxT#T~c$G>A4Ct|Cd67?KT=R%m5gKFg!O+D`t00b{Msrs=D6wSG(YB`i4^P zrfF_hQ3CRepfsGlV+J5>0uk2)_nMt6p2yDH&ZnOrk?+)euH)-vTbqoIp{&SU^i;c) zQ&75sVutMF>Edk5d{c`>OI`C-nBe33N=v4Gr`v; zzM6mn3hO`+w>v3YHH;O;M%PVg*4j|uznB)#9GfZsC9v`}{{|VAf*3OyQ-O7Z+V6Q! zYDV64UD%MYOwao0Y?6|w=>}h`?KKNK(Dc)DMZdNOjER0eDEECF5i8(^_{7_u2yZZnnLv%tj01qJy3t~`0I!qndNaUm__U%n1dErw7$V4j2 zuCNrR|JZelz|}ro!c4k;uD-`dchK|oPK~qN?~bR;;Z|{kK#vcWbbq+Y=5ozUW9>dP zEmkR_cF}3mdNsC{tA~TlO{f{j0hQAEl8S z0*PKLO#$-^DRt$oq}q@GOq3|86!!A3jPNAGj(Z2QY?~QQl-HZPmXPPf)kX-PivqtK zSwK9Dh8fqC-?ai#Nd6=0w5sS5ZGu+LuH=#ob?Jh%fxo^lmp;5OZ#D|KgR?;E58?%8 zh_Q@Kd~4+?SlV|zng8DGjWn~vT~QPnx15gyTH~^;w!O!Qd%0cb_a$1xISZ|RgNG_k zJo=qLDTh{D1cQ5VJRbwj{R1mRi2CWa3g^3Z zPAILLH&SGhy8L42;Mf{!%mPqJ`UgWnT^R>ubxy$h!%DR#NXTLNZ4h}@E~uMnsJfo{ z3VZs5BHzd(rilg|POYHBAQjf%B4Gh!zlRov@y(#+bc3nY*P3@{c-U}0Q?fG1e^_tW zVb%420a{)V011^cy<%)b=mb12v?BQ&HobhcEpFt0eYe0KpkuGSAzCI}Szh17Ty9mc z6_FnL$VHABBXX?<$nrFQc0CzPrlt@z$5f%%d~;&Ua)TFPtwEZM!RwgY=?7Y4D+=l} zbbo%lb^*!W-4_)GaJpl{ytBg^Jlu!kxLaAwI(_FQ@B}VNKVNq)bJ}*&j&6~Fo{rxZ zKUgFfyjdI)LAL1y8&juhIq?X+AUW}u7W}!(whcv#EPa0kPdm`+e9`7b4k9AYngTvZ zy|peHYe?FuH7^vNvqo3C%Kr#xsZ}eV`jk$c=lt{*_)%l&XUevN4KQ@H-g8W889Vi1 zp2sQL3i#+_*^vC|Q2ZqNwI(J|hEw}!*5BB;Kp2_FD()%btK+^-V470(PHm6>?P*!# zvz23(b;!w=nzi2VDX6@Ynaov?xxQlcID3^)D&q6K06a*^KtK_5Nun?vmf|>D6tu~2 zvOT(v(r#Ni*lz%eVA&h*8^2AAOCIxulvv>349t2UfAW4V-4RNK*&hAqsHy>{m@=`7 zlCElWcIRSXO*!nN7@HJ;A?6@sGyS_7XGH{q=;U|aRT%C=Q_0!14!S5BXeL@jwg^DH z;Yoe5Vwh95&YyWo9i<-EX#YX;w|13GBI}x)Mlyt4C?3acyA?E^^wRVa40$Y7^@QB_ z9{qzKqbpEVh0;{&8DzvxNM0c-zcHPrjqsfc{jk17tEGWbVI20Gz}uBCDyZq00@Q)P zALdy0iD_d-=5E{G$NyZ=t7T>Yck&yGAcAT&ud#ZZziNV}feVy&+1J$wf_PU9vhoLN z;B!{aj?^zKL2_0Gr2xB|Cp*v#WI%-V539&TVNLL&i#YYZY*{hK^=;SOX;3T3GtR_N-H>@j6%Rh3Z+$n8$PL0~S?-uN z#JC=X!O5^%KKolgq9SO>pkM%l5M~|1VahMg0mBG|f_kprQMWYtqn+Ff19xJR)yDHH zm(9iCy(@cB*WYBhZiCw}CVQy1$oxA;U=FGKQj(51V0FbVC6e1Do~VujTf!Gp%+Tk- zUFpClco_nFHJ*kWVI56>(;%2Lzhi1^Oi|*e3mq&i98X=bO)j%4u`* z(&=0ir#gXTYidU)izOZF-{l3)6TLmIVdoUF4L-eZV7s0^uf~6^S*0jk1`F>v9*ibD z^{&ceODGP-{vsb0<&^}@#uh!>IR#&5tSzzX7^+sDpEXV%?hRjgZ!3M~X9GUY2# zqObhAxs5I+foTIb+H5Z{TP&okLsrV=_J4sI&Ts_27e{VV9e&9wRtuC@@72ll z|3$$j%bOdu0?8jO2SEvD_le^ej4P5eS+K8f)2$;IKZm~#5mh&2SL zg8KBl6AY-QPrL-v@7~vr2*h|xE9W$qVxDeR*DBT8=i;T69QL(T3R%A?2#+cGm)aVt zUeXl|aF&%q*^^&87z0r(rn@6Ex=mfY>Uth;?}rX2gAQr5YMbphmizkTd*cbYCekUq zN|De2Dcjvh3xPIn`DaSu$!SV-%Gfx&<8{43g_L(!|DygBLtQIT8tDK^`V$HuVn@Gw zuU;}8^C$`ZQ{tJ1LZdEI0^Eez(sb--qFfh55+6|)tf9Z3%oq7H+34N zJnuzbf(mJZYeGFzDwq|O4SY_`eYKiT*Ltcl#>jAdOVx?AT;=)&KwRuf0TSnfa~_g+ z?R4w{=Kib(emrReHnEXMf*5SNv-S^Pa*KAz~!z+Ko^8OCm65t7Ol&VpeYGQXD{JwLZ#?KZQ zzn#t}jt#}yKFXNEDu2l@ecos4TledfS66rBMNrM1baywU4nTRhP&E86bnFOAXbmZ* zq1$LcY@;}kbZZg_48vyUp|>*9b?8YojhfS=bp69rB3O#f$`yVpBxjrJYo+hDqL^b> zz{nqzz&g+&J?m11I}JuDh7@)pfnkZS#ixOqxuw1*Jy0YWC%)km;^jSn-QgBwbv+E( z5{e5L(XquwG?jXNI>9t4{Ad_VAw7#ovggX?TiU(7pNG6%tespQ`U4dK!=v{X5lwLqP<#Aw16_d0)8gUvLmT z7jx3OrT$4O;}-B|vHpsyhuXd=f5DF9WTU#=C)~8^E62=~`#X!PmE7+e9;HGZXqwe- zvB3eTyqmFb^zqQ$-O$z2Iswsz?W>0q<$+o3cuuDOkrl*2RX}u?K1fH;vV=fcy{=d9 zO!TJkST0L92H=hH{KunQEG^D9N|=~^e5+yNSk6!sqKnk}OLd<#dTz(p)OpaVy9KGD z35>`ER|IzQQQ3wY|CoEhJetUI3#@6AX!rU6cNfL-#3vd}*s_0zLjg_J+1l@ezsK`G zO$luBngK(S+#0$^WOEc42d6-PdoWO&btzq%4%g1`TYlM>=tybKRx4BFf-xnMXt7xv z1gG4dV#BiXlaAlxU|VK~NDLsToj1)0O|IM6TT|4m=r{bZ=q>a%ht`o?Zv2K*Th@|} zunNXI&c-}{`uz{k|) zp)gH@fDwm|LtJlAKsuHk(zTD<1t&s=ZG$YwzcLTgs(!}9< z9lCQF)f-vP>#4Axm^H-kn0Oe7cQSWnM`V`s`y z%|D_GDlG~y2(H=b42}EB(R_sYjL6m4XX|mzm1lC&Cl@w+!MWV=4dGU0mN^n{bGB(p zoh}q&+=Fusat|*~4RJ9!N9TQIX;P>MMqX2&_seJ49o6H%95(w(fie?GL7SL0C>nwL zD`?c{o{MK^(=9|S5Gz2q=~8WNuE3NguuUOPNc^0l%q-b=yaU520D;MOr?lt+5@?Y1W`8r-70QE^OsyN2$3` z!hAa2R*q~~?)er)fo`$PQ5e``XasMwK|_thT4L!pmGhOzQ=({HrJP;!N;mCQ(6=bD?v~g(ENQa_H-C3+gcBl-QBG z%Jh{>af>9uBX?w`PWtrU2gH+dNnpfYI1u=w<@bm9($Kv+6E*0GcmIyd(nMq2g$o=a z4LZ7HrGBVQO8taX>}Jpn9OiIk4mJFlP?D-ub6RQ1<*$8ty4_#gv%S2!qRsicH`a9g zM$szgm-z22LEVi!nIb(YY`2+$T{e$?i;(z{plJ$)_K8{1bs zuehGvBdvzrLpHMQ!#>e6izg325M8F>wth8od@xhWs)G}S_T6aW|&5 zh?Eu}*lb(q6neoZHg{aBG;?IZ#g*KA7oi}{%zo?kn+Eq8rG-hu<5&BzH;i%;txu<0 z9`Yt(+-W?@>+eQk*MTRq)fn>Tc_~y_QYQ3FQqGO$kvkt!m^j>XFXP(G-{PR|e|L7k z<}51*Aj(f{DDMYzC`JVssGV#+SBbCIkl33Q7(`akwg6)y8S$SvEMC=37WTCD@xnJ| zw#;=q4Y7v5Tp>tO5MuGdzQU+2kFy~L8;xsCE;pKpLMT{&y`)5c-lfyc2jt42YVDcS zD4E4BP=mfoG(M)k!8nzHDq?N`a@5fo%^ch(x-d5DqrIyDs(Vs%t)XcjDW_n#;gw$y zo03r1S~X>63O-BGqLC4|y&LV$9Iw~!D>b5`re#SyE^)$hYQo_ihWh_F-^=FPUf?li zXH66uTR^M&a^&l;FiN$#+S{mm9mvb<;LR6rI9pNS2RJsJH}~*b?^p4iy$LZ$fhg~Qcvm||oPdE~j{#2d3v>AWqJv`mJO$|1vnWU} zQ4us-cT<4ngps6xuZPSB0*!#7c`aS!TuVQ0XIf4#!g;7>b(nAnxwZ4VQy6;eNpZ|M zFB_&a{YHFayX8>9hgR-0KUSLY1%ek?6yF!1c@yjd#K@>H^dKV<<=M~78RfdCMCT2| zXcjV8xR_su$}>c1sF8;=gO}tL1CU#}Ya23F!6zV^3U~EzmGH?{&hn3NTS}Z_`H;Wi z;&9Sq21!r^c1x5k+|%oG_sG#*->KxM!Sn)1^Z0*>g&ubq2ww73BZ7+~`c{S+LAHF8 z3&0Tw0HVAX+x*1yRW%hh@4E?9LQjLCn9HX#1JL zZ&gL|&fTo^x|}nMTF5DE4C%6_V5L+pcnH(;)+ymrL;Ar2yRR$Mm3F(|xX9qEukJ*z zyTd}6hip^gJ(AaVcy=DwY>s9%o!WMOhDJ5$=)Te%(H}{Utyc4ip=Al&?Ei5GUqlpW z#XFd$E8}rUUcY9O1kb^z*hkTKp7;Whe;AjX@PdFGvEFm z=KMYWn^SQ@h)g0}q=*rKqBjAd9YlM?ZNfFWf|db{_SS-_gA_$yU3y>m|+B zGOmnT`fq%;SDV?Tm=Y-4`;GCWuhh>7OV-b@szHzna}~7-4Fs)afXzG>c+U(ZmbA1b zs`|vqgjn;iBS73AH6^~08UABC!@=DH0D%-$5f9E=fhlKIr>vnW&BL{yAQd>)oZGRo zr^(sr;rSq;3q4ZmuX>;@-@f=pObhZcJc(ETf5j8Rb!YyvU3kAuB+BTK6Z^CXAJHS^ zTkEJ<>LVU(x-i}zHmR1~-{F=E%zI%>y)WY5f0%P=3g+{>+sO3)0dwn_y&~-^=c5sE zSq?OShdB4y6)b#Ar@e(Q%eBUWT~EbK_h>12t_rz~YDDJgauCaDH;rzK$?0GC(4e(S zLcX0Ol*q~Twb3RlujZVkE~*&sH{ulETcDPR7`*G{UDqJpt3tNZmuC!WiPK_OWUOGA zsdE0uN0*Bt^$#!2I?{p{a4pbFhl5qF?k00)S5a)`t~N8Nupa34XZb9>^<$qOo;AL9 z2f2Od!utq2cNv$OTBgz&?CzNW+qUNAnw8m%G$>LpgfB&s3Gk-x;J@x@A`vAWAw(8qwh%|HeXu(UBJ%hC!zXckFuS_Rb-tXEG4~qdVKiR0QrA4eiqUcl`^Q=yBA-o`(8#30{Ap{!uNcArjtN z(mnT$K9=|)iumsM_0kvIoTB@BWZ-|gpJ}DwD!24RPUE%$_5w7Y_sG2#;3Vipq3r*F|u*1v)z1?YmI_rZUY=Ja) z-X&jToa_zq?LkYD**cBYQCn<5BhLd;-woc0-d!}Yg2vS}2OpQTkuMXJY>y%4(bfFV z7;&$6i05{Ybm0T>&0nFVA`lL(weINpdCDrq=pi(}P^t0$@41wwyKYi}uxOR2YP|}x z*?;Sp$>&NQ(A0x02Wd;u53W@X7IbIqgj) zuw`t5^}^>M3Th}>j-tt7v|z5b{2L3++ijfZd&So)>0F6CUsL?&r7Op2@P_?(b5kgd z<{bZ;`YL{*8Mm?e_!5=-SDQWJnWJ>rnV~FJs3lrpin%+q9by z65}TKFKF{lCuv9nN%Xd_d#rDG zE^=_pH%Z`FG2>7Ao`i!2`;FEJPtR7u;bI`$3}`nLz18_(bnR|^hd2=1?jJX}?RsbN z9W3;`bszftc+7X!$YAzpnDL{kxcs9(}O3&qWq}T^AChVv>kY ze_!M}iGpifRHC+A+FHapo;1YOiO$-$5t)wjH0C`bKVhmjUTp!(_?&Jv+KSZQw%DnKJ~ z8L2AL&>dL5uV3K3%ze?0{!* z?>H}TFLNm>g?b&!dt{W6FnH?*C)&S-3g04ajKs4$`_j(J^$>;GaD9Vny@(*<4P0%} zIuo8_w|b4aKGazt53MdU_+GeX(v^nVe7OA8vD;XR*u7W~TouNB9r|>+tm~NKVJr3I z<~!qkyfK_qWCxZJHc5Tn*4z4HT1g@t5qoYr9One~UbY+?JT^QE=THWB$(6-3zvX(Y zTR(y;6Lc(ckuP7)YumsQgGJ`9H}0}!D?kpr(Pc!^@SEEsY~M>qJ1rmt)6!ET>l=_ncPSQ%=NcSdQu3kwziY?ytnP zKX~PBf1km21x&N=)q>X>#EIm`1XwwuWN1*lHA+3OjS*cQW$QZj#0fs0P$ZkAty3ht zjjTdv)fCSp9(iZkw>}nnv!^;N6gLnv&bD1{w7Ke`Ej^&}Z0?q>Z*_XKNCg+vzk^kN z1eV!*VlPKn<#l~mI$1*Q(E@_ED5u7krTr7h@okYRqs3_CNZ$fd+-V0FeI9e_fRDI)5QBbIib@`Kx-vQQ!05H zV4R^WHM+uWRJ9~v0ksae&=)l9X_E` zK^^j?q2-2d)Q$Xx{-vwpf_prDNlqKvihvtR#NLh7Uvz5K-0Lpk=XHJC+&eB`BQ*^( zg$BW|`XJ=r|7uzNelFA1S^9$R81SgCE9LwVPBYT63d#0(z{T<(} zv3ran4;OeF%a<@@j>&I$^%G~ES}o z&32%fDi}r*^RBU`nmR#~UkxvbFqXYJ+xbEDWLt958gC)1kiRk?uaMQ^pb9bCVI=;+bU+jfQJAZCe&KI<1XhhHxbST<^XHi)V1+Te)0E3ZpO~VSkMa)_8JQ zPB;H2+u`1Dpea+M@})^1{kBP79q_c}>2c<$)Kr%HR%T!RE{jUmjk>>cXmbX=fJWuC4-?n(susvRkWl*#~}PUZc-HCE%W>Bs^q)R+s;K zneaVoJiQT;iGz@vnMi!!qA~m}_sXtG^>4At9Sp$WymCPM&B5nLuzFgF5Iia~ZfU2; zVcc+{5PJy^A@(B~+$kyO_zNHJuR3FBmsCVHX=+cbd*Ijs9ZL)Te@4^kJ)i3^*mh(+?Ky4C1?T))0Th0l%ld!0Kjb@Puspd7gxR)bZTcZ zLXgc>oD*GFjWL3ZDJQc+ubVq~k)$44qZAI9y6d#pD1YX=!Z=bS!yFIQJ6(@wTBBOy z)?8wkHr-xNEpt;ti+l(MfNyVh9lPI!7UaqzTauv&W29NO%hr-(2}7?j$BY2!_7vhl zmP?T#!j$~ipky3N`Nzq$O5iK_IX2Q^$fkOWd!iBFU!Ob|-Ue9bdLCPf4(Eoe2cj)gl`IBCRwtMadyik8fMJq>TU( zRTCnG`VS=Gh0l}Ua%rNR^u~)s2!rn%ore6U=R4nC7zWSN@jwTYzUO76s;JjWWyVP4 zB_eUkWQH3mC?98Xp4$T-VP3KC;8EqWc`@Mp;Zs2u922}-a0l>?$f1S zgPl8`3z78&JkD#{q7}B-`~+#mZ+oglR^h9Sy1Z*) zW@xa?j%}&aUI3386AnSk)$M9wR`vCCK>WXVFg$|BQr@g-KJdEoE-_*aEsu6t1ftlx z9zuVTDJV)1TAwaJ2V9GM<#Q>H122+^&Adxp(3;(YbElsHX(@;B5~APIUJgdQDq!Tp zZ7JvAu?~v`Wa?Sdbc}c`VIV*j8W8sCyRn;Zak^SlV+nFX2ueL-GDm6@rll zXs}?~JSl&3YNVq$3@ey{N7AW_2+ZF?5FTzmplznm0juH9B?iIs1roofCl>0WAJ!U$cf&xLWB&~~tWgCaEDeMnsSj3iwf{QwCKhK-j zWq;z6!yWnqJ$fJ4&pi&}cx-x>J(&M~sQdDOSJWj}7suw8F%$gq)JSu{SkUO5vO5G;n$Txa9Av@dA~^rbb~~f3(CA0 z%STLEhOTA6#4IEOmb|!B#jpr{ZsrEOSlh1D=Gf+3mv?_c!mk7PstO%-n{{Tvs97CI zW*ew#t;-=g^)-aL^ew|IpmMx6x5tNnQ6`r!bJu34ph&;)IIC0Oi-*QKd*BDMfO{hz zCuZ{l_r`iJdxrs8iY4`A`T2aGkOf#vTC1z1=X%M-ZE>MhhlT3g%elm1F>_xNk88EF zYr&-R@_K|~J$59Q5eNo!u{DXc-DmWMI>BVKX2S=8ft&p2@6aiPr+XrcLpm~$qgqJvL;HgekY6gxs?I$Rpszt~UIyvNz zlT}mflWfYX?#cL{Emd%f8O%2<88zB}zCU@M!&57HTQDHal^KV=MJES30h%$TcgE5@ zDcwdUiqMi?8ql2_YSSK;#O0LG)g6E4CfzkR%l4c;pA0&WSLiAYI@caFi?w%f(Dgu{ zS*fy?84ypV*C)?zAJS{Kc@X2p1FONTzpJJTL&DsjXszQ2Qf`*!5`!J9wB?W6+thc*6 z9$5gxz$E+D?RDYp3*-r((y~0C1?G@6pt+v8lTdWEM6f;m>`!R<8j- z)lPmvUX7yQ`C3obGILvV7pQZx;cT%jFYKoYA`*rEs=4+E4kB7n3&<8*zsy*_5Mov> zYW$9|25-l%X`1uYEoH0ojhocAmUxkO-&#aDtE^!x<7%@b<{dQ6!=wo^hMFCn{ZeQY zM!}G6baOd8{s$Tzu&aTpY0VBl@C?2tUC!y#=e4Gz5f)lkQ^!UWAFd@aU{X-0QC1VFC69gqadQ4958M0@QZV5)HyV!dn)y2(J!ihG zPc0RWCv&vYn`w4%grglGh&Vn0Fd0zY$d`*6YDStYL$sy(?_;RBXvH}-q=p^TB`gSf zM=VNT35JGW|J?G;%kf)?>ZQhC?_X^sM%we`yE$RukPqwceShhK=;qFP_}-)i#$fARlJ|XQ7g~Rw_bb92JakSrjdiqJ${!q6o~m$1PgU9%NG@HK`aZHg|)({OGSbm7<6MWCbTN z*lB+2LiiNxDIs6m1mkP5ldIK=x!T3t9@dA+;rk}LLia3>-0cGW4OJ8&a}1O@_69eo zP1d$iO6)iSq9b;ol`83MuE1dQ&ywSoOsLqVL&Eg?n{U8)j}@5lP;^1FGV8Sg8JZ7j zyYOEq%d9V<+j*9WR7gW+4xa=4=r!q6F9&UK88~zT5mi~r4ZVYdVt79Pm`xDB8^s>H z6EU%-lmb|WlLBJWmW<3r-&pv}o*X04zGLn4ZBmW7P{W>g?pBsrBi&dQi+t%r4;)Ra*5e|zfFeDITsX>C zH(4e`1%HF@DzQ!!JPm+@SdN-J7=!GUY!;<7mcHA{S+geY$0v5=#})v4FxpWx!%9=> z7-fXqOg5>{33==LYLQ9&oBmJ%kt7rA)_@+ym_EWl#&*L;Z>W61)}H!pBNUd{Ad&T~ z#+Qi=xQR(wvWLWeR~o)SM51;B)oiaM4Whxbu&9)aBJ@x`zCku9-jHgfRTpjbUq~TQ zr=<2j$p=fecEdi<@ztkeM_(3Pr8(;LXE44&hqFP%m`^MO}=$@c5rM#;RpFO=~Pekv?-(CDr z{$OK|AaiqBCZ_&SKTTb#;qbxqi}@pMZPgAESGSfab?i1*wVJA1W!Qf!CMq-e;A%{q zxk(0m(;CdxAADF=LHwSlHi4ZF+`ba$I$1U`SW}(3%R&(}xn6}?y1jMUPYT0cdJN={ z=*=80xuEj_!?iWbydrz`PJZYapRXC~EnHK7U_>Gn!#y6jR&-cuqN?j2l!qeFn~n{= z270#Q|74EV!J#Ye*WGdbS}av)0;_7)R^Ygj)X1$cVC%VZ(9Ugq88~w1zuB`I1#Nu; zRU<@seNewWHZN`tlhUwxiOyz-#8~fjc1rP>!dqkrFZc2pCXi)1J;&fpr`L}Q^$p0K zeX}k%?GK)w{n9T0w)=KQ6+}wM;4*4 z{oL?fV7*F{#j`pR6bl9{Qx1La9(|Tf*I;skIS*RPigKCMpwR~nbLWp(o!jUiF&*;= zE!5*P6sqeX^>KfojwVnB8SBl#X5SS z;d8CsgGoCsu}f2z+rOgc_gd*sV9NN#Uh|UpSYU#if4UB_uj zovBXDI5hFxc0AY}(iTv#H{CL3gAJ_Qa-H2u(y%7(oPc44i8!JEcllCxiN1 zodekooxZrEKi6$fiNl;W>hl|@t$eCGby(`78N2)RGHS_`6iPIID8(imH(F`nklv}Q z`$)LOULWu+f1PCua6dHtXgt>Hwevgb{npPWuHmz6F3;p|pb+tA?-qLDrfRO)8d5m2;bz7xNR^f#Qqr&AKxcybpY!RHF`%+ z4sD1T`yI*)g5#^k5kDmEm1%YQ1Y^y>*OsI3MyT4J`}7<^2Cg2xJ36o)!#04l1YaW! z>g5ENX|&3yqV0#dT-}#{MvNI9qg8B>rC}}pH@WU!TV3vmQ*!bv+^`qn8;ugr&%OgK zZxF<1mLWoW>F z*n^(nbM09Niw9%e7QelAM@K)uLlxJ zH8Ri;ji^l#7-+v-(`J2$PmTrLaab*iD8BGX&XxSF z{lWjwkI{@hMV*H}ISBF8eA04peGi%r(SUX7)JQX3(cCP7JWX>G%b%rG5tu(-thWc< z{L`wwx}1ufI!Ngy$P9APLJ+d}fM6Ye0y>0kmtgCKw#!C#<%iKEg&)F2`$4>eM9ZS0 zKnKCSC%e1kNzX}Pj9qps>cQ)EPujE}Gx}BP8hpF7?K^Z%Wj@t6?1LgLtQFOFa1DoU z3oEP2I+lH4F#&>Zb&r=EvYEQFCm-2KtDb%6@GWWtlw4WHCz+6+RLgfU-o4>y&V50J z4c-P$C}D)%%fltO^-zu(lb)`adj;lTf>8vJmmg~VkC<-x*7Ej62>h6sG_CkYBPhv7 z)-pwaG#;Jcz7_)=GSQ2bO7anBcy4ogbD_ytN=>Q>Vbl`pPQT~TKzd)xznHYjcrxi( z11-B(yM-Cc?|7`~yU%>buw_J*6z{zHb4d9ky8NTY=C?X7@QKiX@2One~RV&HzJ# zr(=jWaUS`w2Gug{^F@&sVIH}{Q^jiwKShMZBvbZ8#IgXQbH7CejEMGb+aFtc%#^+B zdIn?It2_jc^HrU=>(bN{Lyw4D8Ewxmc5Ma$DvUZ(;aD zzh0ZO%e}e{@eRTYNced3s6E|l1ximVK6&sjW-?)@3Hwf}TJ}@)&oYxW*s)m z84&)3c&cX^nMktHF?AQ=27++R`_>b%J9e9-xYvcfDE^B71twl4>&x9heq?_4i@2{W z!h2FIEU8w%YWK2^>(@o-nowmCzDRWGd7k($x38+{Fpe)#yZ&^0kU`B93ni32@rwd~ z6|dzdT%dZ}@}P3F|Edsb_7*54_rXZwLb#;LqIXoj{vLJ)-!~6^8R7?*z(Y3Pr7rDB z#;cwu14H&9cAz*KUvcs)1iUC~d{m_sJf~zo@_WjC3IvA+2L0MqtfTg=Y(+FZL)gIc z5GIaoc}1QOnin}jljh9=CDVOhS&|a$4O1fBnATlmL3ddNYCpQU?C!T`U%my}lwYQR zMF*m1^6E5HLSQb*&D|687g{eqt5V%M4mx>ychN<8KGeCOlyzD?>90cGwWv9b{YWln zR#`Mndw0?HV?s4z+ASPH!icN)KNN7vtC*TA>A_kFbrtsTl>dUr{$myT5Rk}zw&%m! zrm<>?eh=0bM?i$a8+Lk>^ER^K5KTuA9d1`Q(%}E6f32=Zi;NO^HWDT7RoZcQ;6;Fq4-BeLaU0D@(f95iWgp{~JPB4?e>ow=%v2-E9itZzq(* z0L?VZpA~rr7(K{H>gkP2+fg`Y2bXH(*;H@@3=s2~nA#mm8mAy=W;>=Jy1MsXj~QT@ zKxHNI3WWwa%ZmoZjkpEFvZO>(moZ^ga}cF`^#@z35iM7#qrL7_*@M(*eLS~6+xaTI z?HD{h4KlayLaji7x*Hxr((^Pn>)oD>aXz%%F4gwvWp?sA)TFWa^$?-X%~jM0dSRNJ zG35G(cx!l(vTDFCqi?2tG6z{YM&p9L#+}&@9x8~ zDFxrmkbdYu#LoZ%T;k}0{x{*N7)P3}9!?cvpq!ojXZXZvEWqvuhK`mvFmFfXoK~05 zG#!-zL0p^7q|juSo?`ffuwDgWKcZU2>!&$@y~7!$u{e6+aDd55$aL*?G3rpW^`jVI zEkg`WD-&!Mq*VVQgdmRSl_Lgp%2OBO94^WcRJbaT2qH?nyN$1~ltw82d2uU)wI-X6 z-h;Pls0zzh#R8zF*mTh>?Y`3dlm2&B5gsIOm|UKibA@Ssj>jgNr`+miWfDP1uBP#? z@w8;(JfeCDbhCDy()#Bd3MOZ2=cTy{o zDr<2XF#hYl4ux`g^*f2;o$xzz&jovzQsxfW+%Muw^rt{O2OmzoG9+LAdb+ zZo_?);ZbM{!f9C zsJeSx8ib!YA`!FuOCv67VGAgGD~M9sk%7SMK0AxhmqH!?X}lwM#`x;bv^M~yXujCg zz*y&(X&@Fnu|Sh`L{*}x6li&&IY3%d`*CqwHK`kSD!<6xPEXJ#YE=4{{sg9)9?ki-pq|yr^+qTmF@{Y3WI=la zHaD=>QDM1293JnNPOF`Dx}HlEDgpa2w`{8`J%f`vTPA^1mCqChu)Um`J>nyd%iD4+ z>r9|(UP~wpgHXfxjvWygqk33LG_IupuvAZsn$R<&DowWqz-ll+a73y~HI1Qu<+)1; zAS$%=n^cAQ&$B1}zdLU#T8Ph(BcmvQG|SMvyL$mjIuaL8z#fD=a*W;%-Lor2~cE;uZWdX$V4nD#3 z-i&_r-?{jYEukyRrFZ@Nr*V?&&o7&rGB?uHdjimJPOz^xowk0RCNmw6WiVD)WasBtQGj=r1nJRHFiA z27-pi5sU2EH;g)z3a+UxzXmwy+AXrpol_!yL8TUp;6GIlgfV?z{2G7~P+ayyZltK#eOuJ+PyMPX!QoZ?N+=CF(>c3)BfN9b&L3qamA zKE{&fzmNS#*W0ev7i4|^e*oz~7Qdqjh8M1GGiZO;tfnoVTuQH)#M^*+EE^4;wGDJyle+&gy zD*#8JIXP9i$X~D8GK0kjQu1^~lNBCHLcEW-aE@s{>VTE~WCA?V&?3$7?*ukeu|XBsUjFLFXdMUH>sn% z_WqFmNWlE?Q+6B5;JT`DvWB)Repk?H5$elH3jwDnY}*mPMc!()kNXukgi#)TfdYx( zpgS051m5qy{RS}BxLI?XGjoRT#~lR@;#ohImzSrjYCNV$uub zeR{Fr$U~|23ZFlJzT=WFnBa)nXJr_7oC4u22n}9rYrrBsuIm(F9DDG0R2XMG!aNgf zJu-aESbN}s2drn$o<2Ux(u2}04Pg9z^695`>m7I4`qHW{mxDA90OMchGH;5qpsM^n z3a+ob@``ou-rX*~_+lUDRFo?RM{ITU%rno}DW{xby|tAdrq2f)aDct}=9|{8T|3tZ zS2JOSU2$e z=dlT_q;#w+f#7A6qy$QYMyzs$j2RrOvM*x2>ACtTP-y`;G6q$F5?8i?z^)3sQy6O- z1;m)v)}CQGdA3ZZU3D~0mM_&Far$N6V5OQQbN}21E~GGPU0Y?B7A^4mI=Q8pQ{rpa zsb{t8!65UB4KLSaOBTr#k}ZmoH)Mn_CcS`}W;0vE+tWPZa)=%KWumgWt@e-?p8D?y!^4C?HmM1u^SXNvOdzpin0#Kl2RoY(-q_KnI+rP)6~~wI6ClxH>+q#u{5PwM34I!^Q{eID9NU7|y=Xr_M7j8|zRmoL+J zRi9QwOXBN_ZjOs!EM@EnMi}>H$JDCY8xUu^9PbCU&By*q?MWhNyZWV$?-h!cRDHg* zNd3=#m?dKl`YcbzTn4t{mM&&Il}E1xzD{*=_1;$&mnWp>%zzWnM- z-+JZ#`~RHunM$Y+7cVZdVMB*TFy)@v*P1O*vg;!w!t>{!eO9UrdnDs&_3Oc3d3=9q zvlcGYyGeg|XhujO@O?#N9OJ$m^o2DMo^4vU_V1@>QgiAi`T0)M<}|Fs6wr9&{mOI4 z5U-zGUz*?d?cu-t#Wt1}^nd?*Q{I#FtsdV~eZL4k;(Z)zl1ArWX}{gUg9qCS(xi5j z0O?!$@U2tc@ZR)dHFBUnCVwVRo@`G&^_1Ot=bgUY5qu`$$Rm%mlTJFx1`QfyXPN3?Lq&W%@lINxcNJ;mU9Lh( z(z`Fzyz$aw_i#DUJB0YX2hbBfO?K!GaEq#Zp0SVN9*2>X6|v8^u(OxCY`%lcNod1qUp7kcTh zRmeZ`>p>=`{U)dWCUMi+_vq~(^cmlLWr)qZR&c=IvQK|oIBmF1oVv(gqR}^G>o(0T zr(sL~kR)sYZzBoHIW|EsfZ0^PnfetnX2sAKFPv?|hfXrYA%3lT_3@8O(VWRPe$q^@ zPcG=Jd9S__hO|bj?2AnI2v?(q4Y3x=2kiA*s;u9 zI(=Od1V=(z(d-E}aq=85KSha!a=HTj#||6n?T5*LX04m6jk(Qj+{78yMh%##cDEHl z8?+KFnY1a}?<#}fJX&Km!f4;EyN~;o$JDCYD?kLnFPb~W#%atk716d^HzG+X9n?ky z(M8j~F`?@HLQk}5Ufc3o^w3o;8?HWXtScXLE$Uf*i|)3>$N3P?U)ZIK!zfL!q#A!- za&3azMR}gPj_UJz^&48z{OLB~`zby)XDPl@H|neUfX1{>-+p%9dFMIsPMy1G3k|fR zV%QYCmy@mzldSP3lj`}R26_AKw|7kvCHLQNKYQenM{S<8v}kClt9|=+cIlR;7j4!3 zdG+&bauSV_#DJ=^y9)qb{Kt5z;VPMi^*K)X1TyjGH@CMH+QIS8h;Y}BYx?)~|e zTW+!U-+$je)1*;fJu~gwxA%H?+G!`(1Ye z_W`&m*5m1OZ~-UaIIc+=uvjTrLC15NUNsiL~!;c6s+bX_q`L%thgPd)v# z_0Y$4;i84!H<*5n8a>(>g9jga$hOy73|^#HTzRFnY176|`|WA!Kh6GVrlp=n?rN{zdlDzaO%(AN||vH|v)6<@Keo5;I7fD9rD;6k^LOppyDzr`&%D+;57^pg zpaFwc`h%zLv1ubdvz!LayzK#e9T$Fc8Z@?(MTf?%THE+)SmTA z+Vkcu=PSOSYb`0C9fI&X*P6EPZTnsFkk)fHP#>Fp^X~KZ;zKvtVd}^By*H8ek+x|@ zS=)xZ{kNNK{*=+uwik$mK`{3*ICG4(>A#yDc-5(v*PxNjnLgQGz2`z}(Pbms@1pyi zzT`)c9rEF;Hhs(xJNT-zMaTNSM(vaTJYpmM_ox-LE7R}PtO=gQGrqMWuYK5Bb?9!> z#*eVK9=HTDTjO@UN{y+vG^U#MR(qL1DJL+Afo8o`k1cHP3s3ZC!+ZFuDQcIZ}ndV4AV-FyGFFQ2%B&z4NzQY|gmPH953{;?J`DhD~h7 zgwgiKeHU7t`b_UA@DNr}fO$hGWRevP*erdI5KQC87oGymX%GbcvmDYK1yVzpGiR># z?bpu^KKS4mWmjK)wY6^B&Kef5PPnd(A2-&{Jo60OW#^rpDbLfS??MU1m}U~NaysnL zL$xmP6`MM3ngmj5z=caeK?D2Z^Uv+^#~-&ncHiBed{X1Su_lcLw@thD^4l@q_TBea zZpMwtXJKI{`%d3qv^D$ewTFL?pMLr&Yt*!vwQt+j_S$ndJM_>)oH_mC%Rv&x3*55m zD_!rs_dfof`|pb{2EiD_(y(q)Abtn*ZsRf>G?^uhDHaWv$~!V<#1)0d9)3{#+Z;;k zVXm7zGjMP`9LY19oJyXQilF`Uc%Eg#7F%p#m_YO0xj@?+?Yir()>@Vhm_E1F_-7j= zoMc63SbOiix4r%L+jjr`_v_hu(qY8s-*2)GtnshvP+J?>`u@1;1cWP^fmSj{f|>}c z_d^a8uF`;jNPi=s`s-BJ2!I5HQoeUFO-YU)aXEUA#d3UqVj8s1$&of$dA*#cg}sD% z_jy~AG=@ekrF$9CGgm+8?Sivv*7m^;gLYFEY|0B*gi@Eb`VDmgS9RoS?B;qJdt77T z=Ofe;8Q1io$0@Uyr;z@^AK3+AA6#NzkQQiCJ$+GL+bR<`W6mNeedbHpXTb1T3=;$0 z+c$GMR0hT`B~N%W{K(e&AY>qB{VD<#Uw%0fM5f3TL{1<`^3|@I&pR*wpU4<4$cSG0Db^8SQH@Hs5@6n>A~ejUGKpjKEZ{YR^6QbOD$d z@5vJZ%rk{u?A__CvRDPlNPq~@fTa&yhdX1 zrCU-S4|>rd4Cgd1w1TG1ZR*$&HhJ_A>%Zgvw&n4Ekg&MEZGF(GHgobgn>ucUZFRtj z)_2PtZSK?wHgn2&oA&)EE0WNOP_zu8TG~Cm8?!=~scfkF=S-VqW54;tx^1$(ZGXa5 zHg>{P+xa(_S+h2s?E9gEY>R!5v%ZI&YLlep+VwY=OY4`ZOFh^)vn|DbJem)o?fJEB`0KN6t|mAzuP!fO%8Pk#(SmszJ8#;O#S3lggMVvX zyLMF{PPAFtB4pCYZ>&L+=C*$4-Zp#WyE568`Fso-Q{Xsf>O$M$=-*l6mTm3JH=c2U zZl{y4x9>lE&Sq)+%uwIXoiWA74r8*eOr#ggo#p+NBUA1jk2&9Ro3##tXdJJqgMJ@1 z^jq8U^gmm-jd!q-pTDCqwWDo+;te)t{3P3@%$OQ*=*c#JJndciedxmP;`vt4s*~+? z`i<6F?djNOQ_IQMb0RvFW%__IS#vPZnF*KiLq50Z6Gqvh`Lj(+$ZY4c9xgTJ+|*n7 z_d4SbvZ&b5I`rLC&tfZEwpjfLlaUn|AJZlLZvERUt>2FOX)Pu{= z<9}~0+IO`tUj2s^4%o_eIO#?kAx(N+%%!V1z=&ZMH&fpn7j&~w6<^`ZQK&K`II~8- zbDId`tj+yerl5a$95kSK13N zzQq2Yy{iDsvg*QzT(M%XfCby=?rszl3B^D_uv@VUJAOg1uu$wm5d{>HkQC|e7_rfe z+6LRG|9Q@R@ArKx*kEAo!oK%?Z=QQ@o_o$cr(3sf&_H{KiX&4R>S(6>(9RqN3)Z@t zYU8wDzZ>-4;#F5(DIw5uOKu%GTrn{*vNt(dZpR^UQsP$m=F89JGr%)^_y@8S5>!4u z-qN;B8{O!0-M#j@>tyo8iPEq^1FXwyW$f6o^4Vvf%GV&YM~(VH{VZ{=z!&}e>oCCn zO(MXflWQG~f-F;A9>Q>3*guM{(sA7A7+`Yk-%dUrgGOI9_WAoET$IQlp>y-iH%p^N zjkItQOp76T$Z_(<8*iu|6)pjqXZoJSYKU zoqgle*4rc4q;x!3o-2Qw0n^bxTbL_fo7Uvyk;vCp&6=g+5j}hM)bDxo=E+)Y0{Zst1Fd|$aR=i=ov^ONf=+Sf zxe&d1{D~)J{=B&w|E8O6R)Tl+>ebSwb!+vbxCKw{c?Xz|9lpr@_b|Zwh=I5>w|v6K zGXA6gsfg*pU0bB{%}+~kTvOSp%o*GI*1pZjT*)aleyzIgFY*)Zux*^9V0zw#xB!nHDM+*eYqc74~~Q(~T6 zH~(t2Wb9M@R0KK|ek7WL!=r`U_VUqHWbBapBqXMWc){LwT*G#fv~-39hDFNTS4Bw2 zdp{H(aJ;IvxI|XX_)TJJS2Xuvk=Bm2(%@1a+_O!7`FN02tlm(#YTsD zNqmDf;*GjR)ol$TFDs7gUDQjyc;OCNGU`L2xa+;|j+bp9%9qdoL#o$pB*!44tPb@! z26$p3{3NVWH4sN5WykuZ()#MVL2x#fAGhz9&u;51hY*<}wa<@PBcZX?We+}rKvZXe z6XE2jm-Y*hHbsuz#?2kA5~wHYLoKM2G@cujmJu z*t%|qLue4vrDYcm490&tMCx?xA>Mxek_6BvJae;9B>I|%hRFWyn`O}#Z-{TjQ2Fz> zA2fr~zGbAr1wF+pAPjik2k~h17WP-{F3mzN5{dRr0LOjqh$oalFBcgvanYet9z^2U zq4&G`RAopnO!;oO)F|bH`nriI7Q_vO9w?ywCw}<61eC*pPftqSpa;P(FUNzWOx)$& zm$+V*j+-hwHzY`GWC%p3v-Y`l=yO#XwUim(ya6%jRO$ZcJ6Z>fj2v0>K!PIt@r~Qd zE*wIAH{@Yy*!ddNCtl(!MMxPOBCSGyp7_-;#OVmh2HbT;e0%5H4Cox$7On^TvTKKr zvWX*n8f}q0BfLKcH*VZmwPU(>?_NDje*5iV@&}0Q=FOW4*VNKDRO!~O zyNWx9SEwkz{yG77AxA0UOH(gzW7AK+OW_fs4LcKu-l`))<|HLe}v$b#YY z<3a0R<+OajQL7fDyjV%NE?v6l0TVggl(w*O$Z(;;ufRZF=5W_fKML?e(bEk{`yB$r z9FXy@X%w_8m-UC92IlRNpXa-Wch1**N6eWtKR)L;`y3#+%QJ88?`%;E9*`}A!uxcA z;(95(ybGL@14Ai@KsPN@O7C$~l-&>F&_TGr{d8j`zmNcXykO4L=$<{V z)>A?*_?%04|A=>$GI2_6Cl}9b>BmW?6^PY8K>YF)+4C|pPv=sh%!-b-6nUZ;7aynJ zjd5DXxV15HQCZf?70c!F%P&*T@Q9B;mM0*VQ>Sh{*}Z#@obp`h$bJUe^9GS)yy!Rqhr!N4z6SqKAY~WW(mNXVDyK1M%j-auG7~ z+qZQiPy(CRxC^h73MmJ{O$-$ua7gLGu(1z5v>QZeQ&~QJtlmA<9WeSjl@pQJpdurd zQLbKNIlOm^RIl6!BCAzo4#Zn4H*2RH(}1vOsnhNX*_p6PyvqbiNOU!D9}h|6Ue8Hn z_4?``0^;Tf$|R9c3L@SltVxK*;XdWm51*84U-(X5{CR^=%3{`c?@6WTa1hRcQng7( zC4_?`Vju$jiOhWOIlaT|5>M8exPPcHt7_xc;O5Sf9uEw{9TzWI1M%OL^JdD{)eEHW z;4!EoOrwDqT{>@;wC(%25?pP2KP*AuxRc2H=I+a-EX1{ys0O?&OHWWSSJbH65S+Xx zLFl&yhdD{cy!X6xxbh|_yvl}Sh=g;p4g;|p53Y7ZTn(A_)teFuKPuT$WmHrug?HYU zE*sxciA@rV?XG_ST>Mok`W}S%kaMwcL#ZqnKTSGZ zCLQ}dE)kV$%kpVsWZmy$C9**?h&!*BDs8WkAc$2@`{Ff;4iCbi2kwJ|$S1)|m^der z-@0V-C~0@yz48*;<|zDs8FG)r$5#en8zLtb&y}jpI?0X=D`XGi#`^|K`yQ8on7s=` zdJXyW$B$&|@+A^gtG*JW#i2lu>HgxBt&C-@=VbQi;j&`l7vd9u!n9WKLP47zKl3}?)B5?h5D0Yed8L460&2L z9AqBeK7jj#K(d5}mQzk^RCJ6Iph`R=9s7!QIlOo1&>lo`vNUVfRAHvVM|XMd61WcX zp7JxeDzpq-=H9(~OY2sxWa!W#(hVAH6Sr(u1ztSp+PpbI!otH;N~%hg%5u#$*XThp zxyTgzq+1LnKwNWq{fNXbUE{`$bUnTC#{Rng^1gC3B)8}&8d-F^NKx_c`$0ET0v0vP zx|eeIzf)J*lDX#E0Qpjh;Z6)WVWmtK;9fB?A& zR|#nPh=)l}KmD}gfJE?Jcip9ujs*eC?u#=rkISoLR>;015FrOKLdN>e^b<0?PeVfh zBcgkJ7o>!98#li#eQu#B%01s0V1uD=@sWu>MdJ4u4xo06Cf@ zes6sh_kIJ)%Eb2vN?2q#`Vutyrl&|E6zyaj+<^rMiUUBf?%yy+BAT@U!E(Q>nLAmQ zjQv6q7mShZAW$y3{dwgAW*$jXjuVNS-3w<+-AixO1#I!y;SvdA)NbUrBJZ>vD-BTu z?sD4h1T22XrOSY!vJK+5V?Y#?35ftKC!~4zTR?D5g}85`ZvJ^+mjq$`3$NCD#fJ|j z8=?edW*$B$tC!A~xNx8W1 z0Gai}2-yMRKRb?j3v%tE?<1CP?J5KcuG~lodGGSifL8xQ$Sl z(x?Uc&0#32+NXD(5ANC~b0_~KJ|Quh3B|2VeJN&MTzQ<88?{q0XC9D{t5UzqEwX## z21(hrT_nVH`BwMxj%lOW8xUb#H!i9;_#}GN@q~4o%Jpiz#|dUw@e++{V)4G z1U&&i%Q8>;8SFZX7edHAS_4U`Uw{2omMoetci(xZY}>Y7%0tNxwUtsVlMYGIH%z)r z=(_l2{CI5Ccj(>QloXgWVgv@80yPcSQWD3taL4!u=+)>Bap}s{Yk|lPm3mMpNoT~Q zO}><%A}9E!n{JZfFlR&MKRb5paBb{^f&%rOiX*Kf(XeUrW)%-@(5SK2=j~y`gbKCN z(hl2bMbCd52GF0G0>zhqu#)lcLc_lqp{v>`0^Os+K zDOIXe(YcsqQJUe=M<2yK{CkAbhTNb2{`>DZfNBN_h}R&6alb5c6t}v$;-`la#i?Vt zA>hD;`IeMWJj;8Rlo&tWD>Y-^;v{E|d4(8%RK?Xsrk%{%nlqpL_~b0h_~i80+^n~K zIy0Xs$>o_PYOYXZjhZz|9%0to;@6eP2Dm(OdC5-S8nLnPoES?-V9rD<#7MV-Um4qoSaNNClrc@1>p`7 zoewlulH;40l%(I>9N39?&#s-)rfoYt#p5*FzI{902+)wZaVKRTh;X_YGp0jRG&K9- z;yf4gBKKdx05;jUC%I<%BIyNXI$_`*zSk8Z(F^{7mgaJjnYLd-D%F-N9(`S+8eRmA zzXv5VC0YE!;^p%DU&p=A-{q5m{iO^jFz=Ac^5z{E%gASLke`R%r*|EB2b6ph9>r9K z$TIKOu7~)3X+M}sftasvP?&Uk>`ke5(QP2m641|&$?+rmi(Bqtw_lV6#|MC8 zANZ5o7*(SI?wT%9ar08Ro4SAXa%p+TvvM~$gmJBU%F(oB+#Q{$2NNXV*mmSb9|f13 za7DMeMB4VU^?6VNLLDKju_(snMFXxwA2h&!xHaA);f;E~+SSSUiHOT_))8m%vr1Go!3 zZ^~FGPOBstdsoY1NGOaO{<3^_duP4xTCr|h*|=takO&2(;;rmJ{5>QMMBZIJmx^)fo6ra9S})@C_N7nR)S-mh66<& z2KWTJ`}5(QtE7}~plrk8&#I*hG}~nK>5@=hm4RXoAKWJ?P+Uc4`y7P`x2#zuyVlOr zIX`LMD2#(0+9x9GU!eWD75a1P_N6N2kO@vN3EZ!5?j+?ZL4jL3Oj|%gW6awF<^BG3 z<*Na$^bYpX%naGIb+<}epi^s^=i^8B8ervkay>vD@0Z{n3al?9f z7Mh-U4T#EomM)y9+~eiTS11~IrKkq%n$n)>Kyb9SY~8BsVACc|akqDz61Em!v!MN% zoM+lXrP81F?K{YqUwo;yT$%p$A+O2YxpSpjwQ8~h3MUCuk^2{70OQE+rFDsLI<@w1 zgxS02_VC7Hz;ZEFwBL?J%=dg{ekL4a2ID6CII(%0tEb&(PM9!3^*B5PC0J_G3k9+t zQyCWfeAA{)Wdp`s684?62TVdZKX!mgfadnc`f%S#lRaw22k+#_9n<}+!2&*Wq-8qh zr!E_^tbYk-{vlrZM?#x;&JB*!`ng)fU6^^}uiMWBkK^S6(Rp#Fai_9zG_Cz{rFT*w zlH=u+XYuJIK%fn``4O*K>g60D!c!w_e4EJT>V#>o9%1gxO?f7*iD2=qak8bcuw;QF zJ6z}So6sw~fYX8AybuQKa~|P27kDu(R5-r{7lB5(9Be!#o;qWuiaqn26{Sk>@X+$k zM3*;!j88}H&vp^@!hJU`$j?3ZtW1Og4l3{1jFac@F)8^62J1;V#jdULjazQHSx>7k zyS$5Fh=H`;llxLA=!=)BwI-xNab-Cl4WyWa6M zIGHy|(wfE6?e)kp<&N!gND%xpv4x)lf{0x&ww! zQiWB&zWIdogI3#O;9l`=C5fXYKM$9Zpv>A|bR&qAc$x6Qa}o|>`NWB%;Ck+q(8>*@ z85BlQWAyLeeIQ|b*2?w6Cqto69a%klqTKf$G`enHtpqEzO$Xt=@2!Kr)#sn1KTyJY zXTnPH4+)dI-uMYH`pL>Eqtr}NIf#HZ=+p&-v!DEg>y#A6-U~%svGrO=zvo6srMfMY z=v}#Zp0rL_qY8`O^p$6{fc8EI9sUghJOEZCEC_yeygUB}{6B0srRU zA+~-isng~H*}HX}EdSxK5>C7$Ol4#@zVsRH16PGu?uTlQr~9Mts_3oO=l$Pg-MlHX z=<`7mP$j2!<>Dp18Q(W6n@izWj0Jy-B)f-3}IRE>%;{XNmsr1R#cNy+kqq}Lp4o2VU|H3EWm^Otb z=}AiPZ(Fy_)u*Tk?$1&0OGTV5<|X#72OrxOOV0uSlgf?SE57f#?k-_PI^a7Jd-^J??#3;_A1AICg+s zGkCIWSU6qAzW;*MhXjSYKW9RmJQzyMX5kR0fn56X7ZQv9oCHN%8`iJa@k~xUH?&mL zHT#F*iaTCmIB(8b@66(EHuo%BH~G%5g@vX*sExVz^}UpkoIig7M4UT7+w)?KFGvWW z9(0?a=<$+EE|&QV7U()kKjzPHu9Fl?Ub}WZB%1tP<2D0kjOZ3Dm=8jh3MJ!k1!%W? z{P8DHfb}P?1l5&$?i-+Yk89MZA>~3spf!4-4FBK*>C~x{+C?2X@;gb|W)7J{%7@B} z5Cy#h%CblhghzxUuMs$CuAuiMt5>ff2M->Acqla2L)$drD{}ur3^@JU?Cae1StNmZzfBz8D5~s5RoC;|U4hOTdHPzziAd#AO zI8*J*@`@m(M(B9(Mf`NT#9NX7C2O57WA*yeibCy66oX-J1XTb4l`KXh!XjLc)moA!J$-=LDL z2fQ^bS6jbG%K9AxJV#JJYMs|~$P*@gwLUz2P+PxFm|<`^=@2g^jdp-(TiMv;B`l0X zxKeTTqXZ=B)DAEM97ntn7iG zQ*p3TNj$HHhUHE3#!DFxIP7AndorW~IIz6OzHRLy-Jq9=je%J1S{%R}fru#jB{;Dp zILjmN$sav0ZF)VR%7;h{t{(jf`frp>c=su3-D`jg}?oFCqk$Z{oMYUycd)AtD_3aO!a`iGA}K~*)E5-lXp z;!c17E(0z&HBwXSGP%&aPT=DoEEB)L{oB}@64#(L(iBtTW6d}ashB-ySFWr68Q?n3 z!o6uK*`lM;B5F|Hwq^m$DwUBVAn1;y9unT)WWIC3VgBRorzE@_7JRf#1rHqaelyl9 z6^rHl+uh42iceS#6+tH6NX*j|(N5lREmu;?!UpY>_aBuGx4w*fqOr1Q;&(C)1XgUz zPT(FdcGm~>qIfTwUgt5w>FK!WmM-rnhoG^W8m)t2vMB>%?+b9(ciWN)YR{M7i*X=v zXv+>YFGM78S|OpX^<6z*{LqFcGSg(jdrwJADC7!-VzP}e?=I;u_@!SAY^Jnrz}7*!g2e$f2aw5)NjYXMKG`z!{IIFH>K=5BPz0Y;c)Z z&HhcM4!uKozj)FI1Ep=BM_hes)lZ*E@F~n7vHmA;jh2Th^zx_^Jy7AsWF7h;%^2~H zJll5o^>i;VJdCBQ2Xjglp-Gl^3!ArS4z8}RDxEr7{HU50 z`tr-KV9$50ZpJxpXhzHniz+wo>%o;$b0Ao7i9QR0zvp05^GZ!gTq$cyHe*fyMVXa=Y>_A5RNL-}eB2!Raa0Uu2Z`FMLNY)nT& zon_NLE={)i0A5YUFceh$1o2SdAzt2VwNXA*2cOXbXg~-d>Q3qn%`17L)xIj)XgLMSQcLc(w)mk*S}vR%HXw5XIR% z|E8a(8tVlG`HTr*7L*@iH#dNIidef&UAYtYq3HO&2clOzvA*xV`&>yv!y}|pRHT-( ze#1spdKDf~Q5R*KwiY&+d)z!om)BQsDDK?(AXREw7pocpEZzOuE z0}s>Qc}9M8Lcmaz^3`i`b&(nGJ}W;t{?Qf6iFZ(hQ~=CDp%ow|JPTsIQ>1#GhETM$ zCrjSk9)&yj`SInu#oj7Z9Qy{rR&TJ(1c6N5%2BvO%Dbq?@E43Uf4=vujD>$>c`U#o zw7Z-M5pG}k1b85c0A86?fU>ic?d!9Y32&`WFmQJagcBE8PscL6gQ8{g+#h7-xG!Bn z5ooY7Q8iFj3Hf!<09ISV9HR0hh=&ST=Uw%k{k!(5<465v-{uu)$17a<#N)uu7j3g` z$rPCl$%D$+gwgp&*OH}Qy)M6f{<fy(aFWsehqYmLH zw8u$sU5*^sjXrgi=41PmQOxDJ({dG~3f24|wmtfld$ROV_9HrSrEPz%h%z{j{_@&` z+AcO1YW~*kEf!+WJ{Jq3I0WrAY3Rc`Hf(&t6<4dCY@PqJYEQ=*h$>Fw^#s~1o&?*) z97}YxEf;=mY5~SX~N_wswF!&uq#GItKDEvqLUjga)E1sc;#U) zxiJ(!5J_xbpdctGV*)W?%;dCN$o^qPo1w)Q> z@E}LkI8!F`wDFh~^QK~_(jaoTC#T6<<5q|_D~|tcbMAp2=w2T-iE&lLL7gUYvSofs zXfq=^`{XMN(mxF`K~CYeZtCfgMze6@u#T>HCX4g_Yqv3`F1C8CmqM!R0oN>QAa}ra zVB|qY);-X92mUWA(2SPzTsS8rPBdJI**aWk$g|8lS#KCnk((Qn8>A|XT~jrTcq1He2$NUMn*Y}C~s ze#gPxG$NNLrA$OEsUGd6afnIAWqz~28wP^<0L&3ZDnY{d(I6ms&(f7Q;G;I*65vL% zT+T`)z6>`D3T%A*)!YyBWnIXfr6Vy^16(qNP2tAmi+-+oFalwO|4DGI33K&24d{tK zOnwe$meF!`zz@Y?Yt(I|`RK~!qeM5BX{jTh+o>jJm8Aa{z!Z9QtN~}N3;TK) zU}|+zn*7V)I_V^~>mEN@Cf7^rc#k*@YcC*H zFUI9-ajd0uB%=32%-K4^PXdSpHJ#RhFSang7rB2025g^T)3RUiu!DZ&^3tC5sx_DcZi2Clex+jtxw5OBMZaNR%lU(H^BEGlL!-HJKF5GBNSCJ2;z zl|v;g7v$Qaq{c$!QLKiCrp7sz|xFw1; zg7ahJdHNN-|I0Ce{-gv8Hul*aH>$hV&Vk0`0EolF_8&H+oWcx|l{GfwT0$>dh!R62 zh+Ga6A~jF%O;`jmXJ6#+kGy#g$`H;h#nUlwo}_0j*G#2^$z|(S6S@7V1c}bouTgH+ zJTwrKAN{kADNT5)AEY^IZR~o)GkCCka2sG?%6_+0>jeUQ$MWB$RGCoajGNU5@Ejo_ zo81|rJ-aVgHxfqtBMz-oSU@C^h%(Lz%TZtW=TRT#owp+qeB6Zx&vr9-pvWzplLy;@ z2M#&wYx2q3jwYRF{x&Z)n}vMnvX*Ix{v7pXyc~xbEHn2$r5We-gF3LEXYcEj3dqr) zHLk^dwhVK$0nudcm%HINX|$gZ&#wM#7vWs`1IpxB%QD_P`b0J;&gm!HTRAi+#cnz& zK|unMT&Z-7IWKP>o|smocrl&bXxcRD>HL6(EpkN|_)9TBrGdxLS4sTxz=finN(5s* z@dv@!nuS@NgM?E#g z+5ZrQ1|K3HO-+!5gX!X53gWfE-MrMJGU-Oxv?l?aFGN`^dB9Z&AQwUw9sm{m^;G4v z>-hj*h5*J|0YoW2PnD?PApEmZc-GzX9ksCC=orTFmXu5>iE!fV@X-?z;A_^Ve^zA+ z2in3RfbB$RC&pZ0vx7aCpdzm#3=|p$>_9(lhUlwa{-GL8iC=8c3(a98TpHrhbcLRG zp#ka6DBE1=4mjI4%nRN@;DB>p08X$%oo$YP@AUuL`eLky^tu8mhG)G<0 zpUrxP{>(0o?~}N@#trmQaASEdE(&*}bF6tfIpHVIalmnc{PGMpW#ube^T`3Z!m8;x zCUYyt`rBvrEUdqM{_Da~A*W4q#gAzqHy*Rs-}5`KHeej$k1n@1Fd?TeBd<2do<6U5 zAi)@f002M$Nkllq7O40!^g`4q-EEA3haAy3N zircCw7Z@3EJUF%1xcT~VU?X?7V}}lM_uY59t`0CiO3LsaE%m;%YS~h=v~Xw7-^T5X zyS~);Os+A_AB}f^u{iQtlu9<IXzyV4t26(&UC zQ;DRr2zLN*!Ae*Pl7IH}ifh1S=Smx{aI#tstXbO4+Va%tvB_sSr$|o# z>_8L0I7}0xwhm01z`o}au6Xeb$2-?g_TfEyb}BK*wLxKaV}MX7LJUMkQ@Lqp_%l-t zhYt>g=qQb1NMCnfFd-V8uOGs3ol%@c)Yn^ooH?kS6Mc5o6%{W?sO)iM~*HyZI&8_ANynqGqc!2Y-;n&EzJ7aJWU?< z*GcCQXmivU_%QI={7t$1V!lkqcO4JTlQEb^|M&rC;ydq7S@bg@#@~EdU-R?~cj3*0 zh;*kszi$?nEEVGi|J(ZENvQ%3d*X+Gbgppo`JeecYXX=eUjEP!OP7+eKUX4{lXpnD z3J?kN!DI-O+kW()L32?>&kF|7&!_;#YyeQA9o^@p@PA+-7>692RB*$MzizUM#^yh; zA&WphTMV${8!ZwGRB&^g%4*;^e&}}?8p2aHufwLoaL~B9=48ZJ zRPWVCZdkA(c1m!{FyUNB8P|$gvwQ=CDVKQ|?wk?B zw&C3Dm<bC@ zuFV$wOt`uKs2m-XWyA5JT3_kU{8_%6AS31sd>Rgz44xR*?TVv*ER)J_n3nL-XC!L+5*4y?a}D_er;115i_HGP4h1`gBKz*dgFf^hsd+)UJ- zZ3cLbqYQP5Ya7n=jAQzO=8QV?pQcxY0N1(n6~b?FMqaoBctY=)Q`4=(%y1#o_9auk z!2?TT1cP(MA>mWd8D_!_@oVa+aH1_3k{@ULLI_NFks#$a%XYVe$%WrGE`1E%+$Gp> zDz|zX_ORc7|9#~EGX_m=lEZxOJ@-gkhJq%(7`$G=)j#Ivyc2IWjA^t7I{DHcT}0z*m@N|!Fdgw?^Kqqh z!n7^msqi^%K@>1g)`jz+g-1sL%48b4%*=w}TN4%X59{Ssx|H@EBHYx|w3CJ1^c^>T z7>8V7!cRq5`25>D&P`Tp_}Kz`Rs=9794>x7zDAkA!Gn7-aT?`Qo`5mlVVKO|$zqW! z!oWGh00!@I+!6c-jNx;Jf&yoZ87~uxj<^vlazz;UPh$XMfg1u#5afvq-Gp;9Wc~~Z zz}OBkfN-vZ)_HEmb#7uAh+~}*e8d+6K};i9osC0#!JdwrFotP#1bCKZ^JjcldIuI8 zU(+L;HKjA@@y>$v;ov|CdqX5^L}M5P(M&u>W)8OeVkdEf(MhivAw6T*{IR`4WER8p zOliZ-+@rO0g)-M!^^1Fn9!p6)sy3#Xr*1 z%#6bAl#nG{>!EI%3Ik*WogCf%xY&$ZS zk}4$Y;J_4MQ0b(%ZDE?qq-Q!tQW$gT3nrWp8WS%hq_s@ePwU8n%mIQa+2VmuTSm5U z#U+EYhBGHU1i`Z)j$B&WvbN3>C)V#&PaD5{`SLPs*f8ndy}OD&(>h3(F2>Trwbx!N zUAuObK7IPAHtQZeddS9&8zmtjK?&r%@+XcmKtS+mF~hj5D+%CqT#Ha*72%+vG!Elt zf*?zW7IzX?if24e5N`~F9thg{r8y&p@o-675SeN^@= zAV{@-ny2Y+tSj+CqRbV>>Jbl!N2Pz6_~SSJ>mSPm16{+|)+UVck&tPb4rOIz^1#k1 z$7u^@K*E^$@_Vc+~t9wBJckU20RIrQ&pM^vDqq+u(5=I@#Tif z*Uw+Mr`&AVErk-yn$G;#@unMFWWkNF$qC^|W>2}912@e!!uh^TcZd``GYjWo=k zz%c_;zO6sQn5n71LaPUGW@E{qob!?4)N(g#H@8{$669oRnYo0cG~^HBm*oN;ZiWd9 z-&s1Ha&k2oc;+UUZ9&-Ka-=7G&Q8%lZ|bj+)x&Jmo#ZAx@Z%0A(c;n82r$GuOEP8J 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

    4R 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 )k}v_>_@d?YLAeW%OUqN ztNV@7DX6zqr{O&AHsdCc!w z<0gM-5`yM#*|&Osh&X5))dIj?ncUE3*4*sn#w?sJB7b;LHi%`L>n!7pMt33ZU(FQ- zoa)8j5Ks_be%?Qq#WOyDs-RbPtpZ#Aw^U@)`yS>$S@@q64W)Hlb07vm-=Szc-4I{C z7Wa?$V4=K!HF=%%?AE{jZE6^*N$VFh>PV5tlqz8qzjxb>qDzP~DnE;bpM`vJx0U7o zDZHYSv51NesT#8rW&_g(WIQz=A(cYK&*Qt7zZXiR_itbnI=1?b7!%Lgj#I8+KYAOz z`^c%E(x~{pdwU;9q96Jso;LF6VJX&-)8Z!J$8l+8dH;*O2_G4P7T3TO{2{0^gU@mY zki7G{{aP$Wc=H`$&$(KwcaHh74Eqg^2-|rG$KJo+fsc1hVut3g@ZU)NVQ?t-HDlwV z4$3j@h-DmwI#H1`%tmf2d;m()5{CurchYZjeG}!bqap{oTqx_6zNFO6w3lckW~FP=@&re)VUoxYfBI(AXJIi>qPn2CEGQfNNI?8NTdK|0&7y0B=y z^YN!)fG=gEA)%uqf^`DHwoJ#-KRgBMTV)_T| z^WH~QpM#x?GV`p{k+>q`pkVimyz-cH-{?X@#qM5;mc@%!@;9k4VMY1 zl;5VzcHCLidg-V&j33F1+%HuPcrLI$+{=8HJ`h-gjxHS`(!P4`O$-IPoY#WJg@WqI zo!hNsrl#j7Q`PVq-(~4$$1S^tIKaBqs(vF{=En<}uK3(>*MA{e6BtR|-x`lZK!|cx6T+ieXOt7gUF+pC3~Uv@?^~Ip=42kT z=?Bd>RF0$cYNwryQl@fd3Vc-J!fJ!^CA|%^UD$llYnR8&p}*iKZ44bV@y@R-E?>)v zyA(TN$0g(}2~opqk3w;Vc&@SRERj z_C>V;G*GN7QpDa#?%DUc5VP&>T)|~F{UM95K#%9EYT~9~>Pz5y1S;sXhpa`Kf`~zw z!-LKH)N6mb>&bV@B>GZwZf}L#bDvaI!>Pnt>ThnhA@!PG$?xT#q|nug#qN83TT;nCaYbin;{bD8NOI7e>^YL5)Qs` zdTyS5r(+6w!%Q9_0qr6hQ#z?YVS4cD7qQG{RKD(ZyX%SPwzA;lNs^8a3v38h4v&q5 zqn;$tplQ|gTee4yfX#g7}pkyVt>(kcmztOo|g(-<0KjF!-n{~2?^mQu>+ zeOSFW=SSSi+<%|-faKvjVU-sS+h&k7b&Wm76Sq}7pgk-~ZIg$+gt0I0gIzt#n(da69r?Yyk<|WU#=mp)zR^zc$y2XVB)mGA^@$pz-zT z4MWru?iJE7qAVHvka0bOz;Ly#*cuQN*)%Y!7PxN4#-b+N$*=YqSRe!23*-4Fr~Rj{ zY#xjrwBk9JFNXb3ULxVL&1InNqvY>Nw1@jn`fwWf7NlMu{Br6DA5@I`4}= zXJ#dw-$;5ebyO;MTHi*4zW<^<`{loPR}!Q0E}pffzx&jzg!fWin{N@-C&GOZgJ&}) z1%3YK`Kv1Yw_#u#VZtkUqin&+^sL{sHhZNp8hO;=qGpt89ALptNldLc^0)cMRnZ+T z@3ZM?3++rJE*Ei$Nj($RzOlH^+HCzp-^+KryWdK+7iCadZDf8%@eEJs_pBbJ*1JJf zZ;=8Bm;o-J5y+v~2o`^%d_T#=my1yR!C<3xGM1PdVU-BxHJQrDTh^i>N2Hc{+OI08W3&R*kQ`^Rq28m*YS_2fO8)q zC|-HCaAUdfWfi^p)6$j5r2*()Ohvty$-Z-RT79hf^8BiXhj)y5yM_B`^E{?;Ky}?g zZH55mWNHWINW64e7%~1;qwUaPk$Q&Rxs_^dm)G-5i~oJlks&4jp2XFDxx}gM9Bu3U zF9yqBdlm%Uo<jqn^x>I@Whgm(b}`{N&rQ`%0d9uHHzmen_mfxe(;Yi3{gO9c3Z4*X zEm*f-rc&QEIqNM|@Ppt3B1=AVVg*F^q>tT-hESVdRBKJC?W;PDwj?8@^ONOHrTr$6 zAR~v9Ew3*lqxS~pLJ>0e*H4E{9ukhjvB_l`34AZ?ExB4RH8D!oEmO1SihPs}T1_jc z&vr5w4~t3oMuI|T(b#Fx9P~x4-BuxRx*!&3>cTdwzP7T6J?8fNg1RuBXNHatnwF{h z{n88?15nc8f?J+)8h_LJesLtJbJgC5*E44j(}dDM16rH18S0WE?5P{MXqtSm z`TjSLgV~kd5_iAYRa&Hq)9A$)E7f$uQ&{`2;@QK{|0-^>?VU$aWxYf|ug871fSS;G z6mP&IEp>(K)@nnB*W$wb>Fo%;bBVd3I?-muD*c+WyWE5(^ z9edHqt3DA{>tGJq?<5J6Z4OGLioP3y)+1`0!sHx(V`(YezpE(Zky-4Mvh@-6>;_qd zX*FRxjM$vjs_24T7J2S}y7)+~f0tHZj)soMgerX_>vvfQzGuk@bpQi7r3lo8ZfY58 z39yP=grU$eLH?>H=}gA}R>khng+%E+Lyx96x~U7FI0G(`-FILCFx79KJibYEtw;o&HTr^|HW)p#jPMJ91gKS=X-F zjVsQ-5E?l4;upJ}>$r_h#u*jRa@|1AX;MbE|9wEbHDy*%RgISScdbXw=~hYT>H2?A z58|EP6hMp$EprD@#$o!O{--A!^o?m5W0%2&hX~jk|1v=)infmLVz`7Dbd;&DrTTLN z-{#SM`S%S)jbF>j75YECegmbn&Nu%L>w*601j&{OaFuagERx7s3ebj$^t)OrJs?lW z@TvdM9;Epb0hz6r{`Gc$j`NP_$#DesPd69kv< znPv#{x&%HRn6x-85>K6_%)a{cMnx=!$>0PPxC$IP{H7w3@AzT7YaL@p`a)dbeF#M0 zU*XL+7L$&mo9yx`Tk`b=p1xtsL889j{8zAacQC=9$%;lYSJ9?Q7U|z;os?i!bOEA# zc_$*BMB2TkxyI`+n36udrsz9;w0AZgWiiY_%N^5wC8kIxr^WGBCvtanUqV);VH^6x z#{xVKZ?X5# zUtsrIve@25cocl|g@ofM6jqtE^N9)(raDjKH;p?DySJ5YWj;9XXDaG1w;RR$9f&w5m~0)5EMR^_4EvV3hJ1wUq;Z$4*4!+VLj*U4KeuL&6+G_!7`y7 zpw&6wepd+Rj8Q5SYK3T7Vo-fy1k?hDq~|g%zArBQ{?n*obz(K((l(90IUwe)%yYpP zOAIOwO$fZt%lRIS2zL*(jxq6y^Wa2Q42zA=@~>l%=owZ0;4idQWss*brO8s>K^dE% zl}w!FdyzUus%lu(zZ-$|rO_gV7BHG>4!B(WYfSUl9soZAI6TC(J5B8l=Ej>(6e}_= zHGg(j4KvQV>>P3!e2}fUUU#gAay=u~uJTvB)+5R}vaA=*d99ZMU_lFWD9+b~9YYaz zaOi>fwbK>F^Ym2Wu41a<^+I5up7{v&&0VFFJ67z-DHw=&UdRf1T3tML`!+Kt+4n%6 zu!C(M(smtcn^1OJzf*y=XpRh%jx51>>2fmpZK0T-Q*TpGI{X8?MDG4}_;H(Rw_423 zch*^CA!g<-9))OCpjPFsW+N5iW_}_@wbWMXX@_%Ve$Fj3G(LU>yObb1^Dz^@xRrn% zSx_&N;G&a!Ko_L>_N7qs&xd+M8U7eg8n15^|IsK6sHA~S*4iF8tz+AlU!OpQ#RQND z5rB~u^FAK+d$qc+?5BXjlyZ`Wq$eo^FIP+(ek+!rA1LlXQlf|P!>Ufg!_7A%J5}~AV=<~nhJ9LV7V%dR zT2e4t04cU#%7$bWYSy`02m3c0BBDw`78(>3=4VA2DeeEoat};62ch7Pnx}%Jx=$0S9H^*J`Y^Gr*;cxZ(ILhU#lr-YET}4BvIJd(NqGlM)P55` zb7{XB&pvMEk^G=Pdnv;g_3(kZslOY)1^9a7AF)u6ttHAg&+0_HIY}A1h);4D{Dbp2 zHMAGpKq7zO6wIDP*8yA;oLJY$ee!9$QnU|0J%EbEfo@NxO@nNjU4D?D)3y8d>%Jh| zSY6K``^g3TH(NE+o7XGVl?q2mL(ZrfG@@!cx34q#V*0mky2*Z$?Ly^#tv=9pnmW$? zo3Ck!FlA767YUf-8>5;qh}ZOQXXo1Xi5xkyH)_X+@&^qdMu9W!4Vu|2Wx`m+&gkf7 z>>^Zte*Pm|ceRVxri3rB{0Z7Zz7@H5^apiBZ@c1{CS!6{g}A@`W!A42;dA`s^+v+cw+OWx19{p$hF%nVs&?ejXwHfP4CH@;ec2G%h78>+} zLcGO6GfotW!iGiz3Zs!QZ!MJFQa+0xmetSn`Xk3ozO#sIL-21Nb@EmCN5Pqcaw6sa zEd6*XxuBBN{xs*&+%=O~$A&6wk!HK^))1g}Qd~|ec3(x6()%QM_sH5FTHB{~|7QTz zX{9<%HSGox{q~|HeL8>E{+Q8maA@(cOpAcBSb=lgJ^2w4JH=JiVB$MjA#{wfRB^m2 za5Mu$ZnNPjN{@Haf_gEW2#3+>QvvTzqglz(PYJu_kMsg2ln&ehpafrdHdKdPA1C2% zWD)M8mDcQ1opBL%kevH&a7kH&#|t^P$XLBnr8KCV9dT%uT^DU9~_*?sZ4zMmJ`1A_;&&{kH0kkI!4T1g61Sg^)2s;m966m zn(WVGza!cEOKbdEGZL4YH}lkMPrnf3Xn)@>ty(56;i@M+ckLJF^i5 zfy3c2n(8Ju++;hLSNrRcb?k{r#WQKJ#HGlrU!}C;y2P{T*vbh>TAAe2U*rHc81v)2 z3gT}^hs`-`7%FJuvr65QqSd>AQJ#A=Uz?1lmBuI#GA#U}?h%v2Z*jYU?_9DO5KZG$ z{+h#XDZdbp+Pfo7v-TVn)Sp7Oi)diUs(W$wX4~AY)2sl}l$ru5h!LxG7 zXCltK9+Z_`x2w$^H&oh2!3`-kK%%DiY*>Z5l$l(Meq(J2f}n?xv&t?Mp?e3q$&dDU zZQUbyjLjp7;!2rGIAOK(8oTMNrmZ5#JQESPG4EHEJt2yy{YLn9e6Q0K_4bz_IeTx^ zRk3w%^$ERBdeZ=|>%~@|^Nhk2;U=V0uhPh8Cexvzz2*MGk$%}H9tB6r8k8~0L-od$ z*7D|%YWAd690@53`Ku9_q~%jTc$%F)jb@~lD3q-DW7KU-_Su7mV`JH5_&6>~(q?TOO|- z?neUaK@u0qv5FR?edXA|beWgt(u{XXWH^av-M;Z!jLYD+4gPk`~`Ji(hioWG7B53K^!eUr}w0to!ueT2;F6|mxdq8SpA*ulE?Hxj{=XB_5~Bt{&haiRQ?5^}eI%D8mSgA9 z*mgwgUA9rh2eMQ{z_0R~Uxo1ngJiK+=5=iu?*cd)-hQv>r-a2hGweLP=~#1<4ISr3A!%k51N$4>d;WK> zE9TLa*F*48rgnfz?UeJ!gPA;($T&~xnk8CpzTT(5Y_X0O_F!B+3p>{BxO<)H*4fNa zfQ{mz@CF`(WsZ`o74^ksJ7yGzV4;O{vO|UuPJ$Y)y#Vo3ZCMd6UeRd*KG8)OcKN zL?F_`*}UcH$tGT-d-PnYE=S3LW!4uWkvkFscIn&g5qUCo>^aqt72|D7ea&oeSd*#h zbL>cYw*P9@FU)91A-dKt@RXyQW8QO3x=ebk)N#H1*a71@lHk0fb*AkjUqH&z;pCRM z<-HTqr|N?!2c?6eo+je3?D}?*1dc4Tt32_zyd)lDohCSt*hqh#gR9lanikp+-MslV zK)^QcvFN$Beoake9<|L*6z%bzB&LcvwZLmEEBmV4;BHCHz-yUBpzAAoVK1VgPO8Dk zOx^a5YjpLwW!KCA7z7au7#q>=Fx=;hwMHaa@*7OgM?SaDieJuTF6Lj`l82`6S#MLF zEBPVfg7i%gFCTO7*Jh74wGJ03M{7s?F0Z->ZVq0)HLI*7f29?MUq5%Z=CovgjRd*v z8>a#7{uIFNbv>fHNf|-I&MA+xbRygA5_OzyplR)+p)!rBH&@Ne1g6J61?-r+)FScFJ)^r4sZgBj7g zL}#Rl2R4SWxBhUasuk_I^KTP1TVjn`Sb6Bylh~UJV8g4(^pr<>FD>?&oNTnxzAtjk z)fg)xmBOmmpN=`ojka>8>58P6p&MwUk3MMhU z4u-$g0`iX5347m`Qst<>`56A@l#D{rgoq=clz}r{>vwWbp+l_6FyPvasIv>pNCRMr zvVLf{HE|T~F6exHPW&*x4cGe^2RM6KB0vstK1-T$KlwN&BuW(t zUq?Z5FxW1_APlq~U0VS5TO4G}f11RW>in+X0t^GO6g3b zJ;;AtPJhQUuaEmeXBVse+u&J*2iQ!@5kv;x^iO!aT6<<}y&-a2Jy~bxCHhfEtjQ=SI%uu<}})!N)-Venws~v!tA{a>g;U!`NcfNdK+}C z10Sy_#QYylQB!M9n?dKxIs~1nkGZGjP;xZt*Ho*i5U~|_IiN6kOzTrMo_3(Rt?)9= zqT%|)z*uhwM4^|)6dJH?5H#tZY~sc zho%U>s|o#{I{~x#$UDw`%2~G-BZuACx$>d*F#V;YHiyG3faB9N(QMw<)ziu}B#g>> zWC>IgH@1>rI9pJ!H#K{wtto9kSrEwqDP$e5WiGU-pdlO)b~svlHsbRuX(wAAKX1_s zVHVzJsz^b@ORIbHsJ&)8N2y7jjD0=!QRvu!ZE{I%Dj8-+!Pg==_NgUMdhK{cLxitk zLi@St*SdiHm$d@cR1t?=!?d!4sRp=ht<1Hv`0aFk)5x6g)~G5JRgMwyM$9>QWRh>6 zdEWy!O>FGyc)qv(v;5OjoVjCaf#x-_5fHtqnT1*iZjEl=FMn&{Gxg({$fu{Qh}uUnK}cvqwmwS*=*T4cs73wB4BnC zxrpsp#edM8FhV6h-;D73!SB2_BZuF*AYebKcW+9OKusR<@azH%x*1Ns>A^MRsm8 zM5LxNMI1_{=zsRNCA87h=3Nb0N9#A(Rz~ME+n$pPnAyJnSxgcZm_7ias9u%nYc(t0 zI8fS4+@0=`gTdG7iGMUcVEiD?M(^~~RQp4m7|I;mt~uC4AUYF1N?Q= z@|U$ww(1!Sf>P#&K6_N1z4syC(H#&c+TGb!3Ez;3UC89TpoFJ?FQ6hMTaUO?d?ukW zWQumj(u&tuee(-n-_hG!gBk1N#9b4GcUn0tx54~_Z^s9qgc|P@b2KG@OP5i1ztrXz zILkeZ@DljXqY0_3LaMMLkADxjwHUe>Yb$JW1N@a*LuXTVksG9%-qBtM| z^6;f12zlI0GvJZK=!5i))gn-V+NF-t6#iW=(8!sWL1mcllQknc6_PSFvr5sxqDVl%dE+#*`Gd|G*6s+rWfDm zw|PeZdF8^TMU3#z|M^5Zl)h0dYyacgjEqJxB$1?9qGsgP%0Buw!yeqW?z7wW5+prX z0$|R1H`$AWH9gbW5PA>E8O3fr<9dtW5lWH4R1e*>Nv|KttP~WEcGWo@BLc@9PM&)^ z+GI;ID^?V+_Bf98Cx{i@aD9OhAG+k#6lb#Y*vH~Wu2k;c->eD?jaq_^4>Q6m?2CIP zJRP)lD4zuT?#95{jp)nG8MDJq713qeC__E~0*wk_C-q{T>UD|mFxpIXhFE<=ft{ zH}#J2k0jI8p9B0MpMp{zPf$5upfiZU2+m$U{nPoBbYb!LgIrCl{w55!x=t$599$|J zG)nvNuMYQ5nj0;YH;)f}NBVTcuXqNFT6qj5AymSfd)V6+7P4`p_)>%-s@1p&=Ro=U z_rsdc{}_`w=@cWx!w>pNy$eOs1~d1ClYCOaCKc~j*b4%Yw`gIeF$~|O#(wKIy8hS_ zditks!XQ4?5V{<_x?B=WX@FxfcJn!<4xeiM8G`LX%1lrTl~PC@9aawRm?sR5)2wi{ zx<&t&1NFV-w}o5&RUKU_!iW-QCP&^s_^5Xu@wF8tv>c$>u_vEzED}-h%h-(SRIyOy ze}mn>U-(ewTdPS~Hmjqaf(70x#!>|}?b<4Rrf4hKv-lAO%t5y{mqy1gSJz0Shx!Z1 z>oK<+l+#gLH_)i$=%rq-zhlg`?&dhkK@pfnllKKS=CKaIfh8AhTvVczTN6vK1mvkp zA6<^`J+rB@&aXZxFKu*fc^YS}#rmjbij+W$e_yEt6t`*GZ#T?4=_amSLb&+Tmr;*a zyX9gi%fg432v#3!d%|=n*Dqrd}+{I z`EHE86~{y~pRbRVdLae!x7eFUK*ECPSV_dNERg4E=P>Gmt|*E2MR8tjUtAiz%#(X4`$=vzT$cY4kJ?_yT}~^WrRn(18MoUGrYZoWzJz`R%hwNomn}P5nED${`hw zfasK3E9(G)h!ao-g~6u!k3G*@_RbJYbr~VC?mR&0(3=s1epKUYBGWm&$KwCk86`|r z6ilCt&|4{fYAgPgZr$_VOm7T{CkkaW+}>(3@%Y-6tG{beq~sz1PqVPvlpk!$vEYKGfhZvs_@63&m@ z1}AWiHyLSnFgPW2V=i|-{jL0A^4>VcDX1yREzkvrR!{9_`im(PAm6B7uYVt=UK9Vo-^EM+Guq71#fla-33*cf zEHH&J15|agK-_HG%#9vfqB(2IzIn$EmoM151cM)bdW8mJYYF`fjEkUE$fV0whz~i~ z1w}PWjrtTk4qRLK#!9W7`k!2{egW;PK%T8=Jv9I9CeiTY-4nY4oC6kJulS{ zFI*2HTDNzqT5xlH*N>5M*iSo9aCmUd4=+P2WoR6bnUeSA&pL@SP~;cE-kn|o-{q2F zHFbd096a_A`%dA-0NXddgFYspdW6%A^`ZDsStHwnS0c)#(N8;^GDn<~hhNsmgBE2W z0HrHQcaEO(q+*WEU!pzd3?qZ1k;qp!h+OoAPm+UmyPL}8{<9`d8QaUdAlAwQ2Cil9 z()+3}WOpSz$mKEd`&)Y0&&YXlpPpl)1|u$ADr|te=H(G@*e^8+wU=P%n(p6Po~{fO zFOtcoSUv74SDi&}0U@Tm0iiGCS=9kp-c64wseLT+U{dpI;2UY9wYHabDGg%Zo=vES3I6=&mw8gYn^pC?x<4rXb@v*{2`Z zYwv-n`>Ba2QRA9~V0pN!T-hZ zw0`o&F2LGI-En$6KaX{qgf{U{H|UaY7_#%uz1RA@kEelFq7Ry?_`9Es@<=3pFNqa$ z&Ze!r-cgO=?M_r|RCcV)b_ZiPy06MiZZ&c_kXxV%70QUj7K#`g(($Eo zN#}7nQn==`;c3NZvi>+ArMo?rp7bx(P{l$RBdGP>Ezlx(TVmE4bSov%KQ`?0*cJ21 z0>e1@@QvXvvH=_7OVvj27qD*1PJKv8U^g#z_~=#IBMhH;;^9KDvmOwB}b z5M-=sEdM)z`c_u-JCBPw@8i3lctXNAuZrTUSzHE}8DL{CcDywQhu9c@NRYGm7j!R3 ze~KsI8SDOavdzD*xfA>E_e~1X2dy-J&QmYj6oEM-yGU~Nx4##5z=2s+^sjYLc${rlEgL3}YoNSAHs?Zl7$mNGq!C>OqGluTBh(+?!-bvT&%j?Go4%GeT zm-b)K_EGbl#wpG|50o;U1eIazAMk1e2}{wuc)sXJ!&Wm`ftq}EdQ;2;*$izbqDo*K zixhv+n8Wxf7hKI68zAeS_^lpwH%;~&6`C<|^)WDmVl0N$gp`oPFCEx}-an#`jYb)P z#6JPdig4=!64-`CIL!OmpqSejO=O&M1eAB>jHUN znhKzM*^#>IwC_4|K6-NcC)S%RAlTR`xm2ip$60s56`rtxT#aL0@NcHig;b$h0@V_zmO!-xswJ?Z5?I{<+oYxU8P(pmAnd*xr=_DY{1rp&qR5i znI4?-7792PZGbO05-P(cTO1eKzy~(t$%N-R3TU@95Sbj;?KHyb==FzKHzU<`5|PRr zHqJ=%ZvaClVx~mb*XQOIEm~|NM~$@xTBFG{)n!YUdi!3=CXRA?(;&$vwslR7mYnG; z7W%YZ)M=IjHz|rFPph6(OQ2c;)e@-V5?Ch=Fpt%b2kW2TU+N#v$SQZW1ga%aErDtY z{I{2YJP9In;n=1%rS2&te+4IsS6Vn6qK{9M;S8j=UFMKDox2;kJP1;9oKWD2-kmOx z0J7YBaYb6nP|x?d_>j-`yjk!jpj|{eF!t`pfyVvcR?s^7k|}PDdUyd}bp;Jtf0ug)RolNSMGwMJ6(K zSMmSbyZYFysx$sTOG}WC31o&g2yR(vU1k|g5iCs$p(rQ=7hxN*#U)_20*g}q7zQvQ zY#~{exj$5lC4($spe~M##f~vE#R7%MER~W`)|P%?uqCb_{iJNq@AsT@-}fcOb%-pf z=k$H=J@-7H=Q+2@{k_jQ_fVJ0Ti`g3PQ*NjY}qB`X@-bV6@jjG%0sW$uT0>h{TtdH zg>rhJ;wXDewDI*mCd!1X z5P=YZ5P>@xf!hRt@1)DYR)|1|K!`wyK#0KqF#P)ea|s1JhEYHx0uKzyu{>68q98$;WMN(%RZA zQ>NS}ROk?|vlVk0y;Vd&x% zTWV)Jd^OBzeDVI99NHZ}^s|3XAN4AY#*7CP)Of~NpaZ^Wo&Hga2dkbNOiI_kNE3l| zJl!kuemLEceJ8JGsb>IrHj!?AnzO10s^Uz+e-yfp3#IuswEKEIJx&}flydn$L;O%@cj9*p{z_&QbusSJeycVC1TZ2d103%nr$Lni_O01ut93_S@E z2oVSo2obmg5x|2!AO7+D?ogn;SjfXtZC8+yq7VOXzkNt{@A|EzrKic^ci)vIOP0!p z4P^?1d$_d=z*2WtmyFVk+1PQT2rLa)c%U#$UrO{ZRoE{{*a}9QeFoEj-oK@>u~8N; z{*Gj3W=eH+wVXP2N=7J90T{8Vs{PX2*Qwjtf5`MFw>Myw`E9`)@1Eb_Y z7%`9@w4FtHea~Y+ES~OStnx8F&`lf_+j=Sv{CZn{tZ2*8AqG0-^{j>CtHxuQUN$R1 z={jJb110)#h~%9ftPVF2?DTs3PL1!Yfm5oo1I$CWiv_1_n<>u(s}UV`L5_;I;cKmfo)aa2Q}LIgqtLIgqtzOV=oM(6`S0Vt6UpZ_C!4rVZ`dwcJbmx`Pt96p}}k_ z4K|XJljM9;qwLtRLmqe_TP|I?B#Ra;BEW8KZI!yZIyqN&PSPi*N%psP2-tzLD^7wZjmy_?Glt&gm zBFXA^Lt_Iw{o$z^_B%g+j(kvkQfAM}Be3u4?vkn#RgyC!hka$n*!fKXfJB{y?cl66 z;ppOt9(sU}z7i|~NU`StR5b5e00coZy2T$70I4`8(v^|C)=o2AtyVB_B&t*}nnk zz8y@i?p0tP-jA6ijg^#fI0AAB3$e}x}( zwr}6gA82kBv?3N=`lC}uMh4x)btoYMAp&2n2m}E9{{vCAIUF&@hd4TER?jgGlB}#OS-WP9?Ah}>xqSJuf?``w8VeMbQg1s2M59dUOx`X>oc_u(V8vNp%AJt2u>ai-WSwSXiMpah!f@M3X}~* zXS3;71RTql-0OHfTe zYPV7VfalJgdn=*?%AjA~?*SP?V~9YAz>tgp-hmNHh(L%yh(L%yh(L(IAR}Oqz?kvy zY!A@(@XuB-=w)hZiX1xhS1B$ok@f4#q;PJbJiq06u8SlPRwiWD@Y1Tz&Q5vjtqSS5 z+#%0xd`_lLohrM2vr9fwFo|iO21H^uuqOBPhqanRre#S@O^v*{|4jwI zo8^fomdO=ef4p?*QrY;^jdJW*r7V7Iv1BVKKKqY4nXl_Q^YZ3MQ&W>X`DC#ySnx2j zwX0UGl9k0jBv^-km=cXMz&F-0|GoNsPD5S0Zk0)JGH#sCg0RO@cf?o#T%1v9s@b=%@;?oH=Os781Nfqw*xuW1LQi(oqZm ztW8Xhu}X{{-)y?j!h6;E6XQ@il|kCPh)Eb1j=sm3^4|W*C$jIg*W~QkI(hZgaye0T zg7=8=>Cq`*c=ZBjd;zQn(ha%67EH0f9p305x?SDf1PfS8%gmVa zVl6Goj2SZsTCtutJ$;gl)IC`VTAW@gSH1|0=!<-=DtJc0lxR$8&dkV^nVONSs;ZKC z^X5s#pDQGHX0B$MvkXi#vntZ3Q?UWWF_jc+b@%W8gOrx8VfMJ7ut1(#`IJ3mN&yWojNBcN51y;`=tJ#4fGvTrpHgbTe9xYl6U_0j-07I zBN^YwAc(d=`L{>YWj(~L*+rAa&h`lgB12@w*7R!s*=K}U|M-J|1zPE@%^^(yG=MFR zg`T-4m}7!A9WV8r@<`3X1TxW)PE6NzbkI;tnPq~tj`2Vf$$Id*Y)oQw+E;PfCt#&h zCa>*q*Sdgq=y3+o$l~ABZFDrT=Gh|u9i29=NBZF*S?RxNgs;4xsLSZ|pPYUyzzdq% z*S&wOc%MX?^aq#kFJC6hmn|dd-g_s@A9Y4Si?%0}VaOB@=X7c4gZFsZTdX%Roe6UP m?Pc#yHF7GOpI0000#RnGp^EcD$Zz2EyW@I5EqF^Ng z6tT0mb5yoBFftLfaI&{H@K9g{0I1@8V(X*_6i|kHREu(5Q1J2aSi(dS?dAhZ<$7wT)=*jORA-jkW4zDw-Yoy+*1@_+f@3!P7Gb>49Y>D>%(~Qxx+zrEr%F+JP$~`xHc$VwZ|ct> zIleTWSXYj5C8DVhBJtXo{`!QOXI%@je}kBcV(koUrd@f=E&q z2~kRLi~j3|wJ9If6iBI`M;g@nt0@V|o*hVOHL||Q%ftey-PHC6CtwknR;e1WqO9Kv z8M&n})2udS`3H2u%XCFGxIq@n4cSgl%pVcfc&?P=4buS6Z=^{(f_U?;zIy1pDonS5|j)6-`ZEX5dTg4e98bq$|91IpS7}) zqlt;FlewMqteCm>Cj-u2Lc<9Fz$E{tfJrKmeTESDn#B)wXLVT_ZX-Jz1_NU|LlXvf z8~cCy0r0wWf08yP&IUy8HrBRI-0plN|I*<8r2k3?x!t@8`e9Y2t42 zKbCBr{ynYF2{Qg8VPs}tV*I~g&K9QsKd^r!{{{Qky#C7^??0V!D_FRjSgVU#*nGPB zb7=f5Oq}ez|FZM{5&h3d{|l<(Wa21dXY+}4=Kr6?`ZxGLh5sA)FPj?wW0RSg`TKv` z{14H8K>wKpw~~pIowdt9PE@hAaOP*>W&FR`{~M+8KQMmg@1M5*1^Q3!|3;|)pNRkD z{%?f5qs6Bq4E`A!Kl8sW{3q|<`n-()EdGB4!+%BEzqp^F#t+ZS_}^j64?j-ng9`u% z0whI+ez=33cfsiAsJT4&SzmOekgvtlZ`_2d8B&r3#KI9%cT7S{1T@di3+0{pbUdbj zn>csj+fma7K!fKYLdM6636DA(@?1BQYt++QUGlwt_)gz;2)vO=M;m1}GF~*@cg#5D zbX;;heEu7^9WkuCF0qw_j8Iz7<%@v?{(ck@aGFHiF|u*3jr?6KQ%=s#w%tREJdoi* z=RM1kUw~)b3vHpwQHHxDel>fbrfe;(Y#2*;ULe@r0uA{!@8%n3q`ne0)xp{M2=dpjvRK{3-6;KyS&598MM@H(QT;4+Z}XSq=u)_tTFntgn1 zrPuA`B#66fpo|0P8!(A1Dko>gTP`Rov-9!sQOcrIE|M3zJ9M8`v=9x$9q5XF0!TkQ{XkflXqfl{B@1>jcbb7 zqYj#xixvn`w;F%9vbAP{4W62rseLI5t@Lrf*Td?MCDMALO!Q15t*Qxi!YE7wqYec? ze}f2IUjKcrlbRX#2cn^~eHPsaV53sS+^hXN}8HyN{Z!7&>7*wACwV$=gmz)-7 zdy-~>WDSPh4=Kn)m4gGb{Y79v-q44~%d?U;7wi z`8M%Fz}}J~%j3 z_fl)|)6 v59%6xV!W2rd*QW{pB7r!Y^8jAwF@&ofj7VZp;$*CBbEva}d?5C-eyL zrIT_P@lVh-)>fdrMjj?{O>DN?E_rt-=a&#*m~f-}`qS>n8A^=LGXx}K9kLRXK&!Fo zqH=mxLa@0GPO6%9hlD?~J`6HJvo<1T*s&ldvWALE(v7CIa=x=dNM)t9{K(AeYAJwqVB#AV zi2`z|^1_^H$KCFQ?npBTDaRZ)wKai7q-?+&H$YfD@d#qU%$H{0Jf7J4i!2{>Jn6Beyk5xWDDMdzW%w%{EB3LQmz$ zhm$y0yUj2d5&rs@G6~twbwz>Fw<0XN2DbZq(2|yIQ|7s~9qYw=6S9xQ#bjMcj_*3? z@1|Lja3;RIfQeqm0p?4QThGRx5Qg9DUg(9D-c!Af!&;GWFIh?ALY*YDSi*GBb6hhS zoDwntmtoGwBBjr7yO+Zj(cqB_9Xp?hl|Ytf=w};AjkcKk(&0+iHKmHGs-`y>q*XDk z7EKS2HlJPAoTdnN+h41$O~l~Skb3k^i7F6K44x5PBR(*2N~esDy=%R*u_osEC5R3% zKTqr=wo|Ql2*6o5M^GY0zd;A(ZVetVetjas>qTW?=H17d7>9<2=4HGw)4s;0d``y^ zp`t?GPtQR9^9lt_8=M#)4?hy|*ca15T?qZ&=O$dzdI#r1M0hI1eckl+43b6Lb8M*D z@}34b3Z^)a#O{X(6rn@t%-=l1eg0P>TUu1$YPJOyxARCJ3u91MxTf5DF zKh{<_^nZ1|A*`Z)IoK!qa_=ztQLr;Rfk(>G6)OrK(&aWm+CDEJ_SIKCUI*yEc-2 zZk}J8`}2qRWkBFnq~mR)WkcX?V&wt@ORXY777bWFLark(Uoyg{ToN>$cD9h0K+~hz ztEr=7qxUqfR#{p*TX2nNyW2mNaqhCBclo0%FK^DrKM3gp^WOJkieuv-Mb9*b_vY8* z9NZ;`x+>Lr#?~YB+26m-BZPka+3R%!G(oVTxEbe%8Cw^DN-8QBqtctmg#3m*j}dlt z5e%K91W#k6%~FZ?0WX&=>*+W($ovp(PDczIt=Hg$F#JrT_CFR=>iUitHTF2T$M%~? z;csuNN=rw1{&-}eVCeSvc2!ksS@^v9@SDm|z#tJcT0apnEOx0}%SDNf zqB|%NL5#kDcc;kFmkn71@n4={&SmY~yX4i~Htz%7At@|ku~zf!zGe6m!F(_4twT8T z%HFZQKV8cH`*f7^@cfgCi1b>>Tf2LGMpWt}0EL{KJi2gjNKqUJ43)R;<%C6eWtkq> z6>o2E@BA1a9~V7G^8A>;lxYnzDXTedF&c^1u&^i#CFFLN2r^LaWY`zUVhLVM6P^yn za{+J0!NtYbOQzR3HInhx3ql1;Mjt;rQ?5D_Z$u(>ilrtco%rgm^b@kg))LQ(4rjQd zVVilMLeAb9>F+Po7qooS5lzj-+`VP8(?DE&{5s;KF+CNRD(=SZ=V#B~mzS5!r7qt) z!rsux^8x|+d|uftzKWo8>$*IezyPyNn45Z26CZDeQE8vN@Q2?%EvHPzdLPdmm7-{9 z2fI{9gLRl%2X9TAEFVSxAN=Ds1 zB_W(8l`6fG-Ia5Bo|AtUv2zXk=P7g0rJ+_SgviN>@e4KqQ5uGV zcy|_y$Vg4CWPs}mHgn2lUtVuFgvA&Y;6eJA7)M}tH#Q9= zT3R{FAEui*ulFr`EZ7yCRU<9!+BLy+Zdf)$9?ac%xn`|K_?&1|ni7&d7z`Ak6nM-D zNy@q0li&8tj<3}qhR#WZY@yGEe7=2o9U(nY+0Z3hM6pIwm-N+;&T(Hx8jTI>CJrmw zZV|3fRH1*JC0(Jos;sVWl(x1x5iJQDf*g_ep4+UXP)p1Nbg^L^;mzO6%hcn8(X5dU9MZ?%t2lUJoId5jHz$>eL%SGMRBak zUV6kFLG=ons1FoNR)a~0b=+^;HjpTyKif&*O&yyLYPy*}DUjz9u0VEQ_6ofm+r1Zm zX?>T#o1tas_$}466*@4Ao&xD&s)K+VwtylkQEukxi>Z_|)R8|pPj>?s2bb~ucD&`a ziEMUQD=rs|z>kpRt{b!#HLJ2g%HsP+k;*jnjqX#=7k3o|3 zIKJTCg$jXpfe0>cw0kt|sj9e75%;_q(RCeKRkNdq4PLFVXLsNr=`L^obBfGpg74+| z>0G4Vj*n=1n@N>+(>hG79l}m`m)9U>;V)sj+iogGJ(3_?hh>`$F)S`GneKhGaf`25 zcaI738os*K0Deu<8~j4q6*49g;}9K_NlmsmXAo$kxapo>L-C>UdypLt&?i(=d{2b* zZe&6`=cisF1XBPE#Vz5cQ;c%QY-k>RUMW`Ey~Uh}Kn_R4WmEh1LG zhuoyVwZ~tMt(kp)mv?&^ZiC%0WqtUlQoU~htR@e)ABaSbaHX*()de-)#VeF4i_&%qvqzQ)N<7z@qy}$aeRl zBPp9y&%Y*3%FH;+!37c3u+(`fyqITD7>Z!vO(aMf9j=+VcZE7?(3Iu6Ya7xC`hl6I z#wG##Uw&Vj-O33BJ@eo7LVN`uk;)tVaA4)z@rDkJAqsn2ViIIke1G8=+l^*z5g>Q* z{Syeg@8NcPkd~QA0fN`)OI5FwsVch7a?(^%M5O)|btI$m?Tk9sgW|ZcWR0qgXiMND(f2@D`2)T1I0*|5FMnQ!- z`Z@oNCA9XV+BbF4+^;jVxN(!27!)D*=3zgFfLM`%2k{rZDua|5NEQ^KuGH*y82Dwr zq%Y1rG)R~0U=-|U5H<=J3XTXTs4oyR?u%O@vz|8Wk+p4O3SW^Kl?+Ou0`L^0Y4QglFci^^0qaWvwNP+e}9Xz239!j0M?mY;QX4l?S>=!X&S$-Mnd+Q#3~ z8Pig&R~_U*6I%g7-ygTLC5bQ_e98q?*q@66B>r zz#SE!hVv8i2$pt~&$M3YE<0uBGHokgXkV%*1WPtWe!FAd@a%iWW>?`Fic^^h8%0OX z!SXO$?z|)RA$XdrJ!T8JWBs!~&DkzE+$XE)jXFL!q4;F}hgd?ku$MJT=aG8R;LkS! zKRr{FRDxH=za;P|is*!uP_Zed{{CI-1TP&uFFanlFBzokH|kB6No?Q0bKVdo``wh4 zG+q9t6f_`7wcuTJ>4|-q(0i}9J0#@xUL}s3dR<>X0IeDcM1g)b4c)EiJ^r+5)i2;{ z@k)6+(0ea}xI(_{28N@!Fn~gz^E>AkofnHHq#I=$+-7vQZyviFJGbbHg4KGS;l1Rf zV62}2tFTe4cyA+p9=1O=Tat1u@5mXP`+oYKi=guf zivlGAP2}B?pU8*RbTmdxO%9|Sh({jI=EwyzYHC`nzqWYKYIwmd> z<&(>b)urxMS}E!mu$jZ(!*bQK3a&s2o2sgsL>DcA)tmqqMq1jY?-$ZB2r=d?oR%|x zf1luJZ1Q!j>qQmLO;`P*v9!DcK?Z?R8lH&PG!PK_v7Q4)smoc@4J1pzVZ%|aNXU^; z9jjOlUBHCqlvm4|bJgeR_`JSXfP?4g`ce2C1~rr#S1zI?Idh(hd47-*h0N8>1a*qq z7CT{6zN6|KcMaC&M3|xE*L#+2e)PUYOPJ%iI6F(Fxb-OOpp&H+_N!MxfDg(#BcO(V z^}Ao2(Qu0R@kc7$IxDtSnbp_oOtQoMg~q1H;K{MAjjq79m4gVO>JW=;j31s*NW{K21_4Y?q(o69%Y|W(>8bB0{^ifveZOn zUY=sFra@Qu(V1u65PvFB@aZ3$RDu=JBqO&imlifWZp$_?CA|yFC=Q#6WgF0SjE~G^ z#6`c&269udb6xi+&k#A|1>H+w0z$oHJ&{UgkPw3E5Zw?mt4rGm8kB27xZJz!B_ zW=aA*d*jTw=-W@%2;i?brf&-Taxb}8OmwIBRdzlj<22)g`G?_6@8v(}r1@OCDZp2+ zpV{tR&#GF;O!urdEz491EUcaJE&YpyPu@Y7A3~#e+Z&t5il z%a4_CBVv3Bjk+fE+PQB!jq%Zv_!!l>t*tWFW`(Eliva&ui-sX;D9buKpR+S>e7FPM z7TYY+cY3{-^tWZZx8x?vo5w?&maqo(BecPKOZ;ee5cUy`U?d2nJAtAUT!lCD%=->W z1{o^2^S$h}TC=VHec$hm5h(h7hyQJ@C!WT5v3xLJh+=->s8odIX@@nMa~NOr4k^zr z$Oet%D5vWU+iRj|qN=-CGy%4KcbtbgTlY`l&QBCU_N?QtSWGtZ_~s4qoz7lYPwX8} z=_<|n<)cV!ZkpRWsa4uENy={%GBMH7^V#m%KMuXQmoSFXX5EO-aw)Q(N5?VGm8XcN(H#8OSLeJ zMEKS-bIkp{T<*{9)&q@iOf9PG_6e5@1xy12Pn(rs-nOiIeMOCtDDy5F0PHMDp$J+tqRIp(!uV@lZ`Q7;J3(HW?^LSmxk0NDw|Y9>PbP z2R8g%%PwZvSeQ5CL2_G<@0aPgu20g{P1VltpV_wiai;Km?){M?qHo%{dL;@0b(hr_ zppM`gsJ}Y-{UrO3+jI8nrZgFbE^a% z#pMBN`}AISdpNO7g1O$1J@N$nY>!F&s?moPO18RvF@!&@I4GJ8(@v-(=-9t@6%1w(! zHEo0DViszP9R1~`>X8%>5H>EiZyGmi%lbjzAJ)smwI}4{>kZbp0c&MerSj!B;}eU^ zITV}B7lP*&C`r>Z_4EC7C4X4~r(&5cgq(p)0l_k6#7wNdcwFY%zmj$+edS?&u?Y&| zb)cAk^vAh%Yfk?Nr78@t`HRYCR5d6w_D+A{0)MVCnS-2%fVUy2P(oa{gg`MmP8OS# zQNlaee4Vx)FOg$~W2`%|U9@vkp`1{WybcNt%u z(O>RxS85U#Jxzrp)R;ghWwnm^Hj8noXp)?<^l_?SSCaX?Ozd)5_emG*!zftyy2c9m zRIEeu%JFm@B53X1q1EKb@4(EdUPe#fB?6!W|U=?fq9f*SOLwU z$y>b~bhwFL$2*b#p`XpRFFGY~JD*_`ERa#|?HFn`N3TZ+#m(Ux59 zzPt+xQpzJ>9;IH~x-jF%KdpQwH~Z2yu0kGw(opw~X_dvhM}lt^qP z0(@G%qhukrUg=$LZ|=eV!N$@`_wq>c<6xheu%`i*5CDKbByj8X3(NcF2)igs4*z1K zith!Xs?UL(IWMti@-{FZ-e^Kmh(f89nIXS-&RFUTI8=1ESUn1ry&y}wXIce~^xQCa{hJ}YB_Yif&WJ}$?aW8C}%i@swT9#w?Ad-{SE=GCQ7wC)$6use$DPU}FgRwUD?Q3KaB;nH& zEhND)V@(~MXNSF_y@oJPoaLqD+KP-1SIzAzWTRY9dgY^`RetbxjE*ObC~WX+(UD~y z)^?+?QCmpea}~3Y5{*-pH|HgUCyUdADTVAzF+^ldzT9}m-g z;2H5W<@j+sATU(7*1b8t-@qdlJCrb%wNoU(A>iAeP)`4=fP$>(*0p$+1Vo!7p26SS z1v&1X$rO2b-m^IDT}8lg4mbyo%(GI-{IJrt?U_4*_0(^a;sI${luga2E4|I5G{7!8 z(j?)XD&i=CCx?zaTnx4uybYU9+v6q<4@kIH8RnljdHc_DNn78 z^T!3Z^>$nV660CLRxn9AR8M?@IU&%zkNL1ulAt zT_>+}4?GxuHFZFG0!!njSql9ZKewjMe%X$m2a0^J+?HVisY-x-#iWKA5;FQ#b23UrX}IBnV{K;&j$_C;O0;7 z0Y_AhNa{$LzylI&E?k!?YHB0+?}%SwK3W%8^^JVeAPHeHJ&PI@lr%|5ZLFLzoBI^Z z1v&d85S|==GL2gZVM04866<0b=t>oCd#$TCEwO@ zL7E4rQ>tw@;2JNnZP|aotTvw#O)wJG#Wp>w%p!PgD|TJ*JjcIwwOR&4TdhCyXqhNm zE}rVX$W`<_;#gU6@0`e6)VLa|Ybmkazw&cfS?d#aI|C&{5MQ#&OqWayEx0OZ^IN zOVx|Fp#|)go=57q93;8sOy0kSes8oib};_owZRzb#AnB(QLQ8m-#pz)mKM74z>l zMnL&BLL>~XPInNo?&AVSwn3YZ*ElsNc*$V86*w^()Vuo%v(+VT!?wqn>Qb1!dp%d2 z86|mMNfz!o?4)(X4n$HPDB4wxhH9RD5KW$!n1@$ZRZDB{P*q>e_2PGtM!japsp=X> z&znuvnB~yrj5)K1(MeUxXr1qvwi06y_er@2n^sMuuHqybjB6N8kyeUxqO_W|y|IOW z=d5N7FQJ{gt74lARb|ayklX67Sj1Y>WnKsSmX&XEWL)G^r#0T8E-maD@@I-Axez*( zn=|O~m+c=&(g{*Kzk-D5BIX^XgJxf<^q%BS^k35}wrySrJ666XJ)>KqDi{`xH5~c9 z^40ZHvsc5B6^VwdEoL_lzcm}*tK!Dai!C_CM4Uz(V0lIBwOdBuZ+t{%aTY$8X?4Wl z*H50pYf#0xAj;A?v-N5diz2cBDixcXvhK!UNquSqiMY5|W5S(;e@<_?ny3^tSy~}J z%*GJLe}4#~k5fU3MD<5!3=OQ1m>JAoOZ~j?Abiv~2n6Vp_QIiyJ$@dkAbZ&_(PU~1+4sCt05rl?s68KGS7 zy@k1GTTfk8f&6wHLBlf>&w5)`W6=_I&nEGe zbY^8?s*0tZo&VUwKlNL?R%X0Kn~4=(z^5)D9k~`Jw^$k_@H=&>uc8h)tfZ9kX2=hi zHG8=@OEb%2D$gqL1Z4yNm0G{1aaA1>b%gtO5%x@|Hpwi*N& z5Z2sEh0?V&?W|sfkU)hM<;lYV*WS@ObMF+=Gi+Nnd-Q(R8PmqUeMhl-xCYa^j5kAP)0Y*6 z7|8Hj`f}Op7(#V-T}|AvcWt`SuAR)+r+=f6b4xa(b0-e?FbY6WLf>&PM;-fkg7G?! zS1EE`5^K^8F>m(E`Jg-vZQC}q(jR6r6uG%I`R3ia4MUJjEb09=EUVQSSU*&cj#bl; z!~gsXpTBkHR!VLqq-VMCJDS@VEyEH&!OxnMac0)+dc3;y5>a}viBuG zaWy+rH5-*Alwaodp`I$(HW;n@no8DHF<4eM@b?**S0xy9Xb#{)7;2-iXX%;QHYXk? z?CeCiqo{PuzdlzbnZ{C@BgUC9p|)~zftAZRvGUHPMR}U8Ocqnya?h}e5@_JdfFam8Xuh~x}M<^>l7MnhAnk?yFP%kvz_=C6}UZ4K2N7;0yT>5Nktb+`c-kqEMN-0jTEmFlR0CP$Z>_al3}c2ggq3)pz0u?knX|6!BvVP@Vb*!qEVQ~ znJu>v^1`xBJrhJ(bp)T$W#Nao;&NFANi)p|k-YtGVGu>oBlJdbFQmeUh!M)OgQE2C zu!F+Ih#Pzhcuk?6r11gN z&B@05q+`YBO)l-$KAlbR@(AGiLE!1kbh zd^cF6&G|JH^z)BD7?PxZp?xsOF;3@vvtvfJy3%R-xEqF`p5wLuEHfOR-%-?Sk9&)X z{qFk6wPOcW3gp=CpQ*9dW*c%d6V8$l@&QhOz5UmQwO@!*!aa=*-4%R;wu~=2&#hMl1#p5 zb_UyMh#wjlaAaL(D?n9C3?SMV11r=CZPVT9(7_uan4;n0}B_(LAHiZdu9s zG|$JtscLmSd6J6j%f)sxOPcUo^Q*yTp}(9%Q?-OpQdEUHlkpUHdRkl{1Z>c;Hupxr z?(b91mg%|u;^6`M*vmXhU$USROtW&n-;vgmF%{k!B_&Og7DX;>tF)rY3KHk$z&+hd z-g!hf{Dw-Ji=OZzxe12GftYO@Qae_{21;k{}{k9)d<6 zpDe?Mq3(#c>_Jpxo2ZM&_Y`^&VTQu>k!-(3%_i9-nxL#%Vn20fnk`p}m9!nmPHbV8 zQ!#w3u%Z$~FT(DHji8{lIPJF-vb^9#8G)J-F*TFIzIog3$TQig=6?c8jz47d6MGke z+3?r4eP}^*CQiDe^?Au`>HpPUhbQCNQ+bXc#H` zt84oAt)-8N=u(~=pi(*P@PXN5^}Ix>5NIAelzA4>E_qqaaf}%fEAD1PC~_^o^GHI4_e&AImOT9gXy>%Ue~nb_@h=-zvZfO(7$9iCS{hHxg}w8C{0$L{+5DC?Rgx}GPP4XoRx)N59tsbMBG+wj(irZ#LKbC z*R-dBi}@w2eUFQ6-Y2tgw*>#F=&lDfM%7Hol%osbot=H8f#!le2~ogQ7D%G_#J- z#-;BprD2k2F(Y{bhO>}#wZ@WaI$li~y^ksu6|iCq?$$YA5opVm!4N+l-?^mHwZ|I-XB-Ud6Ep;B3jnfr1;U#A%O_m zR^TKBV!wEnx6Jf9)gCy)#>u~UkOZXiQ_+u&5sy=L0zFVMeIj-r?RfdQM&>=#6WN2r zP970GtWNLSEhS0omU?GX-`3o+8}vF$=Zj|UoakID85F(MzU@I|ajsB%^F!*P1c-Qs z8)nTH-OsV>TwZTI{5F2rcI(N=T&wl%stKDr53tcK)`E+*b^L>gDknEuahmEU%WxNm7u-waj@51xuv{)iTl=V4J< zv_Z*VTzd!lnLqgMU0!Ueb&uZ^iK{7V>-%V~lvrGN90e4Bs#kOvFvgXWDWi8KfGKS7 z8ZeF4?knv0Gs?WCFJ6DWXTw#*N9Oii`|dB@Od@n*1k6`__osH@7v;b<64$4n+i#D3 zH$cxBK3RN)_xBGUR}2$cTKl6s^indJ=~F3IS(z7Cf%U+Nfe(WuD~9^h=c(@QtqShT z&U-sLzbdko{g{YcV$Wr}km(XkiK)c^rP0&Xy=zpGa$P~^WLx(;`9TOr0D=EZ4^Zg( zK>=m$>g{_8a@e-}@YJ|+KwUlB+iNFY1!_d30{WwVf6kZDFaX}_d3ridPGf!jqM0r_ z8l)H##>l%O|0O|4Pe~@~FdD4yht|Uqjy!9)qJs0IGf*d8i={-D>khLm|;>x~V?NlpIC z$5VXlZTHl1V|(59Ez{IdLh<9*&tLu<3VJC)#wH*f$#xzsB(ECz@UEisO7N_iB|tWB zz1pIHm=sa{(80$190-x_wv}wvTnlkR9Q{qS6VdJsvFRc;R;}f}EYGLhNi$7m#FCNg zjvDY44~-$g0@kTolrTWqkp4KqblR;edu}R`n@7_SyP=0p8)kRT6eR2u!}G(`7P`)j ziV2>s5v_`))Ltj6@vt-4vSgUzJqG^zuR-D-n69qi&XrAajk%)I=k}YA&@uYwu1Eaz z-r!A$$N<;$=>q3C2?3=latr|<+Dy+-tIk%S$fKAK7hZs=anRVGGi>$aG^+Ae7D09U zeTf1-627H%4BE`7_fvYk2fOG}K-qVHloLX%TKO31C&6*}gQB>ygy?F1Y`=|t(o_aX zMjZ$*?7~GV90f&aW==p{G#or!VPAW{cJG+Z=TX|$EqMl>bxWC0Lw;0^{prf<-~(?( z*&LGeIF$-_TG|vtJz57rZCC)N^psxp^U^9BKKD*;s=UjwBilg_ok(-Zj~rk}Q_oa) zpy?8PlYq)N?tn7>vV+u1z&h&j51lh7u4AHP=L1hG&)1iDsWXz+OyM)Wn_qI@sFg1! zjp=nLhobkYkLOBVp6&*7(fP;eN-XJR9bMH|%lUEZHn%^pE^wszX9erlz;FB zn7k8*W_sK9Bytoc85)qA!LD~KOdm_H_p@q8kbU*MxjNv{r)>~MC^Q5}zQnMYCDi`6 z;5O($vWQt0cE0+4fBaysJz=69Q_`u7h!OQCnD^&Vc@m#h##c!<>B9G9@^J$Egj-#t z;EJ-e;BjDyzqe8>3$(B5oD%MtUCUi2o6_NNbL6gxW`!Y&9PaT`lct}zwOZRLwo8FV zKsLqVGCLd|`{B`WKb9o-rXh73M*b*wE8pxNEeZ0|*CA^1=5NMIc9doqu#bi7D~vFOedqxnS)cvln2zH+U9~)qzb*LlIA4)rj zO7k{+W58O0N$AUO!Kuf2oa@~a%iV_}%T70?muLd7OEYT}S>ofP)F?~R&4)V3 z9U*A2fw$A79_MxdF={aMb&a!Xj~X_tL0;I-07q@*Jcdojhr5&!I<`r>v z(d{f>?7T|4b9$A$8F7<6UV z)2CJyb&)!|Vc+PMM2r8EnUejI<9MkFshu&NiN!sI`xq{vx-S+53ifEM-|-g!IXu!23pR zAa}5lF`Q7k#2)}thUm8*hP#F#+ZO3=)_J&>1 zvzNmg>iT-eXx6B_`!em}H(A6{Zw+ZOp5)!44%6}a90=E@Nn z4gIZsRYl2{2P!uV}r+u}|*W&x_MiB#WDG?7vo;igt~+^&6d!J(~E z`n-hE4H#&$N}8pzV!n4am2o`$sZi~mMdC6?%e3b5L_!k^)n9pHTt=cW^sapNp~~LV zM3;|!D?S|)XKIQ>WRZ&$BI|I8!p`fZ7DuGZKAm$dqpgjvrE=74e_z8~TJRbt(~Gu> z74%7bOwaG%6-+z{aVo+Si*F&6R$;1QR#9@an36k?8y6-*l zY>=bnGy>+4PVAh8!^xkQOYDrs7ksdLad8%Oeh6148h<6{lc$nviQJ)Armd@E`}QKI zTR2c6_qJX?srLN!K-L}jQTleK({b^9(WI(~cY()5)d&r~lJ|^y$$6d|IzNt$)uX$J zTXcnBV-P7J&(XTD?Nn&9R8vJ>Mp=&uadVe*R~g_16H-~8zerC=U}uw{xm}xtA&sa& zhm;k`?9wk@M5jn`%b=kVUo8a-w!}AA-nAI-ea7h_Q<7s@r5s8k1@EFR3=xF5M_{v8 zsntV6t%n36!x7cKH2nr4K$KWk4I(;BlUaS^AxUTx7#>uLn% zE9mq|a`>+C#Lj&p8ELNW+hi`+Ud&D>*!knlN5IMhK@RSz94X*}8a z9q|#yKVh2#0?_(|B-5VuTk@($|=oFQ|mOS))# z2Lfxphn?g%&lG;SG}{ZU7uKf027zd4lGizG-|8Jmp&?0$EPj2avH#9f43T?S+Fi$q zXo|UHxw$jM4I*#)e+sGE--j7Q<{gH6n zM~5{6>4wdHEWlme<&5uz_l=;1&St@2C?09YUu1iybiTxkdpGj(zp~?$$m9;joByI=0JEx(Y*zAz>Bk z$iMgN&xJ9t6Om9Bv}lQ)%jZ+YLEZ9yAJo84a{ZX3tiMH9? zeU|tfHefatX%>Rh2_GGML+NBb66fS>c0DxV$GT1d?5oD zjR9Ie2@1O8jX@UvCW$Odqx}NQ)D|O@B_i0${3p$Smd&wBCMok)^^X*N`>9F(HZ{0^j{#1} zO+^dP)77ERW!FW5UHJC-{pc6J@$m~fDWJ|XY+p^hK-D#x z*+?98T7GqOdd{twm@qiLuff|55dx1rbUmHxE=62}+mBcD$x5nrMd@&Mql}Fa$qaSN z-C!>zUWAQ|ALFvRCCn<(Y-`m~ZgPA@6gPep8NWRcIu@s2s_co;N1Z&rCJf z4>R8Z)vmvM&EB^r41?VH-XQOz9J-ch=eTKm(X_^9G@=8GvHqKbdTG5sriLe8>S8Eq z1Mr!6vNdjykv6%-2gvDG2N+8CdLAoC-& zbaZs+u+*JU0<{Q6GI0YOMG#kN^sr(eZW6G@Nm5%6cH|Tl>$W3>w4WmgDG(!(oLyYF zWt?cmqY~4E@rHwo;H7aG(ryC^R5Fn%K!ze2FbxK%?hA96Z0C+%$zc?hU;4m3Q z_%YMr0l5>qVPim_SfQWgIa#{G-7-b9M4C%bER-F%@&t)5Cyds;MVEe|SfP&Z?`qR}Qir{Z zmy6m;FIp6FNiH_NWM!}@-+t}7D_w{%q5*;(q8qCV^6jQ8F0jMW$|{r6iqDN&BQb-A z0|k^Tb!A7&!ipNt55*Bo`>eX+CQ(xvl^2pDi54TEdPdv@?e;n=tplY_K~e9G@Axg} z5co!1IEiFw8PfL=;?DFK0EMjKp&vgedBl&Lt6}2gAOrFPIGWFW{&SAFSj3#+pduiN z*y#w!RVxSfGx-sfqZj5&ALcTZOHOhP0tT-Sr z6Tr!ntzI7T9*h!1wTS8rVsrT(bvkn7h%ARzL?85x=sM@xsAQE3KFHNg?iN zf0qu`>mY#;n^Q_$E6UmwR+b)g3MRy0`f)b7f7mt`=UIggWn>HRpV1{^dD?dy&^qW~ z-;ix8%C!t>!*q>~TbWc40q*9(1QwcEReeGknAu6u`kF}uHW)LrKB4Hq0^CcIEPTKk zN9$P{$&8$kminsh^u=(Ow9K>zoKdvgS!mYJ-tVFRX^U8XUju7`;4UZD!2ouITKXOv zCxF%`&e(hS{!;p98p1?CrbOQF*e7geQuNPJU+a-@CKB?PFAn)I25=1X)g{X9^xt!! zz|ri)`Pvi|5hzv-LL=s2L$g#ByLH#kQ?|UWN;7@2ooFG=?aTlGKmbWZK~(G1_E3(5 zS7JwchlIq@ojVAonj4SUvf5={;c=~PbH$3%I2!U|9ov>81k`@W^B(Q3d^e|NdqzX~r zc5%u1(>hLgD#=a`i;7D+xKTnP7j}8B(GPW*mJn)07Bt5D8dU}+dJ45&RVXEoqbgg- zyeL`JzgEc3q5K&zo=JOBX^&cF`316=AxXLE4yMjMGCr_MYhl^M)`S(>9Gym@*CIfJ zWdjfbn-+bE>IT)A>q39ORQ}xZ65m6ag!%Jy1Ye19UhFe41SfbV2a>!HqRba3ATLW2 z5SG(WOc)F0m58hh)rmNX@y=B5x!=wKfuq?6)2P0EA}GQn3&!p~{NZ1BJH1?w`NR`X ziUpmrvc*M0V2;PhcvnH8?+-H*(U{l0_8Pwmb7WLoCt@p_n_K)mao8B2TiLUDUnFFk z6s0X*x!sDZ>$Kf7?B{Va^WJDreC_jg!5iM|hX&Zv>F+#leJy(>G}3xQC^8}lVtvHW zAJRQJZ(3R9tTNjL3|3QjpWWHgW*=H#XTxHRGS%*X*mK0{MU5vk2?Phbiwa~x^;%nY z$uDSdv<8(9bHm-=vMulZsI%9ooF07OOESGv>+E#EVNWY6U7%A*%P)JgJ^7W-+S*^Z z$t{4O8;xbnuEu9%w;IW8wC+~zRJ5bB{D_iM7ea8+cj_17yxhK5Bcc58KHHD#8)~g@ zGhI46;(viR1ZQxQjg8zIxy0cmd|nd${Lf?MBlTeoiU1^5mf4*%s}zS#<8 zw+Lh{mXE?YJU*;6d%%Q4DK_ccC)O@eh2!3yfBxrxjubWY{ahR@>EvLmTd=6x`YIvb zVp(;3RwtDzZRJ(3w*sw8m#p02NVWITA6Wa-_gm_U=tK}3?_FB+zCwoUUteEq7u8l< zd;g$s6(FU~5-WC6_Y$otE4B_DEnF`3P}?CJ&Dl~JO`vR)iGlfaNxF+FmrGzJNwNe^ z%U|@5f2J4G;VyfzrD_w~Lr4B4|03^pAx!cm$ zinH)T|LUbU%!_)_X?hQ1Gt-A5Qt(+g5Igt_h0p!IP`nVnkRNIizK8eOe$YYo&@)G! zo$zITg8(7rhb0atqh97JcyZ^9zyMO1BH22^VTKlC;jI#0)z;S9fddB|F^#Bg?urC0`aan-92YS7&b| zf$o!C=PaEMZj(h0Y&9dy8tRet4NV>(m#|2zuufD7iH~Ui`M40bS$+}w)MB@?rMums zJ?^?fS$u5Qg=h+UTuX*5-KR4yfmGBVv5M-j!_-)xSmh+Caq28|!*hNW40g1hfB1Xq z6KRQw14t>+I#`^{thUC|B9X={ZjP*rv^DN?yVRUUI(cZPWlB;W%*s-tR$ifCM_kVM z!b0O1rNrI}@4@#-^ZbPG!Jj9}c@Ofk#OW|*a6QYw<1NPE+%7ZQJx|UAxqdHtINC%q>dE5&N8DU9td?ou4CPc~TA3 zn)2B3R^Qj{?l@_=g(X&0kmq;QGJx@337I7L^R43&J}a$&K*FQZ&Vz9w(PD{iIn9%l zR_Ms3RjQ?Al}WgTJz+p6atwxc3Vbj4q^R)_FNCR1`Qj2%6=5KRDZDOS66J*S9$85c zG>!M2)Yew3%dGC$&;#<ac;V~-_P!y`m?uPQE_(rz-O21&u6T2rocc*1O(BbA+oP5FR=l!lOXT5?H4=1 z4C~5d@54YMwJIz@`xd79htOc^SZV&`+p z$IZA(ks@{JMUp&Ai%D^z%xGO#A_|2Kx5*{87|R{PKq;5Zn3eU+ zLR(^KGmC2k#5%Ab#N_TK_MDQlE1X+E5yksRZO}tV>5sJKunZjV3E4!CEOQh|dnZ!{ z31RzoKC{DC)~|871lvkcdPVjQWjx65Z2gcf_;b^R@OTv$c$gjUKZBAjP_h5=!RLs; zC|XC_KeP~QmCx%`(PuvM8E2ibD#*f~buX0bhU;Y#M(iZdwY9Z6f%Kg3S3)%~MwTl{ z9tRh`{FSdLO{W0u%y+{^RATg;p2&Js`S^0O!nh@+#3no!!r-hb$W@Av&n z)@ZPK*qcJ&LVv&i=9_i@(SyD>iB-`^`WYsH`TT%fFV?3hmW`svI=cVv&DpF2_knE{FzcJXp*+j%9V9~3=Ec- zZ}qF|ZO7BM#xKnFOkd;=6liL+SEq)yS(^4zQHIDbt9ElV+u#0Sv8<7q*^Hb#tuHHW zu7ERGcF{4fHqbWu1pPTeqDa|qSD zDd{nDl}nno(8eVdFIl?M7Qf+xj)Z81c@MHu@U#&EQf`!1Hwa3D3-dthU;iGz2P#MA za4}iS_8k0P)6)RTwp^%?G_Lw6vcUu+Y7uaV%|9NMp z!}=S9nT4VXhzLCSf{OAL2d?GxaD->EZLN5`kUr*z{6oIsU4C$({3My#<#|3A>N(%H zpEWFMTU0X7iEb`ekQg^H-pl=bj%#4CbaLKEwU={@n3Kh%2~s&IEfQx;fTHGhNO#>} zpZGPexy~MV;6Xouv{MxO@=Grfr5IHjuE9L+$Pcw}>l|}BFtKA68r&!j6j&E;mn_pU zJhYk6BbK_FOx2(h4f3E3l`7;O>S@#2TT$S!pPg8U#t)W$NR!J6DQ@Jdj$0+bf`vv` z8prlhL%*zU!d(b(gL5uG+ekSTD`iYTCyrDHjO)=aQ(~>%DQ`|Hp?m`H2N%-jE?MPu zr2hSFO%Bi*1?5VkTOy|BX4DshmgN&n` z`}QA@01obArrx0rai33gcJSjLVh?k}!+Y@O#y`t<;0nEfxw_CK4~TTOMI<6(NPCvm z_%9U=!p9iX2L6I_$~hpglpVx=o~FnlpxJw{Q734+L_s%5abv`qn<7Ehik064ARTmIcG*8I7*gAv|2(k)v zqM=gk{#->4>f?2g^~i~VW=$N~5=IqCfR^c&LHLG?gjll`IS7ZkPD%@Ftmm+$m&i(` z*6G~)gz(ExBWV(f_l*O4bUzS6tVs#6@@3`+#0}Oc81P82rO8Ka!A^Aa-C2N{=7)y6 zuU=QWS)D)YI~CPmz@*dskUsddEapPG)7}|7aSqbN>VN=LN)sz~8i|RMWz})$4T{=~C-&ZL-l}sX0nwJS$g~0gJq+N9vQs zxprA;z90Q+=;^m(;xsPPK5>>huUCNfY-g|KYXX{~p};jd%fptVYhQAgZBTVZZDk04 z@Z=#YU301T9WWfs$_6%;b`g#QJuRom%9g*?jz1%_vTLrg3?1r66%=cmn5Ui3tSqSk zu4r8wWtJrF2uC=M+ZvcKQMjMguY4p*KrZ#;?Dx6fU+A6mu`*`m`vU1=Rgf3e#UEna zkdPPC#f)*amI(oQ*scV@v5v}8Y_+VufRu27AQ`&IIF@rDL2&d5d0{;jUFLn-0)74K zU+;bO@499cq=o8H&YR!-X3vlK*r!H;gXIb3g?vK3=i4WLkd-p&%4M>86DF;5K;VSx zF+g&h0O{^*x7S>EovmHB&Q`5jC4~+hI+vh_f-lmocu?Iob^ibTmw(avx6$ss_eZ+s z<9b`C_*mG-`1`2XJb;gm{>Oj%Cm9FWp}k`nPEVL&6H1@=5f#ZH-QBzFqSsjcCD&>% z^?)ltAT()X|<=CTkSpDbQhBLT8DL<@SdZOTd}st(4~$$A^+rPpN=*bTJ5IGtyq_Kx5yOE zfk#Gd`|E#MK(kmDGiS`o#?8YjgT=DQ(S7iMUGjmqTiupd+TF(wyCL_QjhFg0Ee9UI z&z`;I3k#T)6+p(27~}~^8?Toh2#ZFIm&7lz=MZ*|J|IE=#q*BA$jtk~u{|sSfr<)ekwkrq1-`-A#~yB8%Ml21rnsui52z&&hW$jVRD`hRSQA5 zd~8f7J%;;w#42aVMC(U%#IM)dn-1y{vux`<*(?*S1y1qiE2C4EMmSo?00lF$D|Aw+ zLAO&3s;;bs7i()N+WLxvok*w@5wy0&$UD|Fm_)c7e94+^c0!gtk~NvlHY+=m7Bji2 z;_3&x+dXdOhF9oH*FJ07^{B7sdu5D1tz0+wiAsk$&j1j@Bv?%``v99Jp$MDK&|&BK z(+pa?_>4hb5G3B7gv@9wNCV}a6-UKXR#v(OATR^Lz(F;k#5fQhn1Wj3LjJ)=jThk_ z4q`!Cc2VX!1hm0HcwR0?eb8KFM}dCe`Da)IpMIb}01ZamkGDifoGbG+<)y`L$gc6o zA#ZD;j`Vf+_DhkYP?WV$TTlDc)4H6xyjZ80dLqjcv0kZLRj-Z|`!@sIXVqU&VAy4@ z5aq7jR3yv-cAST6{X3z9{km?~QuCJkA$fh!*92(s*k;E?VF!hb`Km{{0BbakE+#1C;32uo})T;CzG+sQr4K(#n(E~ zGcBofYF>>D%$3W`tgLcCokSryKOBMNeBE%$Y8?r5ut8GjUbShJ1aFzzYi%7EG3}t* zgjnTN36vrUbYeXL9!bL`apqFAWL^B7DkY92DUuAI)cW-dW@V`ls*!2BD11tj5qA(H z#U9uFNGeX$M|T$^#8Q2{lLe?CaMX?n0S2`e#GO&XtnZ=5QSU|6o3PpMA^hd>sW)bS zPqUICfC}{`PU17~bKOG7b++O2#Y+rJe4bnOOYxqFw2Ar82f2yg=Y6ItndwpC!!g_M z-Lu<1@WBuIjSM?=G6^>4idS7`C$u)%w0W}&3BDm~9H0ElzjVE4Bxwi^P~t%N09$}? zfbhGuK1Odk0?c`aTmB09^ur^bE1lU3SmczF=4A zP(KG7xS`>w%*0OUHU!wsxekfvNu2|}SOTlJu374A=~G9K+nvoP?Ne7=WbM+6&Jgwe zizlDA)jEwdOA;;Q9ha25eBBk6r#)z{C_Q=Pd26`m+ji-P|6J_v!e(Vv8Bbfb_EkE> zu)^-udUyLZZ_>er7cwgwfpkP+GZ@cX_Z6Q>;B$09`<4g|FT`YT1 z9Dpw@mj=_d@Ama47BLEBKBupv)iQOzE{MS8-k62uDwf_>nU&p-S=r(l_DFe=-=Nru zn=e@9>)fS91(u`rSGo|ETjVdOUSiK3Yn6pUaS5_|n4)EX8w-zBk~$~J!UvdBUb|M; zomTh(1`N3`)u|$`L0!nKENPK2(_bimT=?o6{XVBcZK)J3sntZZuvu9KnaQ-Lw@13> z1@^?#JNy!}bO~i(OKX=_3$Y{aC|1>ZJvc1-;6?i?U)7&U7vAUk3*;Z6rFWZ`pIEu5 zPb0Q5G5o*dGkp=C3wh+P2aHzsQVB&5{*H*M0lx z-7+~kVTW`+c!?|>j*2<}?7{iHu+nu#kk+d6yUlx_a+a6#(@(#ESy|F9N|J48SrdV3 zW7UI`OGlr3LS|(jJ)>D!X65E%E%w<@{iQ2wAXMbsD%xEL5&u-&&)StM-C9Mc>wJCy zkJEiZK<)*6)qU3Y@IKdHAbm7-svnijOm9XtoGZARuipP1!_G~SLzHf=Sy_1WJ_#Oz z@<#Qt71FHA6Gc5KcDTT8M^`Q>*VfQ;R#2EDUFaNF-ry)=L&I?`*wF4uvl7`^#w1LH z5CFv4IEI^1m$A6+2H_-ErodKf5}4KrB%Ow0vcbHp_*dnv!w5RBo0_-OtyV@8Q79_{ zP^?aJkYP|1t3|9dE>8k1>eVkhSedf@TMk85*g|XIY3XE-b{|wq*4(<=H%*t4v0Vj4 z64gH4tSqHK#gu0(ZF1HUn?{L4+E>({`-0~;E2~S{%PM57?u5PT?QeHBio>ay9dqzflrr0lO-Jt z|FnMHTHCSXS!+JpFuSDK+r~{>gy{X&B=e`ZippvUt)w9pTlA0*)cIT;lon|-l3I+% zIhzmC3J51gH1S9q3TqjtPZEe)Wnf8wQv^k$s!NkDrMgh}G_k*%CL}Eo>)bjt>gto& z$r{$ynYd@`6(xo;&+8NpvT;{2>^+ApkT9RwtZbAMZGdC&(^8s@lC%wmFbgFW@LoqMI{PGjTJLSQqff1+4n>!1dET1f&N{nx`}zdWY2W+0^LQKQ1>y7Nw1EjR%+Q0 zG_m-GLAWp(p`C<-yFgw6x5nWO+K4&a@fWR z+d2V$=B9Zm-f1W0!QoPepMiy*YgTrz{qdjti5n7s{V&`o_W7`J^#yx(Y^Gz-9c2z0 z4+KFG@^szGV~;;(2)F9Beh&M|KTE?GNqECuMnx;OTFs`*Tw*(-eNHKN_uX#YO}irVG(rLZdsxU@o0DmqWx(!&N*SnEoy)~7wCHgEN$m;$pL?FR z&%gUR%a*jfsk7TY`RFcNqti&U6gd(aa7x-!7ryb=Z2wPxC{sAk*;OC>gagogW@Smn zLH@pddu-#oKVmC3ztV2~=v(Z{U;Sg_$Yr(;G%RdZmTrwql&)G?=SJ<&wn}8}qjS^= zLk=PV%`fNz$Ok_p&^w(Lf|(`#{oH*7ib&7NaxS7Xv4g}QQbR+7Kcj@f{w{FFGiGxE zZr}@bNUrQbCm(p+EMcmDzfSldbO zd-W!p5Vhli?_43V_QxF168pdt=*RbSwHH0CwQ%K{3uW3COB$lsLelxn%91V!29q`6 zz#VM+#a~rjQ~Y29-_B%K7EB6A3{?UVVnY`!og5&Q5 zfZQ>0@~m*;hjcSOPm2XVAdS12$1`D2JSZyE7bN0BGo;fvfSEs(IzUvYE^3!(_2DkQ z_+m$Dp644Y!sl}#F0j0P`*xocDCgzU!kSyBFrL)Q|2A&ge2UqHO5skwO63e!t;8^x zOr3?6Utho4S?IDdos7{evH##8B)Asav_qE8>F| z%a3}zD5t>%S^Q6VU@HnF&d_Nj)>0D^R;6a?iWc3%0Bbth%atY4VbwE#?v#!?q>Ei1 zkb*{Y->|*1Qm2^&P(ykz7P1ZtSZFJH(x_U(YImtk9C{#a#J&pvrF4NAS=QEzxdlQe zlEzgBI-Elm^O}{V4MI>i87pjX)>-F@F^4(`j*J7EG)72aSTp@S0*T*s5C~nfNVSik05bK z2Z7+3aMFgp33-Nh($43|n{@cZP5cYE6L28ZBQZ}8i;Y)^mspR)G@*=85Aq3Rla{t1 zpoHs4;HN_L7xJS9%s!2TLRFHk|Lk_dbXht`uvkj|Xj0YI)Y_N7^d;jI68n&yx-`2{ z`oysz*AequVp=1Y@U#<_k6@u+{Syo6!{um=E!jrKlMTZ8%}1OuQ3QHf97fEXNZ;2TIy zyfDG>9k_Jmo>&^v1{Yuh9~K^;?_qM_nY{6-2jO9@%rlN}lo`^7`oc-@OgMF5G79;E zq=e(Vj*3h9Ax~622|ot}hEj+Ep~xYCV=TE}=HQW|_D0>vP=E7Hc2c(vwj68m4(k?D z*DkB_y-5y|qhNr_Ww9jD;Sx2kC}VyO4&-Icn=cC$JYuhSC4sHOXZ-%;0T5q#-lVukRJ={IIocx1ZS6jE(<8MFLV86P3oiweI zrT09po9@P~PugM6YXbWC#yUIEH)s{IWBrAl2ds`Qu(thH_sVzcjB$q@e&9~q7`p`` zLzXFVy}F~g<&isW(>rBWR;rzeW@VwO;q+3O+5iWE0X+7DpZMzMY{MJg;{rLXQg%qM zx~pNwJZ5F1&ePCXuj_A1K}FBB^96k+O~92ndPX_U{(Zr`<}EXV8O8wEIo%I+jJ{JM zK%eMHhX9M4E&*QHV-OW2fW75A%Qe0)U%uRRqe;&?m@wAI#D@*%1YCjAzN{8AqEpSi)#y`G9y-y;clJuxKd|+zy|;@^XB(71keISTI^JRJ zsRMe~w4Xn|)zs8@xqJ5P@d*c^BjHU=P4?+u= zl)iLoeyR<3j7h_*%KuJ0bEjL;;1EAyOms-ZldgP=t=e{#xD`zb31(%vOVhaV-NpuwprO(-35ZcZZbC1FwqYYLwI-({zBp5 z`pNFX9>V-OS{p(%na5FJVeGJzBb zyLjgM8ITo$~y0~P?@v$>8i-lc&Jx_&fOMBQ;BVb zw9Fho&4XE2ghAt|ODY~!2C33S-7xGvC`9RQ+9@_W-_n&OEX?+*GB+g)uwnBGbx8zs zJ)+755@?~&fkAf!S^IPVL(XO^ zp?3mY2<&38O6fY#YO9hjh{B=&&ZiGV%#6(GWz+xj)hSA+@zFn)06wPT)G8`w_JM)7 zfDj-De;@%!4dTIC;`2^8hyvWvFNEWWL*Bf@LgT{s@E*Z6^-OF@bV`YDgh%}wJ1oSLIsPCK=})S-DH4`yWn9hP07!(mC90jQ-y+@sp-#ZL5u z0$9xqsdJ082BvFA^t?pUYQOPa&rBBMGLSbtB@;ner8)`4sUsaKpl(txO_Mq$gX34J z2w6`zE31AGD?QS&hid8+lGHm@4gj5$p5rI0re*Lwy|B*P2C%!6H`}bN1Z~O+q^8X( zQos}mU;rZOka!~@aiel({3zqu?;%e37W@#tpsx>J#*6R)PLM3oqz*uh0~vS@08M*v z_yMSCCoaSzO^C-g-pQZ%yn`&n4G2P7;`qDrWx3-BrG^A(B^~L>H^8t@6pF)u^LcH#^G?AKD-CS0!}i zVr`u(Yf#xMUVWt748boJ=CFg!(w zfJvQ3T@IeD!D|yWE=`RNnV-^xkfIB_8DjEG%Zs)zG|HA-8QtrP;Aui;c1}q$eY#m$ z^@Hl3S)sj3`BS30T=Jc!JW<{NBz*F5MUMH+%F-DCMz;>KGal$YbD`YP6S6Tqq$7+u zxmGF(b-9e$BPdca-i`D=-FYUTyn_pId7m%Mtng6A+4#zsI#HX*=Q(l!IBYHKC+kku zz(kr*J6QwMZkB5}*j%VD2u2##k9>#9!WAOHEiW&3RvYCGt|8?jbdbf_TICCyPFnJU z#qJktJFGQ0abRs>$5~sGA1pWPbFKvio=?geD=r{7h{zuf)}M7O-&h;d7SgkZ22o*? zDfj;`?@ZwPJjy#iK6T5oBwLa#%Q`Gez7rpDu#?!B1QJX_68Jy~hNk~d?oI4N_$MJpJl5JVGY~8YDS-$r7edhnjuVl$_l$7o6 zj{Vnr%{|XE&oR$D1Fo=CM*h^F{NXNpQ+1-=gkd5u%P8zmjI0UH(zb2e627cuq4CNX z7MX_AX?ujM$SE9|0WbkLixw`hhJ5#X-#6KScoBrGP8adVM!A2y)M}8wG=qpu>%-+A z`!ho-oY*~a&*wvp+{#XxKF`?Mraha(9xCPmIHNR>(9Ou zuD$)c>X$R7Tzm3wKNE7R0#t(`Jk@f5@uZg5qa~tBK-50-XE_1;OFd|CkF6B zZe;-qxSCtu{cf{;P7^R74+8uKgu3VXH_!jpfRG6|(i9<#DliZ+sgw7#Uz6{+C-^tN z#<%_7d)|XzQ|z-ch~M?h&42)$0O$Zb0FFEiz#}7qRLF<8#bJq(4fh~~rvVFNI=`E3rafbPJ4!l zzzmLhTNw}lECPZ$=O9%}4yH29Vl!%INI}qOawG=zo$9QGix!!s&@suCj_6eA^$cj0 z$H)Ogo?OSGiN>UH{_lTE@}%|#%RMVct#mZ)6-zo*#nKt)w4TVqP~b}@O*G^!kWmNg z!JLV7>x%iIKwQRnnJTtwFEcDMh$By^=LOb)_21-K`e^RE|48z%9NbARw;nX2i)@o9 zTlLgoinawYU8f~3x%oqOk9OS@VVHoDjxqBNM?(?h(}G}420zh0)Z6VGf^#HzTDahB0&*BDiDYI5|^<;;lz9DKpOG_nLuWq zGel^vyY4z8GWca9kQT2UP;v4A(Pqw^Y4OR&y?=1d7hD2}hW8Nq9gj4m$rj~xzg^_X zSP=3Q7wtE7q%9!*iWMuYetf6?yknxE&wMiC+c0I|$g0=?XsoeV0$AqEo=u(x1koQ6 z+QBUA92yQ%gZQi&<4%^DlZHj0v7{IECr%rKMOE%X01O_~dPNXx-+3V9S1mTcVCf6w zfyF&>v^|{ADG4LBK>&(qFzZ`6S=*&A)q@USH3XXM)EY76m4g z{)Qad_gb72R;_x9tV8Y0YAZz8q?zhWSi5eEW~z~*&f_)MQq+hNr-?ny6g7&~iP10i zuOVDAvWmy@ZTNBP>ySuZhWr>O8$=_fM6OE12plB4KD9i~;+qyR2C5|RPMX69*s zHk1nPp`^GdJn{JBc5)p=_$gI1tcM^ zBZqxYAeEQt-#xAyg7BU^pw50z9`=s082~t&;I_3q#IiS$}CWL2hY{v7Aj?pR?tcysFEzF`!5r z`s-zYQKxtDz-;n`I&jE$Lq~UbSM5|gGUcSG*;pYjZe^RKz*;OX9h^>hLX*++O)X(O zOHGMtTmQ&dBzt0cm{7CGzI2IYZ`t*HD4FvnTNeg!hIA{dNhU28HO)@JD$R+!G=vBy@0JMOqM?B2aQ{NM-ohELyi zTga9?h;jt^>+XBLX6zYb=pBom;tDz4M~3^&uYVnGyX`Y!_Uu`Dw>0F4^7crf^5!>R zBjDpGm!@#{SHEhfX+W7T6gY-<%jQkt{U7*%IeOS3g9_{cE?sb$DQs^2;0H~;095Ew5&@nlBfy$%tj2#K&L?+Sreu`#2*#{_w{OOCLr_9_EzKd z@wV~aTW--o-bbvHKmYm9i^|~cvo+lK_8Uxw*(iTl-}uJYHRv-{*>f{#U%tb*!4CPj z%p|A1^AF8?BWt|V7QIPFndF+>o0V4(o^0F`rd@trD5|Ipoh^q909toG6*4BsBcUO$ zwydc>mllo-Z>=m1RTJ`UGeeUsn=+)dVq4)vtqp&C+2XLdQ71?U=pU?WlxCN;M`Xzu zppC5SvKhamdY&dbneJuE^{ninFT3GZb^NFcx|MC&u{|vP=q;hByf)OWx=c92+RUyr z$e&o(`4-OxP0sTd%nwVJEHSGkmQ|4XkoEHNqNhhVtRVbN6=`K9|1jA&wc+TOLx-Be zW2;x2k8l8%vH@O#Q>eteWbdgXWzsGH7{CWHKwRWPhke0qhj6`Y1 z#6vyND)Wr?nn{BE3ACSY)QvQxM_WyQaZg>CtY`}p6J;~05MF}}3~OR}s58fEX5AeA z+wHfT%N%5|&Q7*{=}>EJTwog(gTu^$cJ2P3{Y;(S-PbuWm*M|qKv~yK&(ncsmtU(^ zjWpB6ykaRUBuknhho}X{5wNv6qQ0CFyno$Ok$PD}0Qg`hF-#GwSuDlX3|U+OSYrg7 zY+hgizeBP+h~1+4Q|@2BurbtTO19N^54=CwEw?t6^NrHOtuVo4XhTJ=-1&?a;NVtv z&5!N~S(7-V%yr)aav@H4$|wY@tTws6#qcAiph)S`!k)EHg@YR&lbA4BTt}q))_x5z z`++s)0wURS%PGQGjmxG@TWn(hN2(MS$RYV&9K%OzAL?5*t=h)HkT=x;@;lUYL<+F+ z%5Q`LD4S4FK%%?IlU5$*4cP)RIt_p~>#4{ScsHDPr3{b;Dom)ToTg{(v=Rq2b?W4> zLZ*YPRNz+WzWeVF<>k}DkN!DsW*{v;5Co*+2NHnDAPWc!^70HqfYcxefX{obPPK>+ zJdz*p;|?*c=_^k!hx*YE{0NlA52PXuKjQP9_e=^NAB6Qff`F6@dyjIFvgreY-&3ZS zLws+Szb78|KFM<5H^~imH>UOYr6m)kp5GOUCzVjMSFCj$;(<9q3Dq9837HqU z9g8tMft=};V}VSaO+18KS#$@dL#STQh)v?N|l>#Q_9lGdq#U8QTl%V^{}B4GKw z*J-dkc^83CN2Mw~Wk+OSN$6RvI@%eglvkKfuac4}eH;3L4FZTk8tUPJJPD2j z-1Ck!#PhhkbHwAD!Jpna;<|+u>62-Qi}@=QcBtTW;7VGo_gE7qZ;;p1^4;SSkNS8$ zyj-4>^-I>ps>r%)#q|)YG;{q zwN`0yS?*|U@6rN#hFNWml^hAxfE6SE{(wNWFBF&r(8Ov`q^vq4r4Qlk2_2gvY77NK z358L_Ss8nP*k^lnihbHdMtwlWQqzwb5#|XA+SP{BO4uaV8%Kq>vqfpgs(xc+t#wRQ z_sH<~n1KBZ}5=|0r{3T|bs<}$iaJ#{E?x)gF~%SUS$(y8NeNvkpk zcPks4!FK=zR*fso0~iPlI%hrE5|rK}zHrQn#Z&*q$uwRaxVS>~HgDV+=3X+x;=+b+ zuiqCkMRjtdneOb;<`gL?iK`+N;&>$UV|?PcQlC?z@bQS0B6(v0U{REnNn?JvWsE7$ z$Rim*1e8TRDRvpQm_XZLYiR@Z^a%@!$Rq(ZcD5Eu$S<<~c}4+wCNHO;ltVk{Kk_CI z;&>jE!+YLADY=IlQV!|(@gBAsq$eNp22njO^^Ri`m$7(G2EI9k^?1qe$@^g;FoSVQ zGrhZ&rPDS_=>NhOzF@rjnl&%!2+_>YuBD4dAN{4dlEwN2qmH#Nuhn1|%AIVkt%<>s zWoS*6MkC0>egK58F2F)Zx3bf({y*q^6%?+Z^$_WkcyX~&8v9JT!N zn$4j`)VQA}T9XD8w4m}5op1DJA+fB14($*1tABn`Ze>Y}ZFl98>%$Y@zBA0f>DG`T z`|WgX#5lNfLpZqpQL)fk6V!x7fEyw(*w|6G%X$7xDG)h8?k7LJFH~1ms83~7qQ2!A zoHmUMUMi>Pz4RKMr$cVp~-q-N}33YqB6abN)t( zu}PEr!vR)Td-m)xD=OG_SYz^YWJQMDwr!g!qEJQ=KrGUrv;)Rqt5FI$lFpek$7D(n z4H=iCamfeAjIgeAWXLgBaZpmB`~tC1qG3hFx-71G=sT2J$ll~pp|+ra;+eK!wZ-Ja z8Q3VeSZ_wgUAJzX)t!2QAKo7DiF`34VIp|><(I7t+QS%Fod7}+fT#4{LBB6 zd!6a(>F5R#MT= zt*i)dXcl|Cv}k;|si+{7Xp(7@(yB_FK|B*!)K`c+T6u}i+kNQO44fS|Ja|1R>!eO! zNY}pL{K*wsE-DDk1$)dh$Sb*(wX|ZfGnIzjM>Wgd8YW2*mMa+;w>mwNrqv zz2YFxf+L?0M3WyUL!!N85@k8+3xE8_nmAt$fA@`VgulA$E(12!aiLtd|Jk2uoZ`+L zXXgO=x5)}<-~N5!aVeVEWp>q7S808gEph4Lzkc+iqI78jvVGD{&kjXeVnQhu6+_WD z-}@qY#ZT9I0qyP5m0;ZP?6c45vsiHTDL}hSWuVLfx%LX_=FXj;Om1-^>mAJ}nPiC1 zm9~Iz06p!fudff~<>iKOAUH^ZGLO7b1Q8cxgeuZkD33ripJbQ-Fx~)x2p|~wQBNiV z;z6xZCV{~885(Qq!*>GTQEu_AM5de61H3>}P8{;0KF$R&F;FM!g3^mgiiw4H#HX(0 z$E5tvH*daRwIAfR3I;)^v?-cFLVWbnE>#6wQuVhLz=dOd&6(;`<9&%3{aySVrS3^ z?M!OixzX5a?j=y#fFo0)56=@8Z;>;C7qmR|uG$LQ16<#t?O$S{QDzm2+OF0ys!-)) z8knNYvFEv!MLMNUY$H6lYg0J9dqc>ZRHb&Fd_}jiF>PF?YuLWdJaVA8Vh0ryRkO@2 zGt0}OmyQ&h=@i&X)i%P>s^%zmUcD*+fZW%8%ls8qRjl+0}n}? zU28VRAO!EJ4{(qvWJ_*Z0Vx4?5DVBNzAqm^&3Fa@J;ZiJ`Ia4Wd?y_Nq$Do_Zz#t# z!o&etq0IJO&y)=!LS+e#^j;5-M;U%k9P;#h{XKbrn0&{QYhWNx>PKF10!%zilF2}Q zJPb<<%~7C9x3csLN{vn1_DKG0x4fs6S84X>l~$SxA2SP}eGQG;t)#(`Ss;FoOG?XR zE!1HK2Cdq;R8cimh^<4XS&~V0hFA2$SB62Ir;7Bn)@h@FJTYq5(b=<_2(UBO`?ES` zWuz2B>=5GqjLt|(FRW2Kw2^d3?0kk45rK^h>spV>ib{x2;X+_s>5S9BXX=W92OAr5 z#8SoszdgO;>Khhey`6_;xN#t)7u4Em3m4^9*3ycCOHMy|tVtU(1T-2j+u5Y7$L6xr zN^+}#UTpJ8QCR@Oy;5pcvUU2AAhMhg?B3fD%C!3{Loz&GQyQd+#+V~b`haQWr6zw; z=f0r6M>C6D%DA?OLZh=F96feC968hw$}6i42^zFVI$u_4`Pw95lp&c2CB&(O*czZu$m<$=e85lJ_3jb00ZDjgt3pQ?;h6?%+qk? z9arM`d+P1aT*;SjT!{}7LHRvD%AyXWL-B>OioRlb&FZDJuC(%ck(NOI0+ZK znm}+X`)0AyU6>l`eXf=}nq)S~J$|#&1&Bw*ZnJJYm|I!o(=nNvNyV9TH_PT)$|&5z z@_bQlWi_c#e;nK47mKt^G#IRqM>6!+LbFW4l12n{*e{l95J@=Ld#w7wfm3j!NjAxb zM@178m=6NLNgj?Yd_d6{sQ}{!P&!43)QbKFFfAXSXi`@ji`2w`b{2p}mx30$T}xA# zdm=B66D%bWnD)X>!d^lRku?d(jj)^K3&n!+LYbX9dm7pd;y~Tt7+_b4&#ol60jMr% zoysEe!7-2z?+^!Bnu1e-;!`H+$PWZ(O`5fF?qQLs4}pFn4M;~HQV!fF_oN|jBfQ@(goV+QIyxgw)HL%che4G_&(8jG>!q>n4b*sP;&7N0Yd4+Wn1N72M zFB$uVd_ZHUBn#u8{`9BKG>=mlUatUSbnL*EP_^O%p?1l&I$}l24k0dXTp#$>pNE@1 zac9&@s}m0CsL!3N?g3#qp2wB1tQm&b03*vr+$U6!(BP%64@yl&JaWvGDiL zZwh~X-4)@GfUR7b*H1tEOqi)dz|rn{Kq=PKzBf#%dV82KwN@NQUO2LEXV|{_JK?&| z{y?BCPC@lqb=Sv2?(|uE`KFve6Nd#4;e{7o)ZyRNHb8U|ftkty&|bX8Kb{wCCe#*h78p_>i@)(*A2sTg zCR=9YVS*`e)ezD_eci2Wac;I;%Vyd6z!T;BitkX2_gymG5HL>)8P@u=jzlg4+8{YJ zEl-mN%BXw+idb*C)|tNI4~(M14P2aC*%M)bT;Z~V$~H8pWGM3JxHLSVOj;9Vz9wmT zuf67tHe26)_ub(mANi>52z%(Ehr;ju-g}IK0t^!bm_PZ+zbYTKKz~w1gN#D46gy~@ z*Gc*H0hvBBh8&#(&@&Me8Rbv66lTrMN5U=ddAHqf*}BCLV7FErI4%c{$^#S1fyP5& z>5|KcXTJ0R05z~lL_t)g%)HHj^ow8oqM1@+o%JVw@&!`_J^l34;imVzNB32p)UF^8 z$O!@99%{x9q%fkO_v}CdX`q-OE!;zy-XZ4_m->U;SX{+6$K5?3DREJ#LFI0~`DTj; z(i4!c*)hbk!GaKwejq>KzPdWv|3lH8vLbskVYtx7<6PZUmjb&d|7z%$)_w5HqERS zbI}-;rK5M&>IF!xK(NbQ>@E)Lm1SSm- zhWs5dKuq!gu(`sb!umqxXd_!(38dpYRE>MS@ywMU?|=dF28lgB`GB}Kaam4-|M5D~ zKgr-_c-_dCz$8T8l;?H9)|^o!FUAjUh5SfM-Dq1f3_}KHPooSmL`BAEtDMad$`}ph zrmcHqw9p!6&AG(Z4>7-E2BPpzIfv`g(Ih1kv?o)*#TsIf*gC$w9zjDO;MPKa+dkPg zgBYhmchg1#vb6l_(4(C_xRu4kvA3gMN-TLtm^34y!~&E6BUC_9Dc7tcNze3jgtKzX zb6V#t<;Z*y7PY-`T{t}=O{X*D>L9f#HqZmxfDFodZf~bNE$Dos(L(4ElDm%|*{*>> zrj)!>SYdUJC7;WIv{-HR9N8AKCTq>PP#U@zF99n3S)%`Wmr^ za+|BMgGI03TW>&zgSY}wBJ6X~ZkG$u@4WL)Ed|arN~o;*1t>E0!0`a`H_ETE>YM87 zig4o%HyCo^5aQe4{jTGy1dV6DQ7^8P!9DLmTmtWsb?2Ka z!-xZihk}c8yxp{s{G2lw1_GaR^iGB7MUG6_DjDKqAN#nm2Y1|YhwVUO13wx?&O(Ak zVSv}K-w@KJyh53>Nok^&4$Vjd2D;y%iJDd*!Dr zE;H+n$G|0CQ#MG?+AsS6sDpD0^bg-)`H7Fg2k)3z zChowOVCNSF~3nEJ) zoWpM2e*2$Ur2&XI;1>1O|4}{IIDbmYs-p6m(0q;G^r(eV-kI@oUQROVd7+VlcJ8XoM4&z7E`pdiYDXrspnaGBSK4x&gZ8w$#p z!wry+0k0Hhy*cBo-elr+^t`A$fQ)hoWI)zrZ!#hoG9rM_6(tXo6Nt_gWa1lQ8$^XG z$O^KM2kf>ZC-s1Wk_I*(R+<3o3PNV4SXh)*ltmyPj5r9Sr~e(Mp|aGKABaaDlnwHF z-o!62FSl>hoxr5wl$vMH6S>~YwfC1?dg<9tX;dkYaIr_LP*FObfNk#fZCj-*`G{rC z3`RqlZJ^A|K4=W7JO_N!C>rsiWFR`9S>@FQfb0Cg(op$=tIdsTSBspsAFL1K!;ODrv00Z9*~=S*R9x-Mi! zxwP}?pNR6aG5;I_3)7YUXvm#Z6H00{qbnZ$d%UyF%0`y$?^gEQmP(sEIuC{7S<6G7 z5VuD=huG7cq2pP`Xfiq^gzjwIAvRjTld3ZmKT?;t8|kRTmGZLaNBD8czU&;rGY3=> z>XQsp%Paa=J#`6qW5BXcly0)d>ZmmD3m49}GpsP^VB$rwRa_F0qah_HlV|CaGPAnd zwR?98p3cW)>e{NYpDm3u8g3|0$)r+q7;yE~Z!-9S%Kr4d^jq>d!gXpAwp8&Cc{*~EhI{Ie%+KEe z-;~97;&RUwnbzv4Nrk{9h*sb8DHJ@sVxi@&%d%$qmY zmS~6r73k`U`)ZMoS*9s1#sAX@A>~+UlmFBZ5uhXZP-Vyt8=Lu0e)5yHT(fD@=J1)% z+!hurx?J*ZMfmDhzG9R86Hh!5R;*ZICmlQ@=W-}&%sR1TWX)k;daW za)3yVU?2l=8FNod8hcj0h&#mL*@Nfj?|Alm;*dXqbY2$E#7mZeVv1bts{G-22p)&{ z)P;~N!`nJq--j2d2oxZd!D+3(6cx%bxn};28+2r6hBne`w&uX^(kbPkMQe#w6=hUI z);8VY;DNpNtEieTHcH+SY_QLD(9o5N?AB1BG5#l@VA@kxXZLVCQ46bfH0(G1MW-gY2;@;71(>@;wb>p@ z;JjhvFceRC^8g_6zCpXm5B)}KYfETQIk1X_nwU9Zz^Fu`es1sq^~1yCefMe4Z$*W% zth5Ez6~Kjp5lo9t8x^P@Y%QEypJ=9Uzbm)0u(nWjkcc0X7*P`B>6u=7{nmquMi4@>~O?PH;} zKC7-dqwS9)V`8j#L<#E@Un+#{ptF)5%F!{7q9vU~$BsVkc!utml|gtC!$%gf7cLZckG59)$a3w;lH zBfqj^hc3}hol?Rcy zLft@Q?t!c2%aJ0D;E0|HJOthFx=SD(H)k^9(sbP zFh(2LL&HMjVwU*XGWyurdGtKDvLGSLTIo7wHA~A#-ED`(3JYmf&}k{OEa-kPx3ZR2 zlojU#*#$ZfT$W^5bkP<&!n8}y2(;dvglVeij=eR`puqeaQ@ z{`|YSvSSQyAzZN$I3!pZqdt3L^=d-|=gyp1#7;c-7g&swQhdjAgwJzwC0_=MH!vOX zct^{Xz$Pxr^>_pGAlks-^0%kqyQf1$pgqn#cv`-D9MT~&5Rb_z8OYlwiD6LSXuZF{gJK4l=0r8H&Qoz!)DWM~lEGeL+vNp0%4nLm};h!LXllCfJFUzFl3PhL8; zjw^!&)`ww}2}8S;Ra(i-%?&Szz1K37c21p@yo08aj@J8>W@o;O9!K&i>!STAumkfg@q5tjU&X!~YREwp%Be zI8T)LoCM6k3rJ*1m0eU3a;MI+O$fM^?byFD6wSO!z$XQeiW}Um?76h#1mvM($! z{pW6F$qVrTmYjU(YiHLzRaZV;QLSGIMcF9P8y18$aYhz^8dAh4y2e^S6<6%vB3T=`ZhN^-SPd4|co%pl&e z6pS$k-Z!WSfpud)BlsMk#jIb`U)2jabIQzx(o$*}NSWbYcJGt-hiQwikvuvjv>n`M zlzGonzm@IuOik_TOhsgS;QYe8u(EP;m?jyR^MMPsI|;Nb)gn9e`>!(bEE<>NCRSl0K_3K@LDIWJnzsFUyNH>kdg98i>!+E7RDR& zJJx6PEtHq~xx7n!>SA)cU>-*W$j+pvo=ix{!_?2IIrSkO2a6w+_(@tUwLokp116}- zN+~3j5B=~O5SYP_-Ro+9j5hGMgew<>ax7W!JV@ak9qpztp%SeBT{>UJ{i1x%UL*(V zHQft9dXH)Qn2_g$C_AoXVfV3`n)9wdG7BhJ*t2SU`=NTBpH!-4o48013XW0wE)B>* zA?J9>z6I*|V_HJmbwt)!8mxCrFAE#AB@cvVBmamoQ2_=b^aUX^>&<9;VfXF07riT< zerafwhYoiut7YWAvis7CmXFX{Gj4!RNN&IE-5-7ut<+w< z`ac7a^3~?x=~L-I3TBdX?Shez##PAZN+J-3b!-qBV1^1pSKsywT!%9at8^Ac?>v$UWsyE^&Z&?x{Q92~bIpnK}?ynV}8@ zC^Y3c?vNJh%{zXCWjYXn{2aFUj-reSz~2p9iN!1#I~wjs6i-OO+8-5Rc4^Z7QMu=^ zioEfSE5Z{`JZ|?`5V2N_-=^{7#~E;WCs~Bo zU$o+<>ykTD0tt|NXd{KWqt%to3RcreeSu?tXUp$#sOpS*6`M`?P9N<)5XS*h#T88 zrOy@`>P7&#K{|&$-g%vqaKt;(5YOZCjFlv8=OZ~Cqqvh0_JxWw^%`0npW=jD<& z&sIm?D<0o?&(-g}Y)|LkxKg_Hl+t^BJs#!Sib<+IJP(5cQwRvlnpMIQOAVAxM>EW5 zu|8u$MMbtDDZsLE^Y&0ZeTFS-Fl(_hsc1sk;TUb(-pUQto}sph5_A zTJCAGC(RW*tNoTTrZ^)i9Y*8${d|`?+474ulbKSJ)EUXLr*RXcdk`^Cdx`UpZ44*d zMuo|_GAI|NjY_qF^^Zd_CQq7gxz<4oojHE8+{$jbuv=LMo>^Rnpq*^l6|$!+ut}!3 zOV&bCqM;bHeiu*(A`IZ-Uf~E-MTOcrr`OEZPz6{%s1a9ojKR{?$`2I?&RQ+h3)Zqh z%YRS`?4@IHP#QQU@F}#_ugUnt_4j`DeEiw31Jn7lkQ0PbPDz9TDc~ahPq~$)FM(Z< zk$$2-VSi!6>1*CYxv3}bx$*-EX$O>+IKaD$kcP(=h5aoEwVOaZC@#cGV8;`Q;{|D(!t_)qX5S& zu`EB!?zR09qCxe>Po1lc1Y%Rv5xw&G!N~@{`n%i1oNM0|CX`JV^-T|ZH~cy@?|eRt z&Ef=xIK%f9u6%mCnq-r#jqy67bc_b9t+77rd-lHYh7bRx*jssBXl)9+9{*m*m?%RK zQNV!#%HJcSj7x63EgY`jrejim_KI$0NpqrIJ8mwyHk3+!e(Zn#X{fpSM)T}}#{9sx zwK|#MSLeBv1vuO%)~-4^3)E5)OLq69E9~O)P{N7=}eM03^oE>BBKc8$l}Tw+8mH4d2(Mk^kS3+1}I;c0LNFQ2izA_6KLNdze8U>5&`TiP{t)SxEJoGGo3e z*pA9i*h?bpqbA{!R+ERyBM_(cur^ZYSQX;Vdh73*K?m+>3nppDN=Iwh``F-aWyu1= zjK$g#$3_7V0dC2VSaLkn`#|FXbD%$2u3(|S$Y|`)0nh*{C_;1d5mUx6mbj8-ywqT; z-{60!d-D5G&;H)8=jR4Y;ob6hOONy>TJugsp&h^y6bktgL;;YwQg7b7;Y7WRhRVy! zjkti|ATJ2Sc%q>uE`*0RgMh%PD~1?*WMr3NsRH+ofYbx(2`rNb90PJTfj%Q|;!r1$ zl6vqSWM`5geLvMa9) zRnm~*c>|>YCX=RxlQj_9XDqVNL<8*D)c)|tKbD!C)_`4Os&NGY)jqw`tgq{<4gvt% z8=O-RCeK`~Wu&OgATyS}ka=;Ag~A3A4dRr zWwS*2OU`y=~w#BcWgt(q(@?A3CFeq@8fsw98)<+tITxmgZ6U&a0JXs{seQ9E^>AXXYd&BIxmuQ(MZV$_qJu#KVQ62-y ze`{c>ao&KzujH&wPzSN=XA}Uh>z>vBAi0&L2vJ8(8Y8tUi0heCQf5iPHa4$3WXTCY ze;F2bQB+*nozOPJ({k^+{n5V*Bhw4y7h1>%iz~#=5ZA#bhwS`G1JYB&m`BtUp9%Y) zzhCJ_YcqpPH&rhsFwJz%gmU5iIqFMFzNL#*KHjuDbnbj2jL9hrhmRf7grlW5A?ny1 zIc%51coefHUBQfrja$v#{Y zOBeJ#>6r2N)a_B7+!~0~gbQD(AN}}B@si((Z58E8{$7N=jt`C~#7F{i64;?uAPHCE z@Z%o1bA<3`+Uarm?rFI9{CNlECvNf`>B!Fm-+AUe6rV4&$@Ah$@H9Mo9Dh$)o`Lk{~ICXGJ=_I+=*(uXXZMyC{7Oq-ynNb7I2lg;~&OB=* z#`08VttbH@zc8|P`Jy6x^Tv9*p{^j|Xq|GGCgARFJER5+Va39Z&B`-s2bXZ-Xq%q3 zVO*3L4KG@04n(7K2>cb784ov;TiMI6`mobbqAbbH>?}fl%a)9cTcqDrV5|XJ zcJ4IGnLhP@>{pQjQwL;LSY0LyJa-NN5?QPq)hc~T| z!?%hG>ogib!`^Z8O~%L5m?>HWfB*a57w);|9$U2Dq%(uNx;m|6sq|f6{<7FXES4f` z5=$N8uYevTJbkP^%)Rz~Vcjo(81kn~4|A5^7!L2~GbRPM8nJR4?B7-vZDwL^=1vPWJbiYr643`fSvozL#I&xD;%{!_U2w(rBlR+k^ys;~sXkt1HT7 z%tK9wkC^<76%;1099%}8PElX)t4*HDAK)f$>X@W-HYnb)x`J3BE#W_tTUij8HXx_^ zBtRWOcqR<2tC&E5ZQg;{D6xpYUm_>F^^nDJKMMkrA3w&(5tw`8I7dMK!$x430|4Ga zBw$uCkh5rFks2{UvrDeDhB8;w%)vtV^yxLKsC*P__H!11_%E+rD>KYRCTs1`cB>_C zSQ08LD#Lnh#lo=~6~15)YspcZ<0jXdlB#spK<8q^>A zVYw!zB?hEWUw11zQPx)&ZmKEk`D3* z02nOJQ13kpCY)%=)F`TzAx;N*y;XGtSS<+9Jpk4LwJBvUa4Jr5UH;N9mg<^7y z3VH;nIBkQvx)m9UI9yG^0l*c2%xIdy8DN8FzM%*MnIVQu7W{_ILR)PeXw}JNMx{VD zmMYMyQTcCt98kCp}%$^~gz-*vaL?kg+VzJ6(?Ip;J9FtMlONHpLO{h z=h)!4wY8e*9f0u?6Xk-1nmk9!c;nfyVE#g5McZX1=PVplH&aKQ066XKGU*h>Fohju z4G6q(6iv2aK(q@!q8MrsD>-RWsd@c?QZdmy_|Pv5rksWLNgQ~9LIZCE>-*G|cRW*f zuB74Ki3RC+?|FDUuKfHv`Fb4U^CNFBlkbBC$|J7l%RQlQGD;=#IK(GU;(MSDy!SN3 z1$n8D$K&dC83qN8ozs6ek~&2*{RVBqMkCvM@~pi2ecS3(Gh=o*uA@4i`R#^KJ5Bzn z1eCqKXTn;YH=83<#ql~~`)l#e-7v9W}M~+q@^Uj`Ap}JsvXgVo3HbRzoHd6nZjToT?`eQAjd;d-;&m@m! z%u7HuFgVisb>R04KS#D4z{KxXVPSy*>a3ZwOfwD#hmC+$LKy+D05|L>z&)5- zS?UeZfRK={BOz&c$2Z=2Jn|!7M-Y#1u%vH}FkUwC2#!Er4*3y>dch`BHzp?XBLKt1 zA&{1O5FCNIk{99=-a|d`o$1dcNdOES2VVG71@W5=$&;d7t+i~vAR z&(^P#wNIsWHp@ZG_zSd=e6@TXz3Gi_G@v3)ngC|)OKVkeoyWU(UpS)<05AbWLN7B| zytU_{{s?SMX@pzZY1e)-Y+LockUM2ws9bcdH5dxQg!96^Uk%l7d8d|VZZKB+Jh!qG zrw&H8-6-V!*!H#hGNwV684hq|T$ zY#~0a3ab``vc)SI!O$W^Zv5>dVcxqxYt)%K4enN!w78XB5Gt0wJv{x7UkQue@dwI7 zRy1rQr!1dzJnfYz{g>@?(r^Qo$W z4r&Cx^XvpL3drem=l^W9#GE*hsz}@9@)~T6%w(r#ErUgsiW zT8_+Cn%o5s54>{(<{$py$1OcpC$QQ+%TWr0c2Pm`pt#YkY;9|C?>O*VN9I zR~s_2xuigd2{3@zoJ(YYV(fHu&Dtt!Ep!q1zj|E*ok`FYILVc+WV0p>tgnC#;E7#5 zEK?=}6b=~@HXDQl@c&MlGba!sIe7i=`yjU8YjZ2R z@EW?LiAV^E0R>wN0bpFYnvsVn8Sfgmp6^yxbB;W1XcL2E%4@2onEiIQfEp-Q8I|D9hk6d>R#yDA z(zb*N)r)n`v5-dw7fb*dI+As?DB{8GFNe;9I|g+ti(~gwlD#>EoB^gxfB^ZO$rkDX za`2upve8R%4)|IEDPZCF&QcC>ZKmzl%Y!^H91#=-`KVX^z3-}<1jj z2-i&bRQ6mTPrmUU4owesKQKR@9l@w0X_BnD*O8oQ3-$GUdB;6vF+Ry>^0Knk9bnmPmUE5v}vRJ z7|G4kLz+LjVk$1=7Y(6*X>=@&8eC$;$C4EZ~lm%6WuTxTlY6r<6ik zLA8*g|2ik_0ErpD-C7G~uv|M0b6+TS4vC>>|>GT{M01nRE`FU8)y?tfGifJhyY8!wgO zlX9u!3v1RG#aZ>}FAaeKY68f>y#ut@m#YJ{!I9#6JkoePzVQqq5uay}g}9XMX^CUc z%GX3N5K7Kn4dNUOqvp|xN@1XM%HjjSK6}D600b) z5glZkS@;2~>)fF>RBLAyT4}V;Ef{o2QI{zM-YF`)AV*YQR2u(Su-*e99mHeXU&h3l@=$p& zq{)9Y<(xd)9L|cpW@po&Ze^9xS+V)^%!*qys~+4{7>gepKhJUd`S!bVE88h{9fJ!H3a$oC zIr2C%DcmHSCUPn8bV%|!!4Vupw&=ONJ{U3=@GxAtckM)EyLnmZ8<@8C(oH~7e?pxk#dBd)b8Z|mRAVZEtZ@b0ZwW8?RxA}#z z|HYq%jDi|hXagA$As0w|MDfKp3_GYlNJw7D z@)$dj#t4}jjK$T;l`9Q_$s3V?wo{kN%1Wy{?HMKl11cJjJ`9Wq1@f{hgF+~j3PsNY zqxIZm6!RVaZ{j zTUpvnpJM*lrZrqfrK6)$^<$|mnc*?(i_&7UC2?$% zZ-#&0KQEr>CXkQvaRv1F<4*`7CQI?QO$tZMT+fw;yoebkvN~zFfv$cT5zFuTMsNJ5L7A6wq|*{2vb&dXCJqVycsA6njdMuOR&m z4LW8;KtsbzbS@9GKI{WKU|6^};XOGzUf@Ms`-8#21S}~@(Ud>g9Kqp6*6g+g9HS)f zo{~K>1|DZ*TRc|mDfY>#e9VM)2%?UwBO7GMacy~VWRLZ=Sx3c;6@Y-`ZCWEO)8>m_ z#p5jDEGB?K+QK9UW$*1e6vkIvrWjJf$S}j|uQGeun}m?j&bnVKph#lB$7%~Ab;juc z_S9(2VWs=1WU3=52jny*mXIAo8Ltl19@ZF(tX!>aqm@1(r5ejN=6Y7?0Gx69X4@LA zB4iBaT)3zE)p^i|q4MzeQGPrjWgvT$3-b%a1!NeDkC`M0?S>rAf&yng@iSR-Z`T2} z<-Nxv&BX?|DkB1^1mr>4yt`077ZcC(v8<#ane{5XQ<$u3SKHf2q>oq>NZ$g4Dn@aj^&mr88ZZh!1(?h~X&V4DR-yp2H^{)G2}OY00AXE$LVb(^Y63PO5<811;sD1OTb(X=jeSF2 z-k;nj!@xZF#yjfKE|H5v*mC929e61ax5LEiCo@o&!4Qc48>|nlT!7xjKGuzTU2Gsv zM-tKuSwxl;Jh0enAwTKi&!T>8+L^i|(nL?= zzlrCvNxmNY5UbbS=M0b`o71R8HEQ(iy;j%0000 + + + + + + + + + + + + + + diff --git a/examples/blocks/src/editable/preview.png b/examples/blocks/src/editable/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..c9c1e697848760c33937f9b88569fb3475c8c8d9 GIT binary patch literal 325351 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�$7fDuM=uToHk;0z5MDp4>stVL%b{G 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

    eU;DEcf&C178z~ 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 literal 0 HcmV?d00001 diff --git a/examples/blocks/src/editable/thumbnail.png b/examples/blocks/src/editable/thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..c6ed927565800dba35837a0335c131e034a215fc GIT binary patch literal 25074 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 + + + + + + + + + + + + + + + + diff --git a/examples/blocks/src/evictions/layout.json b/examples/blocks/src/evictions/layout.json new file mode 100644 index 0000000..b0d0fc5 --- /dev/null +++ b/examples/blocks/src/evictions/layout.json @@ -0,0 +1,23 @@ +{ + "plugin": "Map Scatter", + "plugin_config": {}, + "settings": true, + "theme": "Pro Dark", + "group_by": ["bucket lon", "bucket lat"], + "split_by": ["neighborhood"], + "columns": ["lon", "lat", null, null, null], + "filter": [["lon", "is not null", null]], + "sort": [], + "expressions": { + "lon": "var x[2];\nindexof(\"shape\", ' .+?( )', x);\nvar y := substring(\"shape\", 7, x[1] - 7);\nfloat(y)", + "lat": "var x[2];\nindexof(\"shape\", ' .+?( )', x);\nvar y := substring(\"shape\", x[0], length(\"shape\") - x[1]);\nfloat(y)", + "bucket lon": "var x[2];\nindexof(\"shape\", ' .+?( )', x);\nvar y := substring(\"shape\", 7, x[1] - 7);\nbucket(float(y), 0.0025) + 0.00125", + "bucket lat": "var x[2];\nindexof(\"shape\", ' .+?( )', x);\nvar y := substring(\"shape\", x[0], length(\"shape\") - x[1]);\nbucket(float(y), 0.0025) + 0.00125" + }, + "aggregates": { + "lon": "avg", + "lat": "avg", + "bucket lon": "avg", + "bucket lat": "avg" + } +} diff --git a/examples/blocks/src/evictions/preview.png b/examples/blocks/src/evictions/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..bdfc9cbee2c878f1010a472eef6ec695c3629df2 GIT binary patch literal 375057 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%0Dj&WS z4&7AZi~sq7!Oh-VpjvcSx;WeHMh|$_GYlPCk~1>G_CK6oqK%{D>X*cjlB)AfsL6M% zYPq^JXs{>qe=$^POB}ZsB>Cz5ibgh1HA?dBstR8+7VRX3xx+I##jh5sAZHGs5!E`s zHWvwD=0?Dr`(^%lCU#}w8OJ*6FYM*XAh#V_rn=|O2thED6mhoK=_d>%?af`v&Co3!D} ztfL07Y7?Y?`XKiY`U^g3*EV=R3RUD0R=1hgRrPh}Na-07SP0#!?xc!&z!_ex@GSvH zdkZ?({KYH$uAw<^PD~mD04uTuPc|&!n?H+liyKLT;!u<$2E7Mw9HJMt28dB;TF~BT z-lI)UOWwP@b7Fn2>&sL6W_>%J-;mPH(X~&l3(M8Kw0f!D@N|c>=xyg4R^#}yOu$s; zjQ#41Ml?jG`3H{DRfeb

    T1;N`M@%%j)>-yTqCzI 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

    (dbedax5 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

    Z2N_lwT*M4>L9)MeJW-h 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%0D%dy^;E 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 {}, false); +dropArea.addEventListener("dragleave", () => {}, false); +dropArea.addEventListener("dragover", () => {}, false); +dropArea.addEventListener("drop", (x) => console.log(x), false); + +// Prevent defaults for drag / drop events. +["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => { + dropArea.addEventListener(eventName, preventDefaults, false); +}); + +function preventDefaults(e) { + e.preventDefault(); + e.stopPropagation(); +} + +// Highlight `dropArea` on drag enter and over. +["dragenter", "dragover"].forEach(function (eventName) { + dropArea.addEventListener(eventName, highlight, false); +}); + +// Remove highlight `dropArea` on drag leave and drop. +["dragleave", "drop"].forEach(function (eventName) { + dropArea.addEventListener(eventName, unhighlight, false); +}); + +// Add class for highlighting drop area. +function highlight() { + dropArea.classList.add("highlight"); +} + +// Remove class for highlighting drop area. +function unhighlight() { + dropArea.classList.remove("highlight"); +} + +// Add event listener for drop. +dropArea.addEventListener("drop", handleDrop, false); + +// Add event listener for file change on `input`. +input.addEventListener("change", function () { + handleFiles(this.files); +}); + +// Handle files attached to the drop. +function handleDrop(e) { + let dt = e.dataTransfer; + let files = dt.files; + + handleFiles(files); +} + +// Iterate over files and call upload on each. +function handleFiles(files) { + [...files].forEach(uploadFile); +} + +// On file load, remove the `dropArea` and replace it with a ``. +function uploadFile(file) { + let reader = new FileReader(); + reader.onload = function (fileLoadedEvent) { + let data = fileLoadedEvent.target.result; + + // Remove the `dropArea` from the DOM. + const parent = dropArea.parentElement; + parent.removeChild(dropArea); + + // Create a `` and append it to the DOM. + const psp = document.createElement("perspective-viewer"); + parent.appendChild(psp); + + // Load the file data into ``. + if (file.name.endsWith(".feather") || file.name.endsWith(".arrow")) { + psp.load(worker.table(data)); + } else { + psp.load(worker.table(data, { format: "csv" })); + } + }; + + // Read the contents of the file - triggering the onload when finished. + reader.readAsArrayBuffer(file); +} diff --git a/examples/blocks/src/file/index.css b/examples/blocks/src/file/index.css new file mode 100644 index 0000000..f946b91 --- /dev/null +++ b/examples/blocks/src/file/index.css @@ -0,0 +1,71 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ 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). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +#drop-area { + border: 5px dashed #ccc; + border-radius: 15px; + width: 480px; + font-family: sans-serif; + margin: 100px auto; + padding: 48px; + color: #666; +} + +#drop-area.highlight { + border-color: cornflowerblue; + color: #000; +} + +p { + margin-top: 0; +} + +.my-form { + margin-bottom: 10px; +} + +#gallery { + margin-top: 10px; +} + +#gallery img { + width: 150px; + margin-bottom: 10px; + margin-right: 10px; + vertical-align: middle; +} + +.button { + display: inline-block; + padding: 10px; + background: #ccc; + cursor: pointer; + border-radius: 5px; +} + +.button:hover { + background: cornflowerblue; + color: white; +} + +#fileElem { + display: none; +} + +perspective-viewer { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; +} \ No newline at end of file diff --git a/examples/blocks/src/file/index.html b/examples/blocks/src/file/index.html new file mode 100644 index 0000000..b63b929 --- /dev/null +++ b/examples/blocks/src/file/index.html @@ -0,0 +1,21 @@ + + + + + + + + + + + +

    + + diff --git a/examples/blocks/src/file/preview.png b/examples/blocks/src/file/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..5ae622ce5e92c26eca6cd3eceb74615b4e452fef GIT binary patch literal 239819 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

    a-#EOUsr`&F8n>$s)3 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⪻h?7zBQ?1)<29AQ6A=xP@& zF%7fQyFO}y$A|ls+VNc%QeGV``(zeCoJG@1y$#zHp3XcG7vth@_`jRr=AYhk3pM&Y z*?LUJaK*3p594kw#64m~<*?>qi58JrE*?jQmLB~H`A%e1>eW;#3 zpxyB1QU11P)T*YpxiIYNzcAFl4G{e2jze=heY0q`q-b)uZsq;QUjOejKr2#e@0bN^zu?;@pkleexAJe zb&Wa!wnIGXHNQ_=q%a>lwt+ zY@VrL(6%Eqf++L2hf#>{#rHrxIEN}(;f|c8AQ0d04{hAhr`#;zrrm8UTF zTcQv`LfX@SB~5ihdb@+2e4J27Ja@H|Zq(G0k&?KeZUcQV@?m=DYcN@vBL~6Q3LL3Y zcSYm2Ce5g`(@>|%{1G>!YTy2Nh-9D$xQq-9Ni?t4hA1-URO4~u#TXNxm60)ol;?kb z?Z_5|HG6iCt8%MkacjEK9nxAVLSoO@>||}_N?gRyG_%$4OEBR&rvz!Z5=Up zNnwD{Mher`{R*Q!{|?KYq}?qjy1se{asXLbNPOQF6xf`p;yM)USb87HhO6~^)_5wZ z9SvG<1b2&>!g>#Vdn!rS%uKswj~U70*ZcW(I#|7b4ttVVT&#uL zvIaF%RpLsb1rd)fSGC23#%A+R{-jh8RBlbtp@WLr3e<`coCp``NlVAlK2LlP#rYh7 zzfL0K~XmAiJ$Jx?r zF`bqNg6~nrTuW|)xFw;z2+i;WF+Sfq5gWS<@MNt>|3?6X1EJ_*YtqO9+6oKvC~2V0%D^z*xgAmua+ z;=xL)sUFBRf5s+aD_^4n&yuoV7$4AIDxC)?y!u*dX>0FcLra$|15}tp;z3;;Dmc!g zNyV=cwh^p=0$G`o?S7ye(=oP*0~d^)m`}0SB2W6_zpmnEcUHB;O8}`gq@W$iRv_>I4e5zE%A}UI~j+Yt(22 zAkFiBCh8#oV4ZqQ!^QwQ8}?b%UH~t6j~l3IP!kMcipk;}mfHoV2ztTWY(xS#!f>_z#Q51-M0Mc(%G|3lMxfc4zJd;C{Ic7-H+L?W}uCKW;|l?sWh$_$UnDk>!* zE0v_8DHTNs70D(oiiU(rDkPP3ULWV2=X$Ploxg|te&6qB-1mFkE6KoF+1k_#tvln_ z+e=7xnE*cJ%4tUkTYH)+U7&lm@H36K=wgYwTEWn~RJv9e4PE*k^Fs(fd!rq2M@lwU zio$)J(K~vk2j?bl$6;`kMsE-hGwdLT$`CFqDTz1(#UEf0H~#Gs`gTzgBGk5+hW}5b zj*5ZBe1RcaTFGAnttV?~iIqDgLIx;Fp0b&XOZ1~Gq}o4?+y9}W;xrcAi(s?B?!-W{ z|Ejsm#K4n1tt}0L2m&D-hd^8mk*1`3wi&Dz^@x_3EzKG)f#S-5y3rld4-O?0?u!G@ z1HGpWDtLBnkNOMZIg7XzdWo$7#=^UI;2h~l>~L{tFv=K2YCD%95R#Pe6bZ7}&e~?j8%1 zF}Xp?mGO+tJ5G>r(kCsLBaFQ1JkqBcr!5vQ?#42*w4WgrSGJ8N2HO;#KOcV+d5%IwXGK<~>uEbtN zofL0e&7m#&e|}yWt52nXp_S60PZ|79tPahOcl5^r$Jy}r8LkFL}qP@ywwvDQPu-n=6l6nyYhQ7;Sv`O7&Qq*N_$C-Nd<|LAeQ0U?Sg z3{K9Z^S99)D0(P!n(_a{t~a=&bJ4M{SSw)lTcIwH5u6E#z!Wb6p=7&sQBP2(y0K3G z2>QVf@q4jXGGp#LJWu^8Az@@v%x1WT?pz>rkV9MfMsE_Ed{kc>W@7*O9bs_0i#@ICQ(cn2!i2UWt7 zZcjkK^HRTx5=u-B7uO2p_sHCd48auh{E&oN*?>!I^y^m~Lc%0Sh=a&DJfZ!%#&8V{{KzvIpR z=9C*6pBFa*5T8ZAn*di@7`Vy5{QmgX72_zOi6mLeWPR_b!WlbTjBn(MqB5Mnu?=Uf z<+TktFRP)KciiohyD50eU9JNgXsOHHZBVL^A)QJ8pIljX>Jk3Vrv<5TkGI@w@c?5M z+fvXVoRl-G_O9EsX@y<0=}m{V;-PaJ{lk%IBf7%`=Ah!TgSd&cxaL8kSkgvhvz(#}2H&6$ByM7LD*#du_8ryf z(}O%cS+NTMsV;Frj|-$y@fC9XX-;@USo&0eUM!@{jT`~GG`1Ret3I7MeOeb8!`D`_ zzPPSzT5H}U90Xs~6~w@$Z9EE*x=6N)&1QCW1ZVqe>hm3EGSMPO0Gq_0f3e8c#WO245UjZA+wGdfbYd%FA6ZrJ zD}U1SR|8uRs}Ib)aQMWIrQO#N;@0R0K!#UWV@;wIraOJtS#ulB@_4Gyw=A+O*1pbv z3}Q9`EeNWEJDqTwp6mOsUwKz!7~!p}tLv4%hbw|LZTt49vZj8_DB70!sH)}PYBK7C z>u>IXfe>gn^zZcI78@)fGLhgH>Lz6=F?ikxA8OL;c;aIg=b`YtB_|Qdm_mcK;|^4q zr~qO%i?ECwLNldj@v~?FJUwZ&j!<1Cy%v+}^1iuycqmAsEv|*D_M>22|Euzsx90{5 zbr~1N2^#3=K)QzUeK%x?xX~w1o+!9GF>5xc!s3o|l39z?ERf+#l%G`#Gb8xP7pZqD z_iWx>U#W}zBBol{xoFG0H`0kFRyJ@|J-CaXQ&@ zhr3$ghabxo%lHh+q3n#0I`@5~!^7z$Opl?eN0uBK~$2%NDfHHiL0GlNe~dwq7)WGA`V`Z+08v z1xf<_oD8*y`{w(WQ3hTV=Pa#oR?GNF03WyDm0&uTu%Umt$QjIxF3 zHhRd*4|14E7@(%@Yd23%QN=S|Ip1!vpn)whNyv*R9dGC{weL%#Oqh)|59aul|M}h% z))}(EbZZJYG?qZMM{!K$U|8jkM?jf=LU*zSUExL5b2Qy2Zrr$`+W(JlV61VJn6jP2 z#IEl@e_kB1Bs~5hFlaD`p6+1NA2x%To&>Knf+$CGrvJv2_%ZC`Ms`97AR3N@87|L*kRFp-b(kg?o8T9cXbVBoJ{0sm-`x>z(j|gJ5?nAjEwy@k&`1ekeN#KsNtNK-ZQ%5 z8McV%DIpmMwTKAR=p`IdCZFt3MVg;K)^DmF6Qzc8c1q8}IeB@unfEXxzYGzPgS9x^ zL5vqxyg}n!ho=8HQ#kbL>CnUuk)B1Nv1Xf?toPQ^!XgW;LL?<8vRsR|`}8F-Q)Q?I z9?C?lbIOlddJITz7WLek&B2u=ul|hfDdP&1G>0Nz7|Dhm*Z2W(K3rijx7VqF0DT65 ztogdd9Aa3)kB@+Ar;9EIw&!C9s*K&7UG^u z98g(6MsMy^psBfSp#Dl`_60V;YqqUevADlvB|iP8o_UKl*3hIK?90cpvasB5exf?H z2;#R)r@r%s3|WB4P{sw>=-EvK>sai;?ge`?p962r+GRvLm}RmA&HHI0BZehoZHDRe zn-Q##$|%!kX2VWqfq85wO?Ni|6f%p@G|CTVwC`(blnop#VrpET)TM7q6tM#(8n91U z^{C25&Dfb)k4DUVU*?hCa|n2q?~ujm>sR0rId$)jkbOn=IgzuG?uEje09W?6UrIIg zW~yb-dL&W>bRu}b{dsB;DggjfYSCf@wvb^Y{CZ1}9m_Y<@hRqBLN@%h%KRycC&XqTXDu zw?qe%!K_pFC_i6w6PRb}0vf!X```S2b1Pq5UhH#B1k6bcAt-5576Iu@&ie-Pz8*-;(6{r;n4d5EH*V&ReZaQLtSCAm^1rvUfvmWCr_sIIy`|7Yqb8*y19!0HaEca zXyBj9Q)Kfl(~t{$Ym4+wn^I1U-#YUjGPLm|g6$1-(8{RebQL+bEj3@iE~Jd=*#fgR zNB`0ZvFjKoA>Zia?ED(=)I89Pv0k*pUz=4lHBXCL0eWUlu?c5psKEnhVxDxUn207U zxu6UKNjI@$&-K|lQdQM@=8C4-ev-S}KX*m;vv}zB+)YB1Coj)k=mw|}q2fYgq|f!W zTKYGP?BM;EjOPnyt@yGd)x~WsPHvV_QoGd@&<0`$PyiXS6f4;Hp*#4o$GEWP!YPHp z6C2T?5VRXubZ>v_XPNkQaYYm^!c2gH^OSL0-1wPMY)Ro8hT7)9oyla&RbI@L9L4dz z;ODjzY$U#g^^irF7XQr^d!GoC<0~Pnd(2K?H*pkl>xHqEPk4KK&y!5r@M=~}`v~@z z<(R!Y7aPo%{tbDOL-bkJyzI}iC7i`4A#9v>@6LvXoWoue8$8ec9M>n^_h4R2C4#Tm z$_j+qn z{=GMuz5(pN4R?;9#+c6^&Y2U7jC!0($6v2ywmLc{zc_Y^a!oR@)`wtN^@lB1! zVf|fS|FK@W-;jRaQ_mx3z-%6GcI8*&PRn+d_3GVw{(*tV;Xf%YE!S*e?3?5Zjs8Uw zwZ|`B9EG@Dk-T;Gnj4I%&PyZ0_R+Vcbq>IGWO$$IVYJUX?C)6P;+6G}3^|gM^xSh- zMNTcv8Dhu#>HgO0){Y8qS?l^vMn29OyZEJGbr;R`iG=D5+bRaZtGMjv{x#~;S5EH2 z_K#B;<>%F|zSE-VZM65kq&4tYZ8ruu&lc_V;Z|F-ck4IA1WTvvGf#0hO39iZZY5)*)8qxM&gqlR+ENY7$bsSFOK zq3kaCURx`d+ji&e3yRArhjB>EgH#pc-;4_29-ltAV5t!u+@3j8i|>xnoEmcYhE1h& z`1!qK5w_&XN6v?V=~Tx2I7?~sA3;`zhN)AQYdkf6Z);dEbbbR?!DiT zoUjSLsB%s0k}EavdN25R<=Z*#@8EByS>r(96h5@a}uNwUB<-LDIHe zyE6e-K;wK()GmR|dA-Xm2Ornh(Lunme(i7Z z3OYknbuW%ooJDImB6wbRxhUfw{)QLzhDslJ?d~>I=EU*y8?V>=`RloB-|k(L0=0iG zIs0Q;^Q_JOH05_DZ2z>k_(#zYafq1dzmWbz4CAZ4;A<*iQA6>ei{WQFy1Kq-kB$>Y zC8sUhNZB|a6orXTgGG-5m^X|PJX9v<)W6wxYrtNHG76qtTK9d$J$v>XO5TC^5wJx? z({dH1?gGI&5pQWb)9#5)^5ly3!F%<@xtO`KD^2~5{@vMBcQ!6;ZlVS4wB-Zh+}F60 zymeoT%_%QGTND$Y^V!Km0V-1VHPQaA!5RLMX*C1Iq7!uqHN!`z_F*%uj&N=p^|CoC z*&Z}tlHo*B(hb}u13H__S!Z{R3=KUX!tDNY6xMV&^Q##yO5MB9=}^lOoeylk<-jTG z`D;Rv?$6!xHZ*K^`RS#Pfa*tm8v_ieFTIDBlZ$I)C%-MrWLS5Z7m;tTfqhJ|0SFwm zd>qiLuovSg?dd^J(|HcZ*>TkNmMT+n<(acOYKoCS z@?z~5rO9h^bQj5-tI`QNcg~YsxE21Bc2#_ux0yH8LE?a=%-f!*>0rr);{KE5iJZ8m zy8=9Y_iKW597{q=UejHEXcG6sEYec$Qku5;JUZ68X~<`FsVdGKp0 z53n{21)(r|KVDP0OVX6UV^Wr}XDoA*-8+Aq0micAQkC6KAv`=bk5Bs@d)0VkSCyfg z3|uB3Q^|fz#OVO}j_!#?=Pu>%NPoaF7Gfsjh{ja0Ik|ySZK|7`w$;AIwMDDlM`(?I zFyQwY5(g@UODxe>!LeYzTa-G z%gb+`kPL)GNcmf^V^!t`4dbU*Zzdg{5PEj>ysa`?W5>o9+|4=XIAPi{&t+#9>s?VD zIAm#$<8HZaQ}zNu45t13ROg1*+&jKkSL&3($b#aPr}&>dIY+c(A}WN+#P(huf{^iu zt52Ldr33gmAN>9;5vKa>KB>^69x9x*>A+RJFstxaoqw_P!$ z3$}|3r5;>r1IOI`9h&hc239vGFdmvDr(eO2tVyb>p85Ay5+wrZazF&=npNckLd6tE zsZXkbj`amGslA@N;OLRVhdWvoO=YwaNOe;K-L9dTnFmekt4+Bbip}SM8Pp*QH!(4( zb(_Vg$!=-`UU(Uc!oUAD%2~VI?d(~&vQavHMOIDB{40|IYNWUd&N64z=k$K?R4FV6 z?2GJ8tt~bCWBc20*>a4v51rE=2sd8N$+0>qAJxs^Y0f`AN67yfF~04E7n2=vGdb!z zOS?)qQ4!O(fphLeTLpI_B3wBW;u2g;G1LnA6Bk8VVPPN4D?r2WA*+Kbdy}&ZfEn2F zK6sZBQah3POLFnDG6!rE15ZA7ETksk+QLD+VNkqvz{@=Au@@pEW#R8-(NC)0h8ZxK zB^CK>TxN#~|EDcfQl%mc7}VhT%w62h2<5)-qbI(YK9i%JX*mxmccdghB6G1Wm6Vse z0|$MeYE9gOe2zh<5=lICs7URj7$%DTKAK2JZL@XTp zNzpb`=A*7}JN&`W zysG&r_U2lTLx&E^9k_aRyxOBRRY8d!Y$<7p7|aCUk`CQPXi^$Sz?X6FCVVbP`?H&b znwfFkqAOSQ>gcY~X3pLyt-y|T{`b0vzf_+&vpD>?55Xn0Wrq+e42Gt|`$ooB8uRrw zzBv7?=2+F|o6X(5GlXqKf58IV0cVkh(4vSSD+Y~p94%hfxp|lg?TOctv3Y$U|i9tCyvv9|he!?6KiSv5$ zJHCliao)?Bt(~=HtAEe{d86^DePV@Of69Mf=Q-sG??T?PEvum2aNq=(8mw7lQOF@U z&SpEpfz@;|DU+K4DeQvV*}{Vh?>3)zprW@(suo<2iSGge?6HFj0+EKlgpLhdLj7iRG!F(VE%*+ zgdI=tArGQ1xb@GWxjGCiKS0MW&JI9pAuJcXyz8w0lN_{x$`n5c%%e2x%i2-X4Wkn6 z#wslQQYG8|_WH#g0)~v)8C1KEds?D)+KI}-T0>dj0Rh?hJOtQx4idM8!|wCFqScp# zWeueKQ#i;4fxZW|OlL~XnC#VZ`4!hmQ>7uu;6M;296@7pvfU@lJAuR6t}xB=@R$Vi z<>jHrD*iK=ee$XO`mR4Rl#iYzE}>vukk(FHGBQv=dOMJa5R$dw%P-3l`8#kdE*hbxl=q zHozQ+G{R~kFbQQ0K1{iiXc?Q)VydZ{C&Mutq4OTY1wE*6fS^pv&?(+w^jHx|th#6P zi0D3B>>Q^5xQ<~pEY7s7eD{`=$Y%LFE^#f0Nk=8BNhYjGU30 z`@CS%hU(s??qG*vaLms>n^1Mwqa|O>Bf2^?GQQ0wN&;^9p<`pWUe3` zpL_}h8uPb}v~=`x^Ah9SH_GiNZ;k3<;XJb4lJC1XEhwICSa4!&D6MSOw3^FZAuc@- zbeK+_*x~_ndbHcjKd+*ro%gQ(`=?Pv5(<$#cRaW07LIgwdStG%Z#qt_1KsW|H$rerrJrCAHPpb*ZWsS@I| zF)6juz}4a~Ly`2l?G<9MVR}i}E#*28`M2CWVv?%lw8v|hyWH~cTG>=-IEaxUC>#i+UN$v|;K2eW`%h}>4Ecox6*MPWI|3j}NCLV^h55NKl}>pU_7A+}q{Fg22@6}IeHvFD zGYCqcpECIcA;J__12q@*zlM|Z{P{w+?}dP%1hz&T#UkBN!3$rJLt16^fr3`gS zu-x_SZBBczk`aw9Os3p$Ui({N++oSm_Sv~r^m|KUW*n+Cew_1by1c9m&rb{*Ngy_> zQ&mLLe|_Lb#2i^VQc>bwc`#;q^6~!J2{v;9HWcCeet!zw61`&JxSHG1w}&50bK$-T z17(q@Ejd8})Rke{@ulg@zl{%XP(j(J%0ZqV5*jsdR zVjeqV0Ju88H+5Y0G9tPO5rz__dIsPwLnf#l&`Hi7{)T%uab@I{)+-~lYNUSX_V8HW z%Q>h&o9+;n7w(#|cvWN0?=an)&v~^vA^BlVDT8Ydllfv1bPTSw+kic_QsUgFNM?4@ zOnLcUUOHx(d7sbk?z(bsea{&!KbH_{cb}LSPTUlyZgcn0V|A1`mb&+H+)G=R&L0#q zq&>e&51}7R1^dW?o|*Cq>J@9rp2l*&lqwc4f$73FCHz>BO~TSBr(J?kn({vDT_#L> z`taaA(g)LoSz%98MFa^267?!x!Rby3JL?bSSL`seseWd2{c_`l{PuPVHRkWfUH_G@ zHzILZ!-}4juRX%k75jGmvwXHfUXK%Qn(iO#P2Df**g6g?(@1tCaWO~D#^rh0<>$H! zX`O8^7vYWBVCfO?pk&*))bJ0|%Qs%MT^{eW&~}rJ3#x=g@OieQqHk(-kOXe4Sl&6F z44X0idJSp~mL5uwiPS8>?FZdIh?uU7gyn~iQPCOZE&)>a zPQLp3wef3+J^{&u=Gr|5fcE4bb-(Lk`!x?$(_?Dld0d=6Z4(u5-vD(9HBqyDR|P9H z>gXn-N4EhhGbM7cn_P6ejf>XI=N%b!j*m)i+uoog!^H8q**nX5^E@+VjP94u`01zK zQs3TQi^xbg7oD86%gfvUW91^*K?3BIM3!`oXlukFWm8XqOz83FjQ!0`O~yu}?Tg<~ z8hM(#o$>1nDt-n^6-|L*lR*eg;Hup{4#H!{#skDPmfFeG{PCO)@Sjj zrk)0>j|!%5?^vkm!*A?4$jz{HhC!m+f(|y(OH)^&y?Owsk8db%@ZC=PA3l}7P%-+M zjrk(?Of53Ej#GgtaBJDzwjMliy@v9pzoI>-l%0p-R{ebUrEwT!zCTGia}^cO4Em%) zNPB{MwE;yqzaaa5#!LT)l4ah*W1|N!a@$?E^Vs2UR##8`D5DdbX5v)Xm$o=|d^@tw zG88knuQmuDVXWn=ED_Kjw`iwv3DJ0dbSUA~Kk1V}_R)6c){3-eORBcLWW=rurJql) zHtz^dQv1B%-Sq9%py|#<H0{aIfu|6E+FfNr*H+H8R-ohPwUzSY@ z@*e-Exvt=<^pwo=@po(e70<_Z>%VkBbwqyErhWT!ZTIIm`7>XtFtMvtyz;JP?2tw z@FqQY}t@Ai5N&e zP`3z?a&3tec(QKU&P7inJJ7bcB8S=Y{qgW*Zn2~S)~DY?hBfrRZfIq7l)yjHnXg^D zRw&*#-KRHuyJx1m_MoD@4J#_g`{CpRVxVgj!}b8K4l~ih($u8uG}Dcd?4YN z<)WGsldFBnSVaBMUH*JaRy@@PU&#iuf>5x$TnCw%%`AD9U`^p9l2YvY$PO&Fu)Rp%6_oL}CtD@VDZ7#{kW70EeKg z(sXc|IG5X8n34Xjy`O09|@SY^tv>UTH03$rhJg}R4* zw&Z+qZggKHuo%q#Y_3yRHb=c5@>lY`R5qQ*Ti>h%^cNkWBsJHNBeaL zo?{lvh9-q2TdQp(H1<3BFt26yxCf*8QVV{(Y^cRmZggrrDj(_(niFl=N1p5 zuSuAz5-^o<@#n?Q+1}IiVxKaH`H-_N>(eGJdcNkaej$SSf+A3{2Ur#M&F#>k!_POI zF`0O_xr=C+wYMi>c%eh{hJj3`VGnT#RI6_W#!|iRwWzfzs zNs>OD+X*fq+OevvY)?;jppV)BNg42FFeu%L$e5!=o7**%?KD61=a z$A*voaCz_V37IjpY1LK9CI`c9kK5TveKMJPv#F`E(HE+@paB_;LXzd8Y@2NrhcPD9 z)z_Eb3FB(HSZ@v-MwatN51U1rEKAX0&0EHFDBVp?e-6^z1r4b`tpHta+FL8 zN=ozYb>saOB`lw!`EA8#2kXFPTVt+Cx@$W|)j(!#s8_GNf?r0qJzHAbEemP-r}(6E`)6)mUsabGrtf{ySF+c# z@V>$Kudxd;;D4Ztm`jSq3a3xPSA|eoM7R9RXXIJR?+@ON%w8AOH**!9tai6$&Y8HL zW({;UHeObbuKf;!2hx)< z&m@HQNQ{veHh((qbd;(9{1*;8e|lB1LseCE7Lb|QS`Ys)@&DqgWO#L${HytP^B4I?(8LomY z^|gFyT~5*?dPAt8kQc(qjb+HQC^+&OPA|2JI#Qffg;5P?98>NA3?iih?95H)@c&Zp ztkk(3|6)2Vx#VXhfTT#%lh@>MFL_x`R8jbMgM%N6NJIQNpllwuZ|^?+G8A7hTmS;I zNmx^WBZYgLgWT%KkcM}i+qIztSZmtiHwTFV9|Xb zd7Lgi5p1n8=z>{{ypob97n(I%0kgKxMf8h?a$S0-F|XeAf6;N2f|tn96l~^auOBa_ z)`;4{GFDO9r`>q}&RmRVr#X#(sXgqp{vQikgzF+o!sO+ zbq0$V8`?mVkvaqAW(|}pp;QwJPE1(F)la4kk`cao6E%0U7~tMC*ct1FJ7|X z-mHPCBv$e=WRfhq+_7SuUpJn?IE>%vE5adBJHXbZ#=$!uUo!=^4& zOL86EnNq|56_y9lw^Kz3=M${37^MJ)1oF`?#LyF&S}0XP3I&sch=k&yTfI9EONmU_ zoz^vkURvY!teuYVVDeZ-0Ga6jXAM;tgg2f}_aR)hQ-3=ccj4gyac5yd3}rFP4fnch zuRu!ZI!?gT?Bp43Ysj8|L{lqmNfh71|O?=88fiw(tXXV<>gvZj%Yr) z*H>D*a<;8|>Heb!dzs8jF`4Z9x{u50Ey<}CqxTFeT+Nv#y!%WMo7eVOWt;He!;?E) zj_T7RN?XZenyaCA0;yHFJCrv?lZA7CYnEmcU$(q-u>g^%hv^oA-Bd?P05X{n7V58 z!li}H(cf0Y#C={qVAJNmU1vOQ4$H0pe$i|D^75sXF)!;r*X%0}{^(5FAL#9z*|S|~ zemx=v-1qC1o_6 zR{I6}s>c8Q^S);Hia5U$Ck}Api>lpsWxIBIF1?lhHgckg+!9ew(!pG97gcugm}{x6 z4mSjay;EaZ?~M)eNrSE?soeQgp8s=cVN?F~(Tk@N3(yHfkqrRMWmq@ULthNt>)N%e ziR^J#*V2juXEmknXM3VJ`#`@#SzMmcI?e>}GXzPd7}8?j*<*dj@Wws8qldfIZ~MA( zw(2H}(3q)r!v`rZzLp+Gh=f3BnH$DwkZUob+16cWa2kC+C(mQ$gHr8=xTcxE9iZfM zdHVTw@k%8TGNam^WwE-J2J7sL8Cf=eMOg61vjuK;L@-S&e6!1{ZAI{~IljletfzOO zYNV{-9}i3U?1JM(O^IE%yCn`Acl)|bfZdSv%z0zm+`s1I+OK!%TA8z@13K&)J}7<9 z$N>>)vYu`mQ`cGZ&+pxj;wmN{)d<+q?3Sjf_5WIcsg>)U-v>li=ReR4@D+cFM?|N^ z?wt}1A`WsxoBzqLb&56X1lzIcoAxq5DviaeSUTIZxqr{+Pu_!D%ib67FZ>wZ?(qA? zp^sk5juZc7!aSEa0B7EtxTd+e*vEf^V^2_vH`mtHrBP3}p7>7tc9c+$Ws6R2K8lvH zE$4{XgM{<_%W6X!olXFdC#Bi78%O^=rj?8KZE9K3%kOi5f$qj~sVgIUUl}RCZs=Lp z(zgfGtW!DYX35Uh(UFlIB)pVds7$GRG?lqPR6DC zDqM=8PeLC>4k;OTbz1hOv8HE;CDKh1~bWW(~q+V zR{t>oq<|he)LvZc(;QuB1;m9d`fU2Fs>!(Tl$8GFeoP+y@y5h&uQa{hYZkA6diw1< zm4bp|&24_y%@*q&=&f{KopvdOdzvar4BcBbR%z_UH~bqf#pU#&Um$%V0K6Fc&W$ON z2zMi(;OYb=a_;Fu1+P!VV{`f=cKX3k4d2gErsB**xGs?h=$oDjAx;|!r4PM5w45u~ zTmJ96%-0%A1;CmqMTYjNIwmqQm9h#!K^r=mJ+*1=9c%71h$5-~s=a6wNEblg}J9(WI>>EC8SA~<<>LyA4u%uMlE!_Ml0 zo^*gx5|N}Rz;_lPY_SMSfbHqad`)U5i5L?M;X&irUl`#~P>3WmZCOT%e|h2o`2xF9 z3vwI^NcHkTW<0(+F>O=S5fsGj>FB6LiRR<`B@`X_x1ghj+{^VX2k|7=NF)^cqDl}f7yp~e4E#d1v8|vA?x&?mVI&qP4yBG| zb;}}<7dSvM(3Z=4)UPp&$-^5Y;;fj`N`QnYoJ9=ish`J4C1ZrPP;_Z1cM;^uvvZTX zqjD3uE&OHixGBXjX*SGUWLj2x>U6~#LW+T;fho=%9l7X|IVDa$drzrS94X}8#g>Q)SN6fNHGTrb}wX*BEAlDzjT{6 zPXD}+SY5jy@Fhy(NB?P(rsKkr3S=Oh=cE_lIc#HCu}_S1=I>q2B4jkjXh>`kF@EaI z88JauT)B+&0vOnottqHPR*^2B3GM#F!d{AsX5~(dqSHda%+%8k1ntpelX~uX#bASm zvhasex*bTfCYNylT)6&w1(9ux4I?x$u}4u+k@zMMGv;G5Qu(L$hVRN{4{k*N2p(#1 zR!;kU`{$2G&DC47){eFOQylTPytN;om-WACI zSn8igg(%7xUSWmn-!Cr=9SW9lZF(NUF5K91bD~THpLyyyzPnz?Bn3#r;=@E>c7fk4 zR16a01==vmTWAhcrT$jmD5mpi4^nq*{p5*vW^(eOq1|2T!_9t2$;Z z4k(e4jYdM86e2y|%F5taA5tO+>^ZSQAU0w+PxjE z=-Lx_29O=r0HC6?rGk}`fOGzSx&JRi*$<G0o6X%Ge%KNCB9KAp) zX-~+o+8qojxa&7TmC?uE616UVdSMB_6ieuT#d#iH2%6 z;DMA$JLhf~9hPR|0kwun&zZRg8OU#PCoA@G@dWQ{0)*;t7Nya9!e{m<`RIv?ck8aW zhDPj>mjiotMIJ<$If$iUmyHjY2KF0q#Kgv-@i++k|<5wsB{IcgJ>Rjqcds_+hUa7-7iPoG)U1~ zXbd4q(S^+!?N`RjOX)K~Nsx)ION@=6ouNoqBr4h_LdH9|x+dcR?w@1V5%+w>PY? z5+?_%@5tRf=SKdA)LdjCFqJ8*EO*MF@A{n|jT|@d#v9oo6WUCmh#xy`P-E=jk9i({ z@;)vNoU*Lh-}21!r%9#}0q<5WDhR)>bDrA2xI>bq4<0QW;=2{?9%#CxCY7T(YIK^KWTsMO*YdPi<~pSu@J5T6pvyS#mm@R$;oHWki%sj=70!U zM%vYVmf!Nx5$~ceUVIRJ=+{p2gg0t_&p^xTiDt1xo-~UDPZfL7FNNs&uPwQV&l*v8y(c!_0 zh-`~_XT394SL&JO9*_)@L9&#jkYb7WgxkdvGdUOvz=eN)e^lJsc=yIJ#vu9DEg^Z# zQMU$N5v4wsHm}w{pJyWZdQh@!QoNPdzlv+jpvTuAZ83fDI`7`~Q$hC9OT6Q3C!1RA zjwx7gdETk_r~FlCL=WV@@F?Pb<-n)CL!%wuCh3_p5Q_>hC{72)rN0F*~=v(;{wuYJDpBgWN$+wxBnTR?la2vtC$M`th@Ve(gaD zC8q=CvI!I$0)vAWo1XL08-nmry&wX6#Y2mOGSX7@MCcF^3c7Xyrbi1CV|_q1R5Jjr zUSSF6-9v&msBWaabPk~B$O)3PIc@fmrc(@_CY;#zbPz!WlV`O4*)H~xU(6&8jhxEo zru83~L^V4mH{E({?YylV#5?CCWAhTf4;kO#iGn(;DgU6O@yzSWyrEZbbzEgX(4Lq1 ze9~I=;b%0F_FLmu6VRPczhQ2*f2v4R78xCriX7Vr9|0lO^d>&s>cCtVKz)*TR4Dvk zI_-y7X>+#9das%;X1cby&s6mQxg|erZW{KZ{<&kdIQHthj}g(|?dP~GT5%qY3tbtw zaLc_UCr?dnJD*z1?R&38pR0V3pwwZVwd0gQaxRA}rB9DbI$Z&m%vLnfFM@ETPLx z9-FRoD(^`W6_MeGl=U*|>&9~#d><7-N*Pu6&l(`+Ik1;AW}M$VmHd_1_J@Spg1%VK z$Vi^rSaKmT@zk)j?Zot26!c5JcapFM+)dPC|1<2;v12;dm}8rK>Y0t_3jxO*-q=tcl-{!V7tp9`w z2tqx3yO7WT0GF~k!q-y3DGN=0?w?FeU1lsGITvZ5W=_wzUT4d?9y|83^+!yBl`6m? zzj6qBBWsOX+zTd?e$ZE8BF_mbxH}3P!BJBf(D#Xf5+Wd;EKrui$D330Whmw{P+5v3 z9g+Aba)Eb!L##Qo-vxFvDR9$7^sB%(0V=k_fGc;=7AF{@j#|`}LVvG1dh|nj7@60AJMsTgAL@y3+b%lb8zU%tm%289W4 zQra0CJfiOMlMGAJeKRuJ0+YR}s%HTpwG+S)LD*EZ+{I2KPF$AHt{nB?(?`F5_|Xzl zINP8;zEAI7hm=w@nJk6-;A*wV7;0tCKNG@WdMXjV$A`Y6IZ4`uo-zalnBvD?5@I<8 z>N7+|g()< zvvRIb0Rk6ULQobN$Y{1U{j`lKy=LlT#FNh8gnR{|2+S>JkKH$SYHF#iU=iH z#9|c7)S8qw7sjuD11;xH=mk)H2>jlL!kAu1Th^_)BlqtZU#WLIR({ozPVtZz^M-~h zzG;d))TsG9dyxO_IZI7UN~m!(R={aox_QuE`@iQm0#AM~c-VG=!n4%c9Ryb?u@BNY z7DV)0<)=NcJ}5rwj&_lijej*hfU{YM!-bHSH##wnxKc-t$y_jKD29-7Us;DwM2N}x z7BMQR;5F>kq&G#hBLakgZJsQ${t3xI>3QOF!SnL(iwq2C0GQh@%sb6oGotqr=LTEs zAVCUwaVr=9kPg&V?U7zwT%6wKh7fQA8EpIdW*ntHGtb1hXUsCKlcbr-Dvfw0og7^m z*I5!=m9%1q9u97ES}jZ3cpEmBSuas|;uH5y>$$IGh~cB=nUh{@Q-o=jxns3ul@k>_h@J+gTa?bW zZQ~FYmX?>d1st;aGqz#Oq`wZ|UWISY`qXM!xNg%*Q)_{$7JHRG?NnO3-p*)hhuXCV zmL;`RWjEc)Z+~pJ!qJSR1U~xXB)Azj?Nz5=hP+sis2P=5UI5_2Yn^Wm!=u-o!QS?<{wGm>gu~Z&@KPe*MQb%4N|RJR7yOfzPd)F@CmvOmuhHFF4S9_B!j|4XeJ7JSx{}YuMxJOFwyQkNscPwA<)(_?nK~ zb(4mi1J}ON+T_fXzj9b6-%TzsGsEHHnQLzxK3DFTLYV<S`vFF%)Kr^yoH7o+BNTyOM?KAy%LvzD%UT6q1()rDHCH*Owu zW$~^JteySc3MQSns>cH<4VK;CO};?m@}F5<^kxm-_h0)WkP%_40E9-9<69DO>d)bf z)u5$dbp#eOLdp|CpcP_~$TH)cpt+&wE13)bNcN)hFi-J@x3Y&S3cTBI$5; zK8PdTB0D66%_t{Z!ikL@HOQbnF(mUN=%zmU%ZUfdor-XSW2zJd_12v`-BE|ej-2bU zC^kJjeMMf=gj^N$o7f0s+VMbTj)WqvuoFfe0^N|QwXkE@eB-}#X(zn zYAQsQ#jtxj-@nXgu=H-j>)abRVw_1#Zcd8@nyOKXB0K+*F`;TwtbIrVkw}VzGw%aY z@nQ@k%6G2tpGwRJWpDHGNKH(A9&7Ax8kQyIVb~=pBS)j0qtE4Zhi8Xu5KM#YAa>hF zi@l4M^flEz5OY;#Y3{m>omUIuXuWIs<5iA!Li(4OnCQGjdHwHQg1qLeMWbi!la^ZB z>*U%c+V<*y^DXx+fAQXeoe-CwJz#wWb@epwF@t2B@0<@e91H%aBk!hfHl*wwAyf^_wRwJV21;D*|}(lsb3bZ#2Mub9wC1ul^5PpR5@V*I+0=a&$Ytsd_^djC$Eh zOup!|b#rr1-_pB%>(=YN=jJI6k|A`|MCmE5=4bES zHd(kVS~8gYCWsWt>HbrB*luzhb8_0mxN}6|2tqRimU30=UhZW?}8Vt(pP2(^oT)c z90#-@Z-^SKv3|uwWgjq=oLd{oy5#VUGU*s(FqbP` zZR+-XZWLKr*;(|Cxa`Ean_#}MYSVLtjnPV*41V9-oixlXW7OTwom&l_6))-7zWK?P zd@Je8eLlZXE?;>xB3*j9mDOLrhpiMl(-U?g8Vk92@#C17Fyl=R%1%64Y2_vVaQ%_? zCYp__9se8t4QEe%gL>|oKuwhmOYFIOF4Wy#9x!BdJEh3w4_@0P<%Au2`uM4rIj$b% zK9^CgWFb4twM#PTSD+CQr!zRjEnrCExkAn1=_MAu9%>EIYMgZ^?!y9gry#oOz4zgs_JIm_QEqfC&bVigU0^(jR?FS;79PH zoHX10l@gZ(LoqZRvM`idLLwp`MJ)v{@_Rw$@3Jk*e;OPv+4~pCj_Ok_y?*kKt(;7p zO*2<-^pUY#aB@w@7nja1*{A5Gle6<}QBi+dFP_h-feVHz=zB1QJ~Hz3yrBn>49fHr zTp9p(XV_V=U8+Iu(11Qlkvr~8RQYr+_l?G}Wa@PK88MRpbiwBE&8EQc?H}gdsJNLo z>W=0jp9iDv^LF}k?twrb{X0`lts@+WFaiHQO2#S#s$(H1He-xjxi7#AOrM4bOpfO! zIc+tbgM-30#YPYpT)9+@=I8bk$0qBZu3V>a$LbjFgD|ugxB=qdv?ACLuq_}R>Z2Q` zS#JgBO9reCb)T5+>fORVp)%K{I1v+TNkQ?p!Eoh<+8LYM1z0t+xb5Kle;_4AG%9Z- z_Ie>eJ57jaD2VDWc`Un?9?xoj%fAeA##m5Fpaae%2U!d$lB6Q{7Z5cu3xCwdgdzIf zrn|o_{E%atE1CklGN+CgBz}(`GCq0-VT>p)6@lte5|?&KukSJ>>RIcu#@Sc99{vX> z#SMRO{qLtoA?Bkz_?Kk@OGZ)lJgbw-t>N2sj$ixf5Nbj(nxjzl;Y0fNnV(+wAxLJ9 z+G*`rGg1yaaQqTcMO{Wc9y;Q*R!`d{z1CkjdT!ZHC|mL{OG-=Awx9m=B2H~me8sk0 zuhkaz3q+6oxA9e^CXVj@i>m*ltq~NZ2r@!PVppRp z76V4a=Y<+c*!+HqzBO5Fp7?u&LXPg{_s5fqMaHv$>?MM^Ybe^?jWY@}c?O4n9Tw_U zt$$lcE0>l)vq3UTAs&G2yHC1L7S2JT>yXrn5sZXPBNJxAr}!`e6Wmqg)E@A?{U8}W zK-6@pqU3EuIKKXGim{QjUF8Q{oKsTj^Oo)JBlUNs@X?wxQv&6rC)NF_uB%Bpn3ggB zf2-O3eL`ZRT+B>2zdonAlvm2_OZ8#7>gKdmVzn#}Pws@d3nsRG&q#ub{=WkH`jvwo zP*s$(D7h9dUJOHJWz`iu*R8QumX;kMLt(3^*Km4ax?#P%PF_i0!MF&vg|OTcX$rb3 z$c&w3j~q-xtu+VT%1s(FB#jNd(euGz@ z#Dk%TB6eiJJ)*n0j@f;#LcB+FD%nc(=Fnlo29pJh=I9|9N-26m%oEwBvYTHuf^&#j z3OMR11tBGVR1(K54BbNEM<*ltZT4iOe+!-dBLey18xt_TVY3vYpT*=peq;ihmEgbo zxEXQohmOq5JI+r5zLGxJu1h*JfMfH!_c2{hynOof{@<2nK3f-R42BZgpg%)dBV|}| zH3{nw$sT?N(pI34M8ou!#1p9prwIxrji|QPZbsi(ZSp3J6AZW|543ePN-vJQI2 zepocDHSsT%QoymL)R{54#tCW$W4#wdJjjsW-hHZpjQ5bJ<+oHue;zaZ2<(Vxb*TvW zH^IC!Ym6=%!D4G zydsi-R^IKq5`v8*17`lC24LAqgB3el3|RTKSEv=x=n0cM7j-D>QAl`eL_L|A?%qkt zWZj;d040KEzZnd^MatCy)}f-9-NH3`><=*xpPqBuAoTTw3=j_7q+V%un6tq4w^rq; zd+(+FpsK{nN?SSy<@+WcX#G44aR4JrI;yWpxp423~=pxXtIo8Jrv;&~io z2~II*9ovW#PFB`MJ0w*Qyx3-oF3|Y80ZY=-Fyp5Luq0=C;E=kX+z8`3LN0OD(l9m+IubwpV`7PRXhY4U$k%knwO2wFUpU)ec^M9^Yf7tZWIUl?|%7t+RMgieV8=ZkpxKwmTd6!NJBQW6z*b89$cq zQQYam44Itd<%6aSxzX?DQF0o}eO)4*Rxg_pWN>RY3dClp)afYfnEj{#y9OuX`ZvERZ^YPs)56<40;;%w0CT0n} z-V!p3!tDaroKZI7mnf2Q1-Bs)5qe0$jh?c-{DB%MoW^%S~Gp$HK zkUp>7f`e%TiGcXYOkq2JWYmvdJFl&4-1DbwKr4_z7Ujs~mcv6*39Ks>$?}{75vZ1@ z!6h5VMudlde>JC@pk_DUsW(H%Hax$I$P?nNPWYB#3K)xF9bM_AdF>p(CSzQiJEW0FAz1<_l=mn&y3&T}}T!qUU z8H})#b0CYv3U=egclUn*UWNnh3L%uh z%$HB}!eQMFB2JRxVZ40#D4&EBt1(t_y-q$J+DCKP$@zKBiXRm9eOC4D+Fmcr=xC>- zK0Yc8Rk;1$fUBKC>PDeY#%kX(Z^qg%mRi(p5!qga)eqNQ!+tV0@cDNwkmLDUeTR)0 zF<GTc2wCrmhrM?-gK?TQY*4PN-+ zgg_dw0`A7439EZ1(U?zScTtjZQhX14QH@&(7&`3TPZZBs7)KsTI+d#(I&B3FKa~!X z!Ee5s$hu^tM&#CeVhu6B_A(<3RqL%m{)fT4+udY)r&w(<$xNNoZMqIzEY$`E1UKp} zz1UqD(puOSp?$umwI2|V6Y*PeSywR*%0BLn2qH;q)f#&`_HlHs-L~8B1x?M`v^`*C zXgIL#*0D-QDpxT;GHrDNvG#3GP4T`I5ixhfZ^QtMka`|D=3JUJhOK4ildax+a4Uf@fy$&+t+?pUlC^?2XC0leqW9kSKD zGX{qxf0*v_eBDW-6@kwc*IKJw@7Jf#$S#Sa6g%y^w=!I}u=+o`l3^#kwrU+g)KFEg z(7D~yOS;~lZ@&C|-+Jt$fK(wv?A*>`xR&uNy^bFa8=s5Q`J!fY*>HIvzs&RMVXC)C zc640+Zi^s86B9AQ^y6nZwy3$-x(WMhhr57lSbS`BZTBNfBiCK?C$UzyVo-?Y1C1Mn z&nYj;_eNuwFMqH`S1ag6$7`$Hqe49U`qfWAtkLafyVRL#f%M#li^rd3@@rubJhzEP$6=NAJGz@L^(%wDi{MnjR1&?LYSa1Mw@f?v6 zpJjO!&Tdvl5jlqG0S0y$`UscVQ8??@2#lU=D|7ZMV<1cs(tBwFMz?#a)bRHtJgavNt53#c8?sMBrS&FvR$0R0jONjtPE>hMTmXE|7 z1;yRY^x`F$2%>otSK7Q|&o?BPQw1M-hJWQIKq!WnezDh^;3FsrMfsA zn({ykdEo5Or9bT?gmBvU^de&cw1@>&?3W07Ldg*ByL8mdZAa1SP4K$#bV{`P_HPSL zPjSql;RQzg0wj53vuba zb|%DJg=@YFJ3Vv@I$_AwNd%TI?UQ=0uuT6w3*`!8p&2xMthbrpmR*Qp54Os<)7S-e z`W`bcz7zvHRgTq|kw>!#pGbC~vOe#GMz%TtTTK^5PoFt{jD^2tp!x0pud4;~(r zFU*O`kW5Inzmn{k>uvqe>cMs2lQH*IEDn_>mpPv*aGSJyq;j-hDW9jsx7#%Y=Q3wLb9{QaQDoS{6LxWIQD3xnsj!KJ=7Up z9?YFvjMNXmHCX0YTrs<1g7VoQGU=?QseRYgrk*2t2hX0HbY6Ami@qZs%GAhj4vs(f zhPpt~sSAY)@g&MRd$!nSZ(8{wa?aCI$>ylIz2E5!fB!81SCfZ6AVq)4lE6SuZtXqm zwuYdnAFsUIcYeBW$=W-{@^8Iv`#DXsohMb;V`kQ7^x8$k;Q6a|2{?rZV!J&1mSKnW z#C=3Vui8nGWKMtc1bhs+JqO~_gPfQFr&+qg7C|Z?Gv!=tZR`D^qR$jR6h4Sig%$Y z6Llz(I_Ri#9v`GB{Kbi+^b&g9DK|V66;s@+kj*_9^@`gQOjhHbhZfGKRzn3xWX)QQ zkO0McWpr&amhfZwvF3Ok^B%(As*^}G=;+FLAlS}on5PXOcg{nB|kk?ZpSEB9T+ z7Y?%x&IY$>rdE$r@FK2cNUTG4^UJz0xzpO!vs!(Q9zA*!k*K(bB2m(wlScRBN;X$@ zGMEu&3=hS(EB_lV76_3Inb*=y86LSfIH%6**9__DFA>S%XC{=FyXMPybk2Ugl!-zk z6+Y=h0K_E>er?0+MSAA8gehxg10D7C*&BJzv*)f-tL=xuXjWuLXaFkv!f_iY&6cW3BHOa17onO)W%e5h(*627P+Ub3k1*^i08B zK{L-&* zeQT*2Zk6R0DN)D`lz%u2Ont9<|K! z<;7=@4QJGA#}BJ2_n;fW4Rx;j(}SjVXHaRL7W1S&GM?S35*RLd?34`lYjJ6SsSX{{ z#kXT7{zp`Qw0cd4`cEwSu!K>=(Ds(`Ol!U4*a?qV zpk|}b4*+kA&;hgUs4Qz|=HMsj+yZVp)@FQBol-e!TlEzK*%?Ww#HDv;}v=XNVsB9n+)x*|C+|Tb>wZ7u7>Y#T1wY?RLJ z*Y=XFAz7Arhvr>~vN&pNa@%BwRCdw*UpWbi4OKrp$;F+?WdY^iw#_7r(~W#h^Q-W2 z88G!sf$M=l((11uZXg^D*cHrM#l~qI#gkJL%WKvpzfdq;8~J1*iw@x>c*;R>l@<*< zcr}OSVehRu8#l(e9aCj1#O&EtGAGjFyLbBts75V8_19T{Mw0HmRTm%o$8H_*$!KJm z?6Q<#8qpZ+& zaU5lLnbNWR9z~8s)$Oa#M-;7l-?n7jAkQ0C?v_r;7KGyW&sWP{%ROa-6Wq&<<5g7+ zUI8(!srq9-qfeguSfj{ym62(WXhL4R)xVbWOlQlHbS>nT@@vuX5{ty>rtNI0{K( z4G5 z7NQ5lcTftd)gD71kyc2I4I(3u`~zcljx0+MBUKXH-W{GMhUJ~R<9Hjg1Nw-uA779F z)8`2+*I5HUc#Vh;Q6*YE5b&^v@1iqgvDggc7V^RR%x-!=$UM9;-n&&vOhICGI}@kO zG+TcZ?e&cZUyd4cf8XTIcK5>8we(0*G<;{8Hn&^H4rSX89zLua`2;I;yPk<@p$FDo zjjHbz;h=QOW?4z^IP)apsnnT*{|c*5e6XNfCUJYjV?=5g5xj3jbb*7P)8d0#w*z$_ zc}Gvmaypv;(%7aVB(}98dWS<%4w;)-q?3*#injN>2^4f6hE_!d~$VX zvpW&ju{3TZ0P4*zKk}ZgkyS?EZRuF}1WvI}z>!cQNEhTHlraD&pCF?-S)$5ixJR9U zbpC0@70PSm`+IJU2G0#zGX-(sywUxS_EB3seTDSK)H494E^I@4!bYvK$4|ceaDDg( z7)o(go6B^|jo$Vne<_OQq+W)TaLD=mpB=Nk8qd6bjF%>-13cQekM{nzic)vxeKekG z-M@EN@MQLt^ND2qeaPE9iaeL_^yb;q(8B|S=2wNGgj4u2K_HF&yR-2_VU{$r9Tz8< z;ITz+Pn3$Fl4L#-fro*?k?&glo6{L_1At8}SJ}UjV$;ZDoa}!rU$Qqc*9k_@7|=cL zv!`+7n7O(;Bsu=sKE4u4UO$M1B0<$=w|nuKbB0*&xTW3Rw3KJ~{-Z~tuCAPx-PDH; zS(e8X3&LRvTgJb{rp4&CJf!9gt3vb@N|HyJ6CtY;bU69k(GvE=aFc{(gDnqgRtCd$ z25G&H@oMo=^>#?~KLoGkqkUiy1$NQ}5ouO)?F^bj)77_sMxyOb^gP~E8XofzoeZssy6vYQJ2 zb;*1w*d_62d4Mo9-oYQqjiuC38HF@MB1Ft?c8mZw7?RW1JtcosI>bVzuS zLZ1Zj_Y}&hn83W2e;z!17*GeEe^5V2Nl|h6;d%St4wK3`b|9c?!HR*$+D14Il;6jS zGl%?!#{X#nq~wgcJIHDgs2O#eGkWxr7z#UwBy->8S5+W1N(bc_#|{}ZXw1FAe)T!F zp*df4E-snhvt&WHkG^qpFAg=CXL=zmrRmFBgRdWoU&lW<-#KC6P2ZURzUGY9Jkp*v z$LE55hI&rM*C6ZoY97fd#>s`wnj020XfJZ-58{g`87P7Ja1dZ$G(j||Xg`2xsg!;k zP~@8z7Nl{ePkd=UDTT(#_w@M1tK=4W9$2H2IVvcBe_^$s(@kR^3b6lBIu8>zxsQ3; zL=5J4dSHc%U0`mZd%>9egDRG336>YOZfZOBu0foL!~Fwg{O9{xS0*ZlU76=H@A7sp z8bj#LGX;9nN8BYQ9Ej_GhZ-5UfO?7o53_@rGX47c{L^ziuU+~juz6DOV%Pis<=S32 ze|{Hj0aI`^x#LOsC2~@R?@`WFO;@2ul=QWfP4sp)pC*4w(l@5lm9BRR4O^81B}^=J zzs`KSrsh@g{SfV;9|AMq9ZZgvePv8o#0?2MBY(;>)*v>!t})O3ZKSZoqi^Sqf4+`P{J$N& zJU>0w$$Y;!*h!GBsQyIKyDAsZZs9S6!GpOD#re*9^Ch5;uh#rtk!!WVBP~XK8^L<2oyN=3QLA zxAogTK8qPb1>oaNCVW2A7;oftYB4?E4Sh-Xm&G4daWsSdh$5IY_mB|iUe3B#* zWA86LeDB@r=LxGNlo_+9fycwbSz|M^s^`4^IO4v0IdAlY!P%qtEpwYbBJJhI9kWBH zq&Ilpz{u{Md*%e*T}XY zex&@nW>gA6`SKyZVg_D5*40dLPYe&gzJ$_X(~)Fr-fV-vcgtQ91|}d;MqGf z4rNijiXXnTH1rWq`1>pXKw$KD{|(=Gv{@R#4w>~GRxJ3f9|1^=$+>!qC%^$fZf1K+ zqnKYH;YxhF*{Pr4m;p(CBPV;jZc1QJo66ZLul%xYEv@bLnj8upbP$a0H zS6F(Eu5SIAnq37fw*0N>h2LFdElR1UFyM0$*8Ysu2w>M6WzoRr-H#%)? z&oRnqtOHJ35Bkz;jLPm@TiMyOWuSD+oR35sJCkS+ufBR^!6v2JO~*pV>`%~NYoajN zH)hb1-$RaCpI{u=D&AlHdD8^Ai{8*13=N>k6Ul* ztGARrri2URG*SNrJ#(s}pcY1_)J>RMz_4C^yuZx$D$tg2r^8JHVIm2%fATCtnXwS8 zdeapDp_hR@rH1!6iGjJ4a@}D$eZ&VM4VThZ-T14Uf}8?XuFq}R;ogAPP@{b^kO;%~ zb>4eRq0~uXs`L!3%GoD%&sjq_SNHCEyPs4x#1r*-%y-BAVaH!P+dq+O*Hbms@U<|s zGuAQHU3dXn|1qA->3vqaRMl^D(Fy_Zl`fOKt##chUU}8nJlP(RA1J4()!-$H2hBaT zV*l;cRQ5tJRpZW@+9go(NEyH&SElqK^o={wBei@9h!%w0k)|6M(gLxlORh|tHD523 z^?DJNC;Q*TN8vnC<9UtygxK6gvDQD6wjJD{ljT3Sv}%}>{&c0m?JH`{PWh|kbnz_u zci>aqrsSS)kc|o#dX`9_8YozLPSHcYVtd^U`v1thM0M@|ppzSevRfi7RZ0@6qbM7M zE^Og~1?PEsS=Z5Hm#LM!BAWu5G{{RvW2y>3?~9Gd6Xs^@;a$9V71LLW-~ zd)68~uLJoUKXD?Wm$z}pp$AUOOmI?ajuEBZmYy~@#w}QNZ=k3&kV`5~kzK`F%=DB4 zAR4GxEw67oM#v0mUbQ~iLfD^Hwn5;ngez02+e|fR*f!C=jG=6iTWM;6`lpz(L{U?N4%=a5@N80gzq6|CfVYx#o+fmkS}R`jL#7s+w)4AOlk}j7187wsZ9v(pT!oM-zv_6^N6skehv?iT ziL1|6(s^rcC0>5~HeP?HiGo-9?;W!dYUC3hg?dJK4q(r6IPX*@PE~@zanD?3FgbX! zptZy$6}m}$?xGB)h8LM#mas~9l^33o95ZtAiQwRcLvx&#ftbBu{6M)Bu!q7reIZs7js&vNmPB74SoT|n)*+Os{pvrHB3^L|f zB4R@s@0Lv%aV(%`ZDLGac&G`KKaHv5_x0yAH~-gMo9U0vWn)&81DFEX)zf(EP+$WD zM4nY0f3p_m7N{G1tlp9#Yuf2;g%v*anPg9!`em7x0eYvF8vT6JUw%-uXM)jIYsn3Z zX4=HuMuK!7PdTXd?c2T=%@6ln6g?&`MoZ7IRNh1vH|{Sm0UY*?^X zf!~)<{2GR=5iZpTRFqP)6dwG<>}cSSA*NS$GTLO3y0HNGWMbqjZoy1eZ?OaD%)Wi? zp~qY4NCu1=wG=cOZSzIyub`K08&Y2iNM6(-XzHsz;7G#1?u_`b*G(doG!iYWXD+d9 zK?3S!FLzKKg7xy@HarU^!@hIqg&0ee)Kn_!1IuY$gjf#IU(uHz9F41j2$y(F9lx$9D2{>71Kv#HK;G(7PD_T zrNRwV5*UCw>jO$iWs7SE5X$TCJwvN`4x?TM`|}Yyi3xcEPl%;AaMMgKgCJrFP9Y57 zs;$oH?D%+$%uvFk_%P5y;-|=&VV-9D8ob{g96xlH=Mhr@0&JRyd^#g+4 zgx^w>hn$+3wCU%q%QgJm;F|DM6aKId9J!dV4{8XwS#oTbdo+JTDnE(7=OmbG&>3rr z2oioe(W>g&m$=L$;tvJkqE+_E7ELw(dMgEa5Z44KE|y71U|k_BY?rspbA0LnZ`9+z}Ww20l&XCa$O!y8*@9Ro{+5dnn`D?c{rmevadik46~jft#B zxU0S1XrS4CY`#n-+6ti}Mq0h*su})E5;kbFl~<-5;<*vNb7-S_bZ+@Z zl^;zzi2U=PwW%<7*S86F%_S$rzF+O81qb44Ew^WcqnCV}R>&i#XC0_?vllP!swO?9!PsOM z6&X54>8Z1p2N~V*FL?Fc^TzMiwy4oB(WD8D68+Ldgh``lVluwEQ#3A}^KO~!_I%#9SHv3DJUP?z>WLwoy1PZwn#cQZ>=L^3s`&wl{ZmaJ_+7Y~D=gT`M=H@5jYp6OX&X+V{XUr};{k zU0|q_aOH9T`urB*N32I7mI?x)Z49!-u|Y!w){RIcf1)*+&TfHK%Zk3EWk1whUL? z1?kXqYRqpl}eppZX!jc6Gjy4bc^pGfV;(-nj0LA7oL+H=+0_f6M6Q$Xq{QwbHMdhl+ z0aO2gxC$@2 zf<2cF!@COhNuV9Dudct+xDZ65G4a$i&QEtR&6O*}fW3k}SIOD`N{kdLS{k*~dTO;516fp;o+24o#-h2K} zII#D;`fR;JsRQlH_oW-D4Gq|1oHnC*$LtB~awpF!p1G*|ipp}?uu+vRLjeF@`zh%0 z*#Fh5);G5%-gV)>gfb)m)#BiumA{$+R1DYbx-ccsFu*BegyOK+Wi1T~!+%^^aO$MW zqNddB);@^R>UuCtC91l>Ujag8-n9$jPDqBe(6fw_3=LYj5+X0L3eqsMn1vF`lPknZg|&2z#fLXHKFm)&6}>O8Qouf~)e$ zgO=HfOZO$RHd8o7of9;%lQjS+k$ga3p9gt)Z&`GeYkT#Zu+WHz*yIoP&sK@|zpH7< zT}a#~|vwbQH?0D%b#nQA$bxGuyz0BRYAyToD^w> z#pq8zhkib{g_xeQnJjHU@w<=>wHY$vrK#M-C&C>B<^JSXo?ldW9Ek)Gd_z@NFR%VBR<(5xSQ`49>UhqQ@t<;P;NeJLeKlQ;^Ezc?WtZ(g zFZo|Ez2qsGrX53aR?b|b8!SHu!RpRR8?^&P>jFY&$t+Fpdd~L)b?+{yN~m96Fz^Z8 zH3dp>fPZi2j*Rgg1@uEcI101Ny-^-8r;0J;m%0er*b28NO9&QB1s9_I z34fR^$~_bXn(kkRQc}v#niVo?m0A+;u;^td)yC_;(e3#N6i^7-z(|!}=A6G~SsW-D zBo1BGKMRFJ%Y;2>V+-)heaQ0dahvb@kDPIr-x@PGIc#egyPp(qDuq$s(YT?6Vd`m6 z^cES`0y$9jf}-wY6h$JUY6!C@GRYM{{$f2W79rI_t_(y~CCmZCi^K>h22}+xt3YL1 zE48ij5fVKMS-?=jVADVesXa^%=XGa{II!`Gi4sVRU+%!FFy-ZbS4?6C=Y%ajW@dVA zs9jP-n&oFKy{dO6?~VyC|FcFg~2YbYIogIm0^-9nGV%320rurG7hHJ|qJ za~vgT+!FV?SBFhv4iCJ!%)`iN*|CKtO#Fm*4qqTG)gdQ(`9*HWBL0Zg+!ZNI{L(52 zKO!!uFk#JWUVHIKv@d84(rK_*zv$XVG#g?FZ4NLLbF3ce+snv}>6`+Ib?o@2*HPG7 zGiBJ4fb*ereuB&w3sp9}_?~#r6#AYDQ`jr9(iKOXC7$zp+}SLrxbnAtd(p8@2QOR; zawp0+ywN@Po5=oNotG4X@#`L&H8-$Y>6z-}v0_S#F_?p{{_NVGE@Y-dTG7@~VbjQs z4f4RUH`>ir3I)z?c)!zRFwGh^qenH|DrGm1mfOdhquPJZ=c?mY%(%}mrv6nKq zFzo3K_BTb{Px&ZMc_gY$3bOl-G=i?_LQxC;sk3ASRsH_APIE5KMsy7rKjoUDs_Mx) zKVRRAlTO~Q6p{8963^3CUG~m}A0;ljW-%U8-B?Lk_B|E6mLX=)gX%_M9sJ}ISb9GQ zUY6WVNdYpyxiNK6@2&`$e4WNNwR#0x)wpMRf1A3@u?Stp3F2pCo>KfWstuTe_VCoT z8fO+77@Uk>P_z8m&i6vE`6Ag58saz|(eg-xkxaGygV-V%Cr#y0Bgpdfn#7{&JiLmk z(89bY3;DiyjX>DWO^diJu*Su2P!|P3I8FFC%H#b2bpB)RRn5D6xsJ!Ls#>^&dNWeg zLe(FW@D@ic6U|j9TxS1G;@;R`iLG`Zk_Rju2!T-3hy%tS9vDBp<4o+Gne)NN>W)*g zE8BOdkRqO?Ff_%&YXbcNrbxJhcfg7T(12P{SkaL`2cvPZwS6nu>kUW3l^FlmqF-Y; z-;F@p-rzS0zf0P?e>m8G#eoP*LA9#B-d3{Z%i=VP-7>(J2+FswPk=?`|Zjq&*pfCU`xIQt+L(=?CTtXw6&|jw?*E(>P;)?V0UW3i3&33e=;O z?sf#6lkR{HnHD=ZOZigZ)O!q;xn93|psU~abs+{ro<*SsEhD9UQa)D<>g5qf+IE-_CBNDOFAw`|yT0OZyjs1VT^Ar%oLj>J~;xOG(Z z*S{ApLeYNC^YdkeTXjD?%aC6RmWp;?fYE^5f*fbAUzF8CmD@o`2dNC~iKtkB9zsIE zwYy8y22yc&LsKI+xEBc+$#;j)3_%#dFDmQbK83_jk`@c=!{v%e-ac(4n_!Tc^Motd^u zDtjm-c}44DOWJa$n^(%Ma^FtyUJV*yIMsbjxt?mTR+s;20pJ~wYUxP#F--~Oy14G1 zkyOT#ww-oPTd3na;OWC2W%9&}c@o+IbZ;;Xb^??6b(Lg|-;c}m5hCuB(@h+1d(M;r zUWDYogusuVS(b(VX%1X7679X$=M%n(P4~bJ1Tq=+Q`|_nX|L>1X!LJy_V!bUk2>#m zU@`5-<9)^(kGvfsx2xYsEu+2FJSQ_WMlMqiCuW+dyNG$_q}e_a3gxAEI1?h895 z)kK3T9Kb~1xq%|$yxW4KE>1k=kO<(A(YCr(I5fIV+ zp)WTEO?dqFmvV8~KNpUko%qUciv55Gl3gw4-C?HA@tP!JVRlIBAJ#Zia3?qS!Tn9p zmyR#wUVLqAVkj$d%5l#)kz|FCU9US$jBt}IfwA27}ggg(AhNq3#vP@TvpxB$RF0d?`1(GGF0n8g#< zTT?Tb+JNiLa4*RIU)f18A7t^k6C)bN>TE!Xk?iy=EGp^%EgSb}-!mtgz5A_}8Eyw@ zs4F(6D(`&no*5x4)zbcP3#bKRZOM}hjsD-a-af#h&&RgO-zSY|`{A37N2cvnGv;C} zjVyxS2KlVp7a18D6eAnCOudj-v6dyROx5ns- z_3BIV#ePZ_GWz#=%&)b6+D+=1H8Qq~UVc%DbDoY&DX&rN82dl6cOq{Zhf6UpT(NO$ z@`#&~rBXT4DPXmE-}Nt~<$1@y4^M~9#r1lIt;G04pkbd1|x0Ig3uECEj zu9#d}3LoOQOLEAN@%uN?Zt9(;%K=t)JSYDXqaV>#4lx#d#XH!d5xq$)|uddvu#Jpbf&~=8ZVBc$_E~Z@FrP6hA0? zS1-24pD7@6FQ+V%Ku3k}B!>}KwV`&`%D()7TCsmH2C4y(&laCqGW{TlWVW;3>+dWh z1i7K%o4kFo1JeXR^dr1$A^y&ygY z_EcGZm}c^<>b}c}V7PcE=cx}n>{>2&`m1|pmAlT3w>D}E(8##$kh&-PQ$FlQ&d^0n zjkx0%+7kbC%HkdlLIG13E_YMB-ZBM6$#uqu+i#qGGiZP{xIuVKP8W-dQ&(OwaaMOX#5vd1wlQ<=jY+y zm^wV>nLZjt<;!E2U)kT|hczl=NP`$5?87vY~R1eYixC2B!ai!T^_bEKI+N(t7eoIOb-DFfadZS z;t<3@NnNs;vU;xc(Ht_x{J>?3!yil?+Ix=UcP`Z1U(I~TV~&q)wdImueEAXf~jD>;zy~E(?4B0LwXWp ze?p^#)GOxS5OE&j9B-}E_>rDC>E=9%Ua23JDeOaVRQD_25nWsMYyZ+O@P2R0A5WGZL>!YSESJs5*kbz5?Q-Q}@C`I3PqD*Ixl51~Kq;;vhL zDeNYFP^fuoZr(34HN!i;L^iR1kIGzi{qbI6H*DFbab1F}3aLp%oeo*1ytR-}0oZye zr`7w%GgE!*qRRJ6G9zL|ASQc~gywyo` zK?4U4%p19$pBKYp^xo9|f-_BzXZBjRX4;6pCr+Q%hsg_1sf31S$V|L^U!Qk*E6!8a zE)rlNqA}VM*lo29FQy3jP5MK?#xl5i?;5`e#X(-GNHy$7Z4~7=`kMDNz&EQKh3P5q zSJJI|9g9h zLVHZRuUNfDzGQ&>L9!-AK1fdhJW)N6{$~0)F;2u1buo3s`S`m?XQm886M78%U@&&# z!5#1x<^5YgFY11+NwxrnY>=KfY{9OL%F*(%hWhQ3$!(er1bk ze!zyDDpy^V$@E)bR<|Igno6_Va@CR(#vX6 z?>1DJWjLt|e^wz8XQs5*KmYZfH8PO^7Csvd#2+Bl+rTrU(8Gm-gzqcV@%= zF%|T~EURNlbsn$KM2A>~`1Q(qX%dcP`ZJlO2upm-*hPo#`)Y^W(;gsko+J+3f|GSwCV-G)$%??D{|U z2Yg)Zi#AhgPahHY&5USlxz(oI>2uQ9xx`4^dfJ*X&9ch~*{k`6{WZxq#? zVxD|LI(n?|`~8ee@G3DN!=v{f^#eL5c0){p_SAoQMA7ULHkf zRpxwm-4v1T6$Z;O3Ng%5o*_2%`Ap<3?+@@Tefh}2gO}Ai#)@zd-wncavH>1Fj{xe@ zP#2h|IGYfv;7!*sh1!*>X%6EY9U>e~FTJedbkf4XFZg7H<7tzVRmCfUXJ5YjBPwv5YIpvy&4m=eeOCB3$I468@0V^nQq$r-)+st;JU3@o!9`aFS<^USc##` zY7>qu(_D6c`{n|d$JWH*xC#qXY6-l^&H~d10&#W{#u%)oD*EImA<$)}H1J{BfYP&v zI6oc;Wz15XrHOW1(oRwW(44{mHfn3K|5?)hjlj?970uSYR@q)5 zh?}t#p9H7vtgau^+~eDFbM04(MsH`a)w1)R*9sd$(lt!oyyN1!q#>xLUVx~F1?F+4UJLw@ zl``5aMZy$RPygaNO4a!I_<=W_=w>;{taRg9mjnln1O~_Qy9msiHi=qIpe)=omM&ml zqI1$7u;u}PN^I;QbWZYeTR{I@XJf}!=FD;dR7YQJDla?T_}BPEXXA(-o%du9AyL@^ z#R*XtI)oQhfq{X>9HQEsE<%kCsqplI!7ip!iy$3z zTRJ{O8$#;GVVse32ecJJ_pk2-EW+N=QDR0#g1Cm{d+C5|3CNv)B9@CG6$wzm!SyS% zwAt6(A!avwRJ1R$&e9+^eu;!>M6v{4`wQR$ai}i+TD;!5LoIVEa5u9lFW!RxB;_-9 zviRK0V4lGc8gaDb+dFpbD8YgNR7q-3xWG2#i+2ykhZdbGi3_f#Br648CgnCBG=7v@L#VSrtPyVG;V3xX9e&T|?w{D`~nzv)eOkmciP1`%r-@c#H zj>Zer`;lq^*NIEdNeR1SgWY+Gl5mp9b=}_7VBgIz*5V3`yB3qaT4$d)Yc!HG61RLY z5qfm9S({d@SmA*LL&yc$T7)UzV~R8R(W@x}j*mS?VMC{kcNr@!6g?lLoYYiTKTgFk zkZl5pakaEEK6Knh`uzIp#u))f(lBGE_<)g1iI_9XL4ks)d<3LJq{!RErohBAOV%Ob zz&C)?AIQy(xB!j5Ja6j8ic^FQq3(p96Iw<%efBGy;B$2n0~(p09U%Abz&Hiey?XUZ zOq{w;JVQ7^S8OvahSM|gag^a0!6M0Dp+*!lam8&67wNIa@!h)>;a-QBj~F*juSsUW zfLr1=J$yJ;P38Xv;`Ef>Pl!seuy`{Z}? zch&OKbg#)4Sv3kMI1H(O8ZS7EaY<1K|!d!|ct9$R> zNWV`35Vo|o_UP41*tm&HI`4(X=w#7s9kz;(WL}>W*8N+<7w-+PLk_-)wmh$7@o2xK z+34TRwwKi=n!U;|W+9UK$%Ts+B@Nn+y#;uRZt(TSpFc10g!{f}uJdk>QO-PSAIwKG z$yjk{?839!-meVY5UN2Ci6s@@rR$gI>$`q7_*z?QfV~6$`1+ao@2aXI%#t|@^^J|2 z)4JkRoBK{mtP=y7W=deGxw$kB9T!;Cv}=p>dj3n)MM$7cUX29(LOLpbfwJ{gkKyI7 zmI4?@0G`5e{huoqGx?U9{y>W95-i=^LAfI`CWoS-c+o`RLPMq#o;tP9FY_zM@#uh9 zW3vsZlCg8Ixw89upVpQZo;aqOBwRi`l>S_DWqoNqhoEz}ZZjTQgaX{b+MYOjcCovA z%3hD_>@+fH$xCFW@=|fV229X?lq!kZUMi>n-jFbP5{|>=pk%6u74)Q#f`Mxb6=IHLBS$H=#XC(IMm_9Q9U?t!SoEQ)g1;)l zpG$}kx7KZB0CS|t%^}9ONMAsZCGdbb72@+ z3|WFBU!))ro>&Ygm{_zC_9;k5Quz#!mR?Al`Dz*IJndM4bIsQ-E^KSJFG-n+0xmV? zo$%6QMb$;_uGngZajK8`7ZyNdYJnc9=UX0&?%HSW+_~IN_^y}@T>w3W#SoRhpobY) zu6#U7c<})y3kjGICGid$8CxGTbm#)mYS93SrbS2^kmM+8xBU*kJr!hU_ndDl_a`OI zJvTWfVX~~{t3982Xue)uKm2!D$-iY?O?JI~^}sUyWck4N9)ah$QiLmXG0alZFlC%(XT1Bw9VkA^-BOl{K|lrk@^ zXXo}2aaE@`ZN1vI^ox@6v;tg4kB`kgBN@9$1!<4vWnEK-TUkxzGuljyen5p7rEER} z6^xZJISw05NE*{T@_CSWDsWa7MPs)OWFA#YoV|=HT)emsLNLmrV;Z`Tit8RvDmASb zE8;eYsWQs3zWM)fH3KzZt}qRGvuwVh;js`0@`wy&P^Z|tH?Vj4>sv*=pnE|3$I}(q zN_Q|(+Dzy~zO%34Vf@O}xaHQQ4G&tMvh>a4>B@v2El^*VcU^PnTEAJ}`)7Mt%dm`& z5pU-q=J_9r?buCrLUg=5X-aMN2(0QG<5W2Z)sU8Xy5(@2jNy0QMH}8gnIz_za$CG` z#LAucQZF|s*dzvt^l}^wKS|t_rVGMGPOLUO$Hb)5s(XxCH#JpgJOT!DV*yW!SN3I`qIpxMaC12-qPumcK z$kf`PR@+nbq>LqT=gMn~Y8Gx+18k?YH4fWmFG9kg@Xexf}gs zc-Gn7dfDv`85R52W9W%9h4v{LM-wu>wkSt`+2foPva1Om1Th&cCBbLTf3#~k>RB5#L%k36>V#CHwUy& zAMR{yUz0p?b7U6QlHUIcA{94VAw&vU(3}ib$kxHYr5;-=eFr&F=n2zcR{lhK>PJ2) zduIGn{)|9>_%!x>F!$3}K$m<2wgP4zFSVr*&o5Uh}O+jJ|)`zjOWH-pCzU&i3L9jKp6 zxdtEl4mG}hFd-}YgOp9V^B#7P)YjHgi-wuRlmQgkgk0{^x9<=y1;qc>Sg6F z3EXf}_xidnB1V^Yj-R~9#r~x2nVZcjnMp5z}sEz#lvtrm5OD;VU2O=m4FEg9?>LFX?+t3^X2a+uJeMH~SgU||z4n!{Ha zUZ+M7CSieVzcS}>Tev0xO*y`}Hi2p$oSS8Y5;p;E?)i-JIuIH<3X?G*=7goJXD##u z+g1e5tHCbC6$G@PE?VE`oV@8Y#k(p33ZUa)xKQW#SZc0C9Lt)PJVyP|PWGZa2emp* zS99Xz$-8`h9XbZq;PoCc{U{0J36sODaa)Lo4O;C=keFqkp-+T5o1^yxfslMFx$~_! zG~2eh@UbEO?iUmo=eUm_H!hJmewuH`r%?kC9<8Ri)xY)`K#D2->V(6C%vKFV{5uyl zA2CT7kByk_vWvCjGvclaLpjqpOEkQlcm!xM#m+4P(%IQNPeZoKYi$9sqYRmhN{DJf zfDU2+ioA_$`Wwd0&(7L@k|y8jS+p)P3Zam)dD-#@Y?=q*ZDr+Ldf5lhQr~5+DY02K zZnCmW_2Hm1F4>|yytP3wt3fHF%Zz5n<{NK+&XIh=1_31JT@?d`E*IuZy~*;KNA1(Q zGa^jaelvUdeTI3)h5;At&BtMIk3+TYchhS+rpX3r86}%Jh}DxDUXHNulVG7fz|m^H zjm_v=w{G>-Q{5$Ivl;Xe5@w(4d?q2u;J&Zgkoum&r!>b(Aki&%a>?*;X8$W zQATm{y((d+MwJ9)bMuo)G=(Q0R*HuN^YtG6`laR<(h^~FNiQKF2xeYuM&IGet}Svncl*Dp7RNh`59nL3m)fXg2`ntdy^YrHiuWu%H^M>EltXD{q* zI$xq(Ct-t2hmvG-Fas(~A<}(dw|rk>&072Fv?qnH_kK>>)XDv1x$h`=Or|U_OvgOm zO)F&Y$K;XLueM}FM?HRYC$4{X$6MAbB)%jkeBWeUG{JlGlrWKc3HAX)tc}w(O-&w+ z>$`Ku4zkUb1c{*b8{=%#)GOQ8LkGPr4Nfj8^R!v{c=2JqcSg#u7gn%pefOO1HsZ&L z`l|<6mc6>EvD(6F#0xJIhe@iT?&B+)SL(Pdu{;~Iri1FgWyxI%Gfo_pnkPQjJ_)v^ zh@dlMaHgbp_KEY#*?o@JWERw!U=T$!aGK}1l{4l$p#Vf&944N4Y)Ay=lETsF$9licsVW&fq5onA^M zrh$IfCf{25%=6V}3-A3Oq4qn>D9)gDUT+acwWdD1P-@X`n(S0G>i_#V zD}Bp9Uow5DH1P;6uy0t3dZnphXVci!u>Ns9j6+JxetnMXrTXuzxW&KQOzM?%uFm;Z zYFj+IUr`T!L!0j@74@zswj|i7_uAS=e5$9GolT8XwB(+bZIUdlFPZY@S>4qkE=%O* znFg8$XKvQ5T)Co1=}x@({QX|``rS``vN14q$n}!zpT9P)4EfpmVRzxk*pjV(JjPe9x0X2{ zw({E4D_R>)JRNq_=xrPKY0v9p-urreNA$~}*OZMFzx!SG248`uI^*rmp@9c*~2E|4i!ODR{o*l85Uzjo@dWU&+O>~RmGtZ@atPlP(eLlys zZx?yj^!lvb<`ZWwVzDG0t?19;Zk`8P;}U3br9^cJLPFq0P`Eq(+HD_yd&ikfUY)Z~ zvE0zajKJk^NM>rD0dQOJFW_)wq zRv|V9hn)ya@)+@CZF;rL%vrMnHwzG2Kxab*{{z^}4=shYL*KExCh1)do|Jk6Vg2zFRf~#zjOgw z1l<=s9}p8dE<1C))pyg8;oD&@UwG1cJSb_dm@M;^R2tDh{n5mvkeD7pO)sEG(ak}$ zqlJB-_yvSVpwVKU9LQet{rItC1%J|a#Pm5vMuS6lH1xog=2Ak!vI$8@@`c$bZ9VHB zgpBnske^lN{6Q37$fU1fqR)6iJ=MC>2CyWdbi}7shE34)C}vnI18(HnC3e;PgU)Rz zYjDN>0iJaFno0O4W06+lGD~#8&}AHpN1vvanoZsKJfQ>dhHzb$2kf^>+Png#++aE@ zx3McubEw02p)y=a}W3}R*)YPqk)p;xydGh3m z!TS%@)vvD}*1+?9A%ssbUT>6^smEtyZiUl$;5|HMsYexOnWD#VNGkg@l|6?Oc3w8| zJN!W8s%5K}1$^%n<<;6e_jumQJsaxQm=Ewsbe2qU^*CnwNX~hX-*L-Fo}ce1e*L;+ zqUrQ6bq9?Wp1#q=EOF5HzV`C>G86}8XJ5YJq+)M+#P5vx=-($4CY%WDG5dx|AvaqB z+}(!t45E{x=Pz>%LH$;Y58C)bBi2U?gEPr|hb?75%Y56OX+&I4-&I#vFZ(4(X>_aI zvEWk`j4}pjI{@Jfz9=gyip8Q7io%W!=JV?^2PDcuYR_zVya|n}FxOixHi;yVyn$EYDC&D^=HE z>&Ek0P3Neiqa)^Lq0g3rNlS=63sJ)xFb5==ni9}!YTzYeat}V|Y*(|hQ`egmgg&CO zr8k@b1DNT4n2PyCU|=p-++v!?Qy48uz}KML_2o@7xcCoW{mpL-e3y;(S4>P|+W;s> zkAO*v*7KLkG*(w&4#DoNg_MrwiFQ3`3f=8R+eYUC`X-=#3e3~+T#8M?Tm^ti__v5d z%>#v2ucO$nwGJZy*ia$&1WS7!{9DPDRJWDYci{W!KFB79*#NO8EwW-PfbHA47k1nWg^^z=c zH{W?$zF8yaORw@|<7Qx#{Xb$^cwA)erkbkQVh5?2iWlt*=*)Qm%4VDt+K+nt6xAL5w;(hpS&w26-wV{dpR(J-P> z)Xww6?T&0fDd*E@mn;@a>uhd#F*l9M#`aL>0_2}AYd~UDe{F(}nh(i4+I*dA+ z)=tGn47j+fyL+N*s!YCgh-Yw5G2VPwIu3e!D-k&lD7S)y$}waG)6vpqm9#=6Z;Z3)h6WqRMUD&C) z-r{II|OOYC2!&| z*v-6IQ82Q9P2jypX!}rY}v$nq_f{ONGglYzMoavO#47d`JseP@4kZ-Tr#7lhXg)tJYq~|pZ)B* z2O5mFCsjer)%Z^pkr&1{)T8-;P3#e^tnZdYkQ;oBzC)Y|k$-sg>L+@9OuB z_kalR5z%U2YKen7*`13!>&@Kw-x{5-v4|4qxxbt1?B?0OEwum-{MjmI>M(3zqbK^L zEiy8?o!c-l=vo9E)nBm|T4j*M1Pp}0Svc4+>k+Hf-0PzT_*SA*00+PK@*+u0MnVPl zrU4+C7g*{+$-{bc0-{_jU}LNzoGFOOD~4=9!|TuDB4XE8_b_Ngtu;>Ie{6kh>&-4d zu$x#7cjk}%@pI3N7fUFHy;B>gZ3h<34~duHnTgd6l%kYbRXx%cUI0=9buuitBcQ>t zm=9k&ysxfZ!r8OFmZH8G9xJk#h!0T8<@tC2IiT655<=38M~EB^j{wzm9P&DdF;k!>$;YQd0$U~E1Ts) zXk^E|`iG;#)a&o$nT0oRI_EOtt;OnJz2%H}B$Wim(xeaN=gG9Q+g`}__dhu>^L1yxIjK~leKuaghZannm7Lx6F5hUkJNci z+$vdifxEdHfdRo5t#|>pZe3f{a-g-nz5L}4Ny&_|vL0*RkD@0s0YJ1Ve)b(^3D>-_ z8HX4JWHl!%Oy52BDgEg2X5UeYM8lyNm3a~jbwSL8vcTd=E3WuW^|^;7(dB>&vNbId zMzlosDZ*F>R2D|aqnf1QV>m&y8Z8d}66D@w@G35bupk5Y&b zH)}RR6htiI2sbBcQM^@jjtY9pSO0CO84CtAe}}tm*MDu~E0E$1-6|BaJ4W*1$sb4Q zwf)1LcZzPBns@K}Y+u?i*6LJp@!+3%zdO99pHjYkkXj>X5nwu8LCxfUJ5oVAmBBe^ zwWz7ZlvJWuRr^(Pz2c3|{-~#C2kh1>cD*p#MS0nZ zi!zOKqr4~giyh?$^$gzd8?F5;pgquiSw1t!S@z`d`V4E+rOA6oCxlC@JluDf)Uo*5 z2sC*w%NiQ01oySyex&VxeBuy?yU!`7_5D(VtbJ?aYxX5+=~OQcarkkz@EUGErV_0+ zB{gXfIB>Qe5H<#SUaJ+|F`1JJ1&SKQ?}_*QQY>+pCJav!kd}Xr`O?$c8g-aXD|`N% zPJ)!V0VQ@VCIKBwAD7E{lmNVl;`{iuiQP!zvc2cByHI1;9`Z9mwG zO%y-|Tk$(|NK69CXDXjf<5&-|D6C{CSOR2m19LgB9}e_vH`##V8j2&>p&g@ETot0% zn9H;pgQx+kbVB{|ksBc>DbN9yh=W1oRDi6MX9tBK1k3P7d0t|gdRuxZ`7-7Byapm2 zQd8f%YM{g}>=SrQ@;ixt9#EIoJMd1!1AEQy5w$$N6;XSa*uVUdT4)X}C#uD8Tjyn6 zzn%6F3wSbob#aN^Oim=&IP-eU{R*W~w4i^;Ip6&sC|u3u^6y)&r>tbybkC3N>Ehg{ z`t{c7*q(Pk^``oGdRzN5I0QaxefngO^t0f`BfIS_ybe|JjuG$L*ZuzbL}&b$T|x2z z<1R{^?wc>aZF+uCWHLW5VV%T;L&-A>1Ij#S`O}EB4_z{=vjaWtvHuHt+ze0mw zEIDs7T5m*u)~LVt)-fac(t4wyzHPMmUP8^Ip4UrV1TvR$zbtuChKX-;cR0gdT%pkV zJhs-3Hsi%#nUSr-`xdt7tBwWL$XM(-*3da;cUgu%{$RxEzk`*1)1RURC9}(`Dr1_> z^i;?8{I+_2RA@D^aX@5bbo#FBPBxVX?Fj-Wx)ize9!*iRwbd7HiPvGVNwyR}p#1Qcn0 zo>zn}Z*z2}^dqs`7|({i7ra*UFW1>8i1$UPKpaU40cT}W;rs$NVu!aSTS)r}*aUgR z8#iniWQlJ}&MlUBsdW(!0+K9t_VLVGOxk|d-F*{eEl`@Qwkw1}2Jn=Ke9{OQ7O>tC zx0UyiV}JbJaiZ_RD4tQGS%w+~EbjFkjZp?V!nY=~kySeW7UvcJp1JkR^<21MEdRPk7dudj`IpUz@L+()aUv=r7DP51OULD_Gkrb8HJl9ML_2X<)b>dL5GVj6^klgL3p4U&Y4lR4%&T?)N}08F-m{&Oh$ z2=zCnvF^v=hR!M9qyN^}fOGzidY~!90Z(uvN=izg99@WmsF|>YB+s0r&w;iLlXwY$ zaRv;q=-TH+Mbc~6bJe5OCIhtyorrGvgdP zqTz&$5GJV@tqdDD437UcE66Eyk_bS?Sv^3$|0qaxAs0acY|i!znm5Q{i(;FIAg=lN z0?r`=5FlAp-@l)%XJ~5-%mQHGeGXgmDMb+&FoBv~{mAnfDsy*WE;s(eZ43?$B7V&N zAm%Cmgy@P08@K2g9XLRZ%{4~v8DVrC(#tXBIS4g|I~Evx1=@g?(V!o1I+u7FXbaw% zo3K3KD%l2|YgQn*5HH<`H{2KFfe7IZ)*1SYE0K|3z{9rs5QG5-2fooMOlg&O?70H{ zEd{{iV|scwy4DeK!d}Issp$Bt+irpoqn#8AGDw%?k9IOTq-?_c^ohnk$9+p2VZVyg zC99|A2U0%#HOjHmzI57_LGy;|#7%MGZ2b?BfBBB+-edj7ZMWX|YUP1EySafIu2Xl# z6WL=IPgr^q}>zEsF8unv~Bem;ONo{L@mWD&C`<5(&>fGA=@C=W0R3*(~R}^;J*~(r7hLV(jKDV4C6%b94QBU_5o|H1H2eXQ6*-> z#T4L2h`MSKezoy~Ma3iFHRK@y3xNa^?*_VIs_-WPF37^p);l}Q!!Q6H^!C(pS;$Qx z94S5mQOt!m;+4LmNYHvId<6xEhnRj-Qxm@#W2@;+sx@o)D%47n2O7pYERqcjb9?{2 zN0N6Y**?BIh-ws?4I&~J28eUf(_aXp%kZ5Ezw<%N=~jg25X}fc2#oZw(A?u+bU-=& zU;U_168TP!BLA#84%(FIUtWi6Z?b6rR|}AJMp~T7{K=^Y?rJ-Yx1E@~aLc;tB9CT9 zL%Eu`JV>| zy+3!Z0|juXMBpVmZHbb|@2A8LJM!&3b7S**!&gJB@2)itznA7TE1kb?C}dK%m~^f? z-zi`VU6dh9=F+&;PLnkKbs-NtXP)XOF$NrZ8CbRRyZS4C&Q009XD*NF+&UlIc;dZP zROrPHXH<|*=gXV6AKBA{1DcMfG}7eqwlt5qFD?FGuN(^oK`wMEBC^?I-2X(r>4c zgM=M*7aZ(u7smA5bNjEw^{;vL>(DVkrv>`Xmo8o;ntzNZ`-{mnciUc_I&xX zu<*t_LJne@t5EiLpEuBTH=jSt`d`+)V6i{w$YEZ`7;j$NR|c+m_e1;kQJ2>!axq$o zHOF4$pjiEDSJkL$Cab}A#i$msjZ=Xz&D*YC>@whmdKTmDK)W!K3+<-OpD?jvTEvlo_!fdRHc{Cc>%f_Os5r;zvY*D|LqG z!@OtuEbnhnJKnv=`}tMc%9jh&+Q!=RnLcP3d#+vHQ2r^tCv?9I=g7bY6}Htkpw3Fe zZtr`n-B4RN&HHoH+`Z+5AIHvh(kL|6ycePP;%XUSdQ*ye{q6n6EgBw`lB&b}AyTg$ zH^_3@1#i|p@3}1@RT;JYGd+{TgWvNjKUUJ<vR)I|!NTfRZOycX)}2XEMIj$a$!H&>Bjpl4lmPxaNj{lfg+ z!vi7=lto;vRmB&C!^Syddw6;-8tR*H>hD&J9rzkpp5@fr@VDR)E#FMST+)p-9<~0Q zIL-0)4RCZa{N{KFBHVi8z&b!PSe?Q9;?mu?QNV<=Ahhh9TE+P!@KiU-`nV(v|sf31` z&fH3_O=DNmY~fo5Y2&rV2YJ~7g@@Mz(sSNv?B6Q;>F%Ln&OLW~DyN#gMJUbhCUBLw zPMDhtU%BZl`(TDiJ!7`fd-%qMsBuXeF)TV65r?uj)mvY3sD@T}+P2ZWO^PUZuaUKu zyX};IL9J0_XeFnJX`4}O=bsR(wj&xh>}wRtOWu0tUS;nOeewL%Mw8W+=Ub^VZyUPK zg`clc5tB?}tQ@}y(Zq%){vr~YtVeweC&uF?g>ql7-Px!Y7bCjwV`rndX`Ela<3wSt z#ksM)e?qHLAfp-ZQa!4;vcIY(iecI0^QYYnUvq>}3kIzM!{xC%z2VLlc2=u4<(Laj z_vxDgO1S8oME11U`Mgoj%Qs9SL`O@^cfIhGyg^+;r=Rn_f17b( zd(!Qp$CIX`iWYTe@;@hEDoxKxcwf5t>fkM+cyXON*h#Y*o4md}#NyIlkuO9BLu+>X zgMsyO?#2{jgMlrYBS*z;M60i6C~4Crjg*UUM z_-1n#7nq)(qLN~KPpDbQX19efl#$P)Ue#&aLX-Z4J9s2vd}?ZUfR00~WXm4f9>;5s z_qo5Tc&GkbTYY%!$aJ3h&?(Xb{#TIn;cCfKkq>Wn3O4o58Xe6@-M_Lm>w>_y{qJ@D zuI$3%W#`|-vZR^*NjmM3!l7-H7nTofKl9(;{iit)e1662+_YPkwZ^f`=|NDbW`z7iz4V4s8 z{zl70wIMD(uGouk=Z*q%e>0YdC7a}5&T`-1p6V(A+SpiX{=Z+PZJo`PsE1L_8l^1+ z&4b^+YeLOP!ko}fte%h>g46o}CjTAK&{!6pBg%J_0%6d`>YHRjR!{m1gqMI8iu3yp z^FKN}BB8|ZZsg?r9bTq8G%v+qvvKo(uXf;fvz0WFaR&tlUyY2kgVPa>ⅈDjP3%I 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 vkcpUZ7PVq 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 + + + + + + + + + +
    +
    + +
    + + +
    +
    + Resolution + +
    +
    + Iterations + +
    +
    +
    +
    + + +
    +
    + +
    + + + \ No newline at end of file diff --git a/examples/blocks/src/fractal/index.js b/examples/blocks/src/fractal/index.js new file mode 100644 index 0000000..1807471 --- /dev/null +++ b/examples/blocks/src/fractal/index.js @@ -0,0 +1,163 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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-charts/dist/cdn/perspective-viewer-charts.js"; + +import perspective from "/node_modules/@perspective-dev/client/dist/cdn/perspective.js"; + +function generate_mandelbrot(params) { + return ` +// color +var resolution := ${params.resolution}; +var xmin := ${params.xmin}; +var xmax := ${params.xmax}; +var ymin := ${params.ymin}; +var ymax := ${params.ymax}; +var iterations := ${params.iterations}; + +var x := floor("index" / resolution); +var y := "index" % resolution; +var c := iterations; + +var cx := xmin + ((xmax - xmin) * x) / (resolution - 1); +var cy := ymin + ((ymax - ymin) * y) / (resolution - 1); + +var vx := 0; +var vy := 0; +var vxx := 0; +var vyy := 0; +var vxy := 0; + +for (var ii := 0; ii < iterations; ii += 1) { + if (vxx + vyy <= float(4)) { + vxy := vx * vy; + vxx := vx * vx; + vyy := vy * vy; + vx := vxx - vyy + cx; + vy := vxy + vxy + cy; + c -= 1; + } +}; + +c`; +} + +function generate_layout(params) { + return { + plugin: "Heatmap", + table: "raw_data", + settings: true, + group_by: [`floor("index" / ${params.resolution})`], + split_by: [`"index" % ${params.resolution}`], + columns: ["color"], + expressions: { + color: generate_mandelbrot(params).trim(), + [`floor("index" / ${params.resolution})`]: `floor("index" / ${params.resolution})`, + [`"index" % ${params.resolution}`]: `"index" % ${params.resolution}`, + }, + }; +} + +async function generate_data(table) { + const run = document.getElementById("run"); + let json = new Array(Math.pow(resolution, 2)); + for (let x = 0; x < resolution; ++x) { + for (let y = 0; y < resolution; ++y) { + const index = x * resolution + y; + json[index] = { + index, + }; + } + } + + await table.replace(json); + run.innerHTML = `Run`; +} + +// GUI + +function get_gui_params() { + return ["xmin", "xmax", "ymin", "ymax", "resolution", "iterations"].reduce( + (acc, x) => { + acc[x] = window[x].valueAsNumber; + return acc; + }, + {}, + ); +} + +function make_range(x, y, name) { + x.addEventListener("input", () => { + window.run.disabled = false; + x.value = Math.min(x.valueAsNumber, y.valueAsNumber - 0.1); + }); + + y.addEventListener("input", () => { + window.run.disabled = false; + y.value = Math.max(x.valueAsNumber + 0.1, y.valueAsNumber); + }); +} + +const make_run_click_callback = (worker, state) => async () => { + if (window.run.innerHTML.trim() !== "Run") { + window.run.innerHTML = "Run"; + return; + } + + window.run.disabled = true; + if (!state.table) { + state.table = await worker.table( + { + index: "integer", + }, + { + name: "raw_data", + }, + ); + + window.viewer.load(worker); + } + + const run = document.getElementById("run"); + const params = get_gui_params(); + const new_size = Math.pow(params.resolution, 2); + if (!state.size || state.size !== new_size) { + let json = { index: new Array(new_size) }; + for (let x = 0; x < new_size; ++x) { + json.index[x] = x; + } + + state.table.replace(json); + } + + state.size = new_size; + run.innerHTML = `Run`; + window.viewer.restore(generate_layout(params)); +}; + +function set_runnable() { + window.run.disabled = false; +} + +make_range(xmin, xmax, "X"); +make_range(ymin, ymax, "Y"); +window.resolution.addEventListener("input", set_runnable); +window.iterations.addEventListener("input", set_runnable); + +run.addEventListener( + "click", + make_run_click_callback(await perspective.worker(), {}), +); + +run.dispatchEvent(new Event("click")); diff --git a/examples/blocks/src/fractal/preview.png b/examples/blocks/src/fractal/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..9baec4fd26fb5d3b0d74ecaf778ef661d6f84600 GIT binary patch literal 430901 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ℜq*5Zyvxr^`+ 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

    L)3!%%PCeE&e2~fBe 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 k|ItUB;gwXkwaSwHFJ?#|wBJR~pYTE?RuubxhS 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

    R23v)ro;Ig|Q+=0lTvY+TgrdM6% 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

    ?^4jAqePVJlNan2OUTd7tWQ(8@ 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 literal 0 HcmV?d00001 diff --git a/examples/blocks/src/fractal/thumbnail.png b/examples/blocks/src/fractal/thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..0b818bbf156001dfd18f20c7c49bb47896f97e14 GIT binary patch literal 34823 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 literal 0 HcmV?d00001 diff --git a/examples/blocks/src/market/README.md b/examples/blocks/src/market/README.md new file mode 100644 index 0000000..8059bab --- /dev/null +++ b/examples/blocks/src/market/README.md @@ -0,0 +1,55 @@ + +A simple market simulation implemented in Perspective, designed to be simple +rather than accurate. The simulation proceeds in steps: + +1) Insert a batch of random orders in a range around the market's bid/ask + spread. For each side of the order book, we can calculate the best open price + by querying the orders `Table` using the `"max"` or `"min"` aggregates + (respectively). + +```json +{ + columns: ["price"], + group_by: ["security"], + aggregates: { price: "max" }, + filter: [ + ["side", "==", "buy"], + ["status", "==", "open"], + ], +} +``` + +1) Clear any matched orders in the `Table`. To match orders, we fetch all open + orders on both sides which are outside of the best price, then update an + _equal_ number of both `"buy"` and `"sell"` side orders to `"closed"`. The + `sort` field guarantees that orders are closed in best, then oldest, order. + +```json +{ + columns: ["id"], + filter: [ + ["side", "==", "buy"], + ["status", "==", "open"], + ["price", ">", price], + ], + sort: [ + ["price", "desc"], + ["timestamp", "asc"], + ], +} +``` + +3) Expire any elapsed orders which are still `"open"` by fetching orders older + than the expiration ID and update thair status' to `"expired"`. + +```json +{ + columns: ["id"], + filter: [ + ["status", "==", "open"], + ["id", "<", 12345], + ], +} +``` + +4) Sleep for a bit and repeat (1). diff --git a/examples/blocks/src/market/index.css b/examples/blocks/src/market/index.css new file mode 100644 index 0000000..36092b6 --- /dev/null +++ b/examples/blocks/src/market/index.css @@ -0,0 +1,93 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ 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). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +body { + background: #242526; + color: white; + font-family: "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", + "Consolas", "Liberation Mono", monospace; + touch-action: none; +} + +* { + box-sizing: border-box; +} + +#app { + display: flex; + flex-direction: column; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +#header { + display: flex; + flex-wrap: wrap; + align-items: center; +} + +#header a { + display: inline-flex; +} + +perspective-viewer { + border-top: 1px solid #666; + flex: 1 1 auto; +} + +label { + height: 32px; + font-size: 12px; + padding: 6px 0px; + margin-right: 4px; + margin-left: 14px; + margin-top: 8px; + margin-bottom: 8px; + border: 1px solid transparent; +} + +img { + vertical-align: middle; + margin-left: 14px; +} + +select, +button { + font-family: "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", + "Consolas", "Liberation Mono", monospace; + font-size: 12px; + appearance: none; + background-color: transparent; + border: 1px solid #666; + border-radius: 2px; + padding: 6px 10px; + color: #f4f5f6; + cursor: pointer; + margin-right: 4px; + margin-left: 4px; + outline: none; + user-select: none; + height: 32px; + margin-top: 8px; + margin-bottom: 8px; +} + +select:hover, +button:hover { + color: #242526; + background-color: #f4f5f6; + border-color: #f4f5f6; +} diff --git a/examples/blocks/src/market/index.html b/examples/blocks/src/market/index.html new file mode 100644 index 0000000..284fa2d --- /dev/null +++ b/examples/blocks/src/market/index.html @@ -0,0 +1,24 @@ + + + + + + + + + + + +

    + + diff --git a/examples/blocks/src/market/layouts.json b/examples/blocks/src/market/layouts.json new file mode 100644 index 0000000..e1c0792 --- /dev/null +++ b/examples/blocks/src/market/layouts.json @@ -0,0 +1,290 @@ +[ + { + "plugin": "Heatmap", + "plugin_config": {}, + "title": "Heatmap", + "group_by": ["bucket(\"timestamp\", 'm')"], + "split_by": ["bucket(\"price\", 0.5)"], + "columns": ["if(\"side\"=='buy'){-1}else{1}"], + "filter": [], + "sort": [], + "expressions": { + "bucket(\"timestamp\", 'm')": "bucket(\"timestamp\", 'm')", + "bucket(\"price\", 0.5)": "bucket(\"price\", 0.5)", + "if(\"side\"=='buy'){-1}else{1}": "if(\"side\"=='buy'){-1}else{1}" + }, + "aggregates": {} + }, + { + "plugin": "Heatmap", + "plugin_config": {}, + "title": "Heatmap (Closed)", + "group_by": ["bucket(\"timestamp\", 'm')"], + "split_by": ["bucket(\"price\", 0.25)"], + "columns": ["if(\"side\"=='buy'){-1}else{1}"], + "filter": [["status", "==", "closed"]], + "sort": [], + "expressions": { + "bucket(\"timestamp\", 'm')": "bucket(\"timestamp\", 'm')", + "bucket(\"price\", 0.25)": "bucket(\"price\", 0.25)", + "if(\"side\"=='buy'){-1}else{1}": "if(\"side\"=='buy'){-1}else{1}" + }, + "aggregates": {} + }, + { + "plugin": "Y Scatter", + "plugin_config": {}, + "title": "Closed Orders ((s) bucket)", + "group_by": ["bucket(\"timestamp\", 's')"], + "split_by": ["if(\"status\"!='open'){\"status\"}else{\"side\"}"], + "columns": ["price"], + "filter": [], + "sort": [], + "expressions": { + "if(\"status\"=='closed'){1}else if(\"status\"=='expired'){2}else{0}": "if(\"status\"=='closed'){1}else if(\"status\"=='expired'){2}else{0}", + "if(\"status\"!='open'){\"status\"}else{\"side\"}": "if(\"status\"!='open'){\"status\"}else{\"side\"}", + "bucket(\"timestamp\", 's')": "bucket(\"timestamp\", 's')" + }, + "aggregates": { "price": "avg", "timestamp": "dominant" } + }, + { + "plugin": "X/Y Scatter", + "plugin_config": {}, + "title": "Closed Orders", + "group_by": [], + "split_by": [], + "columns": [ + "timestamp", + "price", + "if(\"status\"!='open'){\"status\"}else{\"side\"}" + ], + "filter": [], + "sort": [], + "expressions": { + "if(\"status\"!='open'){\"status\"}else{\"side\"}": "if(\"status\"!='open'){\"status\"}else{\"side\"}", + "if(\"status\"=='closed'){1}else if(\"status\"=='expired'){2}else{0}": "if(\"status\"=='closed'){1}else if(\"status\"=='expired'){2}else{0}" + }, + "aggregates": {} + }, + { + "plugin": "Y Line", + "plugin_config": { "zoom": { "k": 1, "x": 1, "y": -1 } }, + "title": "Mid Timeseries", + "group_by": ["bucket(\"timestamp\", 's')"], + "split_by": ["status", "side"], + "columns": ["price"], + "filter": [["status", "==", "closed"]], + "sort": [], + "expressions": { + "bucket(\"timestamp\", 's')": "bucket(\"timestamp\", 's')" + }, + "aggregates": { "price": "avg" } + }, + { + "plugin": "Y Bar", + "plugin_config": {}, + "title": "Depth Timeseries", + "group_by": ["bucket(\"timestamp\", 'm')"], + "split_by": ["side", "status"], + "columns": ["if(\"side\"=='buy'){1}else{-1}"], + "filter": [], + "sort": [], + "expressions": { + "bucket(\"timestamp\", 'm')": "bucket(\"timestamp\", 'm')", + "if(\"side\"=='buy'){1}else{-1}": "if(\"side\"=='buy'){1}else{-1}", + "if(\"side\"=='sell'){-1}else{integer(null)}": "if(\"side\"=='sell'){-1}else{integer(null)}" + }, + "aggregates": { "bucket(\"timestamp\", 'm')": "dominant" } + }, + { + "version": "4.5.2", + "columns_config": { + "Diff from market open": { + "number_fg_mode": "bar", + "fg_gradient": 12.9 + }, + "if(\"side\"=='buy'){-1}else{1}": { + "number_fg_mode": "bar", + "fg_gradient": 8, + "aggregate_depth": 1 + }, + "status": { + "string_color_mode": "series", + "color": "#94aa27" + }, + "side": { + "string_color_mode": "series" + } + }, + "plugin": "Datagrid", + "plugin_config": {}, + "settings": true, + "title": "Blotter", + "group_by": [], + "split_by": [], + "sort": [["timestamp", "desc"]], + "filter": [], + "expressions": { + "bucket(\"price\", 1)": "bucket(\"price\", 1)", + "bucket(\"timestamp\", 'm')": "bucket(\"timestamp\", 'm')", + "Diff from market open": "\"price\" - 20" + }, + "columns": [ + "timestamp", + "id", + "Diff from market open", + "price", + "status", + "side" + ], + "aggregates": {} + }, + { + "version": "4.5.2", + "columns_config": { + "if(\"side\"=='buy'){-1}else{1}": { + "aggregate_depth": 1, + "number_fg_mode": "bar", + "fg_gradient": 8 + } + }, + "plugin": "Datagrid", + "plugin_config": {}, + "settings": true, + "title": "Order Book", + "group_by": ["bucket(\"price\", 0.5)"], + "split_by": ["side"], + "sort": [["bucket(\"price\", 0.5)", "desc"]], + "filter": [["status", "==", "open"]], + "expressions": { + "if(\"side\"=='buy'){-1}else{1}": "if(\"side\"=='buy'){-1}else{1}", + "bucket(\"price\", 0.5)": "bucket(\"price\", 0.5)" + }, + "columns": ["if(\"side\"=='buy'){-1}else{1}"], + "aggregates": {} + }, + { + "plugin": "X Bar", + "plugin_config": {}, + "title": "Order Book (Chart)", + "group_by": ["bucket(\"price\", 0.5)"], + "split_by": ["side"], + "columns": ["if(\"side\"=='buy'){-1}else{1}"], + "filter": [["status", "==", "open"]], + "sort": [["bucket(\"price\", 0.5)", "desc"]], + "expressions": { + "if(\"side\"=='buy'){-1}else{1}": "if(\"side\"=='buy'){-1}else{1}", + "bucket(\"price\", 0.5)": "bucket(\"price\", 0.5)" + }, + "aggregates": {} + }, + { + "plugin": "Candlestick", + "plugin_config": {}, + "title": "Candlestick", + "group_by": ["bucket(\"timestamp\", 'm')"], + "split_by": [], + "columns": ["price", null, "price 2", "price 3"], + "filter": [["status", "==", "closed"]], + "sort": [], + "expressions": { + "bucket(\"timestamp\", 'm')": "bucket(\"timestamp\", 'm')", + "price 2": "\"price\"", + "price 3": "\"price\"" + }, + "aggregates": { + "price": "avg", + "price 2": "high", + "price 3": "low" + } + }, + { + "plugin": "Candlestick", + "plugin_config": {}, + "title": "Candlestick 2", + "group_by": ["bucket(\"timestamp\", 'm')"], + "split_by": [], + "columns": ["price", null, null, null], + "filter": [["status", "==", "closed"]], + "sort": [], + "expressions": { + "bucket(\"timestamp\", 'm')": "bucket(\"timestamp\", 'm')", + "price 2": "\"price\"", + "price 3": "\"price\"" + }, + "aggregates": { + "price": "avg", + "price 2": "high", + "price 3": "low" + } + }, + { + "plugin": "X Bar", + "plugin_config": {}, + "title": "Order Book (All Status)", + "group_by": ["bucket(\"price\", 0.5)"], + "split_by": [], + "columns": [ + "if(\"status\"=='open' and \"side\"=='buy'){1}else{float(null)}", + "if(\"status\"=='open' and \"side\"=='sell'){1}else{float(null)}", + "if(\"status\"=='closed'){1}else{0}" + ], + "filter": [], + "sort": [["bucket(\"price\", 0.5)", "desc"]], + "expressions": { + "if(\"status\"=='open' and \"side\"=='buy'){1}else{float(null)}": "if(\"status\"=='open' and \"side\"=='buy'){1}else{float(null)}", + "bucket(\"timestamp\", 's')": "bucket(\"timestamp\", 's')", + "bucket(\"price\", 0.5)": "bucket(\"price\", 0.5)", + "if(\"status\"=='open'){\"price\"}else{float(null)}": "if(\"status\"=='open'){\"price\"}else{float(null)}", + "if(\"status\"=='closed'){1}else{0}": "if(\"status\"=='closed'){1}else{0}", + "if(\"status\"=='open' and \"side\"=='sell'){1}else{float(null)}": "if(\"status\"=='open' and \"side\"=='sell'){1}else{float(null)}" + }, + "aggregates": {} + }, + { + "plugin": "Y Scatter", + "plugin_config": {}, + "title": "Order Book (Scatter All Status)", + "group_by": ["bucket(\"price\", 0.5)"], + "split_by": [], + "columns": [ + "if(\"status\"=='open' and \"side\"=='buy'){1}else{float(null)}", + "if(\"status\"=='open' and \"side\"=='sell'){1}else{float(null)}", + "if(\"status\"=='closed'){1}else{0}" + ], + "filter": [], + "sort": [], + "expressions": { + "if(\"status\"=='open' and \"side\"=='buy'){1}else{float(null)}": "if(\"status\"=='open' and \"side\"=='buy'){1}else{float(null)}", + "bucket(\"timestamp\", 's')": "bucket(\"timestamp\", 's')", + "bucket(\"price\", 0.5)": "bucket(\"price\", 0.5)", + "if(\"status\"=='open'){\"price\"}else{float(null)}": "if(\"status\"=='open'){\"price\"}else{float(null)}", + "if(\"status\"=='closed'){1}else{0}": "if(\"status\"=='closed'){1}else{0}", + "if(\"status\"=='open' and \"side\"=='sell'){1}else{float(null)}": "if(\"status\"=='open' and \"side\"=='sell'){1}else{float(null)}" + }, + "aggregates": {} + }, + { + "plugin": "X Bar", + "plugin_config": {}, + "title": "Order Book (Stack All Status)", + "group_by": ["bucket(\"price\", 0.5)"], + "split_by": ["if(\"status\"=='closed'){'closed'}else{\"side\"}"], + "columns": ["price"], + "filter": [["status", "!=", "expired"]], + "sort": [ + ["bucket(\"price\", 0.5)", "desc"], + ["status", "col asc"] + ], + "expressions": { + "if(\"status\"=='open' and \"side\"=='buy'){1}else{float(null)}": "if(\"status\"=='open' and \"side\"=='buy'){1}else{float(null)}", + "bucket(\"timestamp\", 's')": "bucket(\"timestamp\", 's')", + "bucket(\"price\", 0.5)": "bucket(\"price\", 0.5)", + "if(\"status\"=='open'){\"price\"}else{float(null)}": "if(\"status\"=='open'){\"price\"}else{float(null)}", + "if(\"status\"=='closed'){1}else{0}": "if(\"status\"=='closed'){1}else{0}", + "if(\"status\"=='open' and \"side\"=='sell'){1}else{float(null)}": "if(\"status\"=='open' and \"side\"=='sell'){1}else{float(null)}", + "if(\"status\"=='closed'){'closed'}else{\"side\"}": "if(\"status\"=='closed'){'closed'}else{\"side\"}" + }, + "aggregates": {} + } +] diff --git a/examples/blocks/src/market/market.js b/examples/blocks/src/market/market.js new file mode 100644 index 0000000..38b9ba7 --- /dev/null +++ b/examples/blocks/src/market/market.js @@ -0,0 +1,292 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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-charts/dist/cdn/perspective-viewer-charts.js"; + +import perspective from "/node_modules/@perspective-dev/client/dist/cdn/perspective.js"; + +const MSG_BATCH_TIMEOUT = 50; +const MSG_PER_BATCH = 10; +const MSG_TIME_DELTA = 20 * (100 / MSG_PER_BATCH); +const MARKET_OPEN_PRICE = 20; +const MARKET_MAX_TRADES = 20000; +const MARKET_MIN_TRADES = 5000; +const MARKET_OPEN = performance.now(); +const TRADE_EXPIRATION = 10; + +const SCHEMA = { + id: "integer", + side: "string", + security: "string", + price: "float", + timestamp: "datetime", + status: "string", +}; + +async function query_columns(table, config) { + const view = await table.view(config); + const columns = await view.to_columns(); + await view.delete(); + return columns; +} + +class OrderBook { + constructor(table, side) { + this._memo = undefined; + this._side = side; + this._table = table; + this._price_view = table.view({ + columns: ["price"], + group_by: ["security"], + aggregates: { price: side === "buy" ? "max" : "min" }, + filter: [ + ["side", "==", side], + ["status", "==", "open"], + ], + }); + } + + async best_open_price() { + if (this._memo === undefined) { + const view = await this._price_view; + const { price } = await view.to_columns({ leaves_only: true }); + this._memo = price.length === 0 ? MARKET_OPEN_PRICE : price[0]; + } + + return this._memo; + } + + async matched_orders(price) { + const sort_dir = this._side === "buy" ? "desc" : "asc"; + const op = this._side === "buy" ? ">" : "<"; + return await query_columns(this._table, { + columns: ["id"], + filter: [ + ["side", "==", this._side], + ["status", "==", "open"], + ["price", op, price], + ], + sort: [ + ["price", sort_dir], + ["timestamp", "asc"], + ], + }); + } + + reset() { + this._memo = undefined; + } +} + +class Channel { + async consume() { + await new Promise((resolve) => { + this._resolve = resolve; + }); + } + + emit() { + if (this._resolve) { + this._resolve(); + this._resolve = undefined; + } + } +} + +class Market { + constructor(table, model) { + this._id = 1; + this._table = table; + this._model = model; + this._buy_book = new OrderBook(table, "buy"); + this._sell_book = new OrderBook(table, "sell"); + this._stop_signal = new Channel(); + } + + async stop() { + if (this._id < MARKET_MAX_TRADES) { + this._id = MARKET_MAX_TRADES; + await this._stop_signal.consume(); + } + + this._id = 1; + } + + async poll(progress) { + await this._run_market_step(); + while (this._id < MARKET_MIN_TRADES) { + await this._run_market_step(progress); + } + + progress?.(); + this._stop_signal.emit(); + if (this._id < MARKET_MAX_TRADES) { + setTimeout(this.poll.bind(this), MSG_BATCH_TIMEOUT); + } + } + + async _run_market_step(progress) { + if (await this._generate_trades()) { + await this._clear_trades(); + await this._expire_trades(); + } + + this._buy_book.reset(); + this._sell_book.reset(); + progress?.(this._id / MARKET_MIN_TRADES); + } + + async _generate_trades() { + const trades = []; + const timestamp = new Date(MARKET_OPEN + this._id * MSG_TIME_DELTA); + for (let i = 0; i < MSG_PER_BATCH; i++) { + const { side, discount } = this._model(); + const book = side === "buy" ? this._sell_book : this._buy_book; + const best_open_price = await book.best_open_price(); + trades.push({ + security: "Prospective Co", + status: "open", + id: this._id++, + price: discount + best_open_price, + side, + timestamp, + }); + } + + if (trades.length > 0) { + await this._table.update(trades); + return true; + } + } + + async _clear_trades() { + const sell_price = await this._sell_book.best_open_price(); + const buy_price = await this._buy_book.best_open_price(); + const { id: buys } = await this._buy_book.matched_orders(sell_price); + const { id: sells } = await this._sell_book.matched_orders(buy_price); + const num_clear = Math.min(buys.length, sells.length); + const status = Array(num_clear * 2).fill("closed"); + const id = buys.slice(0, num_clear).concat(sells.slice(0, num_clear)); + if (id.length > 0) { + await this._table.update({ status, id }); + } + } + + async _expire_trades() { + const expired = await query_columns(this._table, { + columns: ["id"], + filter: [ + ["status", "==", "open"], + ["id", "<", this._id - MSG_PER_BATCH * TRADE_EXPIRATION], + ], + }); + + if (expired.id.length > 0) { + expired.status = Array(expired.id.length).fill("expired"); + await this._table.update(expired); + } + } +} + +const SKEW_MODEL_OFFSET = 2; +const SKEW_MODEL_STDDEV = 2; +const SKEW_MODEL_SKEW = 0; + +function random_skew_normal(bias) { + const u3 = Math.random(), + u2 = Math.random(); + const R = Math.sqrt(-2.0 * Math.log(u3)); + const O = 2.0 * Math.PI * u2; + const u0 = R * Math.cos(O); + const v = R * Math.sin(O); + if (SKEW_MODEL_SKEW === 0) { + return bias * SKEW_MODEL_OFFSET + SKEW_MODEL_STDDEV * u0; + } else { + const n = -bias * SKEW_MODEL_SKEW; + const s = n / Math.sqrt(1 + Math.pow(SKEW_MODEL_SKEW, 2)); + const u1 = s * u0 + Math.sqrt(1 - s * s) * v; + const z = u0 >= 0 ? u1 : -u1; + return bias * SKEW_MODEL_OFFSET + SKEW_MODEL_STDDEV * z; + } +} + +function skew_model() { + const [side, bias] = Math.random() > 0.5 ? ["buy", -1] : ["sell", 1]; + const discount = random_skew_normal(bias); + return { side, discount }; +} + +function progress(x) { + const button = document.querySelector("button"); + if (x !== undefined) { + const y = (x * 10).toFixed(0) * 10; + button.textContent = `Generating trades ${y}%`; + } else { + button.textContent = "Reset"; + } +} + +async function reset_tables(market, market_table, gui_table) { + await market.stop(); + await gui_table.size(); + await market_table.clear(); + await gui_table.clear(); + await market.poll(progress); +} + +async function init_tables() { + const market_worker = await perspective.worker(); + const gui_worker = await perspective.worker(); + const market_table = await market_worker.table(SCHEMA, { index: "id" }); + const market_view = await market_table.view(); + const gui_table = await gui_worker.table(market_view, { + index: "id", + name: "gui", + }); + + return { market_table, gui_table, gui_worker }; +} + +async function init_layouts() { + const req = await fetch("layouts.json"); + return await req.json(); +} + +const INIT_TASK = [init_tables(), init_layouts()]; + +const [{ market_table, gui_table, gui_worker }, layouts] = + await Promise.all(INIT_TASK); +const market = new Market(market_table, skew_model); +const settings = !/(iPad|iPhone|iPod)/g.test(navigator.userAgent); +const select = document.querySelector("select"); +const button = document.querySelector("button"); +const viewer = document.querySelector("perspective-viewer"); +viewer.load(gui_worker); +viewer.restore({ theme: "Pro Dark", table: "gui", settings, ...layouts[0] }); +await market.poll(progress); +for (const layout of layouts) { + const option = document.createElement("option"); + option.value = layout.title; + option.textContent = layout.title; + select.appendChild(option); +} + +button.addEventListener("click", () => { + reset_tables(market, market_table, gui_table); +}); + +select.addEventListener("change", async (event) => { + const layout = layouts.find((x) => x.title === event.target.value); + await viewer.restore(layout); +}); diff --git a/examples/blocks/src/market/preview.png b/examples/blocks/src/market/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..124dad45c2e541293a933199f9b7a13dad1e0e48 GIT binary patch literal 256126 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≤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
    nIkrWiiFrpXv8RmPlfcDBZ?ha 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 Q;{X5v07*qoM6N<$f)54Q$p8QV literal 0 HcmV?d00001 diff --git a/examples/blocks/src/movies/.block b/examples/blocks/src/movies/.block new file mode 100644 index 0000000..37ce96a --- /dev/null +++ b/examples/blocks/src/movies/.block @@ -0,0 +1,2 @@ +license: apache-2.0 +height: 800 \ No newline at end of file diff --git a/examples/blocks/src/movies/README.md b/examples/blocks/src/movies/README.md new file mode 100644 index 0000000..652446a --- /dev/null +++ b/examples/blocks/src/movies/README.md @@ -0,0 +1,11 @@ +Demo of a [Perspective](https://github.com/perspective-dev/perspective) +visualizing a dataset of 3,201 films. Each row in the dataset is a film, with +dimensional data such as production studio, rating and genre, as well as +observations such as box-office gross. + +This dataset is bootstrapped off the [Vega](https://vega.github.io/vega/) +[demo page](https://vega.github.io/editor/data/movies.json"), and has no server +component itself, showing how easy it is to use Perspective's client-side +architecture to get a fully-interactive BI experience, on top of +already-deployed web data artifacts without modification or intermediate +servers. diff --git a/examples/blocks/src/movies/index.html b/examples/blocks/src/movies/index.html new file mode 100644 index 0000000..9465724 --- /dev/null +++ b/examples/blocks/src/movies/index.html @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + diff --git a/examples/blocks/src/movies/layout.json b/examples/blocks/src/movies/layout.json new file mode 100644 index 0000000..4be0619 --- /dev/null +++ b/examples/blocks/src/movies/layout.json @@ -0,0 +1,133 @@ +{ + "sizes": [0.34555410083131466, 0.6544458991686853], + "detail": { + "main": { + "type": "tab-area", + "widgets": [ + "PERSPECTIVE_GENERATED_ID_0", + "PERSPECTIVE_GENERATED_ID_1", + "PERSPECTIVE_GENERATED_ID_3" + ], + "currentIndex": 0 + } + }, + "mode": "globalFilters", + "master": { + "widgets": ["PERSPECTIVE_GENERATED_ID_2"], + "sizes": [1] + }, + "viewers": { + "PERSPECTIVE_GENERATED_ID_2": { + "plugin": "Datagrid", + "plugin_config": { + "columns": { + "IMDB Rating": { + "gradient": 8.1, + "neg_color": "#FF9485", + "number_color_mode": "gradient", + "pos_color": "#b87ff0" + }, + "US Gross": { + "fg_gradient": 19729862602, + "number_fg_mode": "bar" + } + }, + "editable": false, + "scroll_lock": true + }, + "settings": false, + "theme": "Pro Dark", + "group_by": ["Distributor"], + "split_by": [], + "columns": ["US Gross"], + "filter": [], + "sort": [["US Gross", "desc"]], + "expressions": {}, + "aggregates": {}, + "master": true, + "name": null, + "table": "movies", + "linked": false, + "selectable": "true" + }, + "PERSPECTIVE_GENERATED_ID_0": { + "plugin": "Heatmap", + "plugin_config": {}, + "settings": false, + "theme": "Pro Dark", + "group_by": ["bucket(\"Rotten Tomatoes Rating\", 2.5)"], + "split_by": ["bucket(\"IMDB Rating\", 0.25)"], + "columns": ["US Gross"], + "filter": [["bucket(\"IMDB Rating\", 0.25)", "is not null", null]], + "sort": [], + "expressions": { + "bucket(\"IMDB Rating\", 0.25)": "bucket(\"IMDB Rating\", 0.25)", + "bucket(\"Rotten Tomatoes Rating\", 2.5)": "bucket(\"Rotten Tomatoes Rating\", 2.5)" + }, + "aggregates": { + "bucket(\"Rotten Tomatoes Rating\", 2.5)": "avg" + }, + "master": false, + "name": null, + "table": "movies", + "linked": false + }, + "PERSPECTIVE_GENERATED_ID_1": { + "plugin": "Y Line", + "plugin_config": { + "legend": { + "left": "79px", + "top": "174px" + }, + "splitMainValues": ["Rotten Tomatoes Rating"] + }, + "settings": false, + "theme": "Pro Dark", + "group_by": ["bucket(\"Release Date\", 'Y')"], + "split_by": [], + "columns": [ + "US Gross", + "Rotten Tomatoes Rating", + "Production Budget", + "Worldwide Gross", + "US DVD Sales" + ], + "filter": [], + "sort": [], + "expressions": { + "bucket(\"Release Date\", 'Y')": "bucket(\"Release Date\", 'Y')" + }, + "aggregates": { + "Rotten Tomatoes Rating": "avg" + }, + "master": false, + "name": "Ratings vs Sales", + "table": "movies", + "linked": false + }, + "PERSPECTIVE_GENERATED_ID_3": { + "plugin": "Y Area", + "plugin_config": { + "legend": { + "left": "91px", + "top": "25px" + } + }, + "settings": false, + "theme": "Pro Dark", + "group_by": ["bucket(\"Release Date\", 'Y')"], + "split_by": ["Major Genre"], + "columns": ["US Gross"], + "filter": [], + "sort": [], + "expressions": { + "bucket(\"Release Date\", 'Y')": "bucket(\"Release Date\", 'Y')" + }, + "aggregates": {}, + "master": false, + "name": "US Gross by Genre", + "table": "movies", + "linked": false + } + } +} diff --git a/examples/blocks/src/movies/preview.png b/examples/blocks/src/movies/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..0484fd67b1f55f187e71ce290caa32ad88dfb096 GIT binary patch literal 135265 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 console.log(`(${p}b / ${t}b)`), + }); + + zipReader.close(); + + let t = await perspective.table(csv); + let view = await t.view(); + + return new Uint8Array(await view.to_arrow()); +} + +async function main() { + if (!fss.existsSync(`${__dirname}/nypdccrb.arrow`)) { + await fs.writeFile( + `${__dirname}/nypdccrb.arrow`, + await fetch_progress(DATA_URL), + ); + + console.log("Wrote nypdccrb.arrow"); + } +} + +main(); diff --git a/examples/blocks/src/nypd/dark.css b/examples/blocks/src/nypd/dark.css new file mode 100644 index 0000000..fc8321f --- /dev/null +++ b/examples/blocks/src/nypd/dark.css @@ -0,0 +1,14 @@ +.dark button { + background-color: inherit; + color: white; + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.dark #buttons { + background-color: #242526; +} + +.dark select { + background-color: #242526; + color: white; +} diff --git a/examples/blocks/src/nypd/index.css b/examples/blocks/src/nypd/index.css new file mode 100644 index 0000000..f7ad9d1 --- /dev/null +++ b/examples/blocks/src/nypd/index.css @@ -0,0 +1,73 @@ +perspective-viewer[theme="Pro Light"], +perspective-viewer[theme="Pro Dark"] { + --d3fc-positive--gradient: linear-gradient(#94d0ff, + #8795e8, + #966bff, + #ad8cff, + #c774e8, + #c774a9, + #ff6ad5, + #ff6a8b, + #ff8b8b, + #ffa58b, + #ffde8b, + #cdde8b, + #8bde8b, + #20de8b); +} + +perspective-workspace { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +#buttons>* { + padding: 6px; + margin: 5px; +} + +button { + background-color: inherit; + cursor: pointer; +} + +button:hover { + border: 1px solid #00f !important; +} + +body { + overflow: hidden; +} + +#message { + font-family: monospace; +} + +#buttons { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 36px; +} + +#workspace { + height: auto; + position: absolute; + top: 36px; + right: 0; + bottom: 0; + left: 0; +} + +a { + text-transform: uppercase; + font-size: 10px; +} + +button, select { + border-radius: 5px; +} diff --git a/examples/blocks/src/nypd/index.html b/examples/blocks/src/nypd/index.html new file mode 100644 index 0000000..341eea0 --- /dev/null +++ b/examples/blocks/src/nypd/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/examples/blocks/src/nypd/index.js b/examples/blocks/src/nypd/index.js new file mode 100644 index 0000000..2128d55 --- /dev/null +++ b/examples/blocks/src/nypd/index.js @@ -0,0 +1,186 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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/workspace/dist/cdn/perspective-workspace.js"; +import "/node_modules/@perspective-dev/viewer-datagrid/dist/cdn/perspective-viewer-datagrid.js"; +import "/node_modules/@perspective-dev/viewer-charts/dist/cdn/perspective-viewer-charts.js"; + +import perspective from "/node_modules/@perspective-dev/client/dist/cdn/perspective.js"; + +let DATA_URL = "nypdccrb.arrow"; + +let LAYOUTS = localStorage.getItem("layouts") + ? JSON.parse(localStorage.getItem("layouts")) + : undefined; + +const worker = await perspective.worker(); +const theme_style_node = document.createElement("style"); +document.head.appendChild(theme_style_node); +let DARK_THEME; +let LIGHT_THEME; + +function toggle_theme() { + if (theme_style_node.dataset.theme === "Pro Light") { + theme_style_node.textContent = DARK_THEME; + document.body.classList.add("dark"); + window.theme.textContent = "Light Theme"; + for (const view of document.querySelectorAll("perspective-viewer")) { + view.setAttribute("theme", "Pro Dark"); + view.restyleElement(); + } + + theme_style_node.dataset.theme = "Pro Dark"; + } else { + theme_style_node.textContent = LIGHT_THEME; + document.body.classList.remove("dark"); + window.theme.textContent = "Dark Theme"; + for (const view of document.querySelectorAll("perspective-viewer")) { + view.setAttribute("theme", "Pro Light"); + view.restyleElement(); + } + theme_style_node.dataset.theme = "Pro Light"; + } +} + +async function fetch_progress(url) { + window.message.textContent = "Downloading..."; + const response = await fetch(url); + const ab = await new Response(await response.blob()).arrayBuffer(); + window.message.style.display = "none"; + return ab; +} + +DARK_THEME = await fetch( + "/node_modules/@perspective-dev/workspace/dist/css/pro-dark.css", +).then((x) => x.text()); + +LIGHT_THEME = await fetch( + "/node_modules/@perspective-dev/workspace/dist/css/pro.css", +).then((x) => x.text()); + +document.body.innerHTML = ` + +

    + + `.trim(); + +toggle_theme(); + +window.workspace.addEventListener( + "workspace-new-view", + ({ detail: { widget } }) => { + widget.viewer.setAttribute("theme", theme_style_node.dataset.theme); + }, +); + +window.workspace.addTable( + "ccrb", + (async () => worker.table(await fetch_progress(DATA_URL)))(), + // worker.table(await fetch_progress(DATA_URL)) +); + +if (LAYOUTS == undefined) { + LAYOUTS = await (await fetch("./layout.json")).json(); +} + +const layout_names = Object.keys(LAYOUTS); +let selected_layout = LAYOUTS[layout_names[0]]; +await window.workspace.restore(selected_layout); + +function set_layout_options() { + const layout_names = Object.keys(LAYOUTS); + window.layouts.innerHTML = ""; + for (const layout of layout_names) { + window.layouts.innerHTML += `${layout}`; + } +} + +set_layout_options(); + +window.name_input.value = layout_names[0]; +window.layouts.addEventListener("change", async () => { + if (window.layouts.value.trim().length === 0) { + return; + } + + window.workspace.innerHTML = ""; + await window.workspace.restore(LAYOUTS[window.layouts.value]); + window.name_input.value = window.layouts.value; +}); + +window.save_as.addEventListener("click", async () => { + window.save_as.style.display = "none"; + window.save.style.display = "inline-block"; + window.cancel.style.display = "inline-block"; + window.name_input.style.display = "inline-block"; + window.copy.style.display = "none"; + window.layouts.style.display = "none"; +}); + +function cancel() { + window.save_as.style.display = "inline-block"; + window.save.style.display = "none"; + window.cancel.style.display = "none"; + window.name_input.style.display = "none"; + window.copy.style.display = "inline-block"; + window.layouts.style.display = "inline-block"; +} + +window.cancel.addEventListener("click", cancel); + +window.reset.addEventListener("click", () => { + localStorage.clear(); + window.reset.innerText = "Reset!"; + setTimeout(() => { + window.reset.innerText = "Reset LocalStorage"; + }, 1000); +}); + +window.save.addEventListener("click", async () => { + const token = await window.workspace.save(); + const new_name = window.name_input.value; + LAYOUTS[new_name] = token; + set_layout_options(); + window.layouts.value = new_name; + window.save_as.innerText = "Saved!"; + setTimeout(() => { + window.save_as.innerText = "Save As"; + }, 1000); + localStorage.setItem("layouts", JSON.stringify(LAYOUTS)); + cancel(); +}); + +window.copy.addEventListener("click", async () => { + await navigator.clipboard.writeText(JSON.stringify(LAYOUTS)); + window.copy.innerText = "Copied!"; + setTimeout(() => { + window.copy.innerText = "Debug to Clipboard"; + }, 1000); +}); + +window.theme.addEventListener("click", toggle_theme); diff --git a/examples/blocks/src/nypd/layout.json b/examples/blocks/src/nypd/layout.json new file mode 100644 index 0000000..99b7145 --- /dev/null +++ b/examples/blocks/src/nypd/layout.json @@ -0,0 +1,426 @@ +{ + "Raw": { + "sizes": [1], + "detail": { + "main": { + "type": "tab-area", + "widgets": ["PERSPECTIVE_GENERATED_ID_3"], + "currentIndex": 0 + } + }, + "mode": "globalFilters", + "viewers": { + "PERSPECTIVE_GENERATED_ID_3": { + "plugin": "Datagrid", + "plugin_config": { + "columns": {}, + "editable": false, + "scroll_lock": false + }, + "settings": false, + "title": "Raw", + "group_by": [], + "split_by": [], + "columns": [ + "AsOfDate", + "AllegationID", + "LastName", + "FirstName", + "OfficerRace", + "OfficerGender", + "TaxID", + "CurrentRank", + "CurrentRankLong", + "CurrentCommand", + "IncidentRank", + "IncidentRankLong", + "IncidentCommand", + "ShieldNo", + "DaysOnForce", + "Status", + "LastActive", + "ComplaintID", + "IncidentDate", + "FADOType", + "Allegation", + "CCRBDisposition", + "BoardCat", + "PenaltyRec", + "PenaltyCat", + "PenaltyDesc", + "LocationType", + "ContactReason", + "ContactOutcome", + "IncidentPrecinct", + "ImpactedRace", + "ImpactedGender", + "ImpactedAge", + "ReceivedDate", + "CloseDate" + ], + "filter": [], + "sort": [], + "expressions": {}, + "aggregates": {}, + "master": false, + "table": "ccrb", + "linked": false + } + } + }, + "Incidents per Month": { + "sizes": [1], + "detail": { + "main": { + "type": "tab-area", + "widgets": ["PERSPECTIVE_GENERATED_ID_3"], + "currentIndex": 0 + } + }, + "mode": "globalFilters", + "viewers": { + "PERSPECTIVE_GENERATED_ID_3": { + "plugin": "Y Line", + "plugin_config": {}, + "settings": false, + "title": "Incidents", + "group_by": ["bucket(\"IncidentDate\", 'M')"], + "split_by": [], + "columns": ["ComplaintID"], + "filter": [ + ["IncidentDate", ">", "1985-01-01"], + ["IncidentDate", "<", "2025-01-01"] + ], + "sort": [], + "expressions": { + "bucket(\"IncidentDate\", 'M')": "bucket(\"IncidentDate\", 'M')" + }, + "aggregates": { "ComplaintID": "distinct count" }, + "master": false, + "table": "ccrb", + "linked": false + } + } + }, + "Incidents per Month by FADO": { + "sizes": [1], + "detail": { + "main": { + "type": "split-area", + "orientation": "vertical", + "children": [ + { + "type": "tab-area", + "widgets": ["PERSPECTIVE_GENERATED_ID_0"], + "currentIndex": 0 + }, + { + "type": "tab-area", + "widgets": ["PERSPECTIVE_GENERATED_ID_2"], + "currentIndex": 0 + } + ], + "sizes": [0.5, 0.5] + } + }, + "mode": "globalFilters", + "viewers": { + "PERSPECTIVE_GENERATED_ID_0": { + "plugin": "Y Line", + "plugin_config": { "zoom": { "k": 1, "x": 0, "y": 0 } }, + "settings": false, + "title": "Incidents (line)", + "group_by": ["bucket(\"IncidentDate\", 'M')"], + "split_by": ["FADOType"], + "columns": ["AllegationID"], + "filter": [ + ["IncidentDate", ">", "1985-01-01"], + ["IncidentDate", "<", "2025-01-01"] + ], + "sort": [["AllegationID", "col asc"]], + "expressions": { + "bucket(\"IncidentDate\", 'M')": "bucket(\"IncidentDate\", 'M')" + }, + "aggregates": { "AllegationID": "distinct count" }, + "master": false, + "table": "ccrb", + "linked": false + }, + "PERSPECTIVE_GENERATED_ID_2": { + "plugin": "Y Area", + "plugin_config": {}, + "settings": false, + "title": "Incidents (area)", + "group_by": ["bucket(\"IncidentDate\", 'M')"], + "split_by": ["FADOType"], + "columns": ["AllegationID"], + "filter": [ + ["IncidentDate", ">", "1985-01-01"], + ["IncidentDate", "<", "2025-01-01"] + ], + "sort": [["AllegationID", "col asc"]], + "expressions": { + "bucket(\"IncidentDate\", 'M')": "bucket(\"IncidentDate\", 'M')" + }, + "aggregates": { "AllegationID": "distinct count" }, + "master": false, + "table": "ccrb", + "linked": false + } + } + }, + "Total Complaints vs Officer Count by Command and FADO": { + "sizes": [1], + "detail": { + "main": { + "type": "split-area", + "orientation": "horizontal", + "children": [ + { + "type": "tab-area", + "widgets": ["PERSPECTIVE_GENERATED_ID_4"], + "currentIndex": 0 + }, + { + "type": "tab-area", + "widgets": ["PERSPECTIVE_GENERATED_ID_7"], + "currentIndex": 0 + } + ], + "sizes": [0.5, 0.5] + } + }, + "mode": "globalFilters", + "viewers": { + "PERSPECTIVE_GENERATED_ID_4": { + "plugin": "X/Y Scatter", + "plugin_config": {}, + "settings": false, + "title": "Active Officer Incidents", + "group_by": ["IncidentCommand"], + "split_by": ["FADOType"], + "columns": ["ShieldNo", "ComplaintID", null, null, null, null], + "filter": [["Status", "==", "Active"]], + "sort": [], + "expressions": {}, + "aggregates": { + "ShieldNo": "distinct count", + "ComplaintID": "distinct count" + }, + "master": false, + "table": "ccrb", + "linked": false + }, + "PERSPECTIVE_GENERATED_ID_7": { + "plugin": "X/Y Scatter", + "plugin_config": { "zoom": { "k": 1, "x": 0, "y": 0 } }, + "settings": false, + "title": "Inactive Officer Incidents", + "group_by": ["IncidentCommand"], + "split_by": ["FADOType"], + "columns": ["ShieldNo", "ComplaintID", null, null, null, null], + "filter": [["Status", "==", "Retired"]], + "sort": [], + "expressions": {}, + "aggregates": { + "ShieldNo": "distinct count", + "ComplaintID": "distinct count" + }, + "master": false, + "table": "ccrb", + "linked": false + } + } + }, + "Incidents by Allegation, time series": { + "sizes": [0.3654752553024352, 0.6345247446975648], + "detail": { + "main": { + "type": "tab-area", + "widgets": ["PERSPECTIVE_GENERATED_ID_13"], + "currentIndex": 0 + } + }, + "mode": "globalFilters", + "master": { "widgets": ["PERSPECTIVE_GENERATED_ID_11"], "sizes": [1] }, + "viewers": { + "PERSPECTIVE_GENERATED_ID_11": { + "plugin": "Datagrid", + "plugin_config": { + "columns": {}, + "editable": false, + "scroll_lock": false + }, + "settings": false, + "title": "Filter", + "group_by": ["FADOType", "Allegation"], + "split_by": [], + "columns": ["ComplaintID"], + "filter": [], + "sort": [["ComplaintID", "desc"]], + "expressions": {}, + "aggregates": { "ComplaintID": "distinct count" }, + "master": true, + "table": "ccrb", + "linked": false, + "selectable": "" + }, + "PERSPECTIVE_GENERATED_ID_13": { + "plugin": "Y Line", + "plugin_config": {}, + "settings": false, + "title": "Incidents", + "group_by": ["bucket(\"IncidentDate\", 'M')"], + "split_by": ["FADOType"], + "columns": ["ComplaintID"], + "filter": [ + ["IncidentDate", ">", "1985-01-01"], + ["IncidentDate", "<", "2025-01-01"] + ], + "sort": [], + "expressions": { + "bucket(\"IncidentDate\", 'M')": "bucket(\"IncidentDate\", 'M')" + }, + "aggregates": { "ComplaintID": "distinct count" }, + "master": false, + "table": "ccrb", + "linked": false + } + } + }, + "Allegation reporting change, 1995-2000": { + "sizes": [1], + "detail": { + "main": { + "type": "tab-area", + "widgets": ["PERSPECTIVE_GENERATED_ID_10"], + "currentIndex": 0 + } + }, + "mode": "globalFilters", + "viewers": { + "PERSPECTIVE_GENERATED_ID_10": { + "plugin": "Heatmap", + "plugin_config": { + "zoom": { + "k": 0.959264119325269, + "x": 23.20639207453189, + "y": 10.58613905921925 + } + }, + "settings": false, + "title": "Incidents", + "group_by": ["bucket(\"IncidentDate\", '3M')"], + "split_by": ["Allegation"], + "columns": ["AllegationID"], + "filter": [ + ["IncidentDate", ">", "1985-01-01"], + ["IncidentDate", "<", "2025-01-01"] + ], + "sort": [["IncidentDate", "col asc"]], + "expressions": { + "bucket(\"IncidentDate\", '3M')": "bucket(\"IncidentDate\", '3M')" + }, + "aggregates": { + "IncidentDate": "median", + "AllegationID": "distinct count" + }, + "master": false, + "table": "ccrb", + "linked": false + } + } + }, + "Day-of-Week Incident Heatmap": { + "sizes": [0.3686174391201885, 0.6313825608798115], + "detail": { + "main": { + "type": "split-area", + "orientation": "vertical", + "children": [ + { + "type": "tab-area", + "widgets": ["PERSPECTIVE_GENERATED_ID_13"], + "currentIndex": 0 + }, + { + "type": "tab-area", + "widgets": ["PERSPECTIVE_GENERATED_ID_15"], + "currentIndex": 0 + } + ], + "sizes": [0.5, 0.5] + } + }, + "mode": "globalFilters", + "master": { "widgets": ["PERSPECTIVE_GENERATED_ID_11"], "sizes": [1] }, + "viewers": { + "PERSPECTIVE_GENERATED_ID_11": { + "plugin": "Datagrid", + "plugin_config": { + "columns": {}, + "editable": false, + "scroll_lock": false + }, + "settings": false, + "title": "Filter", + "group_by": ["FADOType", "Allegation"], + "split_by": [], + "columns": ["ComplaintID"], + "filter": [], + "sort": [["ComplaintID", "desc"]], + "expressions": {}, + "aggregates": { "ComplaintID": "distinct count" }, + "master": true, + "table": "ccrb", + "linked": false, + "selectable": "" + }, + "PERSPECTIVE_GENERATED_ID_13": { + "plugin": "Heatmap", + "plugin_config": {}, + "settings": false, + "title": "Year vs Month of Incident", + "group_by": ["bucket(\"IncidentDate\", 'Y')"], + "split_by": ["month_of_year(\"IncidentDate\")"], + "columns": ["ComplaintID"], + "filter": [ + ["IncidentDate", ">", "1985-01-01"], + ["IncidentDate", "<", "2025-01-01"] + ], + "sort": [], + "expressions": { + "bucket(\"IncidentDate\", 'Y')": "bucket(\"IncidentDate\", 'Y')", + "month_of_year(\"IncidentDate\")": "month_of_year(\"IncidentDate\")" + }, + "aggregates": { "ComplaintID": "distinct count" }, + "master": false, + "table": "ccrb", + "linked": false + }, + "PERSPECTIVE_GENERATED_ID_15": { + "plugin": "Heatmap", + "plugin_config": {}, + "settings": false, + "title": "Month vs Weekday of Incident", + "group_by": ["month_of_year(\"IncidentDate\")"], + "split_by": ["day_of_week(\"IncidentDate\")"], + "columns": ["ComplaintID"], + "filter": [ + ["IncidentDate", ">", "1985-01-01"], + ["IncidentDate", "<", "2025-01-01"] + ], + "sort": [], + "expressions": { + "bucket(\"IncidentDate\", 'Y')": "bucket(\"IncidentDate\", 'Y')", + "month_of_year(\"IncidentDate\")": "month_of_year(\"IncidentDate\")", + "day_of_week(\"IncidentDate\")": "day_of_week(\"IncidentDate\")" + }, + "aggregates": { "ComplaintID": "distinct count" }, + "master": false, + "table": "ccrb", + "linked": false + } + } + } +} diff --git a/examples/blocks/src/nypd/light.css b/examples/blocks/src/nypd/light.css new file mode 100644 index 0000000..aa7c585 --- /dev/null +++ b/examples/blocks/src/nypd/light.css @@ -0,0 +1,8 @@ +button { + background-color: inherit; + border: 1px solid rgba(0, 0, 0, 0.5); +} + +#buttons { + background-color: #e1e6ea; +} diff --git a/examples/blocks/src/nypd/preview.png b/examples/blocks/src/nypd/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..a10a223e4a223ea83fdfd94911407cb17dd01197 GIT binary patch literal 223752 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

    }&fS00iW%wo@@+@*0mwHDP$jmG2ibKse?M8Yz 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 KMNUMnLSTZn(ouZ? literal 0 HcmV?d00001 diff --git a/examples/blocks/src/nypd/thumbnail.png b/examples/blocks/src/nypd/thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..f0eaf91fd244afa375317fef914dba117dce2701 GIT binary patch literal 36788 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 z zAurPu9Q@>B>W|#`e$Ch2t;&Wu6t3(8mq>RF_mN2}vNc*+y=9KhxuGHan70(ttZ_J) z4~GR_LbVSU986RJ-ZARr{kJ@8T=^&y;ze=Vr}FGr);&m|Y!nlp2)@ph0(A$c zK^#45A%)U0F~Lv}N(UAW3TolW0u_br)n%*66rREfI%!IY9>v4AR!(iH?ZktJ3to#f zR!rrwXtch*UbueEf7DambxsGaY+TLr5Zb=BKvA^b#O?}9!*q?J(KLcM48rWr+gJH+ z#k-{>jdyzOyq#}3ZB_g)ftlvH-Mv-fFXB}2n|S^>==t1NS&q-hX})(ew;f&=^9VrLm0uzXiJVzwYCqMt6E<$^ZZW07*qo IM6N<$g6a0u^Z)<= literal 0 HcmV?d00001 diff --git a/examples/blocks/src/olympics/.block b/examples/blocks/src/olympics/.block new file mode 100644 index 0000000..37ce96a --- /dev/null +++ b/examples/blocks/src/olympics/.block @@ -0,0 +1,2 @@ +license: apache-2.0 +height: 800 \ No newline at end of file diff --git a/examples/blocks/src/olympics/README.md b/examples/blocks/src/olympics/README.md new file mode 100644 index 0000000..e4090e3 --- /dev/null +++ b/examples/blocks/src/olympics/README.md @@ -0,0 +1,6 @@ +Demo of [Perspective](https://github.com/perspective-dev/perspective) with +~270,000 rows. + +This dataset from +[Kaggle](https://www.kaggle.com/datasets/heesoo37/120-years-of-olympic-history-athletes-and-results) +provides a row for every athlete to attend the Olympics since 1896. diff --git a/examples/blocks/src/olympics/build.mjs b/examples/blocks/src/olympics/build.mjs new file mode 100644 index 0000000..44063ac --- /dev/null +++ b/examples/blocks/src/olympics/build.mjs @@ -0,0 +1,63 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { execSync } from "child_process"; +import perspective from "@perspective-dev/client"; +import { + Uint8ArrayReader, + ZipReader, + TextWriter, + TextReader, +} from "@zip.js/zip.js"; + +// import * as fs from "node:fs/promises"; +import * as url from "url"; +import * as fs from "node:fs"; + +async function main() { + const __dirname = url + .fileURLToPath(new URL(".", import.meta.url)) + .slice(0, -1); + + if (fs.existsSync(`${__dirname}/olympics.arrow`)) { + return; + } + + execSync( + `cd ${__dirname} && kaggle datasets download -d heesoo37/120-years-of-olympic-history-athletes-and-results`, + { stdio: "inherit" }, + ); + + const zip = fs.readFileSync( + `${__dirname}/120-years-of-olympic-history-athletes-and-results.zip`, + ); + + const textReader = new TextReader(zip); + const zipReader = new ZipReader(textReader); + const entries = await zipReader.getEntries(); + const csv = await entries[0].getData(new TextWriter(), { + onprogress: (p, t) => console.log(`(${p}b / ${t}b)`), + }); + + zipReader.close(); + + const table = await perspective.table(csv); + const view = await table.view(); + const arrow = await view.to_arrow(); + fs.writeFileSync(`${__dirname}/olympics.arrow`, Buffer.from(arrow)); + fs.unlinkSync( + `${__dirname}/120-years-of-olympic-history-athletes-and-results.zip`, + ); + await view.delete(); +} + +main(); diff --git a/examples/blocks/src/olympics/index.html b/examples/blocks/src/olympics/index.html new file mode 100644 index 0000000..17a9abd --- /dev/null +++ b/examples/blocks/src/olympics/index.html @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + diff --git a/examples/blocks/src/olympics/preview.png b/examples/blocks/src/olympics/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..5cb91163a93b28d2be51cfdc6b1fd1efdb7ebd95 GIT binary patch literal 231933 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
    _u=6=%&7tR!JAv8z2NLX!vu-d7}Il+3oN<6OFgF z1*)@$MITF4sJ%CRnIFlv7d&9K%)qBKfb8aS{OA$`iJafu%)g1vaB3sex6gDPf_rV! ztr7IUFzMO=c=OujDHK@<{4{UkptM*3^n*+pUdYJut23UMw}Ya2E#S#f%O)A5FZyp3 zf~epHP~80G=IAw$u2Ve|rJOf;BlhK@sks%EFa4-~q4}T0S|_`0c%)8!3YL*VnCJaU z(Wfk1ratVI#kzCLe4!(DN;{@>QyN04POP_yA}w-}u;mCPiTlZdg@va2vm#2iDf73i z$iKnx?FmLB*-Q)DNK>%ZZJ!^X*pP4c zbEF?cq@^SyJHnfkyVV_~ZQEm`SZ+@!KAMOsB;vVE!1-eK^GtY?L36;8p&6PpkVvYY zf=DZ4tvO8$#jDpXm9Fi3yI#*^~ zpM+SAmCZmi2k!S+s_|Gv4Hxn){6w6{0ELRXnQozGIHcGi%I`@=t*|56>Bh$x1vL~( zb0dQqw**~>&+6VdG|SD*yu0FQJJ>f5tMPY8R?H*yL3lnvH%(6jhjE)JrI&u%q7T}U zRw!o~ntg}Xo2!gJSMlLChb5&|u}jjt5OCeSS{Twaw;jnleDfWg7P0JtsTP}GP_vyr zCQFrDl`}Q1r~recTLx4P1T#0Zc<|et{1ROFANK1l5us^q8zUm2!sEVnN%5zxBxO5w z-)&bS%o~;!$%~fcVRySZ7CZghpMuCsa90^oV(#^#&oHq7&MfKw0e9UD?$o4qrM}w` zSoQYtp#?;gzs{VHy3#u~!V3@<4D9gvlQw}A!J^}U&C;*bnGetiJo{U6(s_fw?#(FZ z_@e!!FH>7VSpHGeou7{y@Rn?&9i}rwH1r9j^kQNc**?jzR zqxKbFc|S^G{om7MZK)aoo)e~coI8At-N?M4M`&TeXZ1q|N4ogL5bW#6B$-=#C=_R< zEh(5nB)(Ks%X|4?$It)8x-i|{4kC_j2b~#{Wt;_((bE~lSDz2=e-_5jSFC*)D#1de z@^ScDteZGej$Wr~QiP6H;{7%O7J0y@58nKOR!a;jO(^B29^xP=?qipeUZFu38ts5* zGfOp>;hZEX4$?|tYAI&r8qWeyx41O)`s(^^&i)0nxzkg7x;Ev|9daG;I zTD~bzt|g%5!@HqQ{Yj~A-UD+?Wu>Yq_XBfZM|XQ0vH8Wq<;P9`*8bA!H|6$=D)TmE z_0AThOwQKHPOTggP#YaKDWH6h+9scWUHTn?2`Qw4m7L0Xb zw>2&^{DFqmMQial^q9t_tOQuV3-*Wi>WlG*4_)#ap@7ol`K5Op{m!aB(yIO+nmU}N zm+yP?@%0(wQ){y39mn>6f7v93f{t}fQV<2<+d8KSeGa2NnvOJ4#op#R&({E-FogmC zrY^|&oFY1R;mSCcRAE=-2fv1Cj5g!j8IE8B`DZB;%+KT4`LAsmgUeZqad3!cmnX{p z3YF+^1XL()^*t)jyY+e_7n@-0XjVGxQtw*WZd@9EDq1~-iRSsL<&yc)P}k$&tvaEG z8xN=UVIygNu7*VL^TaL|ir*DKgnmd#*%3W=bHt{xyYcdjm6McJrq9oFaGY@sAXhIv znBVO_#?*>N#*mWON-N+h&DJr#t(k9n91|NuW*I`U9A^P4)UL<#9Z0<63j`lLSrLmP z@n^#2P5CD&hk^Vo89$BLuXOJ&4^We~?Pf*(EoZzx2$Wm&%v?1md379GEh3uJbb`DF zM2ov-^LgLr8m+wJ0D26!%$Z&^kUsd$@u@}0+-F#T)3T1*CoA7rq<)8A{iRRu#c@35 zoAdLO+aH$q=9AoQCJP81jLIWl(2|;0w2QCIFzpB*H^K5}vY+fpXm5G8`3J|!5dpW} zDsg0$+>$~~R!E&(>+9^$P#x!Q`#(BJkf~pHvz3cf)I)-omMqqathoRu*-LtK+9

    +dfBh-1(dDBQ?eAl|lIY!8XR7~;Al3fGKD|kAX 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

    RB+{F-;1ieTX z&HhYU`DBu>o_{7rs}YHDi<7?yzuS+3CaWC~fVbDsn!i4^M1>h&U%XI`^QR2nN( zUr+ja=5$O{~;}~;1*J5@6wKo^rANl1i z^q4hr4L@sWGsG!XWs!L;@@K8y@v33L{zWCpf^Bfq7#i<#f1qvV|VY0Bpj#CX#tn9$KCaBU}53-t80CNsenbWh4D!Tfxe(NN<5WK_@$Sjv# zTKZgux>kMlVPf*@r2Bd)D@ijg7Y{jatl!fQ6{Xzou|M9`Pb|JNN6;Ib)E9L7k|Y=( zoLQs2-nE<}AIK*eI4)%x*Xd~|0IGNlkJ#gwbXtroE-}nZ(IHN@DJcor(}E)D4I9~@ zAtITeG&I+y_E#UUUBMMxNcZH+?(+mYaz81Q*R%2#nhxq4==}oJs@~?F#U}B~yG*Gr zMm0M=sd0L!E$)xgvKXq9$j|%oJi>XjK4`J3B-lDU;AN(x2o@;RV^wafMyj#tFSN`g zQhanQ6l~g%te&Z!zdqd?AkC_MvQQm&Q$7kWqKqrDA7_P;wqhq7OLPaTl{l0ZSmGQO z`=Y3NcbSk;Yey?ZR?Oah3&D{mTev=TL`k9A4JVUkHvqXiVU=Lsk)=e*v~0J)B<5lF z*-@lJ>75mjtlxPqHgZbk(dqIA<(*K2Mp?)hcQ;i!KnYo?7J*EcIf%nOxN7@P3wzU6 zPi#IIbMhqWUhtf`Ll(3xEiXpsU~8`4pwJVV8W1vlVii8S!{g%H7uQtkkgCT<&1OfH zt0co#;X7u)8}lpG$(OeTVI0X5Cm>z`R%QONGTEZr$ooVS>?t` zf4^tbqX*?*3|&!GR7wQZ*S!;M_4mV_vR0Y>Ls1e7XV8b;I+PdoWN?^2VEfC3#VPqm zG-DbfALCO~?eu#-F=JEF$Isc*UlQjY@_St!`$>jMwa$UFO~sX9!f0Zbrl-_7M+d+V z67VB-JVlL+;7z1E{k_85s(;iR!}r8FJea#}yJvBpz4_hpKSH2W5YK4Uxr4Z!lm~!Twz{#G~dkpIm@Cf zb|%r2iB!JkEeUja4cU@>UmASn^}~i*|LGEj!=Enz!38SrF<0kz zA%zI`ABmQEDgAgT(_mFSOkSfhvqbWo8vevZTF(%jg#V6JFmH z?P~KT>btsp*l(6J;OP2%PNQDK{-kdmHm%D0+1GF!+vlhED#Ojl^YTlbK1NfVA$N?_ z&c`4BV7ycg8Jqo?q7cc0Q`I6TJED-an*ZEvcCQlE>VencNum7eOF}ei4(tEC0BB0* zMvA`YFZFb4O_){KruV(HHWl8W{A~hDD(K|1FPS4fAeb@aL*eGc@grhY#(ixr~UiZ?;WG{pdSiQ%1elS)|Q&J4O z{w3PlfbPem)yKIInk9dgOTDavL$H?L6kz6xlZT^aN&gBL8x<`tZGI#B6dO_6+ES90 z&wu~c>)r(c1*|cmS7NkP`jz?L4z}J`z+#lki3CMJjPyJQ5>KW2&D|#7zC;!2>V+ zydPLktWp-jsr!{<9BGy`pxj6mX(sr~pEk7oKEf+c?HxM%C|cLU6JCxK#n0plj$~7( z!~Ri)@?|89>Zc~J%m`?-w|W`#w8tnv87_?b%&~(mHj>z@(8NM~j$dI6{7Tw-IkXsf zxV>$AomHdmVU8Hqehq4@y_TE(ttJkW9$r7LTH}qcV%J;uCf{r!Uvb97i*&oi2rMkG zvuoc|jZfCV^IwTbSudv=D^BjY@E@pWpUmC&nm!^RWW^2-p})SR|J~z6vYOXIBnbR@2SreS_e=f06W&h`88x7s-dxMQ zd-wh^RUx+=Sc3n#1AX65;3i!I4f9lz*VFNqN8dUxzd!7bTmh|6JqumG1i!eCJrD-i z;SD@!s=^G3a(;&%dHoRd z0y?^(RUDx)b}M6aA7Q7<@bwh6ExB(FIv2$=*2o&yIDtu-$Q&~f6+Es8VIMF1kNBTB zBb4rhrok0H9N(4&cG{Rf{#z0gqAcb-#z<^R=oHM3;;j&wM6zgNfP5T`=7fjJQjxqx z2E%75KR4xNp{vf6IEJgB0&)YgLV1yJJSWD0L4{!9q%h4V7Dqo9qYc9clBw zqG!xly8dOH+?0B6m>n`niBaq(%67c^ZJvDg(UumrpKok`g+g@(|Maa0jCnoZM|z>H zkQP}-q4~eHLdbeFjl+pUz=%m0F?E-hiD~rqw!(5#0d=U2LH&{C@gKs@+FMf%;(o8E zn#5KX2@G+4K)9C8mvWAC6Zist;2qB->&lk`5@6Wo9!_EYn0h&@pYQII^=!r{S-c1~ z-_BZO@cYM?GJ#f|b43j+JxXa?i&F>YJYb~NQmD7p@7v1^OPOz4fWFk5l2WUmdZxJM z&Dm(O?L$)>uw?bi?&~#vxI>h3e+pi9y!xu{p0yhf-16(ShQfQ>Y^Oz98RIl%;w_K9 z_6b<)z+4|;0lE!96Ag__jz$!6;xI+}2D!?X9Wt`>6d+eccP_AYJc&1b@TFjMO|w&{ zq~6t^A8!^J`pluF8OF-I;*m}oaP<0AC)4`-TKKMjc@-7+nq=Ql5^wk-Um8sbT?)e( zu4nVNcb~Ma$k8Q}n1Y;dP-wfJ|Gm9njA8(Uw7lMT-M3G_cyGjd?>}0JlDbNk>SdMu ziA@FKz3}PC~SUL5H%Oa416j@T(`uKYz$lBNmiy57;scz<~JDAbI!? za{-061s6<1B4~VJc~|3h$A+)Y4gWQp11`vKaPW#q-tKB{^kUHt0PbJlcHS$RS7%DE zLZRj@8{@*2qknnvKgR@r2`Pn~d!SM-AV^j@rf;fUR^%8}5{KE!mhKUK&o>m6d={mh zXe=QnI9KGtz;iz;3u!v{ao|VfN2PSG&m%PrYaV=}2CWn5ZkL3SM9-FeneZP7lP4Ch zeL??HzN3BKzPWXP@&M5t(n8@7^V;wC73Nh0{afdHwJw-Eq(MT+^pr(-b*rwR&)~N@+ z87J`5Qvd!Sa}QM<^XJUg*3G z=!j9`uP#^>OL5b+_bPv|k367(y$}R1>g?@m$0#`-gS`H@TFjxg~mZA7pzc=JcasOA@jB`2l)9>Vu zrrmqzAIR#nuSFVGh!K)+4F=b2`3ds{2JPyV$~~p(3DZvV{_K?=1=1>Q^Y`xi#3{4# zf45NsWUPIYE7I4N&3j7uhH}wp+T}>^xnE#$B|TTkhLs&H=*Dp{ z<+O6My(_0K1Op+5ZFo6279$W9%qu({%H_b`g6Vi6L2@yz3N_4fBPBt zLho6CAy`ZT7uOo#Li9aaxi)}oe<;Vj1ObH5UEmlZ<$<|i(jq(USjE}@j&AZ9f;MdEc}m(5`h~=vAt`OX}`;O(i|$U9}z@20OyENS7lN8Ot=W5?!_~P1sH{;El{@aU{cwDYl6kxF}Px`TUP__L}n!o$-OCVTYK*N@h zUKGlQE(2e_P(gr&H2`kwdDwlW4jHP+ZyrqyqSj=q<-JSCvbgt=aL2f_;VF~)!`rrO zx}lR_G863KcQPbCxQ&o!BXz^uvTqri_b2fsb(cjKnxDR2QYF9Nr#@Rd*u#h2$juzC z-_6DRdgG5njJEdsgA02ggT?dtR+r!xi(s-u*N<;xgt`VGEX()tBLZjRDwGeSILfOynhy66*_B{FZ=s+hSF?~EcbQ8oJP4k(b|YSBDpe>U+VV>g zA3tw5(G@vC)jn=P;3VSnkAFOWJ9Si)%z0v=#_ZEi>f!O2)BZubaYg9i1Il4u%zpas z-`jUCV&hVJPZ8o@9|?g=6eiYn`HVk-LNB3Hj?1&!Xqm6WMaQ19OS??K`(l-)pIWT# z-F};RLXOB=(_%G8qrc$BQMwz3f_XLxNz5zcHled`-YBT!x=Blb2ql5WFzyKusvo0@-X$?~DrYXU82>3^;F=4od4N!7y&?!JogzfHhk$C3y^pS*nk&WV0u@CV z)h}LHR+$icIJDkp=G~~BFiZGGI|+OLTZ)Ang5zD@q?090XzCg1vsJ9-*;MxI8UYxR-_h4o|)}uwNz3;{nzqvr!E*%Tm z$g*#JCCgL&CVBp7AVx);|LkB&>NZs@eX%#)iFh@``s$Q^BIudF#sprl0>ZWT4Q$@U zc{@vW3HU!TrC@Krx6X~sIGZY&aIk)Cpi*I#>{06UqX0AC2f{J3JfJi;ogn@O@wIgO zO{%ULvwLd%{Gcypt96n5wl2MJ8 zfypQHS>FhqDA|zunE07pik>u7tJKs9A+zF77^JjiQ^(Z;>I73KSz#3&S{$Xj>u!9K zUGZP|G$Iy^u8WF)6UN$P*@B~yb`99pUBK;VNllDF_O+QmLo=D8h}(XFN{dwF(Ykl- zZG!3kzp*~v$jgw=s5ossh72t9|v|MZl}6ElD1`xdD+&NOXmfGL*^N5d{toq zrfGOSAEx#P=2fOTCpW$Q?DMw9ukg}c5?-TC`qsMmnp*Z8kjSc_zqUjDq}+prweeg! z)sg&rsWdK(aL;&n3=50o*GmN$HhWkq+Fg`TUggDZD+yQEMU@A#%7PqCLkY+J(poR^ z*hTc-VSWbN>h-qeER{>WU=pkWC0Cd&tF0W4GO`-`KAlV$N_y=!k)a;tXGv*k<#lB$ zX7M2{QL}G^n!)m28>Tq0LzZl;Ty&2ZaCYWyWc}RX8-Hkc(NIA&0xK2macM)~`>Sc#=FsR#fG{?xUUe;PS||^X1Wf z9Oo4hV?*MK6z_vbjPFTP*E2sS?9iWoFSwz>@bX(IXV%`oN+X{2OE+?UoUx%#>BlkR z_V;bii(Gu_6+2>@Q2rs2s-Tao|Q#M9%%?>eimEOrFi zjYizRv6pa61kM-=kysq%(|ucbRoZ@92}eRYpgSsDa`eOD*9 zsl?5eyCRl^3}02_e3|PK|2RJSP&(U2pws>m#W4Aa<4U-aC$?k1&}-a^2dq~wUtCeT zJq>A%XgtYm*wNJJj;suw*_Hd!;cPyt(;NPi=b*mqfs*InPSKHFRhkc^H$1m)%8Q>P zH7TBGcxN=M9c|4nR5y6g)w#N8Q%~gW-mit z+%If8dM(Gi4lNbSG~4Ffw!ISnudH!%dr!)W5R*Yi{Y5mR4TmHSd2r)Wq?`Yfo1Nm3 z9h3d7_O7D}qkFWI7ppgtqYYH2Do({#@$7rY-qmmiQLqfEG`+8rb3b|Y``}`ft1%yn zjW7SO*zt_O8lv^5DVB|Fw1g zvp}v{&)g^M%3g|Rs=-;bQ6b(ZlPEDh@YJLIT$~(J6YhRcWP-{J$Gav|`|yd|K-jR# z%ed0BCZyMuNG)>ytX!nsCkfcZ3y(SB-!C+!vUPWqEXo z)g1I?#ng1cx}$wSm}in#9T};}-MF)<7ARKS{ zV6Mj~i^$x-Ow44#B*G4LuO!~s=U~UI4>PyKKn~m5KRXHiwio5A24Aol5!ISiCQVBH zrl~p4PX9#*n9rKTdYE;x`Lfx%Se~x@r(f4J%Spy3y4y<0BB!1Y?Kg3b4WBiG9hO<0 zZsLnE9cF%XqzRD{5$2s#^O!4XeWJHX@x;Bg-!$OCjftAZX|I!EA%EWP(Ncz24%bv( z>Po5*TgmhnK%U#{RWN^WkSnKJb0Dzt~^@pYYoosJt@Qq>92HR#k;4)Axy)7vyzQTA>=uQ!%D+Vz>`RrH zSy%Y2+pB#hM{f^pvDV+GxswnjTk_%T4lTO0ams5atuo$N`_+(O18mJL-Icf_w$VBb zZ$($c97zizMX~8*8e8|V-J=v{am~~zTjRL6aNarp(T3RrS^r{V+jG?X@aq$()})BJnm|_MBKDs8NOJ+l9amDD=6@Ukq|! z{r+?G_aBY-Uq*gMNh+%Q_H*}O6d8vAcC6krK&!>pmgobUK}iTf2m#c-Z*Ox*-RU?H zuR}_zJILLZeE>r3E8!*$8sY670$sZ#$?*n79M1|1)UimAh@xwL^^H^x7P-L?Wo31P z&QyRGLf<$+#}Y;K(&zxOyQdQ_Gg_T@;Su@xhDjQ^&s5HdjW(%4?;}}~-$%nZi9zEX z-piP_ks#eG?!6kPkJ7^!H`#{1^(AvwMGGWx)=gWjP}VKPziqL5@|3Oh4G(VNWEd;e zMoABH!0qGZ?75JPtQHRL8+92G=~W^nyd95SZw`ihufw#Bl$oDx8UlWkIV07P@b&`# zj$Bo2%r5~Jz6p!M!(e2R&=WU{)%>*xedmg*ih5urN%fM8D=Wo!{Krk32TE~YSLim0 zxz)QS8mxs66&#An!kBS(cT$PVJR?tqM;qi<-2WDq=3q^gh<%qdbmv;N<04;ws@@3)>%?gC%VFs4I;U zDl*2}=Wf5=fE%anrb-ley8OrL++i)TIK21UFp-lNXipY;~4f%Ik2S+R+IA9AW+XKJc9md5LiX+=`+z z2>is7E7Yvj4g`^+V2wj*(s09+ifj4AW22n=`V7uzT{^723TAOR?*%?q3Q4hG)5OX= zFiXdwDbn=#RjsX5hE0`a~9Nc0d5FD&i98J643U$6&Ly0sWg~P02{ei3t1) z5C4T!FIPiAWVH+0T|-cG$e~zrCgUs_~){UZxf9Qbw;h)BNirRRdc5x!gWGN^z(mpX+dO zBPm*LkrzSnfYnR?VaKpg+8&ixWU}_ewKizn zu_27U<|zJjLqP=o^9P+6o7*Tde8UiZ-sx|bo%;{U@OgM7x7&A@+UFPdde_8`{G`pIDO0QHw%xYfY`#S_4o?;UFHmsY3 z`v$)A+woCrMI=h|+mo2q8T-p+W;a+E?QW}nL#WcMziNhgM{vepFb+2sNIz7m=Cx1T z7@tGewvXo>cdygD!%}kLaWdp zY0!Mkdu@a<#EwFEnOn9qrEQcd4#F=sAL!^-ZxkOodyZ3|VfC~3k_9jLrTl7Mpg!{A zK#!okL_}MD*)9`G>k~=)RsbI>)Y8A(TyJW$!-(gH7vTVMFX}8M0}%ce{BaYg_$3Y@ z8-OrZQI^{WWY1EAzt{d9AR-TZ0{@t?S06OjQ|tVgL66g)yC}y%^s~IZJ|AhogY@@* z0y^}a>0M{c&fhx7|LN@SyKmk@&GOKEX`QUjd(fI2UPdQS6|qMo9mJx3W}UjBMj4_; z9zw_`XkqYlo3JAz7ZD!vRQv&vH-nCsN6gR$9slKLmMlSIgG=5B7UkOQ{AgX-Y%y(r z`O<9Thtzg=+8!nLM&2aRk^Df*wCrFJRz!X+{-t8ss(hdye6)x;=RaK|H!q!SxDe_| zvaNFJ);gh}i|6%>kQHNDTr8{hz#-SoGYRf`(O#CZxwIM(*Pd$;9`(w#`-XY-gLd+{ z!@K7Q$Ch|kRgi1?Bh3mI`Aru{D$B}FKf~Go#q>Tdt|09zZSIAFU%T|{$wchj5~7~@!@<9J!yMw zRp-OF8zs0$PSIkI_5VC-W}2xQbVw^$IA|Z{lUTHh43SEU(O*ebjknP|cNDT( zI$#+&LF+Vo7iLbat_Z6b_<9}*>D9+TrRI|0MRT1|VO@iCZE!D6`*H|s&3v5!gzwG6 zXG35z4K<*7ic&)&5u^@+*yPZ!&3FH;Gx-~JqB3uJUw|H&ue zG8diMfvD8)CwdJ=3^4+QEEW_OGWxPkun%s<#KMiDY(YqpY6BO2j>5vjqL*8xzUSK7 z(Tp{6C4||Ax1Jx_f5P}ml*eRu%-T#y#Fr7tn&1qv!9Ir6RWLUypE)dbh|=_1CxOtF?45Nf z#J2(6$lzoqx4jq1OcgBz`Mw-hbV#7yI%)>|CrJy5oo+h6S{72)P$ll^oEiSl3s6!g zVE%s?d#ivdyD#dO?(XgeDFNxuO*beA2q-1e-Q6K65}Sq%l8S&zN-K@Dl2Rfd-Df@T z_nrUkIbL{yAZ+$})|zX~F~^Uk)JF6sF*;iIZVyNpEsL(PTN8PA2JBI2Rn{>7!gWqL ze-tqEU^V0X<4LA?wHb4Qg`*>G!5@9Z0=|lVT;RbMLeTXW_}lhEDvF59+}ya6ynShQ zJ3FzYwo5k(j>6vOZscgP=f-8OnV&g!;Ui9~8$lUa)FT*0WbtnFN}+t@`!Rb^6v!?e ze6J&3uTT)t6R{bpWgdElH`2o_P}jz|tas400m`sOF$wb_q>q3vKxTk}gT$?U!h zGm~{AaB>T(B0pY_Mw)O|%vU!E%g|SxGAFO&AZ@L*lPAnZrOuD!`?@Wd4_b>9@ zF&IK`+?RY7Us@-vCR*LF{QQ^wk&kyY2A@_Q>pxrI8>)>A-*Q!X)yjpJ>v&SrboihCtf#gfP*NwQUCNedzC~ zPbNZtq*yAM8E0a>?V=#X;pL>&1oi;C<02@CW+u1@%DFO)TfKo zGsvpQ^H}xDEm_=W`7R8?>~P{sxc-Z9mpk{A!{kPXx?DFSrh?JO{-L7R2onixW*A%7PWU7wkSy*#cNvk z6~e&%_3iw`6L_&{b!QJ{$wECnfMviR_0Kx=`D6()jv3`aTW&!1Oy~wo08&bi^}hXe z^LzJuMq~6H(kGuzL9yU(O?14!15HqxGf9#>Ph#JkUQlgO*-=-a!+TpxTC$S_q%i8a z#_i07T8Su@WU>Y_B@XUOs6P!T2-zgJgY#WKy4~6)UdDY|O z_4T=%NpssW538Oe4N-%tNDx^ucipyHU-j$}#cR}Ql|}ZjE%-i6an*43n=%^GFAq8r zq)M_5TVYPLXkE3JI@tl-D2jA?R4B-fm8U3i;Jgm#X~*w3cKOAcvZupzvcDxL#~f0Ig`#pC$T*Nq+{qLZ@(<1OXJu{)Gzq*od&n?pUFSidV-?v5Pvrw!ml|B zVz-|PU*l%%mak4Kn4poQzv@J<-Wc?-kx8kbG6)s>e*EG!RkL4zwb@8$iU)T^ND9M= z#cQOml|K%8b3V6WixUX=YOY}!aOoKoQf_;Z;sF!HC(A!SZ|Mqy{%L&Pvphhv#bdmm z8#n?5I76!Rji76r@poQ<1s~#{Zkx1jy^mgXE&KjTj*dqEyz}6d0-f5$WX_$O^uNPi zvrZfOpLfokDdGu-l%YqV4bM*-{np}(R!SMKm5}VTQejvMWHEq%U<#%zgU&zw>I3Q7 z;q}4$&d^LKbNPRr{rmIX8qis+^m_M&f#T{`=sQBq@k8F5ik)2D;t@NUcWT;R2BNcj zCcz;i5#a$aHH68-ECz4ay2OyqiU%A<=Zz0|zVGG|+8(lnZ(X;OkB7IL^i#=~P2$hO z0+Q=QaafHfd7amdE*;>;Wb$q{^ds#pxwzp>C+V|S6sdcu>`|^CGM^^~c|~X<3ctdh zEbY-(J>ZCnpnlmX(V3+ioG)Ht=A0Rbxtq$#pgHx!al(uMlbeD9<4_tJxDx{dcc3uq z2a(-O<#cbO>dYETCii~)x^Z1>d3SXVvgrts0Ic&n^5S1FNhk*?y@Ffzv|4u+P*4=< z2K=^*W8FZIPV!})g-~o3@9+MQ^KZ>#3W_DO@i`$}p5Y1{6WVL#LwzJfz0B?SWDYRy zH9<2UJlU0GG6&P7&l`Ss{?>%Je^8BuyA({%;*e(9Ia;*7W-Mtmk|#wWdPx3M%QA$l*rV!39q1@?@y3|T>R)i zYr!GN6kS~}xYWcu`57FNIsT;s5$3DF3yB?D#pt}wZmSZ zynDWW}pC@iJ zw={p;dUez%TZ_J5BZ+g5o5>c>*V3i+XGrX5_~Ic@X)WGGiMaM^>z+wFy$UthxuMT| zEF2k_+5S<1_OE`4>C=MqDpm~2X@9y{&7_3FKXD5ALK|O&T-H^*W)uwZcH#22B~*0> zFFonbsJwQmN+%Aj^ep zGovZQ*`}D6SAW6&QEx@aopmR!t+p^*1zV8xoBi}N8d$;aZ-?%aAAPy}4#v3eF*`u) z8WdsccU|8>pGl&waf(2o$f@X?P3=(_``2pZH}P3*818m7E^xM7_ziQ8G_eQcy&pcm zMF_ZUW>B&=kaP7gRb7$+zwIt*KEVf9@fv4`EW(`Uo1*kPsip4kj@KGRWL0gyotpLc zb3WcECH>p<*q@$}a~*Y=bMANPaB=bL6*iOLgwpLW_mcPid|$BpC!ajyyN*O2ZbEzU zSv%~>lcyeE{EvSl%1M~_mCFBTS}%wuMqX{s`^fbM=A4p)7G-qxxSvYgmYmA*bl0}g zqYt)p-OIRkxnVazX%(4HnhQjB-r78A;#R>F=;u^@iJ+n+b8S%)CB0`ZIY-Ehc5%NS|8vbgshCdO)h){mQ=9@BA)8=+Nr zAM~p^ns|Bz76^rbW?t@@e)({(hzeOu`+CCJgmtY=UdikpleQ9xiMWaj`K4DpZ6OSbui<2O^~@{dUJ`jQx$r}UyXOYRpeu<}n`njBd((A@IpV@ksC7|@u!pi^P)vHG5=l&ce{&`VLWS{3ZOxuFw#<&Pfn`%iP$52JIhFRL-3YIy{WuFEk(VVOtz`~EOC z_oO?PK_hcvcGOAUBn^^MIVvM;S2{;Ca?6coL8{X&d{#GE*hWH$d?m2?BXN&PV3i+A zXLRmJd@8w6Ii=li@)X$|yw@jLx?(h(>eVE}E_BKtrp;)~j63D3K!@q3 zv5^ksZyeD~2VCsBBQC3srQA|BU#}}04N#RnkR`Kqyka;HSO&efB^SWWfUGpYToWUr z4agZms2vCE_^^MJ+M}@wHjuV+GgK0@8{I?cU674@x6tfE{CqzH; zFQF4f6r;}S<_@COFx`EjRx()ccHHXq*`IO4=Er_1R1rje(+cs~VT7(@+Sp9u^7N+x zD0&;u9Hj^x8Fj0^`URUZ{oBWO>qb``>%eEk>wW*8G>;amS#4NmL93D6dX4Do3QDkq zHlB(uKi|p5Zz`V4e2y6A=!LPYUG1F?jyzj%mXdy5v0xfW+#JYFL)i!R{&=Cp>lX`w znVQi0;xaL0XD9MdrLiuNH`C7El7L1?!S)JlNuxo;D{d|KMPD8gHkU{q-=9wh?EPF^ z7IB^ebks-%$9Xb)w^?!b=~mSjTA4RmK_xoBg-YK@cPG)TXZw@bCOv1%i>Kk_k58D$ zZ7oqHMJC78b5X$>B*0(xt=Z)3l+!UjeYuSYZ2S0@>Brtw!t~xKK;UDs13-e9<~lHZ+G8lUm;?T)Z>FsL zm9$}~wvTb+TJ$M@p!=ioc4>=a6(q%D4Gqe8d)rfk$@!jzCP&_xCu)RV0UpY|2FlOh z5R{Kk%d2G*@r*TRdAm4LVK8K~@XV`cZH{7W7JA8XiWg+>=T_9dsJWI5V>u3fx%kok zTE0d;_H+X+$4<&;=>O2BfiG1x)%VIRGuAk_(aU z0Ffc*ZErum1eoLGDN#ABY#woe%zewv^_L4=O*(w2Y=vnWx`KV z*ghJ$Qr@&FotGkAc(+u33WsbPHs&KLc4d1rEv0H+I3uHa4gx7ceZztU#8rWI0Xh9j zKYSpE4#OyHQa2Es8v5MeOT^NUhjqrfxS$qkl7V1IXN*mxZ=Q&NZ2_|imp>UH_&Db(l@6RnP8^4k?B*KwPf+yH{pS@5R+x%r z{wT#>c}GMB^j!y7!Tc!Bo?0QCS|jttyRPA&r0RvRMNmcrz+bHt2L4q5Tpr79wKDvhWsOi(q^IX z07yUMd;A`YltZfvvL?TM)jq8m$5F6#>lgzno*xPj_uy#Q>xTY@3b6JARUJyGtUroozc|$IiQ20GDqm zuBlo4)%X6^Ly})a8}kL81=e=EReNO+<cYzR{z}tx2Vlb|kN$XJq z`_9m`Krfr~6ivm1^P_1&d_CPCf1=aU@wg3tf5T9=VdYvbC(V}Of)Q0{ z_I_S?pY>X&v^e(b3q>;O3g>b?p6r_@HJMMj3QueI0`3UP`5ZSg_lQvX>Y4L(o^VmP zl-*3Kck2ja9gSQM>g_yhjLO_R@N$ym%9s$yLk#85TdPU_;na>X#6{LVnE&w8^y)Pc z`hy3oK?7ps23?T>y*$iKb{Tc&+nEW2We?_BbgxW|v2P}m(?~42C~hD35MVJ6J{R`b z5_f-eE+YZvnB1rJ)w;bcwF;%F38fE_K)|geghMlHbz}Y+dLQiN?G0difDnXcb$>NG zv9y#V;rV+-AoLO!$$F{PJr@u+A%HO0lAZycFcf_2^LJ?;w^jwK8`!Lcyteg{80Db2 z+X%-z5W^Z{V+}}lkas)%`}2vPKT`l)6}%tJ1v=}k>nTbAx3#dis5BkS63_Np{lCAy zF!1VY+?Vg;gx?3AzYH40{c;yDn8tjuv?YtFh$Sf{CG`9oRTUxuAQpSw>HI>1C&wbn z3@VcNr$jqb-jV-sw(JZ+tu?}l)hW!EejAS$2d1g9X&=4pK`wo)&ol4fFgR?n^`2e9 zGDJtP{99}|`9#|HDDM!{1$aczm`p3$+uO&Nmhi@o6}Nsg)sA;R7^D2FIc)X! zlR+iwc9;b{U$jasxk~uoSpJ}F4?7YKwsd9auW(8QEWglTfHR{ zJDgxYV=jI=v5A?jwYmwPeKLmY72hshfb*F@LlvBqKjCXyqDr4<)uAQH-6s+TiQV() z`$ZPj($SY_MF}t-OLclO2jZ{WDjT1%0jilOVrsVe`yQQ~g4N$^Tcd?IjMBbEEcG^2 zSEXrJC5A2LtY#BhqHRptxR1zT?j||-f5+UbW@2<@>t$P6bq19{eC6ThAvc0Wp$e2V6Qm4#{zC)) zEU2AW>#651 z0q_5f*<{w4WfhODA`ls(-mOBc*z- zO4Cq*v|UNGjYEEqG`Rh#mp}>+Iy_90g;w#D;~8E>J=9ZEOf9)mEhb|~+#y6C$CL3GNXNpc|GIW#Rh5DXTlc-1q59WY zh@#$1uB%&t$5$~O?N)ulQ0h{JWIR1c$j*$^tdGv=bkfTsE}uCVSRN7H*i;SfVhG%YN;4&%X4c9p2D{ow1?? zEVx?CTYCeL0HuN_Bdyk{jD4|D#YKPo~&E+E){ zSzm&$|KvCjPOZejAv|dkCqAeyuUqHV7&YyewH>aN2%Fw>a`|cqw;f~V4{Dn{;(35> ztN7L1R;G83Q+@Vf$Q8}?^%~W?lbFO|+PtA{X{W%g%XQJK3xo6sVcd}eu5 z$fXbFyIhFkcnU|jXuCa4KjE(lgc-LKProP~Ju*C?!oO;k>))17M7r{=Y)d$=Dd8%@ zHp@Ktf4uUrX1zgoI)`>P6kJu7-c>0{Nw>w9A6N67>$;&)Yv=keJ|C|KsCqFO zgNU-PcJ|C=54%u7g_0k%9SUO~{DS|JdXv^6kR{(US5@!Wyyb-y!QFW%V-GaYmCma6 z35&?K4msTR)t{UUUI}`UsP2RD-iGcUq!+a z|K~{$de=2KW^P)7eVE*_LDNe-S~0JW`LI+22jX<{3{KsC-OwvTXe0Gq_BJij`2!8z z4$%9qq$v^XOmXl2{TpL%-nxsZxk`c&DO%4Ui~-QRR5cmrz7uTOy|IM4lCF$yA@z_e zbRhHw6CCYKQC*+{GllkTK!^yO&aSQG+e80Uhp9aazrb~6RZZ%;x$Nv>C6oijM$hu< zKdc#O6EeNM{wVkxF63s3`9_PTPBYLW;w9~l7frMxHz;E`iR}-QTWLZ!c^`lo5dpe> zvzg+JtC$afVA_xnhkR-9RV|D315p)qSNs@Ftcre&VmlJ{!7!()ot z*5T#LK5$+TXiov48TzOM20jg%B9=+5S-9NYQEDT|R|QZTh&B!y{R5yBOqDsKdOlVN zezVtcF26dKV?UmW_kjzD3{--GUmarCC!i2C@EM<$b2Zh0G|ab0Ac1`qauMF#LX(su zR+mjn8fQg|nOPCGpC++QjLC3>zsiA^MY@`AJ5i$YA49FKjvw%o*THexJ($jVJ^dnS z6#%RitBrcV05tAmi_o*LSWCb+0wLf99me5ifk{HpZ4812Lq9xJ*-n;e80S~Uv8fC9 z4{v7$Z}4B<3o>Bxk}#F%(2}cDXK@%9?Atw2F?(0#*MK4;(H~Q_c3eWD@wm=E4#6*a z$IDr0qOGhY?yG|1Wq*NA)Wqt{Xn z(Te)nDLYEwl@iCu{{r>J1;{ms1z&n#@!m4T$w;I@vT$9^lPt zpMGgC>7|R?^5;@rQEodNs%7UqTP0vv>tHK0H85$$*W45@n@G<0lj$!eLJ?&tOmIgY zM$<}GxvF@g*+uYZVRe;gJc%k)6NwHdN!=z>f=w|iLs$Cyqm|Mv@n40y$MA$>Tl6)U5>tx5fi8M9xHt&@TVHbfS9lm z(`s|b_ZhoJxz-!Q>eCIQ-^b|S>J5Ah)nmULot^(1aE>SCT+4pqTD`pu*2QWpqvEVL z7}8|-$fyvwgc3Y38T^=PlSqnk!WD+DnC~a|oxj6!EJQ~gd zFA@le3}85b(<=?m0f?PZp;M3`b?})Kd>Os$dD|E}p@Q56i}R0K?5&TA#bJ;L0>WkL zc?Le2@fCkC&i>`(Fvyf6&;=`pS39{-&v-ZvK9?s$&(2aTHd9to;z0XEo~W|%54|ek zHQ2Ngp<^DBu6DNDzzpV)55q4A@dAA^i_rjoj`ox7NX(s2iLDhag&Y z6&znZz>Fa1|IZGj|3Me)?d|O@P%c1>FrXc-LY`3&5R%m2ubS`PPn|3F^0D@oBaLJe zHLu5-Kqj5tWVLz#JFi>WgJ#zN`+WRiT&jW@7T=JB6x6Ii=N(N8n2i)a&BNy#151H# zEnQAazUZRU;bo~$p=8!f=+M5fR*cWTAXf?VU~hk)N~9->E)pa1Jt}NvqQ)bSr+|k< zshNYWQA|7|#Vd2WJ(OOEpkWlfO7B@gaEb#{E&@kTBPISbLG-a1EfS{6i9aGL+=D$E z1wT1V?I~Bo6zg``#0Ykt^*=g|lUO0D)R+@_>o|0~N!9PRnUt162GiXd?~`q^<31=H zBWJbB;&}ZDgfqGrv4{-J6QRh<>A?&5ALk@HagB>u#kJ+k00AgG}_A?%}A&9e3QL604+n5U2tsCL&yw6U76_1<1O29xB``k15shf zm7`X^xKMSB;g}ye3iKJ^0@vH=?CRPNWM1n zw_mLZbCYR~djnh_8yE|{JEk$U8LEsBtofQ>xmKh}MsAFa-K;-KQtc|*PBSS| zefY`SFq-!f2_s;9O`G8l@EXEdcSb(t#+Cb+?ur@AtXo~%o9q%A*mFr%yVWCF)2_H$ z%J^xq(F1f{n2P019a)Rz4=$x8$Jg91rvwNDtJiL?sVNo7w%a+7m5}h1)P`Epg|)}A zB90+tW${%f{XOvT#Xz;g#o5QsWY$z^;Yn3PLsDq&AA-mO>uqhWhY5d$<5~nRe^>IO zpL4wD9|2Em*1{8CHJK(F%H;$m5&T8`5{8jd%1193qEcnAu%P^7EM_2~0)2C?)KjDE zz;_a89ON-w^&p`E;xrM49HIi1#puy*nVk&`ore9v_UXI|<}Bfw2o$PzjEC%$7Z$Vl zi$dC{mqEx$LgQ>`0r%-%lF0|b2Z2!b5SX{>v{~(mhTxV;mEPceQ?H!L6|%*EtX&ZH zuf2}7K&Q8fuhaTH#z+%z)ek@y_lM8Fb%9qI0p%Kj#1581|KHTm!3rRIal1B1&XP(6Q&mI?~;hEVCUBl74MEBa+zyDe)Q9imA`F+1iZY;-5Vzr zW<7FrJUWJQKwSf>Ri*)1VmS{bHx`Ut%HrfudOl^ny`h#&d`rFD0LW3DgFVr>=_8xa zFuwJ-Tp?(g^M5{6gdFtwo_ajTCE!{OR1Tj&s=zK9yuN3|NI-{yWRFp)1Helu8i4%v z-|{6$K~>=F(M$S2Rn2oy{oVjhie>V-wGM#QzjIw&zRoltVum}(lsouj0S&Pfz!vi= zYyb!>STY@8@dUL=!Q_8v*}iRxM8&gB68Y>Q3Fy=TGXh9RUY2TLR{$8PD4X7xRq%_d z&MkQa8bx#g+r_zZp|UB`|I}|lpPCVR=s>C~E3(ZD*Jkm7BGzYMVs70oNd)ORhlIwb z&Z(&Hx=y93+8I#L@s$XD!@g&lA6>3QK^L&`$yxL)}1d==rcG*jd1zxzGL& z=?1!?wi0+%7-O>6e0N8T_rw6hg@rupeFQMWwqVl&8bgu{^uaBWrTZLRp$l$a;x=_p>5?Z9kBvPB*!_r2vtdp1C*%QbGisLnM$ zGjILh_j(GKKmH;g{yQ)hWx2>c4H1eR>eJU`Ft~-Q{)B=~?YAbzp&<=2V*qdrxIw|? zpMKr)LuFvlI_6-XD)aTHMPz^sKqa=45*U+`FLC z5v+B3J(&CchuI!)OCYBOfdQw83ddHk;Dwx~xgd2dWxY!f4~om_2gUFB{CqsL34?VK zfBa-mF$=KaKp60)gL@3ZEU8P?d z(6>Q}gDwpgAXNSEa`bUd5|Ty}K?MZp%*2COo^_y0_JguRFzCX~%g5&#*m@wj2oz9) z!B<|bb%}s4L90gZO$HREr@^Pj;HZK|8DbEKi^E<*_rq??b%3Iadb#Ka>K1)2GB-fl zwFN^hR1crywSW5LKCXai1FmCK(td9B5wn)d{w|9htT%4RVb3O1#WMOEiod64@Y~bH zP~6hBXDXiSUj<{^H;K8E6}QpQrl{HMPB<%Nx#;mXAf6oIVBZ ziCKNeBw>E~u<#&4LH;iE*t#FCz2$g$2xVE*nYjiTLT=0Yd5qJ7KXZF~n8;WSqKLACuPW97R{DD%z_gBC5ks}oF-ymOC*r52WzCzls zq_b8Gqh<|T-Ps6i6fpnbL@r`E&|srvz1T!i19L0ia(*|iAw8h&4ypcH zZ-mjo3Q>9TWRW4g;QE+W+^ys6luOD>* z7$)=ha=~8K;s_V2{P^@7(t!67fCj9C6I7lfBYdxYTtr)unVu48{JoiHXIH*q5?g){ zk`h!*=N@6+mII*G5d%6x&^E0xM$kZ{DCoYX3~DFln|o_LLI!E77X{uXW*8cEC+6Uu z=hG^A4!R4-QAAKz2+8d$v079+kfDLx{Cv(eEP-%=5pk>aDJSgQq9Fs5`|1H?=BIsn1~ylY<^Dyyvnfu zXiJw$Y++*kg%Ql4B&!`<@y{9DWN<23(A$YL9Vp64zTuELRO&#YWy)U9y95-=qU2q! z@gq+q88U}qs(;Z2mdCoHV!K7_Y}%sq3Y+hoQbx$H>>23Q^|9RytrBOHtL<2`gnbjYamzk@5M8=~A`I{Aesu*NpOa8ixzn{>tcepCnBe#fg%|I& zuKnW$i)sL#sc=krE}h!bw2|Vv04uhWQh&oiHw(dI`Q%=AH}Pe<(6(177?YFXpbyC) zW-rS#WA5?)X+;x3bq`Lo;^f;v^JhD+-ABjy2Yg;u-uQhGz#|DqOUCo8ew|i5tx#`- z0Lq1KD0!nd2{un!1@H1Y0_!`ASdbR;U5d@OgORj_E6%vN<@tnJ{Yyz+$22F8pXCWT;l# z^$ObkY*;}-&}^H@NVedYa1B0hwmvn0gG4lXz(1Gkf}*TIJ@yP-7>ZGsz*zzwqq74N zob>>+w;5Y6{Rjg9#3|CrTkG>}pQu4LeZZ zk~K0okkfcGfFhW|p}nwm92$vs|8HgziO=Arj#rx9G9ybA7H$ygk#*SJ`;U8;Dy>BpMr2>~(wHA`Jl^UV~Fuu03pmUBmu-0A2!(Z3i=&de?D7q z2De|D3f-c+>h}3eh+9bS05k+Ji%CH-bT?IW7X|7wMliVYi1!D&ZPF?;{Q8JS3z+R$i`@ zuj@9N=jc(T+$}b^_Tf{CUTKO`7GdLfSrAoX711*k7(TB4@rLVOiSDgv$dQskx;nbl z!~^3m(kzI!l(~%LZ_e-HaLBXvq%*GCUBA6#mylGfl?womg$hk3aSpk+D{ z&$|D6lZ>B4nT6e)Rnn)j6v%XQeQ~5jor*{MOO9)N4wuZ~4?2zr367WCsCyARju&Y0 zVj<%x#qJFUSy|57gPRUZ!O%)+s;nYM#?J9M&-cceZ6o78YpbdG7n1d%bz2AXIc*jM zr3B(gXlz~)^o$;x;{4w-u?KvtFK&p&7A-lmgsrd1U!Wn4od%g@e|s>xg=?KZLZWf{ z(^Ey?ZQefTRD$cw8$d@Ps|auX=Ljh8D%&2o1(tk&1w%DO=iOdsjE*Z(8V((d8I8); zI>`p;uF&tWsC`qaow@F9T7&X*9lGYMtg)OyijjFQVMh1D5iVnTV9- z6L}Rf4d7HPDemZfN6&tRTDX1fvg)RSe+_aInpLywW1}%t`}$iD$1?o6a;qeoHJu(& zFhN3ErUUu}V^)zD@3x1-#?fabd9K7`ZRCgj7|2mfdg?dsSq+2n8h{vxL8FmNuGqy! zR&H?OU4b>Q(JyXI6}c|t*3Tl>q4pTPs8+7{Wb$y(v*`X`R3dg|% zGbBVw&#`naH);7ZM@o7Ed3k+uzdA2?Lf>oYA_MFgn<(7GJOos78*q~)sMy^G8ClNW zdISaUlPu)3Po}t0xz$k+ugG40q&(DGWc-Hy8Mh? zG%m%qIRE4h*}v$L`gKKPdq#EoWkRc*wtADtF&LPVnL=$1HaRB+yriDd7SIAS7r4uLijE9D@HOjOag&K#k5FEd-2j73*<98WrfUm_GGqhaNWY zz>sxb^a&h7C^4uD1AQ=wPJ(1B-;*GrkL78=4*Q?EMpVzO(LNwcLX9d&ABTnxLHczGAbpk%yWdi^X~CiM?^$q zf43q!+zE{@CXY=V)1K}3Ei2o1$+{4J^22Atj#cK&^$%lE_JzJK@yfHK@nH~5?R$8& zIVF*Pm$!9sV}=v;aXvi{Cc#;i-ITbJG$UiDK_aHZ6h$`IOugw6$aJ`&B3F6;n8%3y zS&}4OUdcepM7kS-O0K%Szp6~W5^Ter`f$U*a1{DSj}AjhG%P0|%^DBr@I`1;@*+FL$@0Tm@%is&F>{CO?HmBCmhbfew=evFNO{&v1I z#65)m-s5v~VL=^XE@H{AA2`$Y{>sJi&ZDubYd+9(%HyFo9PK?^P|!{u+{6K56X<~F zAr!;;I33F$WWvNSL`9i%Mpz446zPq#Mx9rz37wpj`3If7EvpbX#`^N0H4Nx`=f{s_ zaw4j5yi}0O@!q)5^eMtwujM5xzz5LF8HDHY5~5jX-qHk|mV-5EgkGoy99?JMJHDO# zz}`j4flX46ZQ%~u)iwykcTzh>;j&O~c%si^D*S9XxOf}Ge9Z||bv^lIF1dax2tCAS z(RyeovGQP~2b(iL3|Jrm52Yc2$IhsLfU&FZQDCOOomf+Q;F`U_@beLi|V z2K@pGev7!R!axG(@PWRS4Uo?u_!Ja9zm~vT*Hz&AGaeWyq08_bFd2fDWGuH~h4s-| zAEXEiI!>}d`H#?59Lh$4qFduwQ@UP-9Mfv$iC{Os80!aRmpu^pZLZJu#s9XhyMeyi zI*1U(2lwjU_C2S~o40Kd z2H&R%el0&(FHw8=kI_h6C&1FkN^bK$ex;Ec#P$xM#n3>u&&q-Nf@3M`!Pz#E3UU-u zDPDzR%%`TlNq)&P&L}Y^BuY&&tzM*3ksAVhMF-r=FcQ9AXC*NrW}7-kSu1%-+wL=8 z3Tj)uoAb9@0YFFc@ZY&vXjxjuXAvVe{)<%kKvQzUBvpsX(!3&LjM5I9@rk5ccypB_ z58V@f<&E3VCIhG5RIx;Kd3E4Fvd`#P`XcavH##D8)cX}^9>5(JDk-v+vAW+oPPsvA z0AzahKpG*`Z-V%4;2>xON;{-ag0dTudBNfF1loWhgE(mQ3xaYKk5QJc<>fk95eiC5 zs1$r=Vc*|ADg|;jt6IueNZfh3ATrmI{QLKFNI^y*p7lgwZ}0BzdPa*O85RJ}DIUHSF0C^E}TUp3RA{{Jif-{#tOcsrhYP;=Q)=y)GEf~GarB-$LXUimdBZ~ zu?oKnC*ktA@6?3RhsvU}nhuePd2_`nxB}RAD{Q*l?R5m1_V6&J_HSRK20?=YK#&u^ z+Ag)p`=S(jm6#%(Mk4Nhyfvl`S|ioazQ}>67s(`R|kYw3Z9bWa7=OJWFdklT!-?sjC3tB zzKZaTAz4IF9WA1w+w;r#DuK^_#H$gD4xkql*L+4aFeYR768mIH7~?qTA>Qsa z$z<2@s_x0Imw$*9m?H7r@K|ngb%C$9GZ<7%Pl%95;BK^k3f%E(drGxdOw7zZ|RWSQd!sVg1D7fnz7EQ!OhU0g0G|=e2t_p-IS+5dM z_yPFC2ZV>xePIa~4s>${k2D#0*9aMV6e2N-zkH#GY{0-;k^sak44`^ZfMYAR0oUs5cC$Tfl(&? zD&G?--9SWe6aoR6`GGv$0H#a8Rn@N9oX@I5dg*ds6I}Vgu96G|X5i!Fo3*%5Lv07J zcusftauWAj8}@%nD6%hx?-15@L|vk3r%swFa-4=+27hE`>e_22R%*j{aqC`HnS&=q zx+Blprb~F{YYJ+KL@5`lv4!v?8B(Ojvfqm5mGRz)t@%wn%TdXy@|C}F>|ZrdzR;Ea z-u^n-p;>`bikd#{1O77KgBh>R8VQEfS7di3zq(Q;T92lzY`tE6 z#^jkNV!84Y6^lxf&=VnPH=4%)78px0z-m(1YwGsj7gKC_2!NQ93|hUJvySBf|ANsK>1Qi3a$sTx)kfPZJjE##?@Ec>MuX@- zYN`DmaYEGhQi%l5`q=$^SD{Yd@3(#Pn446^YT#I!tC--}n{BDujHg>fOOIxot0RSA z_Vl_7GgyBZj)iA!H5_skc*1T=hbTFT2p?*2z@8g^J|!&DURtUIDfpnVE4u%~V-(uS z*MR|Q9t=1+UaV1ewvLgM07Vv@GCIkl9=T3vxNjUTw2Ntty}&fU8TcgeM0mgJR!2e1U@W zGw4_ws-7S(#Y%Ui5NK-LoYsdzv5118GXtG|VA)>)=4ZstR3&)vLEv`)x;nr>V(r(l z+=kNP`}dD`=W#U4q$6%`jEGvuhF?;*Ug@m8g=%rl#7c#=3M_dpt8N&hTu_8ue<+hZ zwY2m}s@Ky%4D0ucYSm#3o=Y}eEiCbMdTdCmO=ec{`o!iO9A24bH?ep^WMA!z0th09 zZFLlx_3%DC^Kf2zhT~0@37ioVGwP&EzFM}FrCQ{OVv|)7Rn*>bI)KqlBul^~F}6r;Ou`+u1F?rJ+nJx z?>&yaw-DKiY(n;4*(-a6va*VhO;%)Nlf6^F*WKs(e7?Uw-Y)Obxm@Sm_x*Z4AG7kw z{kwGVsl)BcKZ%Mz8UUSO^NymJp8^Hr{+UGyvf!i+RDz^LVY+f}|Nitg?2R}M!t?ld z4kGi(uT7WhxTram2=qU{3OqmfD~w_DyxNyk#QwVLcCXh@C-C__BF(#ZYib#C_|d15 z=^MYB7w8R9n=FhHr@FFI*bT|Ku)KIg6UnDwn3VU@GlLf2P(Hxh)S&#nLIU4Cc~y7r z9Q}UpME2+RGVwCQ%?XCbyk?t8dkTx+eUZK&^z|LSClV+dv+lhm-7|W6<7I9~Ny!5| zC*Wr`L{wIv6T5tDQGBNPZ5JitF()y{>l>LDPOkoKW-3IhCb7=~MfRtcX7Ya`#~!WQ zW(>ybKYbp*tm^M1;5^%iNrYC-5TUT=tK48C(-&2&?@b={#h|=(JJn67^%zsYwg_kV z@f~h6gSV)RtvWI6@G?yXn!7y|M7{Y8AOYn;-@rXhQ^NbPZmtfRmrX8OIM^li)1ekN zcU@(*^TpmszpmLsUnC-;;7=Q?atMlSB}dC|#;&u+Hkd)FaJ=gjX-|a`iArP|?@2Al zqgn7N<(ybEGSZJ3#lk%~R*B)OA|c{MbJ5lS(@ZbzjH5+lfn;cCzh=Hd26r4~iS*?4 zvmyg`NxFn)B)X7%TQqmB`P)#1?8tJRJ$(-byR{!`iQdDoM5Ow zD~l!j=eG6+EnT%`zy*ZsDJ14PqXjw^m%{yiRR!crKKhSj8t88x_3J7G$K|ZKW7_cR zEuPqGuc9326Vy3meG#y^{jQIB|15#8gcL9e&CZLs!x4`$Et^x6H`!r6XQGrl7(}A+ z*KHBGcsZ#ejs|%qBI z+=GXr`lgeTY_E16I~0D!`K9u!#REbXtkFG@E5 zpusG)rS9+k5)mVKcGZzAA`rHMdHYj@+td2O+xN_twprZSe{NO)mZ^HLwMMD`hjMp4 z4QoXRSh!ZUisE+i_!NV!b5#&miN$hYT~V?{Fj*P7X(|WO|vlL$Xx|#O>)W%og$(^CxYEu>q74Uc+v-WLv%)75)BWeNngr&_3r7~F_fM0E#)zI(oAf2*}hZY1+S)n`nACdD_vzIJmn5tnDg z@5z@tUS6gVyFTJ>hTKbG1j#j5BEiY)yWr;Y_4O2$m@?eiYTJT`6aR{0511=FK4G1~ z8Km_|k$%|uFu<&GN-O_bJ@?~K2gfK5f!8(SNec&ifxa2=KrNPioD#qF$Dvp8^bJ6C zt|`_Ti<<{6o8+1Y#!!q117Q;&=^rkG_KyC;m0n2y2hN|Dk688o&qD^jYdDB3bzn_Z z1}8(!t~5vtQNuiIC;C0b!8!drTSphuRxD2apiT#i8%6i^xFsHtTpA_Bx3&>jtLiC? z>+tNzF?jG`&`ydB?@;3pDd^-ESmNU;LXN`ra1K6p|;gc_36zzpnJniErDma zfx+QUp2oMG-}+~yW3nxejMlO8YGmWj_w*E%s7Kh?C*%Uu68c%la8Hp}0fv1*VR+#R zX2>E<*5$AKqn|F1){cs3Z(*Il3f!Bp=$rjinT@q1b%p!om|b8KCS7s%*~I~WpCns# z>utW@#K#x${}XtFDgV)DHjAKf0Ua`kLKbDG$yak7IEQtX=Ar!j& z3QCjs^KVC@Wl^{fP!4nJzL|Q+-jxtkObIQkl4P0PP8ny89S`dfBA5&jLl_apKqZCO+$_2++_b z<9L{>ES}d7Ny{MzEEtPGK6W<{VN$030Cg3#FP2e3Oa~iW&{3xo(FunBe&E2;A>Gg7 z4F~EpDySi})?F5waUj7KkoFKlx=@I&03%6%_=7;8{DSg|KoeX{t9yd*CUENh32JT@ zf=H;{J82%jI^ox?5)a5-5nr27k9&d(FYVRB2u1s(ws(FoXTOlLaI>$#GWc zr)47DCN+~$FZVg2+xg#8~ij`325u@ivczFE-9r&GahKXC= z)eFf|h#ArgF2LBvyyl2>O|Ywnur3bdem;KCC-2&RpDN~PP>4Nt9O%u(09$%sU`us)D;*^V*VSdLn)*S}=#S0)#lJ)cEgdG6L2k@G zGhQ58SrJ7`z?Cy||4IFOE;;)Asgs4oVmq9OV-HtdIg#327F1+2e(w)d?#|ANieJqh zyQ%NB?-6vol)x0(LO*T#dsedBYVYKP*IR*^xH8twpF3Q9^A>Lc;D>Ht`-?cU$DxM{ zyW1E%e18wy02T~Ypg{?rP}LcbyXW2RJ+XiGjDT%0IGNnjFCmb+t9&pX+k%}Q1BiJ{ zsr^sK^Z#HFSNUO(s*HJS{(WT6fw-?q=N?2v9dNla<|Wohs_5GOJ-}7H+h!QvTPPRJ z^ZeI$GukqMd$JHn{C554!v)b9keT`S>061WT@Zle*N404N7S}gQ%b~-nb%mdOf#`q=Uvang)3q+1GCKuzdLVl~XoKR@|36#Arkx zPvHAGBxNCT*Rv)t)@(U(Qdw9dzFpnv7Ow>f?!rAmi3MN~G)ac(YuxxY(+nm~kjaik zp-5Ri9v{7n|1^N`JKQ#9ZOo21Q-PJvLg#~iYM*_{MNl!zyVfQ2>A7XrIrV)Z)@ZIu z?YVHzvYmQ*k7n5_d>@+e3!o8^Mq%Q1Z0<%xtoeegcv;9xY(W>Adux_>2O$ z^@XhYxq-va%tdf@bm!JTru;CUAHw<7mS+#=>yWsxwNCzO{;*c*YxhcP4d-i}hTZ2u zK(Pj(zC)laBLJQs0PDyB+sZ-U-AUFXcb0IG)qz;4F zCJ;k9z>@a~;>CeC2FgE%z;2*!6ow4;Af*N{3Lg$W-qd+)s#6F4K7t;h_24xjtm5fR z&F3^OV{jpxKyKFB&Oaja^YgC2LQ>S$_Sh2-ax{TRaG=Ar1g0khXuNj=wMRWXc$@}x zD7Ed!xZtP3>y9ke+ycg_2WUCt0c>bBTW5JW`uDd6@Ogutw7)$Pe@`SBfM;Am;+nYT z!z!+TfWR`a4fOzmIe2|29TH+g$p^rHatOQyt3Y!PEu&Bm9t6-s+S_LxXL=Aa_Tpst zksEkC_nwFX)ISiy(1K1zC1@eU21XBpYi^&-h5JawL;We@)BKnYfmt)py9Hr))E>+ak<4l;v<9)B3A)|OI3zf04~XE4 zpLkiRWa@`oz(wD@wkjFQ;7pPDxb3Piw$zFJxMg=x+lD=c7Ujz)psJ@SQXc4&N<*8B zX(P~^0q#B^Mk4I-#{M3@=KG~|GV$~$0^CbiR>Y+Ry^)c=6@B58)28Y83vMe~Mk^x6 zycZ23R52%CHftL2gKPYVw^Ov<;Sa(M3Jfmb)iy;E8y|f8S%>ReH^-&N)kXVmov{(A zNi7wvp7%q>bcd8d8UqI-qxPV5Mv^7`&q%J;20}?eWyUvOJ(!dPpB)uLGECH zc)CXv>HnO@taL4P_wsRUz$K4$6=%H80ji>~J9(l2^@+CWZ=4^`4)vr!)&fy5B>LwSv7jL&n%F;?8rE)j0+FvOh1Z{_>J06 z?STh*-XPZ*B&vXV=pcIx@TNrUwn9lTh)r zW#3yo{!M?hfxmh0aT$eyU@n@y6- zZ;hvt#wo>|+zkoUZ+^8A0%-ID;J}Z-J80D(M`O_D&E?)=k~h;1o;Q#K9weK741+38 zA9nuc^WK})x)@5=$u|zy*@Z{MPB|2bWV^IavJQ?sT9!-yt7Ao}-y}JuLAoSayLxb! z;I}x&8x~#(Hx}dJcc$gHU1IURzkL7X?vYwIX6=?W^(v+6^Nk6Gh}Ih!!6LukjkfQf zmz0=gomo72jI72-_FE94(`0p>1 zC4<5+NJ!{$cvo)Ae$QKl6%=+}=kE|5J-I57E?F(gUw=fj*CnzwWcjrGF0B{cXCsyJ zd^Th*nw%7UuqaOwi;qdt%h9=mrf&!FP3Pj9-@kW^tmINDuI5^I!Cf~@Jq|j~pZ*f; z%*zy5z&O$qt6-P$cE(m>U%=2(Haaa+%i}csI*%c^K>HaRrMO`SS&ow?zJ!6$`KIxH znq~Kq)0|!nRcToBcD&e(47DICQuAbLiuTD#N||`K*w>QhTcahr2<6FN1>^QVZ=S)i zt{ru8X0zg=?JO)%ARj0&&Ld;u;(&exTu~OvS+jb^;A;E`BE*(KR?R1fQUh4#-EoR(lKY38C;GXatAik;5&Q0g(PxpI$*;zV6BBeGomk_5zR|@hdBqP^OOJB$>oz zH=!$72_P>G0^GDb;^J|C{sd(EY-GLw0`f>;83CGah+Cxqww3)u7+;1`3A&csAwi1kJQT^n@90pBIXY-eEL z!nx}AO^}tBk1V&iI$j5Y5^{4tx2vD5)r}L%9MG4?s$9l(e0VJ(detxBwU0E9ME59Z zN7p^W%9=%9t77;|*cZ`0asO2H^_zz&h9yeB*7Y#ncZ~53(r{F#g@Lm&WGjn4d`*$W zujIUGT0s()={YDnCRf&2>W~zYg@r+mn_@#B^FT0;eP2yGBm8s)R3WAP6in973?hC| z{H?L8KR-MrZhbARMT0px1*w>nE`x13`*GM|Bb3_yE>`fw*dWq2ciaCphEYo#x&&U< zwSo3Ek+N+iQwi6IxXn*-?VO?f6BDe0DUnVy4~l-?>+73wdyKN_3VQ*+4>h6ky$T&m z3DviyyFI-BiEja7o4OZARyvi_xz{To(_Z3sQsN&`>$#0I@f?@CERwip5CxtsaI^G4iF()p_h(4-5+C)Jp?Nx}xCjKTy~9^KyFUXf=pDjh#0 z0esz+3q_gZ>PH-Upl`(bcZ58VxG{{0i2-8Gem6*11SJ?j?GOPM&jZPuoj2DRm6Cqe z^DWd{V~d|;j&_}hR)O>ZC_VCU;^8Hg+BX5 zb?c;K&0e14wbU!JxljHhg93bur@&okA};)&PaFEk3A9xvb<)P}l>Y?^u)7*bv2Q%h zDo9f8dXkxB)NaebNBf!A>G4UTQm$0Fsz{6dc-FjfydyhpOV1bK2!~8|3df5A`Mx^K zIuBaIiKnc-P2)Z2gGn>YF>DKVXsqHRsN&&M6?Ro`1aiK!gyJ_df6&UMWAS-hRQ0?q z?1`|mh%ruK5=S>yNy8o@)MZXhGCgb-RmUO3^zGTD(teE6f3*Om`4l(Ug*bg#TOLH4 zNaoJkA0~Fr874DQy)FTguGGe9651kS99^;U=s*7cxo`s_iU%+>VC;apHDze8iGX?T zaS1P5iNQxd;Xm7Xe{i7kT@)1P-Gc1klLGbNBWMTCtzHH3V`803-Myubn;>4*$xq;L za|5Ci3qU+vf(-6BsP%xxnikw%Tf4ixfK!bI11lvMr61LFoEte8J`xd$qI&iL6&!Be z(4(r__`MYnia?H%dQcR4dU3G^B&KXod`q_PPWgwkZ^jUI1s#&$KGs+;U|?XVbEblX zt@Wpf-z_$_w`g)$d;ECqw;yPLvge& z+o;hMr_>yefAB{+AMvi5AQ3Da%*hEP_S;{iVK4%JuBSj1OBcHd5UmB)NslP@Ss3-nfPRt3ey)1I2! z&bhq%>y*UY5+^YEQAnS^;4}}1bJB5g?0!Cj8giZAk~$4F{ikxi@VLUZVx|$rSHB6J zE5`bXB7lc+>gB=EJ9_B13KQ&+bpho%~H*V06VC=?7jmxFf-}z$ToaHe$ z8+HHSkk>lyv-yBMBP$T{!m(mZ~;vXE)^!OTSx zC2|#R10MKRo}Oftdesv(X!km|JRp7ZCAhJZcg_8O^v4IBGC;03^PBmZc|AMxSUIgQ zsDv9*Ke3iTqag$-nUDoQVQ_cMFVU6`!yj2dxx_24$X!9Q8!8Hv4+&9j-~=5PH6a_LBKuGdkx-bi%PJ-4a;&6JX@fN^h6hIszLnem+ zuA`M3@qfXDG5jN4kb?~TcOP(bqeGZ5dMFBU#pnKgfR{mSC@gt?e9*859Nv)Y84SyP z;8A6f#S99WPn*xxi;9X~{7h7+11vi@*eurvQ(yE_Eukp0L5X6FN_92^B#@Ud+XLEG zA+{W3F%W!x^N0{a+Mo_{-;Ow_)eZ_P3Zd%)!MQmuCqPo!GOX9yXSAPS`q?TnP@i9&0L+&?25}@G^Hcs;~y>Sn#9E5 z-TnBJvHbV3w#^)QLt%Og>x#kq(QmllJ}ke&nBQaS8~g3w6Wmd+MJ)q6l|vO(I~KVI zWZU>A$}pZhhk?)GYV=WN2-LcSU{}*!TFFDJ+k6zVoR${~aFof?&)g=Gj3z4fLXKFG zrOxCmD1zl1)`sV`tAwZzY>gI$npp9SwTT!oFlGW{;lD`h?CVFJSo+5OKX)D+2{&KI zc~hm~^|^BTj@9JFV7|&Vx!IXjUy8mP@bn z*IkV4cS1SNHU7FC&lmi&=4bS1(OQ(B8YN%abUbH6)y8q0LNYJRFCffIax|5n8kX*4 zF03Y}inehVA_j*7SWXiCz1G@a#ZQeTwd*3^rD{AF@=6s?LtZ6z)u;aXP=aSzrss}~ zepA-4bq}AbB;nd%?u@*PU2f z`X)RW)Yrg45!2tw3k-ysHZ&0aOZoM-+8Jfq-A6b5%|AgnjhFjs2a=}Y&fq1>>x7ev@i@O1yGQr>aGw$P_ zzUZCl+wy$&oODg|gZxG8%(#B_sb4HX3eF*MDvEL1E5mnB)_n&U)ok76g8YkWyDq4l z=Vv%^5~IcKHSU2fZTPyUnRNbO#AF)ds2rJKvii16YW!jntdBHFJ&G}vsbhGljot8P zVdf#(m5sBQ1HYluSz?PO>37CtQF6;te>Ee&4;YV{9L=2<(z0~dlT)!a2dwCBe%sMv z8T;EDm})TPd_z@wYpYf$VTYIUB_5s4<^1dL=0LNJv8pml&RYgI?kB^!`2Aj%nRdUd zvzYt(cMaY@;{u|HZ*5o^L?J-nx9)Ezym=*2vs3y#jASpxPc{59n8~i!wRjYUHdCF(eW^yN+i92OX z)U9E!6bu3`%{z+xYW8jM%K4vU^GXBwRpN@{bk>SIiw#u@LEqj)u3d+%M|I`I$o!xJ zGQltxA=vM*iLGF^mT~Ldg~9y$(xB%jig3*r5vdbmwi;DK2~@Lf&mTfEot($d zt{@;qmv41Ey>XBH?<}@^z zY(96~r>CjW!uJK4)*J1F8NqiqgVk6}m7f;VD2b`k&f{z>K|E~^<7rcB)3H;w-WamY zTgcclMLZ2nqUj=S*stMsP_bVr>r1m7T?0$ij>MabrKUF>w)`TVZ5l#xCc}(jbhgNo z`BR4)X&}s>c69TgzT+mUl(&43)|fYb{s!ZTc&T#uem3K9M)6xpEZqfby4TuXe(Si8 z^yy}Ly}6S|JSY^DIcVe#*^%ka@CG&AqLtp}>TN^RfQ5ynG!|(?iqlUGKlltqF3*8c zYZcPT0`2SE-4`()aS+D_CA+dWlhJ(~U^RE-we!fx*`(r|?{ilBY`@!LbI~B{g z#X72N?VJ2q`qoIT(~GNv!WS^q0@jga(k$b@I$O;Jq?kte2{CvmA^EQzxG#C8jtpQ* z!OX2Kpy!roE`ThL!vrQr{_ulP*gc)49gj5l)HS+q{c-5!vUn;~XOW{+Cf5l|Y67Mp~2Yy3otsOO};-9LsFW2`c|k8D$8ORa}TI2%V!QDaEK`kNDraaaWjMq32VJlMj%aFAErB|K}*tG&(&4>oFg zAYp&%U5{}&9v=`!2ZI8PLpv86C%P?*P$M5Sln4;Jl0dy)`TQjCjPtrkWPfP~TEG>eYZyTG6nN zpURTu8Cj0dI#OXE!(FdSQt+M7I#KWyE`gLAU(s^&gjW ztvv^_PLl|>U|90%71m+&B3Y@HUzLGl4_#K6ib2wz+KcX%rzp}vj{Q7R0t;Q*q(pd0 z#Nt<8>Z)kUJ}cye^r}P^ue&+Nv=$FAsuF{e8ZX<+DD&?<%pOfy;JLUn9C26X+>){V z@DoW72L0y+VJoc+yQABh`#4VDWpFoDf>3CzzBZ~M^xR^OG%Y6KxSP>;&|J|m$~TH4 zFgYB)Xcoj1Z8J}Pfn&Vb7Hy@;Nf8-s4YN>L{Pf2^>m~8K zpEKlSH`$^|6(Ig;lY*Q-Zqv*ScN7N&@r{AUzWA1|AUcsRE=BT0YISUrJLP<5xPJe4 z&cmn&I_&rRcD-~&3D0~PG#=NJGRiBEy z>K(7AW2N!zm8ok0$E3hYJrD>{$wP$e9}#IB(n|;N*N}9>b2q`w^vgPk2(=GEJ)>n~ zUE4yE&8K;0#_j(-8%fju+GO2Ul{YLXrNosmBb51c=S!9fX{kd`BPSv*R-Qq!$tbwi z7xfXP_zu4LFYSjMK0iP=(Z7-%=mJMY0VY+6kHCYq3M8DM;7%g8^Xg4!H;5w4cShHm zKb@@&ynPC4i$G)#C4}sQnF4o2_|NLYKWLCE>!bgT>i|>wGN1(dAq+B=Lmwg+&`}`n z4g@DZwpb9V`FQO}?eUct^5-9Q9QYH45w)M>9DIT}vA~=6fxUnfhB9Oz3=#bGLx?2s z+WiI*1D^ockr<~`w>!I4t9$2uYZB+2{(fwN@Jx|w)K)56?>DT7(Q;->t5*W%C_Jyb z+ELNXP;U`Y+Q02wLK2>iyd3UozLEnKO~D~hmNN}!XhXAm}bAOHQU$N!H}SH_a}V z9E_3@Mx*}3*TjqG%0u%yN2PXEJDh%Lv{6?f&LMfOOk7wGhJmV{rOunmrseGrp z(F4ok(e)h7xC6`5pT}q_vl0tyckcC-u@HN|DisRv&PbaY0l(KpKr#=Hm3`dZeztpK z<{r37uBd?meM4GDewiWkm|VqPTHWwijq=PBeA&n@H;b+s}qvi znljgIEg)<$(QtMC~@eu930!^vY8=Tt6#B z+{-S6LsrGbeci0@#}}mjHh^Y{L?rjJo8%oAM7vV}X?=b)$<)CQLz1(}W(EPJM zxZI2tz-<2@bYMhWRdewLg1^3*&Ocs$*OPgD*bV~ zHk_$%{l;%sn{beSGi;nr_@{C$A{KE4HRFCx!+dpG!0SdM19#D_MHy;O}+P zyxh4TJKI=E$If%5G7K4IuZH_InL+|80wyzBwug+^#J2mvq8e9-xFlTItmE{kn{Cg! zPmPJN*Iu?%GLshefg&}rzp|L(vA=t)t%Q@ zsjg^1_cwVIVWu^|lg!HJ78A8OAifli>ZrJ-`$mJT21qI*{ zi=e+7xWQi$q2+F_K#~Fw@(yJoEkh=!!$Y2$)*W`JI3SD&!cq`BWks%FU53(xpuT$0 zRte>zLiv0^=?w@Dy0GQKXzm_>3PI2}^e}_d()I`5&IDQlVxpw4?Z1Qs$3Bn1Lc{Wn zR4xX31GUKRdR8^Y4Q|Zm%V2RSqH^RmN}O{j+cKb!75SZ?5ak>?6^~6T{i6%Sgx?WyXggiq+9WUl=VK}{4Yg+14A1Zuv zrtfSprWU{PX09-;Fvsbk1>F({b_qRJT9%7)Rq^z_vpkjYT@(4muePGwCUz&<)q175 zD1?NB)~>EZ5O55t$Krt%Y~jPtBozQv#DKb-E_6Z=K?eklZS9rRHXsXe(-q|P|C6VI zPiYBY+}6Wcf;+oJuQq*Jz|IH4WTE%E=_xr%;~6qdk|6|&>xVuZ@TtWD-qs(o9ZF8= zGonBQ78W-45dgnNA^pGIaF(vRtV2nvo)PqA-qPNZtU#~wfo2dxsplKRfr`x*H7-~9 z##jNT+n_+^nJ+p3*DT>Ze8zrLQSK#3DgX8Yf!qSl^>vf9dvBO~Smm2OvqY`7f7gc@ z8c~N6SESMsUh1#)G6hdUKk_R!dv6cYfm4^Mx{V;rzGreIA$AT(; zm*YX63AAIM}1Q;n=|_qAi%OZ-c(rPto9-zZBKWY1W3_3XeNT$SzzNy0#m`*IL)uM2Nq5c|?tz z$cg-}!R{UkEpH=)N#3-z$9uAq@4MD&T|`?^@E%-V;hp=595*s9{(} zMj82g=GbwzAlX#hLU~f^Lx^f7-r4zEcQef)V8t3`R!wmXVW&s_5*j1L8fZg8-N;fs z5gGwMi5ZcFxLC$Cu@@9;6{_zSStdt}lu5I%rgj4eUOQfA5H0&;K5JIa?nmB+qed~l zUh(Av?Mkp-cr!z=W`jmufgbNPZe1bm!ucm3B*#SOXAg=p;S5B29K&WlvN{O({>=bc z4RI}|T7E1aojQQ+f41Z0BS@p6!ww<_5BJ`Uf*4%DCYL=iwRgxNT?S4YGAPw+dD-HR z^aESK7SE{9zLLZO6OWO@F${W8Gp`yj(8;2|MA8v_2pg7g|fKo;J+^aG_csH%Ls z3@$ps`=BBXr3+G>LVWQ4d=s3KvL{>E1La@p03e$jz6adw1Geg^hW9PNq~hOquvR*Y9_X}_Hs_NCTDj`ti3E&nrbVOV!_Ob_`2>-sE323;e< z$G?Q0h~MZpTF;9JKb7X&F|7zya1b$CHWlw)c1U9jnWOuPu`E#=c`h3M5=ln>{BSS9i1rLgiJe*@0^>}`)H zR=d_j54yaZ+`79a35@Nj=HWW7t~Eb2##i*MD2?nXUX4i)g;!y);E^4a)7}wndkf!_ zMakyLu=t=Xco9t2Y2MBt!1owop6_qd*NB zu*TDi3cRqU2))-#NV2TjB@q3dUwvKd9nXMYPqAZ3ei9(k#cD#G&CMTd*ZFx2?cGU2Q%Wu!sr z+@yyN3Vg3J?V;s=Q1nt;OB)-59e?M?Gm-LIc)(}&NF{SZhu!%QBhTscWOFro-|IDl zkR(>Atg~9@SL7`ONeHY0GOUt{e&=08;;~jqcZ$%>%srH9yj}o*$7+;%H9Jwc{UU9u zu4&#bFzy@hJ;41Uq+ETo_d%#o^ZI9X8mC83eX?XZScfv7J@yvrDZ}YwKTfQy#P$80 z!CA?@+2G8bEbzHD;7Wy4;%sV(WO;%>Vn{Et^@=AkRYY~84KOg@!5~nkSNY=L<&TBb zKhGBk4Bh>|j=#hGw>OyY#-c6acOc2Ff4rax^>6C&_bnl|90tfX8CO^kQ%2fX_6f4F zF<&+WNnB4lk{)BrAPh17wWwRlR3%xTuQ+i`JHJS0fiH^fR&HhJeRo$_L43D%vFbZo zogkw$A^DzDrDoj{UtG^Dw5;NhMvROS7P~k1rYCyo;gWdH?(BI@pLrvdqKy4OXE$n1 zbT_iD7(u)#9i;cQhumB#Yr|+lfC(=~Q>|eXb-KTf6;%jhCaE;u>p`~g40pS@S-FD2 zyXs5IS40SZ#-4WiCxaKk>K9oEC8Ezbj(1&@dBcdxZcwQgLQGx%JSVk2_{fV#c&;HV z5C5zq3_|2|3dyUvSoXr~Uaybvr$(30Alo9uYIw%){&5LS{ zwy%UmG?}{;bkM%f{J`#aJ0_V`ePei(RZ@B7@q>rVQ?W+AIqiK2QC&)R10JT+@o{R4 zsM~|a>iFu-@@Ykl^WNdeo{4^BhiylDPVCeeiLQ9nX}x7>yso$dt}^qbEI4sX<5jaG z!}Q7-xo8zq*@qZEY%?^~7EH`t^R=u}YwpXhF$V8`vDUBFIp+IWrLbQtZV6l1*<(ny zFr*Q1%dMzrN|-Aw;M#2P=kDd{84Wn+Z{Jqy40ZQ8fB&`NPsd`$9=%Y@0oW_$<>g(0 z$V`pux1$K#n0D+q4W?(SeL+dVP>~poc3NzqHQw_QFk`kZJTXxU=5{W`#NHhr z`L7m03a0E}HbI4f3WP(KQe(}Ojz7g4Y+7q;u_Pw5_)@zvk?}je$anK^Ps=FAlqFfa zw31lNC{&f2agtJH!;N%PQP^mwD=?Tc(=uksME4~*BGGP!2 zS1B{AuAPM|A18TlrLxad>v5FLP&G)nsJx*Angty9Y9r^6`UW8>&k&7j=Ald)mYI9n z6|jkBro=L0b$a=7cLl#!WbX2i*db*#3c0FV_+II|Y7^?Me`hvcA!{0b*GpMM&oBqh@6ZxQ}A)`A*wWwT>rglb4i_1}UGJ(#lm(l5;NxjhLN-$l@@D>5uaRf*v||%fLCWiLqQdR9 z8|Sc}F@4$FtIiAZy@Z;TdLGj5^43@w2GPujx2y}*;>BtQ3mcOSQJFV3N@lFdBPk`0 z1yMh_f*2!}YknCuvnA^{WlZBXCQ;fNvgfdT=xp-HR2NBi%kH{B4~pbN6{mIvy82Ox zzoL+l6B5q1JwF&3N_`0e_MpNXL|ohPj|SU2em{W1?=g>%ke<5g|37-h9JN=gV`(2V zOXsL+hrD*rQ@oblUw->lp;;z&xiyZuKdr-&ONH4&T5;crDBRUlf7>!O-eIHFU}40M zTGsMAb-%z#vxwnp%C^BfH`S7yVUq9Ow3XXMD4ugWRl5TI6O044g$`LgUfHGbXo^fq zXfpMZSsb-o;%TY17@IgXEdJRz;>E+8l9zf(j4kTs&5rH_hr*tl6TbKxB*`NsYqKC2 zw$xHJ2hDVT_X!xe|Am@EN%C4`mW!=kmmuXW5@-}ZymhzfgMS3l;JTJt3PDguZt6%eE++oCvMGe&5=No#UH1@(xhay@e4|erOX%b9Ue}1ij~c#js`Dc@_yx>pOW^l zf7=2av%jGdUicnvl6tdk?dki+Gj4pBpZjKZahJblX4Bq5As46n&f@GOywS?58<83o}>~BPcYeIQoTIC$tJ%5DUb}k#VxmA42auZsy~Fvh#b{9A z339L?dA%#OkGrjGt9rFNUvx6r$B-AQ=SjVA83w#NKdLFL0g9brHXsaBT)KS8&Wj*F zyc2+pl8alF0U#CJnQS;4%RD9v$RIN>syY$enZSrmP`eTz+cxWc^`*Z{$C`(L8(}Y9N{tRKotPm)%bfF~ z@uzsx()R4UVb@#4eMeK)kyx3to9HDsbS%;Z?006F*ZT#2;t7OVg{_@r-6&+hN?UFaJ|e!LnS< z0`*(7+TJr!MmIDfoCsy3e0BdO%AK=t3~nYix-4Iv8LyQ`Xs2gvwEB+r@7ztd*Shun zy{PadG_n!oJa_BoPE4j!&zn{_xN5F1Gq$yW(lOYm8tVt4#bcpMP)Ih>Z|5K2ynyzB zbGu1r!cc3y*D%m*p@A_n>WKMLX*=Hd_R^gL^7jT~X3`u6uf_FA>A5>2b&U(jjdBbsX$#qI&`8 z{7klglhcs#bf6Dw-6DhxO$`G@hDq%eFC2TC(V3Z8@pbqZ9_l0`9iJHNMYeI0Rh`q&p*iLIDC-z3fsUy7ZfXK>0A>WfQ(JLW>Du4avP=c~?w_XyE7hs{ z!)Y;a?WcowTOt{7Oh}~ZhW&o$bB)I(%bJCv`Ai7&QjBh(gwg3{S!CMNZplPCG5z$Z zIWAYdG2vtOYL3`75!yjMGNE<(cdrx6gM!fPEI~inIia zCFCC%KZ`o3tnYn=rUt4>oMU8ut_g4ZKY9Cv;Zyel>BQ#%)gflj`JQKVtv}Be`<@Y= zxqKEB!RI9aIhv!qW0vBlH`)OEzMzP3Xbn)t^>iRrOA4~>N!c}8BP(_IDKeofmxlI! z$0*r`FHaA@I+jt*LX%-8QLiFbx!p*xkxy&Hvp6*>!Zk!(NR5Iui-&1D*+I|Rp!t=6 z&zp2* zIN+m#-S|Huzz5Oeol}kC68dDlRj*&3gvA|`oa{A-k^U&LEvm$$_pPQZdkB4FqW(lr z1Dp*R^A)<4u4mKwAj3$265qso&K9Z>f&^_q+>Hg$pfAZ&V3&s!d5|^h6%c{0TGQUZ z!I4q_^5WTduNj~PnpA&`A4Jwdh>s8MZy-MhwegU)6XJTHvPA&krC@jd9}o&_pu^)e zp-K`#mOS-ZWF|(aL{YclkPVWgvqo8FgcoA=yYh)ufH^;x^6fOsWZ=fNntswNd}UiD zSL$9poMh|AFou%9I_|})5j%}Br1Hcj{c=f+MC@*kCvG%h1*%&~jhTq1>&6`|ei{c3 zS+)zD-gXUFtiGti;$ek}oDyaO=0WT}T$v{JiX@BvrW5P*o3gtaD9WQdKeLwgXn}5o zan@~5Uod?RaorNLqPFUR7`D~B2b3y=+Cz=+vpoQ0%woRN30SMcmQjBK+RHBNi z%c-~GuYd-&gMHZLf3u{KLqxMr?wzy->X4s4_aSs?e)h3QRC>S(iTA-=ETqcqkK3PZ z_Q(EnB5}}t`GO|SI+Shrg9D{7BRjf;ZMCC;I+;)cvje|WO_{Fj!pQ5MQcF5DzC-QE z3M08jPAZe}5K!Ef^re^|&z8*@u8c^rKN_G@v?OAu&E&Mpy4B08Qqw3~)(qmjNzcw! zeaGJadKE6ek<4eH{4{IZh$A)1@r0qo!eP)-<1i70ZEhn1IU$5yvkM047c~pg5ke{P zK0z8ugWjrBJh+>DMKW(n3+6Emg_&Bta+&i~F6wes=!4{Y0SCX1d$?i`Tn|ur4Dc0M z;&`~3`W+|deF6^w$mOsMA-zD12BgAnKm0FNAa@c}==(XE+!{){0}*$1PIG*a${jje z;(@yIQOCC0*1W?;50Itj@*sae}r;ySP&|y0nAX&1rf_MVvRTS z$ARRbIgDJ!fmLMvKF8M6+l7pCLY|RYvKk|S3M=qoYXPqZLUcM;g{-sqiJ|>b-u$*% zyV#P0kIjozQ5e};|8>ANo8 z={<(FhtI#qH@=5wYlwyW3Z0TL+|(@qVe-oNUt~oyPfQeW1rCzX7S}u)8yQB?B!V`5 z+y%8$chApsdCgqt@X)~9$^fU#LLF)VjYTfNE9KG$K)k5u?Q0}4vGGNRrR- zn^v6|`_{v9p;ft6T7LSm3a4;qw^C3_ry9Tq{a!>EQsNqSYjG+cq=3CHcb8s?aXJ$@ z&pG-`G0(T7oU2p^tvU&zJHLt}?wE@=nUKIXDptHXA%L&LaUX6?2Nc2N(D>5iIE|{L z>JU+C%f)n@j+9P8F+0aw0Q5w7csSs!`}+EL5Qxa5BR5F%)Z5z&r>1r~@uJ3-k&%h| z!#pqoUIYA5*cyU_ry)=R@QZ+B>fyoAwZ%7_bda9!7XC^058b8VD=uA3rS>JQ1{FG2qesZCWzf5nk>skmjK3Bax(|G4+dCK7-Mf z&^=~*marf)4}dY-b7O44nc|jo{}euX6f>k}S4t*Bqq4BGF3jGA$uq0ZW&S0OXaTG; z?9;f8{{D_lvE4{n%TW(-M&19tsNY$Uui?IaoCWl(`sV(E_Z}LCjCZ^|TKRhtSodPU z>ib{wzpo-_ec`w0w=YItE=7Y;1MBbc!@nVr@##%q2Sxgyl@buli3*+}FK1i5IMju& z$l5nM4iYOfb0@OwD7DUNQj6I{XLKtwNIi>jzh?x$;WPbzOucnnmRZ!cjg)kW2m%6v zNK2Q1bcwXIN=hTG7<6|_cS!dwA<`j8cc+91s3=H(>ze0z-}jq8{LMHc1J}K;z1Lpr zJdd+vJQx)&lX`R3F72zrXRT{k-k}F51!tOx9i8Tv)PW4wL@J8jrEkUUn6W#QXAitH zc)byr{k@qwaCgm-s7;Q@+h%tq6}een|6rkSFM%>+c%(_`@oeF+;j41_7=M5XrHF{c z!+MYz9|bTU&m7Q9Z0g=x`tda8&irROiXn?w)Igi9$oq)EC}d_0Tu{{p^tZw(b~)n5 zeqX9N5UItn*H98e+yI=}VCS%XR})5o24uK}L}OwiqK(TZW$vC^kJ)aqUElYv=tCRU zEh8^~K7OwLkoudOPw^&&XX&`E?mWBFit@mqhXg$5nMr%_p7Q=GfnJgac(T*~*_#`U%|*?|#mZ zkSe+Ss$IQ=Tq|qLDbQeo-P7j=>0viBCab5}eig{p{r?SpMr`8f1do;1SKK?iMA4YdFmCzgq!hp)#)o`n$OZ{^HPWX z$8$#2`{%9@9FJZkG9Zvah9+ldtgPQ2Z~csRN{X!i2FlLOJ?E*=!<($p9q~kzJymnA zdB?(WC%sW$zSb2t zXi=g@)8mo3%o0f){%tmxQGdfe>3NugNXBv;%cDw_=_gaH84q{d(jF>&PE9J%xAA^> zKfd(7H1q2$T~+)~v413pMC_igIf}YO29I3|1dq|;9~pOVMTn}r4qrOYW)_c#Ax$Y1 zMBm1Kw#b8Bz#y+1MBepheJ^TK+cHndXYY>uOY*HntI6gEYp>r$y3Y=6r4C-yb71+C8Ddb}q1q10`%T3U$z{u2HE*Xf_9_l>8o zu1JxXT3O))0-l9~L(fr6c7e?Y>q@PJbu z^K`UxHi&B7$~5=c`=fBz`%w0VBDKZajB#?(sS+9-*S>Jat5*pR^0!szjOkRePT5ED z%^0DW8>whQ(YOgQlwxl=zNPKC@=(8=dhlc6K;h$ScDJfsr~*k@^6y}l{NEZIM7ExD z13H|h1}4hb`OrpgOuGN#ma4d$NF?~KdWP;x6n+p2H1UUGwC%4Ehv8>}$h9n;k~Z$}w~xn((f>@?`~)fGoHZqOtnfga zTAe{%GC6L{Y451+4NpeNvI0q^L|iV+dJ=BJni;MSE4e!M?eX-O(01S~(oc{e#BC0} zjn#acBu(h=hm4@c-ysRA?@4l$<5L?ap%B#l#q_5yE6d-zhPXouaXX<~LMvoT(t0yjMZE|7u{ zjIMx_b^ies-?CCj8$+)b#*Z@3o-O4$As!!?Q+8CBI_LcVY=~y~9XJp^ zx(Pl2C5u>jR=qwLEWD7)HwduTpKh@tWlO+YJZ`xZVi5p1I=$-sT~`{uO{=$ma_Jz4 z>l{1K#B{r=$J@mR|6&SjYoAV%=FW##w*c&n3ET^l4td~Jhyfx}vRqmoBpO})ZE7B= z^xe)$nv`Fd?j32^`$FyF-(H*Y#S{#p;%^P#N(PS)vMK+3wV)tZcI&x>FX=?Cpk$ej zO!G?a>K7e_0t;Wh#JdIhu~SyP5-m>T59zvoIMLi!`^_t?P4kfR9;WUv`_%~fjcc|4 zShgOwJL^5BHfK{ZrhGnl7$ZS;#y?}k95O!m+>zx0&WdpLX1NJvmb{kVeThZMy6@lE z>ysC!xCuHn&|!%^eIMe5ozu6uO_J6x-=SM|6YifZ)Mo#sjKf2L2@H}*$ld)D$=3m& zQ{0KyasRV!$iWMSG*|1MMLzE{lF zwk=R@id~Mnw$^jjj-acmtM-!9ZY9X^XflVi*J3=@v-;4!;O?Ds^E)>q8pCVnDop$9 z5t2+3NfbTw^Qh#~;>0?`szTQU9w$bA6iM875r4mb)v?~5CI-Jnpo#@VlnRjL+ zgk9_ZoaFh3zHB+701(-3T2{bC>erx+Syt{!M7MhoI{<##~7?0eS3# zZSvf)gH7WHYrN<#_jqKZhBS0QuAq2ebi+qEDM#_p{n09AfkhiP%f>yu==U*$z`N8S zUzlyH(zO*u-0X6yB#WY;tm zh3}_qMSVR=&WhT6ZGZ3lV+}WEr&Ey1A(OLn`1kpH&m2upwMuZQTBQNuz{xMvvG>Qa zB$UlFJ~}kTRm}38>w|alYq3_ZZ#0MGtBp?Ht=Ei^P%RHak@Q$MMZG_sK7LDA?6UoV zEl9bG{o1n30whfSsP19>0_hp&9+DLixu?4k=ryl8WCBx zs_+UXfL>O^?wNx71tEIEOE_(Jgw}fW4v6a%AP@snAB>TaMiq~+a8d>K3oz(`*j29i zX%F03NqTsYHaenAgH`|l0Hhd_N+x3Y~A*X_+I_3N90aKJZmt$+{`ZWc*?Z??%H zsQ}f@JCgO0=$w+8*jC+=w?owEDrWd`bPmz z_mjV$dPjA)<{AnsN)a&QP+t3vF}!}bO4*etI`*}Jkhf1@OsS2Zjz5?rY3aRjFZ<%i z0%4#6isI6NG&EnvX{DLjf@bF_59yByySpDvtPVS}$sY!3<%L^cD{p^OKte)-)A8Zw z(^y_?7H3C|*nxoI?qwtTa7f>10yiz%m4aJ-PJv8q@n*qXW?TPf6g~WqpdqtaLZRu5 z$Y|EYT#C}PUNpYLw_VZZ5o7*40df`AsoafOU1+H*)A?<*2&~AF!r+Cx@3)E8mG{oO zB?~v%2Thcd)dW>HuJ!P+`bAt9!SI?If zWf2u?bR%W-awa2IB`xm9f;0*tDxQXK&GG556ARe%BW4fp5+q3I+-U>vd&)9PDAO{V zp~z$7(#ikrGbxfK)2Q|s%^t?^zbB|G=CHU2YjnPv*J_OaS08(YF(?%giQ7*Em0Oc} z&arqh`Oy@lhX1~(dHULxH(*PVETiTeI#QR;p1<9-efD2ICNLHdLFucW`-?t^j%)w* zRMT&B0RkE1caf+p2LINhmdkgDptT;Z0{ zJ1rNsMviS*h^_lSnghV0x9T56+P9RGTOIq7<+MD%(QVa9it6ri)w>N>;KU5ETxIST zACgQB)fnx}y57@Wr>TrEU9BMDZbP&B&>@I1p&3MFUZO}iAj+)h%5NLnK77XwEk`@< z1OAM>lHYB8ELW<3Q*2ZMjng4zhHm+F;B zQ@mR1@6S|3Fb?JASJL>n;^d1N9)aR)di6}tn4ec*oQaE_*vg3@6PLT-R5Bi2bQQIX zmfhv=%)s4&wK2%@nK)d5RQ}wQHAF$HKGHAreuEl$p%u5!yQ2JLV(j8Ff6yK9S4zEj*8qUads%ZeqXMVERsdS8%m)8GfgI7uY#lK395*2S^H%Oua7WjYZe6~|uOiJiW zM^%7)NCUPTBJYIVjjQF%8S!=l<-fS3yd8;q;pX;6Vxy7h2ef6-gLPfkHM;WOcn}}$ zKll`qVGHO$ZnR~XCU*j->fXaIqvVLUK;owl@iHRFMdbd#9gt$Kj`Ty8J7SIll^7HU zhvj6(&CS^4TUc2R`WTHtYU46An-5cD-5%+7X_vc_Re<9Q7h? zb0xIVm9PV<@y&ZRdx}O z>K&IoKGc>QrO9|Y`TTu#Z0Jivy1f=;^Oyz}q8mnDUq$H6FV|V(Pp7_vF%%YO_yLF@ z#s>gLy^KNE3OqFe226wozxHP7^cK?R2DA&$jTmAA0#NyZvJqSv|F8T10^&ynG}HW<_+w%ZJ^oDKr%(oxNF#G6W6e~iHY7f{ z4M^NbeEHe$_br_so9RJF<-a+Yp2TNK1iEivD)NA&8A2?OVgZaWo<1-kUj4+Rq<(nX2~DH62kXYZRv&JzFMkLPYh zeS^m7wuv%1c@;WWfQCO)sMFcJM}Wmae!^r4EiiWgB8TBmQmMi|rKiv^sc+*KCwv7f zVSSTZbhxMLz}X6}pS35#f*>^I9r*9VI}n=(wEA)AE`X|hiM{@H0Q?1s%|^21O<<%` zfA+hdhnLqEcpUTa6W4>5xRnw~HA7nGmq(Wubl_MFhrvD)9s`th$0s+COeLTnxFTiL zavGAPfjl3hsQ?RogbeZv5Oh=kBk@@iJ|3uOX?bw3s@ne4J2hwj>huGh7pGokYlP#) zPluMP5*qylk3lRPALnO=8KwUX={TGiQAJHzLFV7N0bGI0!?Eu9ldBha+&5jM{z!=S)vC+xm=bf= zDh=3Nr8}oYpK&7TYPsf0W#)WlAb34svs^SX&ym`o+$YC{`h*faHu@>%7j#|$R3QID zrh(=yMe|>7Q&xqi-rnBX3-Uhk)_Vdf7iA+R4@7%NxPys)bjIY##aoU@`!L51%Eb+< zh|cqcnelIb5${~i`Kjj~$zZ@TtQ9t*>XY&PpwK1!X{OU7ygwiVuznSp=)O*6W)zbp zqnC)Kd_7+2uLWmWt=}c7ytfmPpYrb-Oi!hMiSHBC-&Bi}2XIuV#xfPgS>~@KGgC$z zY=5=<^z^C#0j3E~HSkw>0M7y;TnW$IiKO~n36Ake^N+>4oWfyV4x;yLc=Rt+IsIF= zwpc(Tzakuo*%%&WHU<5!Tek+c2h&_2rKX$GeSeV<4XJGj?z|q_8nI4U1r10Wf)9lV zEkr(^c+Wl1?oawLsvPIk)BAPDi@Eo@hY=mskG|4Su_IC0@O_YOy=863q#JR;*)cy49{5RcY_Dfdp|bU9uA8o9EcaH zK2C?C==-_b=#+&oce~pUJLv*N=P$b@5$S&^8Jo4e*h1qr>o}7yNMm>`?lnIso4LZy z9`MJ5N9lV;smn`g+ghcgS#5D&NlQ^>gO9z9TDTfcfpa&!%f@*0q;FQc_fQi|^Dtu+ zo99XC@lFJGX4&%29q&%>K8<{p8L3!jg4W-MZA2y6$h$UAJ6sv_{fAZh6Wwn5FhNa} z@I2e-_L^q2dQQ!#IK!xY5<`!c`C?(O@Ow7yw9#uT1>*rcfr_j~_nV_8JA!l8%#Sog z8YO0Fu4>*53w2P-j}%b0cK1i`uhyb|Bp%LEuWDn@mY<}~U9>O58XUX4B!sg~oI$+O+**68)7c?D`I%9Ytki8=0Wl3FvyoztyfX{N za{v=-If#7uNjwUT?t3*KvrqXBF9bZ0_N!%xc@Qhy6(OFVm=O3e6iB1fU!BBj(O0=c zKH3-EE&y#WBh}TZ_>+PJXrk0xq`1>nk~!Ot}Ro*tFO0wOn4=`+Vv^)59ET%F!>q!<36zKuRAStJgK1IN;Pn zH*~lB0<%k+!6!Y$KUiG0{Z^^A^HpJ86oBM9XkX5^!C{reZ>{OVet0pGE#>2|k2iA% zQv~bv(VP1Qw_Iy?H_!y|t1@3y(;C$Y6P#>OPWLkNhY6lUQ05bMx5_u23l~a$JhfHz z8FXW@u{w3ht~^z-pbVUjDEq)@4V)YZ`0yWkyTONKg1ueAe>UBJMMwG9*};&|K||#D zp5@o1HlTqY?HsV?8^~7%-CmSAN~m!lD$S9=Mwt0wWN1pVYLzBJi-9v!) zt?xX$6a|hpos$XB{=7}AdJ84~7Ln3wn-Sdm^b=i!96@1)^OWjU`9i=N!Q5_AzHnp% z+@%`e$lc!CQ|0^!r2x{-s+~mCD~Q>wqKIfsSYEl|8OZh|{o}SL59q^hhfgZ9rp6fi z7`L@!z5PCP5e_JCQp_vqw6p_q>7B`f@%V_3q~E<92DxJKG;}NN$XSgc2s3khA%vX z2tRx)$&b&esE9%|J9DTRn5`Pv0`!+u`X{J+Twu^|@$_ivVs{BaEg>Ze)Wp=fPu#9U z{vBwWDtQ7GZh814)lKp@2!HHe?F#eQ$_|>Yws_cHYvmFap&1I4#RTBuctbNQ^7!oq zT!h8p9()4>4B+Zy+CV7zL*&fLFeI#=dX3rM}0a zQKtP%jF|NfAo0$&f*zOx?4-!+GW#msDkI%7AQm+^UX6cu6BX^#W*5Mdi~68bXi#P{ zr?ZDf_hr<;1t7*XP@Wg4ruPAnbj0A`c*rYbs(H`tY)j$6Dul*9fNZnHlH3Ka%X3H* zjMh>Wu~6ApVwEG2FFGozWmY++rJoE00EF{8aV1^yx?M?#mi@n~w!_?URz*sgi72hB z;t$3Zt<_(zpN1!N6Z1zVT+nEklT-~RJ>Bx>PnSBI==1(kA1D}1U|@;PK3e2LWfDj~ zM73_pK*GBta?{*AhA_JCHa~t$Ybx)4n*$GntrH;=&WNul2X(^lSvubZ1yjQ%%kOIF zr3BSHN!a2u=0=Ogmg+$XXc4_k@nJfy<|e#z5#(Q}I!D4Wx|7zAv?*icZ^qfkum(t= z3C62>4Z7R0lm$bMO>>!DlK+|bP3kI5mNLU>Zhs9?&I(#;gH(-~;cgPS5sA5BLOfS? zY7=tCghfm0vRQX_Y8lsG8b_I5H+voCL<=S9Vw7)NV6Ex_VxrEV!{fswOEa4~Zytn5 zH8xVf zo$(!sNP8`|>%YZX75w5gjb3l(BeYMevg4T{&u-*3`Q36cb-4;R>AaGXFy#MmO;z|x zj}wBxhIRW1Iy~l(z6NIF1bQ5h#(;*D574=djg2@R(MSeYkJHxPUV#zUM>kJKolx#? zG{I0d#c<;(9Abu>7(S`=d~?DeW5yBA+e>cQjfPXmbAz9eVz>(5z(P0?{hD4@E zSDuVz*=*CLOp_F|*ftY$S`?_=sgvI!=&8o5&hQjeoxAz1*v?CZsux{b|K_f_aZbKe z>g1nw0t&a-lb%6l;kmeZSL{R{9QL^e{^%?3(OC&^W1N|D(?Too(V#Q}XU_H`>s}Smm-_`p}<0T&ZI;S@LnTP91lg zByHou8Ld^*HLcRW34g~hHDjrGo}x9?7dV+hsi_rRYlb0jDXLT9&}mYQ3X+CXTOY1eE{)ehu->%#7D; zv-cV3mo`5g46Ye2olqb#xJVuw;+@Mpo!>;3(eaemxi0jVGv4~;1cT%(WN*&_GR6l7 zgqFTSC#GA>rX+q9xa$sM$tZgO8rA!nMT!*YZxmT(emj0#Qva_Vzm4+Z`0~R4Wu}+J z!jm=mPaCI8m!1e3*$K2(1Us3^te!A{`JFuUHV~RR?}^`S$pn29v|1vrw8vjMVoi7m zdmTiRqU={1RUclZj7=vfU*mU1JB?TFdT!oeP_}x?V6aK_Q-h(uI_2`+>DL0>{1B+rNlJNTuKM;#5}=`X|aBIqo2|m*ee{C0s5MoV(pjHInz`3?Zh! zLHZj9YK4=N6Qqk-e{r${c0m_|#jwprd-be`MJ3O2ZdEg)#+s?hc>2sWgX~RsSy9ZP zBUFFPP57&bdHpgtY)At1{hudL5vYvrd6Q6eKFc0)3Y7VV;l@4@{>PQ9r{9r=R_S@@ zZT~hseOC*=$T1%d?3?6U1h08-GK>YW_46dIwUDKqT#G5MekRK<_@(b;li!2 zTkd>za2gwo#LZD%U2Riln|bvyLWg|Iv1QIm&T6oFgh>22I!7{URNjz$%lYI~GvQ`e zRhFdX^l-u~rAF4#g8V7@)(=#=R+dY|A1R^^oGCZ;H^Ob6Bn1TvY;c@sO3?OKzgpON z3MlSmX22mTQ3jHS$vp+YB8t}78?pum4T%Dt%(+h+R;jH zy&*T(OfUJ`#(-Hz|Gk5I&{81tXV}bmr&D}@?+8W&KHEtSC{-^uOetI8OoTWS-B*wR zJgR zz3U2o*d*wiS}4ql_|ar)=?;2%cD+uur&4=!HqHSJ3vP8QhGPc}Rvp;U_M z=W0Uw8^58Q69ZzOi;rP5DF%cb2;_jTch6$%XQ4Jun?V z^3aZ+?P-;V3YT^GG&d^p(d8G`;f6zZn$RwP%-u$!o9XMuZfWCD?Z~Nv?90vr0#iSQ zybgrk9@b=j11OM69S=m4B3)F``}c$F%^VcQ3e`3NOSue2+vjjqf7J5=hEYh%UXe8< zCpa>asCE*%M~R52Q7;jq6l@B9y3zyz`#1)%o zf+o`4L9Pprnh=;v5bgb1&ajuw=euXmw>6)uq;{7k^YPgWM|ME%bUgOpTnon1wHx|N zND$;4N*^el`I;ecHl)B&vQn8UL>)Jt3e#+q98K4&@-X|kcumcwT{l8|mY3us%6DU) z&N+-`UA@Xaje69)2Rpz>0g&MU2erMuZSBbfI$3RGR?7<&GVEBYFifv(frU`IF6mLF zNYnFUz9DXL@hM(_Io5Gq=`vgv{v%%)}YNr`cDG2>&+cuM#6Yi1msEsnNYyF zfWzQ7$YnM=P)B8zQfj2H4+%nM!dGT!j^1MYu8KEs66bLordV^E$7Yud6 z5&C1DxD2c?Im`mv4sKqyX7zMlWM-?qM7rp}

    BmM+pxGRziDHA1&2V;q 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�ngW8+OrMWHo;tO&O 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 literal 0 HcmV?d00001 diff --git a/examples/blocks/src/olympics/thumbnail.png b/examples/blocks/src/olympics/thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..2f4b9d11f9c16fe0537dd693f9f044b1c2efa404 GIT binary patch literal 32214 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 HY0&=xCO@Zh literal 0 HcmV?d00001 diff --git a/examples/blocks/src/raycasting/.block b/examples/blocks/src/raycasting/.block new file mode 100644 index 0000000..37ce96a --- /dev/null +++ b/examples/blocks/src/raycasting/.block @@ -0,0 +1,2 @@ +license: apache-2.0 +height: 800 \ No newline at end of file diff --git a/examples/blocks/src/raycasting/README.md b/examples/blocks/src/raycasting/README.md new file mode 100644 index 0000000..ddc872d --- /dev/null +++ b/examples/blocks/src/raycasting/README.md @@ -0,0 +1,7 @@ +A ray-casting engine written as an +[ExprTK](https://github.com/ArashPartow/exprtk) Expression within +[Perspective](https://github.com/perspective-dev/perspective). As presented at +[Open Source in Finance 2022 NY](https://www.youtube.com/watch?v=0ut-ynvBpGI). + +This example has no server component, and all ray-casting is done entirely +within the Perspective WebAssembly engine. diff --git a/examples/blocks/src/raycasting/index.css b/examples/blocks/src/raycasting/index.css new file mode 100644 index 0000000..f24441d --- /dev/null +++ b/examples/blocks/src/raycasting/index.css @@ -0,0 +1,41 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ 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). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +perspective-viewer { + overflow: visible; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +perspective-viewer[theme="Pro Dark"] { + --d3fc-charts--gradient--background: linear-gradient( + #242526 50%, + #071952, + #088395, + #35a29f, + #f2f7a1 + ); +} + +perspective-viewer[theme="Pro Light"] { + --d3fc-charts--gradient--background: linear-gradient( + #ffffff 50%, + #071952, + #088395, + #35a29f, + #f2f7a1 + ); +} diff --git a/examples/blocks/src/raycasting/index.html b/examples/blocks/src/raycasting/index.html new file mode 100644 index 0000000..e9b4475 --- /dev/null +++ b/examples/blocks/src/raycasting/index.html @@ -0,0 +1,14 @@ + + + + + + + + + + + + Rendering ... + + diff --git a/examples/blocks/src/raycasting/index.js b/examples/blocks/src/raycasting/index.js new file mode 100644 index 0000000..e3fa58e --- /dev/null +++ b/examples/blocks/src/raycasting/index.js @@ -0,0 +1,145 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 from "/node_modules/@perspective-dev/client/dist/cdn/perspective.js"; +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-charts/dist/cdn/perspective-viewer-charts.js"; + +const RESOLUTION = 100; + +const EXPRESSION = ` +// Scene constants +var resolution := ${RESOLUTION}; +var fov := 30 * (pi / 180); +var camera[3] := {0, 0, -600}; +var light[3] := {0.5, 1, 0}; + +// Torus constants +var radius := 1; +var tube := 0.4; +var radialSegments := 12; +var tubularSegments := 16; +var rotation := 40 * (pi / 180); + +// Mesh +var arc := pi * 2; +var vs[663]; +for (var j := 0; j <= radialSegments; j += 1) { + for (var i := 0; i <= tubularSegments; i += 1) { + + // Vertex + var u := (i / tubularSegments) * arc; + var v := (j / radialSegments) * pi * 2; + var i0 := j * 3 * (tubularSegments + 1) + (i * 3); + vs[i0] := (radius + tube * cos(v)) * cos(u) * 100; + vs[i0 + 1] := (radius + tube * cos(v)) * sin(u) * 100; + vs[i0 + 2] := tube * sin(v) * 100; + + // Rotate + var b := vs[i0 + 1]; + var bcos := cos(rotation); + var bsin := sin(rotation); + vs[i0 + 1] := vs[i0 + 1] * bcos - vs[i0 + 2] * bsin; + vs[i0 + 2] := b * bsin + vs[i0 + 2] * bcos; + b := vs[i0]; + vs[i0] := vs[i0] * bcos - vs[i0 + 2] * bsin; + vs[i0 + 2] := b * bsin + vs[i0 + 2] * bcos; + } +}; + +// Render scene +var scale := resolution / (tan(fov / 2) * 400); +var x := (floor(index() / resolution) - resolution / 2) / scale; +var y := (index() % resolution - resolution / 2) / scale; +var d[3] := {x, y, 200}; +var color := 0; +var depth := inf; +var light_norm := norm3(light); +for (var j := 1; j <= radialSegments; j += 1) { + for (var i := 1; i <= tubularSegments; i += 1) { + + // Index + var aa := (tubularSegments + 1) * j + i - 1; + var b := (tubularSegments + 1) * (j - 1) + i - 1; + var c := (tubularSegments + 1) * (j - 1) + i; + var dd := (tubularSegments + 1) * j + i; + var face[6] := {aa, b, dd, b, c, dd}; + for (var ii:= 0; ii < 2; ii += 1) { + var i0 := face[ii * 3]; + var i1 := face[ii * 3 + 1]; + var i2 := face[ii * 3 + 2]; + var v0[3] := {vs[i0 * 3], vs[i0 * 3 + 1], vs[i0 * 3 + 2]}; + var v1[3] := {vs[i1 * 3], vs[i1 * 3 + 1], vs[i1 * 3 + 2]}; + var v2[3] := {vs[i2 * 3], vs[i2 * 3 + 1], vs[i2 * 3 + 2]}; + + // Render triangle + var e1[3] := v1 - v0; + var e2[3] := v2 - v0; + var h[3]; + cross_product3(d, e2, h); + var a := dot_product3(e1, h); + if (a != 0) { + var f := 1 / a; + var s[3] := camera - v0; + var u := f * dot_product3(s, h); + if (u > 0 and u < 1) { + var q[3]; + cross_product3(s, e1, q); + var v := f * dot_product3(d, q); + if (v > 0 and u + v < 1) { + var t := f * dot_product3(e2, q); + if (t >= 0) { + var t2 := 1 - u - v; + var d1[3] := v0 * t2 + v1 * u + v2 * v; + var dist := norm3(d1 - camera); + if (dist < depth) { + depth := dist; + + // Lighting + var n[3]; + cross_product3(v0 - v1, v2 - v1, n); + color := acos(dot_product3(light, n) / (light_norm * norm3(n))) + } + } + } + } + } + } + } +}; + +color; +`.trim(); + +const LAYOUT = { + title: "Raycasting", + plugin: "Heatmap", + group_by: [`x`], + split_by: [`y`], + columns: ["color"], + table: "raycasting", + expressions: { + color: EXPRESSION, + x: `floor(index() / ${RESOLUTION}) - ${RESOLUTION} / 2`, + y: `index() % ${RESOLUTION} - ${RESOLUTION} / 2`, + }, + settings: true, + theme: "Pro Dark", +}; + +const worker = await perspective.worker(); +const index = new Array(Math.pow(RESOLUTION, 2)).fill(0); +worker.table({ index }, { name: "raycasting" }); +window.viewer.load(worker); +await window.viewer.restore(LAYOUT); +document.querySelector("#rendering_label").remove(); diff --git a/examples/blocks/src/raycasting/preview.png b/examples/blocks/src/raycasting/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..19e17b54ccf814139ae37d2df4f51f6b48118d6e GIT binary patch literal 130610 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 N^gvCaP|n!ve*h`lpD6$U literal 0 HcmV?d00001 diff --git a/examples/blocks/src/raycasting/thumbnail.png b/examples/blocks/src/raycasting/thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..08372f4a7eb4a931137e6418f47588a4cde7c1aa GIT binary patch literal 26461 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 z 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 b3FrR+=#XY|S1a~p00000NkvXXu0mjf`GM{Q literal 0 HcmV?d00001 diff --git a/examples/blocks/src/streaming/.block b/examples/blocks/src/streaming/.block new file mode 100644 index 0000000..ad08ee8 --- /dev/null +++ b/examples/blocks/src/streaming/.block @@ -0,0 +1 @@ +license: apache-2.0 \ No newline at end of file diff --git a/examples/blocks/src/streaming/README.md b/examples/blocks/src/streaming/README.md new file mode 100644 index 0000000..87c10fb --- /dev/null +++ b/examples/blocks/src/streaming/README.md @@ -0,0 +1,8 @@ +An example of streaming data into the perspective viewer, simulating a simple market +blotter. The example creates random rows of tick data updating the table every 50ms +and limiting the data set to 500 rows. + +This example has no server component, and all market data is randomly created in the +browser. It is a good example of Perspective's optimized real-time windowing +performance using the `limit` parameter, as memory usage will stay consistent, as well +as its interactivity during frequent re-renders. diff --git a/examples/blocks/src/streaming/index.css b/examples/blocks/src/streaming/index.css new file mode 100644 index 0000000..bbad97a --- /dev/null +++ b/examples/blocks/src/streaming/index.css @@ -0,0 +1,20 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ 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). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +perspective-viewer { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} \ No newline at end of file diff --git a/examples/blocks/src/streaming/index.html b/examples/blocks/src/streaming/index.html new file mode 100644 index 0000000..60ad588 --- /dev/null +++ b/examples/blocks/src/streaming/index.html @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/examples/blocks/src/streaming/preview.png b/examples/blocks/src/streaming/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..f35984e4a3be3ca0d094456c021cf4134d7aca7a GIT binary patch literal 229259 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�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 DOM reference. +await elem.load(worker); + +elem.restore({ + plugin: "Datagrid", + columns_config: { + "(+)chg": { fg_gradient: 7.93, number_fg_mode: "bar" }, + "(-)chg": { fg_gradient: 8.07, number_fg_mode: "bar" }, + chg: { bg_gradient: 9.97, number_bg_mode: "gradient" }, + }, + plugin_config: { + editable: false, + scroll_lock: true, + }, + settings: true, + table: "streaming", + theme: "Pro Light", + group_by: ["name"], + split_by: ["client"], + columns: ["(-)chg", "chg", "(+)chg"], + filter: [], + sort: [["chg", "desc"]], + expressions: { + "(-)chg": 'if("chg"<0){"chg"}else{0}', + "(+)chg": 'if("chg">0){"chg"}else{0}', + }, + aggregates: { "(-)chg": "avg", chg: "avg", "(+)chg": "avg" }, +}); + +// Add more rows every 50ms using the `update()` method on the `table` directly. +(function postRow() { + table.update(newRows()); + setTimeout(postRow, 10); +})(); diff --git a/examples/blocks/src/streaming/thumbnail.png b/examples/blocks/src/streaming/thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..7279282d3ff03c7a8004a5895bd05246b7842e9a GIT binary patch literal 26755 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 R0-yi@002ovPDHLkV1n1wfEfS) literal 0 HcmV?d00001 diff --git a/examples/blocks/src/superstore/.block b/examples/blocks/src/superstore/.block new file mode 100644 index 0000000..ad08ee8 --- /dev/null +++ b/examples/blocks/src/superstore/.block @@ -0,0 +1 @@ +license: apache-2.0 \ No newline at end of file diff --git a/examples/blocks/src/superstore/README.md b/examples/blocks/src/superstore/README.md new file mode 100644 index 0000000..68c378e --- /dev/null +++ b/examples/blocks/src/superstore/README.md @@ -0,0 +1,9 @@ +Demo of [Perspective](https://github.com/perspective-dev/perspective). + +This example of a `` dashboard is generated from one +[Apache Arrow](https://arrow.apache.org/) source dataset, converted originally +from this +[XLS from a Tableau presentation](https://community.tableau.com/docs/DOC-1236). +This layout is just an example; you can explore your own visualizations in the +Perspective configuration menu, accessed by clicking the dropdown arrow in each +pane's upper left, or advanced options in the pane's right-click context menu. diff --git a/examples/blocks/src/superstore/index.html b/examples/blocks/src/superstore/index.html new file mode 100644 index 0000000..c55f055 --- /dev/null +++ b/examples/blocks/src/superstore/index.html @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + diff --git a/examples/blocks/src/superstore/index.json b/examples/blocks/src/superstore/index.json new file mode 100644 index 0000000..86061a4 --- /dev/null +++ b/examples/blocks/src/superstore/index.json @@ -0,0 +1,169 @@ +index.html: 48 { + "sizes": [ + 1 + ], + "detail": { + "main": { + "type": "split-area", + "orientation": "horizontal", + "children": [ + { + "type": "tab-area", + "widgets": [ + "PERSPECTIVE_GENERATED_ID_0", + "PERSPECTIVE_GENERATED_ID_1" + ], + "currentIndex": 0 + }, + { + "type": "tab-area", + "widgets": [ + "PERSPECTIVE_GENERATED_ID_3" + ], + "currentIndex": 0 + }, + { + "type": "tab-area", + "widgets": [ + "PERSPECTIVE_GENERATED_ID_2" + ], + "currentIndex": 0 + } + ], + "sizes": [ + 0.3765432098765432, + 0.24691358024691357, + 0.3765432098765432 + ] + } + }, + "viewers": { + "PERSPECTIVE_GENERATED_ID_0": { + "version": "3.5.0", + "plugin": "Datagrid", + "plugin_config": { + "columns": {}, + "edit_mode": "READ_ONLY", + "scroll_lock": false + }, + "columns_config": {}, + "title": "Sales Report 2", + "group_by": [ + "Region", + "State" + ], + "split_by": [ + "Category", + "Sub-Category" + ], + "columns": [ + "Sales", + "Profit" + ], + "filter": [], + "sort": [], + "expressions": {}, + "aggregates": {}, + "table": "superstore", + "settings": false + }, + "PERSPECTIVE_GENERATED_ID_1": { + "version": "3.5.0", + "plugin": "Datagrid", + "plugin_config": { + "columns": {}, + "edit_mode": "READ_ONLY", + "scroll_lock": false + }, + "columns_config": {}, + "title": null, + "group_by": [], + "split_by": [], + "columns": [ + "Row ID", + "Order ID", + "Ship Mode", + "Customer ID", + "Customer Name", + "Segment", + "Country", + "City", + "State", + "Postal Code", + "Region", + "Product ID", + "Category", + "Sub-Category", + "Product Name", + "Sales", + "Quantity", + "Discount", + "Profit", + "Order Date", + "Ship Date" + ], + "filter": [], + "sort": [], + "expressions": {}, + "aggregates": {}, + "table": "superstore", + "settings": false + }, + "PERSPECTIVE_GENERATED_ID_3": { + "version": "3.5.0", + "plugin": "Datagrid", + "plugin_config": { + "columns": {}, + "edit_mode": "READ_ONLY", + "scroll_lock": false + }, + "columns_config": {}, + "title": "Sales Report 2 (*)", + "group_by": [ + "Region", + "State" + ], + "split_by": [ + "Category", + "Sub-Category" + ], + "columns": [ + "Sales", + "Profit" + ], + "filter": [], + "sort": [], + "expressions": {}, + "aggregates": {}, + "table": "superstore", + "settings": false + }, + "PERSPECTIVE_GENERATED_ID_2": { + "version": "3.5.0", + "plugin": "Treemap", + "plugin_config": {}, + "columns_config": {}, + "title": "Sales Report (by State)", + "group_by": [ + "State" + ], + "split_by": [], + "columns": [ + "Sales", + "Profit", + null + ], + "filter": [], + "sort": [ + [ + "Profit", + "desc" + ] + ], + "expressions": {}, + "aggregates": {}, + "table": "superstore", + "settings": false + } + } +} \ No newline at end of file diff --git a/examples/blocks/src/superstore/layout.json b/examples/blocks/src/superstore/layout.json new file mode 100644 index 0000000..d77a10b --- /dev/null +++ b/examples/blocks/src/superstore/layout.json @@ -0,0 +1,88 @@ +{ + "sizes": [1], + "detail": { + "main": { + "type": "split-area", + "orientation": "horizontal", + "children": [ + { + "type": "tab-area", + "widgets": [ + "PERSPECTIVE_GENERATED_ID_0", + "PERSPECTIVE_GENERATED_ID_1" + ], + "currentIndex": 0 + }, + { + "type": "tab-area", + "widgets": ["PERSPECTIVE_GENERATED_ID_2"], + "currentIndex": 0 + } + ], + "sizes": [0.5, 0.5] + } + }, + "mode": "globalFilters", + "viewers": { + "PERSPECTIVE_GENERATED_ID_0": { + "plugin": "datagrid", + "group_by": ["Region", "State"], + "split_by": ["Category", "Sub-Category"], + "columns": ["Sales", "Profit"], + "master": false, + "name": "Sales Report", + "table": "superstore", + "linked": false, + "title": "Sales Report 2" + }, + "PERSPECTIVE_GENERATED_ID_1": { + "plugin": "datagrid", + "columns": [ + "Row ID", + "Order ID", + "Ship Mode", + "Customer ID", + "Customer Name", + "Segment", + "Country", + "City", + "State", + "Postal Code", + "Region", + "Product ID", + "Category", + "Sub-Category", + "Product Name", + "Sales", + "Quantity", + "Discount", + "Profit", + "Order Date", + "Ship Date" + ], + "master": false, + "name": "superstore.csv", + "table": "superstore", + "linked": false + }, + "PERSPECTIVE_GENERATED_ID_2": { + "plugin": "Treemap", + "group_by": ["State"], + "columns": ["Sales", "Profit", null], + "sort": [["Profit", "desc"]], + "plugin_config": { + "realValues": ["Sales", "Profit", null], + "treemaps": { + "": { + "treemapLevel": 0, + "treemapRoute": [""] + } + } + }, + "master": false, + "title": "Sales Report (by State)", + "table": "superstore", + "linked": false + } + } +} diff --git a/examples/blocks/src/superstore/preview.png b/examples/blocks/src/superstore/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..0aae565d2d4683afdfde9ee6b2467c019b135501 GIT binary patch literal 142958 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%0A^{rpkkW3>6pG?D6egKV| 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%0AX1BF<4>1Xe?!`@`p>3<2sds>5oZ&F}`eoJ& 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

    CA$f@Q#Hp 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 Q(ZG+Ag4&Bhx%Ynm3$3!pEC2ui literal 0 HcmV?d00001 diff --git a/examples/blocks/src/superstore/thumbnail.png b/examples/blocks/src/superstore/thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..f49194363d5f39df6a9444b52fa20f50a33bb4e3 GIT binary patch literal 21798 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 literal 0 HcmV?d00001 diff --git a/examples/blocks/src/webcam/README.md b/examples/blocks/src/webcam/README.md new file mode 100644 index 0000000..bb9a184 --- /dev/null +++ b/examples/blocks/src/webcam/README.md @@ -0,0 +1,2 @@ + +A Perspective example which uses your computer's webcam as a data source. \ No newline at end of file diff --git a/examples/blocks/src/webcam/index.css b/examples/blocks/src/webcam/index.css new file mode 100644 index 0000000..36092b6 --- /dev/null +++ b/examples/blocks/src/webcam/index.css @@ -0,0 +1,93 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ 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). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +body { + background: #242526; + color: white; + font-family: "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", + "Consolas", "Liberation Mono", monospace; + touch-action: none; +} + +* { + box-sizing: border-box; +} + +#app { + display: flex; + flex-direction: column; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +#header { + display: flex; + flex-wrap: wrap; + align-items: center; +} + +#header a { + display: inline-flex; +} + +perspective-viewer { + border-top: 1px solid #666; + flex: 1 1 auto; +} + +label { + height: 32px; + font-size: 12px; + padding: 6px 0px; + margin-right: 4px; + margin-left: 14px; + margin-top: 8px; + margin-bottom: 8px; + border: 1px solid transparent; +} + +img { + vertical-align: middle; + margin-left: 14px; +} + +select, +button { + font-family: "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", + "Consolas", "Liberation Mono", monospace; + font-size: 12px; + appearance: none; + background-color: transparent; + border: 1px solid #666; + border-radius: 2px; + padding: 6px 10px; + color: #f4f5f6; + cursor: pointer; + margin-right: 4px; + margin-left: 4px; + outline: none; + user-select: none; + height: 32px; + margin-top: 8px; + margin-bottom: 8px; +} + +select:hover, +button:hover { + color: #242526; + background-color: #f4f5f6; + border-color: #f4f5f6; +} diff --git a/examples/blocks/src/webcam/index.html b/examples/blocks/src/webcam/index.html new file mode 100644 index 0000000..882379a --- /dev/null +++ b/examples/blocks/src/webcam/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + +

    + + +
    + + diff --git a/examples/blocks/src/webcam/layouts.json b/examples/blocks/src/webcam/layouts.json new file mode 100644 index 0000000..302c07c --- /dev/null +++ b/examples/blocks/src/webcam/layouts.json @@ -0,0 +1,152 @@ +[ + { + "plugin": "Datagrid", + "columns_config": { + "color": { + "bg_gradient": 251.04, + "neg_bg_color": "#ffa38f", + "number_bg_mode": "gradient", + "number_fg_mode": "disabled", + "pos_bg_color": "#346ead" + } + }, + "plugin_config": { + "editable": false, + "scroll_lock": false + }, + "title": "Spreadsheet Cam", + "group_by": ["y"], + "split_by": ["x"], + "columns": ["color"], + "filter": [], + "sort": [], + "expressions": { + "New Column 1": "bucket(\"color\", 5)", + "y": "floor(\"index\" / 80)", + "x": "-\"index\" % 80" + }, + "aggregates": {} + }, + { + "plugin": "Heatmap", + "title": "Heatmap Cam", + "group_by": ["x"], + "split_by": ["y"], + "columns": ["color"], + "expressions": { + "y": "-floor(\"index\" / 80)", + "x": "-\"index\" % 80" + }, + "aggregates": { + "New Column 1": "any" + } + }, + { + "plugin": "Heatmap", + "plugin_config": {}, + "settings": true, + "theme": "Pro Light", + "title": "Downsampled Heatmap Cam", + "group_by": ["x"], + "split_by": ["y"], + "columns": ["color"], + "filter": [], + "sort": [], + "expressions": { + "y": "bucket(-floor(\"index\" / 80), 3)", + "x": "bucket(-\"index\" % 80, 3)" + }, + "aggregates": {} + }, + { + "plugin": "Y Bar", + "plugin_config": {}, + "title": "Luminosity Histogram", + "group_by": ["bucket(\"color\", 5)"], + "split_by": [], + "columns": ["color"], + "filter": [], + "sort": [], + "expressions": { + "bucket(\"color\", 5)": "bucket(\"color\", 5)", + "y": "-floor(\"index\" / 80)", + "x": "-\"index\" % 80" + }, + "aggregates": {} + }, + { + "plugin": "Datagrid", + "columns_config": { + "color": { + "bg_gradient": 2463.68, + "neg_bg_color": "#ffa38f", + "number_bg_mode": "gradient", + "number_fg_mode": "disabled", + "pos_bg_color": "#307bb0" + } + }, + "plugin_config": { + "editable": false, + "scroll_lock": false + }, + "title": "Small Spreadsheet Cam", + "group_by": ["bucket(y, 5)"], + "split_by": ["bucket(x, 5)"], + "columns": ["color"], + "filter": [["bucket(x, 5)", "<", 0.0]], + "sort": [], + "expressions": { + "bucket(y, 5)": "bucket(floor(\"index\" / 80), 2)", + "New Column 1": "bucket(\"color\", 5)", + "bucket(x, 5)": "bucket(-\"index\" % 80, 5)" + }, + "aggregates": {} + }, + { + "plugin": "Y Line", + "plugin_config": {}, + "title": "Max Headroom", + "group_by": ["x"], + "split_by": ["y"], + "columns": ["New Column 2"], + "filter": [["x", "<", 0.0]], + "sort": [], + "expressions": { + "x": "-\"index\" % 80", + "y": "floor(\"index\" / 80)", + "New Column 2": "-floor(\"index\" / 80) * 20 - \"color\"" + }, + "aggregates": { "New Column 2": "avg" } + }, + { + "plugin": "X/Y Scatter", + "plugin_config": {}, + "title": "Scatter Cam", + "group_by": ["x", "y"], + "split_by": [], + "columns": ["x", "New Column 2", "color", null, null, null, null], + "filter": [["x", "<", 0.0]], + "sort": [], + "expressions": { + "New Column 2": "-floor(\"index\" / 80) * 50 - \"color\"", + "x": "-\"index\" % 80", + "y": "floor(\"index\" / 80)" + }, + "aggregates": { "x": "avg", "New Column 2": "avg" } + }, + { + "plugin": "Datagrid", + "plugin_config": { + "editable": false, + "scroll_lock": false + }, + "title": "Raw Stream", + "group_by": [], + "split_by": [], + "columns": ["index", "color"], + "filter": [], + "sort": [], + "expressions": {}, + "aggregates": {} + } +] diff --git a/examples/blocks/src/webcam/preview.png b/examples/blocks/src/webcam/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..d4f9dbb0ce15957cc9d7b9a360977c6b1441b17e GIT binary patch literal 140278 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

    yqilKoT1h@bCa6RbYjm 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 literal 0 HcmV?d00001 diff --git a/examples/blocks/src/webcam/thumbnail.png b/examples/blocks/src/webcam/thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..6c06e1fa0b225c899391227bac196c1c94964ff6 GIT binary patch literal 34887 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 literal 0 HcmV?d00001 diff --git a/examples/blocks/src/webcam/webcam.js b/examples/blocks/src/webcam/webcam.js new file mode 100644 index 0000000..a7076b3 --- /dev/null +++ b/examples/blocks/src/webcam/webcam.js @@ -0,0 +1,95 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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-charts/dist/cdn/perspective-viewer-charts.js"; + +import perspective from "/node_modules/@perspective-dev/client/dist/cdn/perspective.js"; + +const canvas = document.getElementById("canvas"); +const context = canvas.getContext("2d", { willReadFrequently: true }); +const video = document.getElementById("video"); +const WIDTH = canvas.width; +const HEIGHT = canvas.height; +const WORKER = await perspective.worker(); + +async function poll(table, tdata) { + context.drawImage(video, 0, 0, WIDTH, HEIGHT); + const data = context.getImageData(0, 0, WIDTH, HEIGHT); + for (let i = 0; i < data.data.byteLength / 4; i++) { + const r = data.data[i * 4]; + const g = data.data[i * 4 + 1]; + const b = data.data[i * 4 + 2]; + const color = 255 - (0.21 * r + 0.72 * g + 0.07 * b); + tdata.color[i] = color; + } + + await table.update(tdata); + setTimeout(() => poll(table, tdata), 50); +} + +async function init_tables() { + if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { + const stream = await navigator.mediaDevices.getUserMedia({ + video: true, + }); + + video.srcObject = stream; + video.play(); + } + + const tdata = { index: [], color: [] }; + for (let i = 0; i < WIDTH * HEIGHT; i++) { + tdata.index[i] = i; + tdata.color[i] = 0; + } + + const table = await WORKER.table(tdata, { index: "index" }); + poll(table, tdata); + return table; +} + +async function init_layouts() { + const req = await fetch("layouts.json"); + return await req.json(); +} + +const INIT_TASK = [init_tables(), init_layouts()]; + +const [table, layouts] = await Promise.all(INIT_TASK); +const settings = !/(iPad|iPhone|iPod)/g.test(navigator.userAgent); +const select = document.querySelector("select"); +const viewer = document.querySelector("perspective-viewer"); +viewer.load(table); +await viewer.restore({ settings, ...layouts[0] }); +const regular_table = document + .querySelector("perspective-viewer-datagrid") + .shadowRoot.querySelector("regular-table"); + +regular_table.scrollTop = + regular_table.scrollHeight / 2 - regular_table.clientHeight / 2; + +regular_table.scrollLeft = + regular_table.scrollWidth / 2 - regular_table.clientWidth / 2; + +for (const layout of layouts) { + const option = document.createElement("option"); + option.value = layout.title; + option.textContent = layout.title; + select.appendChild(option); +} + +select.addEventListener("change", async (event) => { + const layout = layouts.find((x) => x.title === event.target.value); + await viewer.restore(layout); +}); diff --git a/examples/esbuild-clickhouse-virtual/build.js b/examples/esbuild-clickhouse-virtual/build.js new file mode 100644 index 0000000..fe2aa0b --- /dev/null +++ b/examples/esbuild-clickhouse-virtual/build.js @@ -0,0 +1,43 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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"; +import "zx/globals"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +async function build() { + await esbuild.build({ + entryPoints: ["src/index.ts"], + outdir: "dist", + format: "esm", + bundle: true, + sourcemap: "inline", + target: "es2022", + loader: { + ".ttf": "file", + ".wasm": "file", + }, + assetNames: "[name]", + }); + + fs.writeFileSync( + path.join(__dirname, "dist/index.html"), + fs.readFileSync(path.join(__dirname, "src/index.html")).toString(), + ); +} + +await build(); diff --git a/examples/esbuild-clickhouse-virtual/package.json b/examples/esbuild-clickhouse-virtual/package.json new file mode 100644 index 0000000..1829a3d --- /dev/null +++ b/examples/esbuild-clickhouse-virtual/package.json @@ -0,0 +1,25 @@ +{ + "name": "esbuild-clickhouse-virtual", + "private": true, + "version": "4.5.2", + "type": "module", + "description": "Example of a custom VirtualServer for ClickHouse 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-charts": "workspace:^", + "@perspective-dev/viewer-datagrid": "workspace:^", + "@clickhouse/client-web": "catalog:" + }, + "devDependencies": { + "esbuild": "catalog:", + "zx": "catalog:" + } +} diff --git a/examples/esbuild-clickhouse-virtual/server.mjs b/examples/esbuild-clickhouse-virtual/server.mjs new file mode 100644 index 0000000..6c30eb4 --- /dev/null +++ b/examples/esbuild-clickhouse-virtual/server.mjs @@ -0,0 +1,29 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// This is just a file server, the implementation is in `src/index.ts`. + +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"); +}); diff --git a/examples/esbuild-clickhouse-virtual/src/index.html b/examples/esbuild-clickhouse-virtual/src/index.html new file mode 100644 index 0000000..db1840a --- /dev/null +++ b/examples/esbuild-clickhouse-virtual/src/index.html @@ -0,0 +1,25 @@ + + + + + + Perspective + ClickHouse + + + + + + + diff --git a/examples/esbuild-clickhouse-virtual/src/index.ts b/examples/esbuild-clickhouse-virtual/src/index.ts new file mode 100644 index 0000000..f044394 --- /dev/null +++ b/examples/esbuild-clickhouse-virtual/src/index.ts @@ -0,0 +1,47 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 from "@perspective-dev/client"; +import perspective_viewer from "@perspective-dev/viewer"; +import "@perspective-dev/viewer-datagrid"; +import "@perspective-dev/viewer-charts"; + +import "@perspective-dev/viewer/dist/css/dracula.css"; +import "@perspective-dev/viewer/dist/css/themes.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 { ClickhouseHandler } from "@perspective-dev/client/src/ts/virtual_servers/clickhouse.ts"; +import { createClient } from "@clickhouse/client-web"; + +await Promise.all([ + perspective.init_server(fetch(SERVER_WASM)), + perspective_viewer.init_client(fetch(CLIENT_WASM)), +]); + +const db = createClient({ + url: "http://localhost:8123", + username: "default", + password: "", + database: "default", + session_id: Math.random() + "", +}); + +await perspective.init_client(fetch(CLIENT_WASM)); +const server = perspective.createMessageHandler(new ClickhouseHandler(db)); +const client = await perspective.worker(server); +const viewer = document.querySelector("perspective-viewer")!; +viewer.load(client); diff --git a/examples/esbuild-duckdb-virtual/build.js b/examples/esbuild-duckdb-virtual/build.js new file mode 100644 index 0000000..4bae075 --- /dev/null +++ b/examples/esbuild-duckdb-virtual/build.js @@ -0,0 +1,43 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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.ts"], + 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/esbuild-duckdb-virtual/package.json b/examples/esbuild-duckdb-virtual/package.json new file mode 100644 index 0000000..11c1b9a --- /dev/null +++ b/examples/esbuild-duckdb-virtual/package.json @@ -0,0 +1,25 @@ +{ + "name": "esbuild-duckdb-virtual", + "private": true, + "version": "4.5.2", + "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-charts": "workspace:^", + "@perspective-dev/viewer-datagrid": "workspace:^", + "@duckdb/duckdb-wasm": "catalog:", + "superstore-arrow": "catalog:" + }, + "devDependencies": { + "esbuild": "catalog:" + } +} diff --git a/examples/esbuild-duckdb-virtual/server.mjs b/examples/esbuild-duckdb-virtual/server.mjs new file mode 100644 index 0000000..49418e5 --- /dev/null +++ b/examples/esbuild-duckdb-virtual/server.mjs @@ -0,0 +1,29 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// This is just a file server, the implementation is in `src/index.js`. + +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"); +}); diff --git a/examples/esbuild-duckdb-virtual/src/index.html b/examples/esbuild-duckdb-virtual/src/index.html new file mode 100644 index 0000000..f44c409 --- /dev/null +++ b/examples/esbuild-duckdb-virtual/src/index.html @@ -0,0 +1,25 @@ + + + + + + Web Worker VirtualServer Example + + + + + + + + diff --git a/examples/esbuild-duckdb-virtual/src/index.ts b/examples/esbuild-duckdb-virtual/src/index.ts new file mode 100644 index 0000000..5fa0a8d --- /dev/null +++ b/examples/esbuild-duckdb-virtual/src/index.ts @@ -0,0 +1,87 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 from "@perspective-dev/client"; +import perspective_viewer from "@perspective-dev/viewer"; +import "@perspective-dev/viewer-datagrid"; +import "@perspective-dev/viewer-charts"; + +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([ + // @ts-ignore + perspective.init_server(fetch(SERVER_WASM)), + // @ts-ignore + perspective_viewer.init_client(fetch(CLIENT_WASM)), +]); + +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 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; +} + +async function loadSampleData(db: duckdb.AsyncDuckDBConnection) { + 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 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")!; +viewer.load(client); +viewer.restore({ + table: "memory.data_source_one", + group_by: ["State"], +}); diff --git a/examples/esbuild-example/README.md b/examples/esbuild-example/README.md new file mode 100644 index 0000000..c380a04 --- /dev/null +++ b/examples/esbuild-example/README.md @@ -0,0 +1,5 @@ +# `esbuild` Example + +Simple [`esbuild`](https://esbuild.github.io/) example, demonstrating how to use +this bunlder to package `perspective`, `perspective-viewer` and plugins, as well +as initialize the WebAssembly instances. diff --git a/examples/esbuild-example/build.js b/examples/esbuild-example/build.js new file mode 100644 index 0000000..a049096 --- /dev/null +++ b/examples/esbuild-example/build.js @@ -0,0 +1,38 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +const esbuild = require("esbuild"); +const fs = require("fs"); +const path = require("path"); + +async function build() { + await esbuild.build({ + entryPoints: ["src/index.js"], + outdir: "dist", + format: "esm", + bundle: true, + target: "es2022", + loader: { + ".ttf": "file", + ".arrow": "file", + ".wasm": "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/esbuild-example/package.json b/examples/esbuild-example/package.json new file mode 100644 index 0000000..b02d737 --- /dev/null +++ b/examples/esbuild-example/package.json @@ -0,0 +1,23 @@ +{ + "name": "esbuild-example", + "private": true, + "version": "4.5.2", + "description": "An esbuild example app built using `@perspective-dev/viewer`.", + "scripts": { + "build": "node build.js", + "start": "node build.js && http-server dist" + }, + "keywords": [], + "license": "Apache-2.0", + "dependencies": { + "@perspective-dev/client": "workspace:", + "@perspective-dev/viewer": "workspace:", + "@perspective-dev/viewer-charts": "workspace:", + "@perspective-dev/viewer-datagrid": "workspace:", + "superstore-arrow": "catalog:" + }, + "devDependencies": { + "esbuild": "catalog:", + "http-server": "catalog:" + } +} diff --git a/examples/esbuild-example/src/index.css b/examples/esbuild-example/src/index.css new file mode 100644 index 0000000..4551a99 --- /dev/null +++ b/examples/esbuild-example/src/index.css @@ -0,0 +1,20 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ 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). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +perspective-viewer { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} diff --git a/examples/esbuild-example/src/index.html b/examples/esbuild-example/src/index.html new file mode 100644 index 0000000..37852b2 --- /dev/null +++ b/examples/esbuild-example/src/index.html @@ -0,0 +1,17 @@ + + + + + + + Perspective `esbuild` Example + + + + + + + + + + diff --git a/examples/esbuild-example/src/index.js b/examples/esbuild-example/src/index.js new file mode 100644 index 0000000..50099c3 --- /dev/null +++ b/examples/esbuild-example/src/index.js @@ -0,0 +1,38 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 from "@perspective-dev/client"; +import perspective_viewer from "@perspective-dev/viewer"; +import "@perspective-dev/viewer-datagrid"; +import "@perspective-dev/viewer-charts"; + +import "@perspective-dev/viewer/dist/css/pro-dark.css"; +import "./index.css"; + +import arrow from "superstore-arrow/superstore.lz4.arrow"; + +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)), +]); + +const req = fetch(arrow); +const viewer = document.createElement("perspective-viewer"); +document.body.append(viewer); +const worker = await perspective.worker(); +const resp = await req; +const buffer = await resp.arrayBuffer(); +const table = worker.table(buffer); +viewer.load(table); diff --git a/examples/esbuild-remote/build.js b/examples/esbuild-remote/build.js new file mode 100644 index 0000000..e71f99d --- /dev/null +++ b/examples/esbuild-remote/build.js @@ -0,0 +1,38 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +const esbuild = require("esbuild"); +const fs = require("fs"); +const path = require("path"); + +async function build() { + await esbuild.build({ + entryPoints: ["client/index.js"], + outdir: "dist", + format: "esm", + bundle: true, + target: "es2022", + loader: { + ".ttf": "file", + ".arrow": "file", + ".wasm": "file", + }, + assetNames: "[name]", + }); + + fs.writeFileSync( + path.join(__dirname, "dist/index.html"), + fs.readFileSync(path.join(__dirname, "client/index.html")).toString(), + ); +} + +build(); diff --git a/examples/esbuild-remote/client/index.css b/examples/esbuild-remote/client/index.css new file mode 100644 index 0000000..69eeeb9 --- /dev/null +++ b/examples/esbuild-remote/client/index.css @@ -0,0 +1,21 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ 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). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + + + perspective-viewer { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + } diff --git a/examples/esbuild-remote/client/index.html b/examples/esbuild-remote/client/index.html new file mode 100644 index 0000000..e196fc9 --- /dev/null +++ b/examples/esbuild-remote/client/index.html @@ -0,0 +1,13 @@ + + + + + + + Perspective `esbuild` Example + + + + + + diff --git a/examples/esbuild-remote/client/index.js b/examples/esbuild-remote/client/index.js new file mode 100644 index 0000000..3693c02 --- /dev/null +++ b/examples/esbuild-remote/client/index.js @@ -0,0 +1,50 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 from "@perspective-dev/client"; + +import perspective_viewer from "@perspective-dev/viewer"; +import "@perspective-dev/viewer-datagrid"; +import "@perspective-dev/viewer-charts"; + +import "@perspective-dev/viewer/dist/css/pro-dark.css"; + +import "./index.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)), +]); + +const viewer = document.createElement("perspective-viewer"); +document.body.append(viewer); + +// Create two perspective interfaces, one remotely via WebSocket, +// and one local via WebWorker. +const url = `${window.location.origin.replace("http", "ws")}/subscribe`; +const websocket = await perspective.websocket(url); + +// Open a `Table` that is hosted on the server. All instructions +// will be proxied to the server `Table` - no calculations are +// done on the client. +const remote_table = await websocket.open_table("securities"); + +// Create a `table` from this, owned by the local WebWorker. +// Data is transferred from `view` to the local WebWorker, both +// the current state and all future updates, as Arrows. +// const local_table = await worker.table(view, { limit: 10000 }); + +// Load this in the ``. +viewer.load(remote_table); diff --git a/examples/esbuild-remote/package.json b/examples/esbuild-remote/package.json new file mode 100644 index 0000000..16dc284 --- /dev/null +++ b/examples/esbuild-remote/package.json @@ -0,0 +1,21 @@ +{ + "name": "esbuild-remote", + "private": true, + "version": "4.5.2", + "description": "An example of 2 Perspectives, one client and one server, streaming via Apache Arrow.", + "scripts": { + "start": "node build.js && node server/index.mjs" + }, + "keywords": [], + "license": "Apache-2.0", + "dependencies": { + "@perspective-dev/client": "workspace:", + "@perspective-dev/viewer": "workspace:", + "@perspective-dev/viewer-charts": "workspace:", + "@perspective-dev/viewer-datagrid": "workspace:", + "ws": "catalog:" + }, + "devDependencies": { + "esbuild": "catalog:" + } +} diff --git a/examples/esbuild-remote/server/index.mjs b/examples/esbuild-remote/server/index.mjs new file mode 100644 index 0000000..5af6a74 --- /dev/null +++ b/examples/esbuild-remote/server/index.mjs @@ -0,0 +1,106 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { make_session } from "@perspective-dev/client"; +import * as securities from "./securities.mjs"; +import * as path from "node:path"; +import { promises as fs } from "node:fs"; +import http from "node:http"; +import { WebSocketServer as HttpWebSocketServer } from "ws"; + +// Don't need this table since it won't be read from node itself, just need +// to create it so the WebSocket clients can find it. +const _TABLE = await securities.getTable(); + +const CONTENT_TYPES = { + ".js": "text/javascript", + ".mjs": "text/javascript", + ".css": "text/css; charset=utf-8", + ".json": "application/json", + ".arrow": "arraybuffer", + ".feather": "arraybuffer", + ".wasm": "application/wasm", +}; + +// node buffer -> JS buffer +function buffer_to_arraybuffer(buffer) { + return new Int8Array( + buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.length, + ), + ); +} + +const app = new HttpWebSocketServer({ + noServer: true, + perMessageDeflate: true, +}); + +app.on("connection", async (ws) => { + console.log("Connecting websocket ..."); + const session = await make_session(async (proto) => { + ws.send(buffer_to_arraybuffer(proto)); + }); + + ws.on("message", (proto) => { + session.handle_request(buffer_to_arraybuffer(proto)); + }); + + ws.on("close", () => { + session.close(); + }); +}); + +const web_server = http.createServer( + async function static_files(request, response) { + let url = + request.url + ?.split(/[\?\#]/)[0] + .replace(/@[\^~]?\d+.[\d\*]*.[\d\*]*/, "") || "/"; + + if (url === "/") { + url = "/index.html"; + } + + const extname = path.extname(url); + const contentType = CONTENT_TYPES[extname] || "text/html"; + try { + const content = await fs.readFile(`dist/${url}`); + if (typeof content !== "undefined") { + response.writeHead(200, { "Content-Type": contentType }); + response.end(content, "utf-8"); + console.error(`200 ${url}`); + return; + } + + console.error(`404 ${url}`); + response.writeHead(404); + response.end("", "utf-8"); + } catch (error) { + console.error(`500 ${url} ${error}`); + response.writeHead(500); + response.end("", "utf-8"); + } + }, +); + +web_server.on("upgrade", (request, socket, head) => { + console.log("200 Websocket upgrade"); + app.handleUpgrade(request, socket, head, (sock) => + app.emit("connection", sock, request), + ); +}); + +web_server.listen(8081, () => { + console.log(`Listening on ${web_server.address().port}`); +}); diff --git a/examples/esbuild-remote/server/securities.mjs b/examples/esbuild-remote/server/securities.mjs new file mode 100644 index 0000000..b895ef6 --- /dev/null +++ b/examples/esbuild-remote/server/securities.mjs @@ -0,0 +1,153 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 from "@perspective-dev/client"; + +// Cache updates for faster update rates (but less data diversity)> +const CACHE_INPUT = false; + +// If cached, how many updates to cache? +const CACHE_ENTRIES = 200; + +// How many rows per update? +const UPDATE_SIZE = 50; + +// Update every N milliseconds +const TICK_RATE = 20; + +// Size limit of the server-side table +const TABLE_SIZE = 10000; + +const SECURITIES = [ + "AAPL.N", + "AMZN.N", + "QQQ.N", + "NVDA.N", + "TSLA.N", + "FB.N", + "MSFT.N", + "TLT.N", + "XIV.N", + "YY.N", + "CSCO.N", + "GOOGL.N", + "PCLN.N", +]; +const CLIENTS = [ + "Homer", + "Marge", + "Bart", + "Lisa", + "Maggie", + "Moe", + "Lenny", + "Carl", + "Krusty", +]; + +const __CACHE__ = []; + +// perspective.initialize_profile_thread(); + +/******************************************************************************* + * + * Slow mode (new rows generated on the fly) + */ + +function choose(choices) { + return choices[Math.floor(Math.random() * choices.length)]; +} + +function newRows(total_rows) { + const rows = []; + for (let x = 0; x < total_rows; x++) { + rows.push({ + name: choose(SECURITIES), + client: choose(CLIENTS), + lastUpdate: new Date(), + chg: Math.random() * 20 - 10, + bid: Math.random() * 10 + 90, + ask: Math.random() * 10 + 100, + vol: Math.random() * 10 + 100, + }); + } + return rows; +} + +async function init_dynamic({ table_size, update_size, tick_rate }) { + // Create a `table`. + const table = await perspective.table(newRows(table_size), { + limit: table_size, + name: "securities", + }); + + // The `table` needs to be registered to a name with the Perspective + // `WebSocketServer` in order for the client to get a proxy handle to it. + + // Loop and update the `table` oocasionally. + (function postRow() { + table.update(newRows(update_size)); + setTimeout(postRow, tick_rate); + })(); + return table; +} + +/******************************************************************************* + * + * Fast mode (rows pre-generated, cached as Arrows) + */ + +async function newArrow(total_rows) { + const table = await perspective.table(newRows(total_rows)); + const vw = await table.view(); + const arrow = await vw.to_arrow(); + vw.delete(); + table.delete(); + return arrow; +} + +async function populate_cache(cache_entries) { + for (let x = 0; x < cache_entries; x++) { + let arrow = await newArrow(100); + __CACHE__[x] = arrow; + } +} + +async function init_cached({ table_size, tick_rate, cache_entries }) { + await populate_cache(cache_entries); + const table = await perspective.table(newRows(table_size), { + limit: table_size, + name: "securities", + }); + (function postRow() { + const entry = __CACHE__[Math.floor(Math.random() * __CACHE__.length)]; + table.update(entry); + setTimeout(postRow, tick_rate); + })(); + return table; +} + +export const getTable = ( + config = { + cached: CACHE_INPUT, + tick_rate: TICK_RATE, + update_size: UPDATE_SIZE, + table_size: TABLE_SIZE, + cache_entries: CACHE_ENTRIES, + }, +) => { + if (config.cached) { + return init_cached(config); + } else { + return init_dynamic(config); + } +}; diff --git a/examples/python-aiohttp/README.md b/examples/python-aiohttp/README.md new file mode 100644 index 0000000..1c1157e --- /dev/null +++ b/examples/python-aiohttp/README.md @@ -0,0 +1,8 @@ +# python-aiohttp + +This example contains a simple `perspective-python` folder that uses AIOHTTP to +serve a static dataset. + + +This example will not work outside the Perspective source tree! You will need a working local Perspective build to run it. + diff --git a/examples/python-aiohttp/package.json b/examples/python-aiohttp/package.json new file mode 100644 index 0000000..d9281af --- /dev/null +++ b/examples/python-aiohttp/package.json @@ -0,0 +1,11 @@ +{ + "name": "python-aiohttp", + "private": true, + "version": "4.5.2", + "description": "An example of editing a `perspective-python` server from the browser.", + "scripts": { + "start": "PYTHONPATH=../../python/perspective python3 server.py" + }, + "keywords": [], + "license": "Apache-2.0" +} diff --git a/examples/python-aiohttp/server.py b/examples/python-aiohttp/server.py new file mode 100644 index 0000000..6025af2 --- /dev/null +++ b/examples/python-aiohttp/server.py @@ -0,0 +1,55 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 os +import os.path +import logging + +from aiohttp import web + +from perspective import Server +from perspective.handlers.aiohttp import PerspectiveAIOHTTPHandler + + +here = os.path.abspath(os.path.dirname(__file__)) +file_path = os.path.join( + here, ".", "node_modules", "superstore-arrow", "superstore.lz4.arrow" +) + + +def perspective_thread(server): + """Perspective application thread starts its own event loop, and + adds the table with the name "data_source_one", which will be used + in the front-end.""" + + +def make_app(): + server = Server() + client = server.new_local_client() + with open(file_path, mode="rb") as file: + client.table(file.read(), index="Row ID", name="data_source_one") + + async def websocket_handler(request): + handler = PerspectiveAIOHTTPHandler(perspective_server=server, request=request) + await handler.run() + + app = web.Application() + app.router.add_get("/websocket", websocket_handler) + app.router.add_static("/node_modules", "../../node_modules/", follow_symlinks=True) + app.router.add_static("/", "../python-tornado", show_index=True) + return app + + +if __name__ == "__main__": + app = make_app() + logging.critical("Listening on http://localhost:8080") + web.run_app(app, host="0.0.0.0", port=8080) diff --git a/examples/python-clickhouse-virtual/index.html b/examples/python-clickhouse-virtual/index.html new file mode 100644 index 0000000..efd7c2d --- /dev/null +++ b/examples/python-clickhouse-virtual/index.html @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/examples/python-clickhouse-virtual/package.json b/examples/python-clickhouse-virtual/package.json new file mode 100644 index 0000000..cd553cf --- /dev/null +++ b/examples/python-clickhouse-virtual/package.json @@ -0,0 +1,22 @@ +{ + "name": "python-clickhouse-virtual", + "private": true, + "version": "4.5.2", + "description": "An example of streaming a ClickHouse-backed `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-charts": "workspace:^", + "@perspective-dev/viewer-datagrid": "workspace:^", + "@perspective-dev/workspace": "workspace:^", + "superstore-arrow": "catalog:" + }, + "devDependencies": { + "npm-run-all": "catalog:" + } +} diff --git a/examples/python-clickhouse-virtual/server.py b/examples/python-clickhouse-virtual/server.py new file mode 100644 index 0000000..871351a --- /dev/null +++ b/examples/python-clickhouse-virtual/server.py @@ -0,0 +1,103 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 logging +from pathlib import Path + +import clickhouse_connect +import perspective +import perspective.handlers.tornado +import perspective.virtual_servers.clickhouse +import pyarrow.parquet as pq +import tornado.ioloop +import tornado.web + +from tornado.web import StaticFileHandler + +logging.basicConfig( + level=logging.DEBUG, +) + +logger = logging.getLogger(__name__) + +INPUT_FILE = ( + Path(__file__).parent.resolve() + / "node_modules" + / "superstore-arrow" + / "superstore.parquet" +) + + +def arrow_type_to_clickhouse(arrow_type): + t = str(arrow_type) + if t.startswith("int") or t.startswith("uint"): + return "Int64" + + if t in ("float", "double", "halffloat"): + return "Float64" + + if t.startswith("timestamp"): + return "DateTime" + + if t.startswith("date"): + return "Date" + + return "String" + + +if __name__ == "__main__": + client = clickhouse_connect.get_client(host="localhost") + + # Load superstore parquet data into ClickHouse + arrow_table = pq.read_table(str(INPUT_FILE)) + client.command("DROP TABLE IF EXISTS data_source_one") + cols = [] + for field in arrow_table.schema: + ch_type = arrow_type_to_clickhouse(field.type) + if field.nullable: + ch_type = f"Nullable({ch_type})" + + cols.append(f"`{field.name}` {ch_type}") + + client.command( + f"CREATE TABLE data_source_one ({', '.join(cols)})" + " ENGINE = MergeTree() ORDER BY tuple()" + ) + + client.insert_arrow("data_source_one", arrow_table) + logger.info("Loaded superstore data into ClickHouse") + + virtual_server = perspective.virtual_servers.clickhouse.ClickhouseVirtualServer( + client + ) + + 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/examples/python-duckdb-virtual/index.html b/examples/python-duckdb-virtual/index.html new file mode 100644 index 0000000..ca0ab8e --- /dev/null +++ b/examples/python-duckdb-virtual/index.html @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/examples/python-duckdb-virtual/package.json b/examples/python-duckdb-virtual/package.json new file mode 100644 index 0000000..c34ca66 --- /dev/null +++ b/examples/python-duckdb-virtual/package.json @@ -0,0 +1,22 @@ +{ + "name": "python-duckdb-virtual", + "private": true, + "version": "4.5.2", + "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-charts": "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 0000000..d1bcdfc --- /dev/null +++ b/examples/python-duckdb-virtual/server.py @@ -0,0 +1,70 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 logging +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 tornado.web import StaticFileHandler + +logging.basicConfig( + level=logging.DEBUG, +) + +logger = logging.getLogger(__name__) + +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/examples/python-polars-virtual/index.html b/examples/python-polars-virtual/index.html new file mode 100644 index 0000000..efd7c2d --- /dev/null +++ b/examples/python-polars-virtual/index.html @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/examples/python-polars-virtual/package.json b/examples/python-polars-virtual/package.json new file mode 100644 index 0000000..3e756b6 --- /dev/null +++ b/examples/python-polars-virtual/package.json @@ -0,0 +1,22 @@ +{ + "name": "python-polars-virtual", + "private": true, + "version": "4.5.2", + "description": "An example of a Polars-backed Perspective virtual server.", + "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-charts": "workspace:^", + "@perspective-dev/viewer-datagrid": "workspace:^", + "@perspective-dev/workspace": "workspace:^", + "superstore-arrow": "catalog:" + }, + "devDependencies": { + "npm-run-all": "catalog:" + } +} diff --git a/examples/python-polars-virtual/server.py b/examples/python-polars-virtual/server.py new file mode 100644 index 0000000..078d9da --- /dev/null +++ b/examples/python-polars-virtual/server.py @@ -0,0 +1,61 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 logging +from pathlib import Path + +import polars as pl +import perspective +import perspective.handlers.tornado +import perspective.virtual_servers.polars +import tornado.ioloop +import tornado.web +import tornado.websocket + +from tornado.web import StaticFileHandler + +logger = logging.getLogger(__name__) + +INPUT_FILE = ( + Path(__file__).parent.resolve() + / "node_modules" + / "superstore-arrow" + / "superstore.parquet" +) + + +if __name__ == "__main__": + df = pl.read_parquet(INPUT_FILE) + tables = {"data_source_one": df} + + virtual_server = perspective.virtual_servers.polars.PolarsVirtualServer(tables) + 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/examples/python-starlette/README.md b/examples/python-starlette/README.md new file mode 100644 index 0000000..9e841ea --- /dev/null +++ b/examples/python-starlette/README.md @@ -0,0 +1,8 @@ +# python-starlette + +This example contains a simple `perspective-python` folder that uses +Starlette/FastAPI to serve a static dataset. + + +This example will not work outside the Perspective source tree! You will need a working local Perspective build to run it. + diff --git a/examples/python-starlette/package.json b/examples/python-starlette/package.json new file mode 100644 index 0000000..e138ac9 --- /dev/null +++ b/examples/python-starlette/package.json @@ -0,0 +1,11 @@ +{ + "name": "python-starlette", + "private": true, + "version": "4.5.2", + "description": "An example of editing a `perspective-python` server from the browser.", + "scripts": { + "start": "PYTHONPATH=../../python/perspective python3 server.py" + }, + "keywords": [], + "license": "Apache-2.0" +} diff --git a/examples/python-starlette/server.py b/examples/python-starlette/server.py new file mode 100644 index 0000000..5635b9f --- /dev/null +++ b/examples/python-starlette/server.py @@ -0,0 +1,60 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 os +import os.path +import logging +import uvicorn + +from fastapi import FastAPI, WebSocket +from starlette.responses import FileResponse +from starlette.staticfiles import StaticFiles + +from perspective import Server +from perspective.handlers.starlette import PerspectiveStarletteHandler + + +here = os.path.abspath(os.path.dirname(__file__)) +file_path = os.path.join( + here, ".", "node_modules", "superstore-arrow", "superstore.lz4.arrow" +) + + +def static_node_modules_handler(rest_of_path): + return FileResponse("../../node_modules/{}".format(rest_of_path)) + + +def make_app(): + server = Server() + client = server.new_local_client() + with open(file_path, mode="rb") as file: + client.table(file.read(), index="Row ID", name="data_source_one") + + async def websocket_handler(websocket: WebSocket): + handler = PerspectiveStarletteHandler( + perspective_server=server, websocket=websocket + ) + await handler.run() + + static_html_files = StaticFiles(directory="../python-tornado", html=True) + + app = FastAPI() + app.add_api_websocket_route("/websocket", websocket_handler) + app.get("/node_modules/{rest_of_path:path}")(static_node_modules_handler) + app.mount("/", static_html_files) + return app + + +if __name__ == "__main__": + app = make_app() + logging.critical("Listening on http://localhost:8080") + uvicorn.run(app, host="0.0.0.0", port=8080) diff --git a/examples/python-tornado-streaming/index.html b/examples/python-tornado-streaming/index.html new file mode 100644 index 0000000..a2427ce --- /dev/null +++ b/examples/python-tornado-streaming/index.html @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + diff --git a/examples/python-tornado-streaming/package.json b/examples/python-tornado-streaming/package.json new file mode 100644 index 0000000..8d61598 --- /dev/null +++ b/examples/python-tornado-streaming/package.json @@ -0,0 +1,22 @@ +{ + "name": "python-tornado-streaming", + "private": true, + "version": "4.5.2", + "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-charts": "workspace:", + "@perspective-dev/viewer-datagrid": "workspace:", + "@perspective-dev/workspace": "workspace:", + "superstore-arrow": "^3.2.0" + }, + "devDependencies": { + "npm-run-all": "^4.1.3" + } +} diff --git a/examples/python-tornado-streaming/server.py b/examples/python-tornado-streaming/server.py new file mode 100644 index 0000000..705d691 --- /dev/null +++ b/examples/python-tornado-streaming/server.py @@ -0,0 +1,136 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 random +import logging +import tornado.websocket +import tornado.web +import tornado.ioloop +from datetime import date, datetime +import perspective +import perspective.handlers.tornado +import threading +import time +from functools import partial +from concurrent.futures import ThreadPoolExecutor + + +def data_source(num_rows): + rows = [] + modifier = random.random() * random.randint(1, 50) + for i in range(num_rows): + rows.append( + { + "name": SECURITIES[random.randint(0, len(SECURITIES) - 1)], + "client": CLIENTS[random.randint(0, len(CLIENTS) - 1)], + "open": (random.random() * 75 + random.randint(0, 9)) * modifier, + "high": (random.random() * 105 + random.randint(1, 3)) * modifier, + "low": (random.random() * 85 + random.randint(1, 3)) * modifier, + "close": (random.random() * 90 + random.randint(1, 3)) * modifier, + "lastUpdate": datetime.now().timestamp() * 1000, + "date": date.today().strftime("%Y-%m-%d"), + } + ) + return rows + + +SECURITIES = [ + "AAPL.N", + "AMZN.N", + "QQQ.N", + "NVDA.N", + "TSLA.N", + "FB.N", + "MSFT.N", + "TLT.N", + "XIV.N", + "YY.N", + "CSCO.N", + "GOOGL.N", + "PCLN.N", +] + +CLIENTS = ["Homer", "Marge", "Bart", "Lisa", "Maggie", "Moe", "Lenny", "Carl", "Krusty"] + + +def update_target(perspective_server): + client = perspective_server.new_local_client() + table = client.table( + { + "name": "string", + "client": "string", + "open": "float", + "high": "float", + "low": "float", + "close": "float", + "lastUpdate": "datetime", + "date": "date", + }, + limit=250_000, + name="data_source_one", + ) + + while True: + table.update(data_source(num_rows=50)) + time.sleep(0.01) + + +def poll_target(lock, perspective_server): + try: + perspective_server.poll() + finally: + lock.release() + + +# `on_poll_request` is an optional keyword argument for `Server()`, which +# enables an optimization which can be applied in cases when deferring +# overlapping `Table.update` calls can be done safely. +# +# In This case, `Table.update` is always called every `TICK_RATE`, so it is safe +# to defer these updates as they'll get applied when the next tick occurs. +def on_poll_request(lock, executor, perspective_server): + if lock.acquire(blocking=False): + executor.submit(poll_target, lock, perspective_server) + + +def make_app(executor, perspective_server): + return tornado.web.Application( + [ + ( + r"/websocket", + perspective.handlers.tornado.PerspectiveTornadoHandler, + {"perspective_server": perspective_server, "executor": executor}, + ), + ( + r"/(.*)", + tornado.web.StaticFileHandler, + {"path": "./", "default_filename": "index.html"}, + ), + ] + ) + + +if __name__ == "__main__": + executor = ThreadPoolExecutor() + lock = threading.Lock() + perspective_server = perspective.Server( + on_poll_request=partial(on_poll_request, lock, executor), + ) + + thread = threading.Thread(target=update_target, args=(perspective_server,)) + thread.start() + + loop = tornado.ioloop.IOLoop.current() + app = make_app(executor, perspective_server) + app.listen(8080) + logging.warning("Listening on http://localhost:8080") + loop.start() diff --git a/examples/python-tornado/README.md b/examples/python-tornado/README.md new file mode 100644 index 0000000..f26446a --- /dev/null +++ b/examples/python-tornado/README.md @@ -0,0 +1,16 @@ +# python-tornado + +This example contains a simple `perspective-python` folder that uses Tornado to +serve a static dataset to the user through various data bindings. There are a +examples of end-to-end `perspective-viewer` integrated `.html` files hosted: + +- `index.html`: a + [client/server replicated](https://perspective-dev.github.io/docs/md/server.html#clientserver-replicated) + setup that synchronizes the client and server data using Apache Arrow. +- `server_mode.html`: a + [server-only](https://perspective-dev.github.io/docs/md/server.html#server-only) + setup that reads data and performs operations directly on the server using + commands sent through the Websocket. +- `client_server_editing.html`: a client-server replicated setup that also + enables editing, where edits from multiple clients are applied properly to the + server, and then synchronized back to the clients. diff --git a/examples/python-tornado/client_server_editing.html b/examples/python-tornado/client_server_editing.html new file mode 100644 index 0000000..85bf004 --- /dev/null +++ b/examples/python-tornado/client_server_editing.html @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + diff --git a/examples/python-tornado/index.html b/examples/python-tornado/index.html new file mode 100644 index 0000000..32defb4 --- /dev/null +++ b/examples/python-tornado/index.html @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + diff --git a/examples/python-tornado/package.json b/examples/python-tornado/package.json new file mode 100644 index 0000000..9154d42 --- /dev/null +++ b/examples/python-tornado/package.json @@ -0,0 +1,22 @@ +{ + "name": "python-tornado", + "private": true, + "version": "4.5.2", + "description": "An example of editing a `perspective-python` server from 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-charts": "workspace:", + "@perspective-dev/viewer-datagrid": "workspace:", + "@perspective-dev/workspace": "workspace:", + "superstore-arrow": "^3.2.0" + }, + "devDependencies": { + "npm-run-all": "^4.1.3" + } +} diff --git a/examples/python-tornado/server.py b/examples/python-tornado/server.py new file mode 100644 index 0000000..486f8ca --- /dev/null +++ b/examples/python-tornado/server.py @@ -0,0 +1,65 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 os +import os.path +import logging +import tornado.websocket +import tornado.web +import tornado.ioloop + +import perspective +import perspective.handlers.tornado + +here = os.path.abspath(os.path.dirname(__file__)) +file_path = os.path.join( + here, ".", "node_modules", "superstore-arrow", "superstore.lz4.arrow" +) + + +def make_app(perspective_server): + return tornado.web.Application( + [ + ( + r"/websocket", + perspective.handlers.tornado.PerspectiveTornadoHandler, + {"perspective_server": perspective_server}, + ), + ( + r"/node_modules/(.*)", + tornado.web.StaticFileHandler, + {"path": "../../node_modules/"}, + ), + ( + r"/(.*)", + tornado.web.StaticFileHandler, + {"path": "./", "default_filename": "index.html"}, + ), + ], + websocket_max_message_size=100 * 1024 * 1024, + ) + + +if __name__ == "__main__": + perspective_server = perspective.Server() + app = make_app(perspective_server) + app.listen(8080) + logging.critical("Listening on http://localhost:8080") + loop = tornado.ioloop.IOLoop.current() + client = perspective_server.new_local_client() + with open(file_path, mode="rb") as file: + data = file.read() + table = client.table(data, name="data_source_one") + for _ in range(10): + table.update(data) + + loop.start() diff --git a/examples/python-tornado/server_mode.html b/examples/python-tornado/server_mode.html new file mode 100644 index 0000000..5ad3930 --- /dev/null +++ b/examples/python-tornado/server_mode.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + diff --git a/examples/react-example/build.js b/examples/react-example/build.js new file mode 100644 index 0000000..e1eacb3 --- /dev/null +++ b/examples/react-example/build.js @@ -0,0 +1,38 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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)); + +await esbuild.build({ + entryPoints: ["src/index.tsx"], + outdir: "dist", + format: "esm", + bundle: true, + sourcemap: "linked", + target: "es2022", + loader: { + ".arrow": "file", + ".wasm": "file", + }, + assetNames: "[name]", +}); + +fs.writeFileSync( + path.join(__dirname, "dist/index.html"), + fs.readFileSync(path.join(__dirname, "src/index.html")).toString(), +); diff --git a/examples/react-example/globals.d.ts b/examples/react-example/globals.d.ts new file mode 100644 index 0000000..26ce93c --- /dev/null +++ b/examples/react-example/globals.d.ts @@ -0,0 +1,21 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +declare module "*.wasm" { + const content: string; + export default content; +} + +declare module "*.arrow" { + const content: string; + export default content; +} diff --git a/examples/react-example/package.json b/examples/react-example/package.json new file mode 100644 index 0000000..4c135b3 --- /dev/null +++ b/examples/react-example/package.json @@ -0,0 +1,29 @@ +{ + "name": "react-example", + "private": true, + "version": "4.5.2", + "description": "An example app built using `@perspective-dev/viewer`.", + "type": "module", + "scripts": { + "build": "node build.js", + "start": "node build.js && http-server dist" + }, + "keywords": [], + "license": "Apache-2.0", + "dependencies": { + "@perspective-dev/client": "workspace:", + "@perspective-dev/viewer": "workspace:", + "@perspective-dev/viewer-charts": "workspace:", + "@perspective-dev/viewer-datagrid": "workspace:", + "@perspective-dev/react": "workspace:", + "react": "catalog:", + "react-dom": "catalog:", + "superstore-arrow": "catalog:" + }, + "devDependencies": { + "esbuild": "catalog:", + "http-server": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:" + } +} diff --git a/examples/react-example/src/index.css b/examples/react-example/src/index.css new file mode 100644 index 0000000..6a29298 --- /dev/null +++ b/examples/react-example/src/index.css @@ -0,0 +1,69 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ 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). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +body { + margin: 0; + background-color: #f0f0f0; + font-family: + "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", "Consolas", + "Liberation Mono", monospace; +} + +.container { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 45px 1fr 1fr; +} + +perspective-viewer { + /* grid-row-start: 2; + grid-row-end: 3; */ + grid-column: 1; + margin: 12px; +} + +perspective-workspace { + grid-row-start: 2; + grid-row-end: 4; + grid-column: 2; + overflow: hidden; +} + +perspective-workspace perspective-viewer { + margin: 0px; +} + +.toolbar { + grid-row: 1; + grid-column-start: 1; + grid-column-end: 3; + + display: flex; + flex-direction: row; + gap: 10px; + padding: 10px; + justify-content: stretch; + border-bottom: 1px solid #666; +} + +button { + font-family: + "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", "Consolas", + "Liberation Mono", monospace; +} diff --git a/examples/react-example/src/index.html b/examples/react-example/src/index.html new file mode 100644 index 0000000..d06e153 --- /dev/null +++ b/examples/react-example/src/index.html @@ -0,0 +1,19 @@ + + + + + + Perspective React Example + + + + +

    + + diff --git a/examples/react-example/src/index.tsx b/examples/react-example/src/index.tsx new file mode 100644 index 0000000..d6c6d5f --- /dev/null +++ b/examples/react-example/src/index.tsx @@ -0,0 +1,167 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// # [Perspective bootstrapping](https://perspective-dev.github.io/guide/how_to/javascript/importing.html) + +// Here we're initializing the WASM interpreter that powers the perspective API +// and viewer, as covered in the [user guide section on bundling](https://perspective-dev.github.io/guide/how_to/javascript/importing.html). +// This example is written assuming that the bundler is configured +// to treat these files as a "file" and returns a path as the default export. +// Use ./build.js as an example. The type stubs are in ./globals.d.ts + +import perspective from "@perspective-dev/client"; +import perspective_viewer from "@perspective-dev/viewer"; +import perspective_workspace from "@perspective-dev/workspace"; +import "@perspective-dev/workspace"; +import "@perspective-dev/viewer-datagrid"; +import "@perspective-dev/viewer-charts"; + +import * as React from "react"; +import { createRoot } from "react-dom/client"; +import { + PerspectiveViewer, + PerspectiveWorkspace, +} from "@perspective-dev/react"; + +import "@perspective-dev/viewer/dist/css/themes.css"; +import "@perspective-dev/workspace/dist/css/pro.css"; +import "./index.css"; + +import type * as psp from "@perspective-dev/client"; +import type * as pspViewer from "@perspective-dev/viewer"; + +import SUPERSTORE_ARROW from "superstore-arrow/superstore.lz4.arrow"; + +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)), +]); + +// # Data Source + +// Data source creates a static Web Worker instance of Perspective engine, and a +// table creation function which both downloads data and loads it into the +// engine. + +const WORKER = await perspective.worker(); + +async function createNewSuperstoreTable(): Promise { + console.warn("Creating new table!"); + const req = fetch(SUPERSTORE_ARROW); + const resp = await req; + const buffer = await resp.arrayBuffer(); + return await WORKER.table(buffer); +} + +const CONFIG: pspViewer.ViewerConfigUpdate = { + group_by: ["State"], +}; + +// # React application + +// The React application itself + +interface ToolbarState { + mounted: boolean; + table?: Promise; + config: pspViewer.ViewerConfigUpdate; + layout: perspective_workspace.PerspectiveWorkspaceConfig; +} + +const App: React.FC = () => { + const [state, setState] = React.useState(() => ({ + mounted: true, + table: createNewSuperstoreTable(), + layout: { + detail: { main: null }, + sizes: [], + viewers: {}, + }, + config: { ...CONFIG }, + })); + + React.useEffect(() => { + return () => { + state.table?.then((table) => table?.delete({ lazy: true })); + }; + }, []); + + const onClickOverwrite = () => { + state.table?.then((table) => table?.delete({ lazy: true })); + const table = createNewSuperstoreTable(); + setState({ ...state, table }); + }; + + const onClickDelete = () => { + state.table?.then((table) => table?.delete({ lazy: true })); + setState({ ...state, table: undefined }); + }; + + const onClickToggleMount = () => + setState((old) => ({ ...old, mounted: !state.mounted })); + + const onConfigUpdate = (config: pspViewer.ViewerConfigUpdate) => { + console.log("Config Update Event", config); + delete config.table; + setState({ ...state, config }); + }; + + const onLayoutUpdate = ( + layout: perspective_workspace.PerspectiveWorkspaceConfig, + ) => { + console.log("Layout Update Event", layout); + + // delete config.table; + setState({ ...state, layout }); + }; + + const onClick = (detail: pspViewer.PerspectiveClickEventDetail) => { + console.log("Click Event,", detail); + }; + + const onSelect = (detail: pspViewer.PerspectiveSelectEventDetail) => { + console.log("Select Event", detail); + }; + + return ( +
    +
    + + + +
    + {state.mounted && ( + <> + + + + + )} +
    + ); +}; + +createRoot(document.getElementById("root")!).render(); diff --git a/examples/react-example/tsconfig.json b/examples/react-example/tsconfig.json new file mode 100644 index 0000000..2bf55a7 --- /dev/null +++ b/examples/react-example/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "moduleResolution": "bundler", + "target": "ESNext", + "module": "ESNext", + "lib": [ + "ES2020", + "dom" + ], + "strict": true, + "alwaysStrict": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noImplicitUseStrict": false, + "noUnusedLocals": false, + "strictNullChecks": true, + "skipLibCheck": false, + "removeComments": false, + "jsx": "react-jsx", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "importHelpers": false, + "noEmitHelpers": true, + "inlineSourceMap": false, + "sourceMap": true, + "emitDecoratorMetadata": false, + "experimentalDecorators": true, + "downlevelIteration": true, + "pretty": true + }, + "exclude": [ + "node_modules" + ] +} \ No newline at end of file diff --git a/examples/rust-axum/Cargo.toml b/examples/rust-axum/Cargo.toml new file mode 100644 index 0000000..141bdd7 --- /dev/null +++ b/examples/rust-axum/Cargo.toml @@ -0,0 +1,38 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 = "rust-axum" +version = "4.5.2" +edition = "2024" +publish = false + +[features] +default = [] + +# TODO(texodus): Due to feature unification and `wasm_bindgen_futures` +# requirements for `Send` bounds interacting poorly with `rust-analyzer`, +# this cannot require the "axum-ws" feature by defualt without impacting +# developer sanity. In a stand-alone project outside the perspective repo, this +# hack is not necessary. +# +# This feature flag is passed on the CLI by `package.json` instead. +_hack = ["perspective/axum-ws"] + +[dependencies] +perspective = { version = "4.5.2", default-features = false } +axum = { version = ">=0.8,<0.9", features = ["ws"] } +futures = "0.3" +tokio = { version = "1.0", features = ["full"] } +tower-http = { version = "0.5.0", features = ["fs", "trace"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/examples/rust-axum/package.json b/examples/rust-axum/package.json new file mode 100644 index 0000000..238e21d --- /dev/null +++ b/examples/rust-axum/package.json @@ -0,0 +1,15 @@ +{ + "name": "rust-axum", + "private": true, + "version": "4.5.2", + "description": "An example of a Rust/Axum virtual Perspective server", + "scripts": { + "start": "cargo run --target=$(rustc -vV | sed -n 's|host: ||p') --features=_hack" + }, + "keywords": [], + "license": "Apache-2.0", + "dependencies": { + "superstore-arrow": "catalog:" + }, + "devDependencies": {} +} diff --git a/examples/rust-axum/src/index.html b/examples/rust-axum/src/index.html new file mode 100644 index 0000000..910a72d --- /dev/null +++ b/examples/rust-axum/src/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/rust-axum/src/main.rs b/examples/rust-axum/src/main.rs new file mode 100644 index 0000000..c5d09e9 --- /dev/null +++ b/examples/rust-axum/src/main.rs @@ -0,0 +1,91 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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::fs::File; +use std::io::Read; +#[cfg(feature = "_hack")] +use std::net::SocketAddr; + +#[cfg(feature = "_hack")] +use axum::Router; +#[cfg(feature = "_hack")] +use axum::routing::get_service; +use perspective::client::{TableInitOptions, UpdateData}; +use perspective::server::Server; +#[cfg(feature = "_hack")] +use tower_http::{ + services::{ServeDir, ServeFile}, + trace::TraceLayer, +}; +use tracing_subscriber::filter::LevelFilter; +use tracing_subscriber::fmt::layer; +use tracing_subscriber::prelude::*; +use tracing_subscriber::registry; + +const ROOT_PATH: &str = "."; +const ARROW_FILE_PATH: &str = "node_modules/superstore-arrow/superstore.arrow"; + +#[cfg(feature = "_hack")] +const SERVER_ADDRESS: &str = "0.0.0.0:3000"; + +/// A local error synonym for this module only. +type AppError = Box; + +/// Load the example Apache Arrow file from disk and create a +/// [`perspective::Table`] named "my_data_source". +async fn load_server_arrow(server: &Server) -> Result<(), AppError> { + let client = server.new_local_client(); + let mut file = File::open(std::path::Path::new(ROOT_PATH).join(ARROW_FILE_PATH))?; + let mut feather = Vec::with_capacity(file.metadata()?.len() as usize); + file.read_to_end(&mut feather)?; + let data = UpdateData::Arrow(feather.into()); + let mut options = TableInitOptions::default(); + options.set_name("my_data_source"); + client.table(data.into(), options).await?; + client.close().await; + Ok(()) +} + +/// Host a combination HTTP file server + WebSocket server, which serves a +/// simple Perspective application. The app's HTML, etc., assets are served +/// from the root, while the app's embedded WebAssembly [`perspective::Client`] +/// will connect to this server over a WebSocket via the path `/ws`. +async fn start_web_server_and_block(_server: Server) -> Result<(), AppError> { + #[cfg(feature = "_hack")] + { + let app = Router::new() + .route("/", get_service(ServeFile::new("src/index.html"))) + .route("/ws", perspective::axum::websocket_handler()) + .fallback_service(ServeDir::new(ROOT_PATH)) + .with_state(_server) + .layer(TraceLayer::new_for_http()); + + let service = app.into_make_service_with_connect_info::(); + let listener = tokio::net::TcpListener::bind(SERVER_ADDRESS).await?; + tracing::info!("listening on {}", listener.local_addr()?); + axum::serve(listener, service).await?; + } + + Ok(()) +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() -> Result<(), AppError> { + registry() + .with(layer().compact().with_filter(LevelFilter::INFO)) + .init(); + + let server = Server::new(None); + load_server_arrow(&server).await?; + start_web_server_and_block(server).await?; + Ok(()) +} diff --git a/examples/vite-example/README.md b/examples/vite-example/README.md new file mode 100644 index 0000000..2b32ae4 --- /dev/null +++ b/examples/vite-example/README.md @@ -0,0 +1 @@ +Simple example of Perspective + Vite diff --git a/examples/vite-example/index.html b/examples/vite-example/index.html new file mode 100644 index 0000000..dcb7ed5 --- /dev/null +++ b/examples/vite-example/index.html @@ -0,0 +1,14 @@ + + + + + + + Vite + Perspective + + +
    + + + + diff --git a/examples/vite-example/package.json b/examples/vite-example/package.json new file mode 100644 index 0000000..256d4d9 --- /dev/null +++ b/examples/vite-example/package.json @@ -0,0 +1,20 @@ +{ + "name": "vite-example", + "private": true, + "version": "4.5.2", + "type": "module", + "scripts": { + "start": "vite", + "build": "vite build" + }, + "dependencies": { + "@perspective-dev/client": "workspace:", + "@perspective-dev/viewer": "workspace:", + "@perspective-dev/viewer-charts": "workspace:", + "@perspective-dev/viewer-datagrid": "workspace:", + "superstore-arrow": "catalog:" + }, + "devDependencies": { + "vite": "catalog:" + } +} diff --git a/examples/vite-example/src/index.css b/examples/vite-example/src/index.css new file mode 100644 index 0000000..4551a99 --- /dev/null +++ b/examples/vite-example/src/index.css @@ -0,0 +1,20 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ 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). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +perspective-viewer { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} diff --git a/examples/vite-example/src/index.js b/examples/vite-example/src/index.js new file mode 100644 index 0000000..417648b --- /dev/null +++ b/examples/vite-example/src/index.js @@ -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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import perspective from "@perspective-dev/client"; +import perspective_viewer from "@perspective-dev/viewer"; +import "@perspective-dev/viewer-datagrid"; +import "@perspective-dev/viewer-charts"; + +import "@perspective-dev/viewer/dist/css/pro-dark.css"; +import "./index.css"; + +import SERVER_WASM from "@perspective-dev/server/dist/wasm/perspective-server.wasm?url"; +import CLIENT_WASM from "@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm?url"; + +await Promise.all([ + perspective.init_server(fetch(SERVER_WASM)), + perspective_viewer.init_client(fetch(CLIENT_WASM)), +]); + +const req = fetch("node_modules/superstore-arrow/superstore.lz4.arrow"); +const viewer = document.createElement("perspective-viewer"); +document.body.append(viewer); +const worker = await perspective.worker(); +const resp = await req; +const buffer = await resp.arrayBuffer(); +const table = worker.table(buffer); +viewer.load(table); diff --git a/examples/vite-example/vite.config.js b/examples/vite-example/vite.config.js new file mode 100644 index 0000000..7a543c0 --- /dev/null +++ b/examples/vite-example/vite.config.js @@ -0,0 +1,19 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { defineConfig } from "vite"; + +export default defineConfig({ + build: { + target: "esnext", + }, +}); diff --git a/examples/webpack-example/README.md b/examples/webpack-example/README.md new file mode 100644 index 0000000..4b5cdb6 --- /dev/null +++ b/examples/webpack-example/README.md @@ -0,0 +1,3 @@ +# Webpack Example + +Simple webpack example using `perspective-viewer` and plugins. \ No newline at end of file diff --git a/examples/webpack-example/package.json b/examples/webpack-example/package.json new file mode 100644 index 0000000..59e39e1 --- /dev/null +++ b/examples/webpack-example/package.json @@ -0,0 +1,27 @@ +{ + "name": "webpack-example", + "private": true, + "version": "4.5.2", + "description": "An example app built using `@perspective-dev/viewer`.", + "scripts": { + "webpack_build": "webpack", + "start": "webpack serve" + }, + "keywords": [], + "license": "Apache-2.0", + "dependencies": { + "@perspective-dev/client": "workspace:", + "@perspective-dev/viewer": "workspace:", + "@perspective-dev/viewer-charts": "workspace:", + "@perspective-dev/viewer-datagrid": "workspace:" + }, + "devDependencies": { + "arraybuffer-loader": "catalog:", + "css-loader": "catalog:", + "html-webpack-plugin": "catalog:", + "style-loader": "catalog:", + "superstore-arrow": "catalog:", + "webpack-cli": "catalog:", + "webpack": "catalog:" + } +} diff --git a/examples/webpack-example/src/index.css b/examples/webpack-example/src/index.css new file mode 100644 index 0000000..7adefb0 --- /dev/null +++ b/examples/webpack-example/src/index.css @@ -0,0 +1,20 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ 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). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +perspective-viewer { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} diff --git a/examples/webpack-example/src/index.js b/examples/webpack-example/src/index.js new file mode 100644 index 0000000..c54fd0f --- /dev/null +++ b/examples/webpack-example/src/index.js @@ -0,0 +1,40 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 "./index.css"; +import superstore from "superstore-arrow/superstore.lz4.arrow"; + +import "@perspective-dev/viewer/dist/css/themes.css"; + +import "@perspective-dev/viewer-datagrid"; +import "@perspective-dev/viewer-charts/bar"; +import perspective from "@perspective-dev/client"; +import perspective_viewer from "@perspective-dev/viewer"; + +// Depending on the phase of the moon (and to a lesser extend, your `webpack` +// version), this may or may not have a default export. +import SERVER_WASM from "@perspective-dev/server/dist/wasm/perspective-server.wasm"; +import CLIENT_WASM from "@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm"; + +console.log(CLIENT_WASM); +await Promise.all([ + perspective.init_server(SERVER_WASM), + perspective_viewer.init_client(CLIENT_WASM), +]); + +const worker = await perspective.worker(); +const viewer = document.createElement("perspective-viewer"); +document.body.append(viewer); + +const table = worker.table(superstore); +viewer.load(table); +window.viewer = viewer; diff --git a/examples/webpack-example/webpack.config.js b/examples/webpack-example/webpack.config.js new file mode 100644 index 0000000..db23a18 --- /dev/null +++ b/examples/webpack-example/webpack.config.js @@ -0,0 +1,55 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +const HtmlWebPackPlugin = require("html-webpack-plugin"); + +module.exports = { + mode: process.env.NODE_ENV || "production", + entry: "./src/index.js", + output: { + filename: "index.js", + }, + plugins: [ + new HtmlWebPackPlugin({ + title: "Perspective Webpack Example", + }), + ], + module: { + rules: [ + { + test: /\.css$/, + exclude: /node_modules/, + use: [{ loader: "style-loader" }, { loader: "css-loader" }], + }, + { + test: /(perspective\-server\.wasm|perspective\-viewer\.wasm)$/, + type: "asset/resource", + }, + { + test: /\.arrow$/, + use: [{ loader: "arraybuffer-loader" }], + }, + ], + }, + stats: { + modules: false, + hash: false, + version: false, + builtAt: false, + entrypoints: false, + }, + experiments: { + asyncWebAssembly: false, + syncWebAssembly: false, + }, + devtool: "source-map", +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..673ad00 --- /dev/null +++ b/package.json @@ -0,0 +1,100 @@ +{ + "private": true, + "repository": { + "type": "git", + "url": "https://github.com/perspective-dev/perspective" + }, + "version": "4.5.2", + "changelog": { + "labels": { + "enhancement": "Added", + "bug": "Fixed", + "internal": "Internal" + } + }, + "type": "module", + "emscripten": "4.0.9", + "llvm": "17.0.6", + "pyodide": "0.29.4", + "engines": { + "node": ">=16 <24" + }, + "workspaces": [ + "tools/test", + "tools/scripts", + "tools/esbuild-plugin", + "packages/react", + "packages/viewer-datagrid", + "packages/viewer-charts", + "packages/workspace", + "packages/jupyterlab", + "packages/cli", + "rust/perspective-js", + "rust/perspective-viewer", + "rust/perspective-python", + "rust/perspective-server", + "examples/*", + "docs" + ], + "devDependencies": { + "@fontsource/roboto-mono": "catalog:", + "@perspective-dev/client": "workspace:^", + "@perspective-dev/jupyterlab": "workspace:^", + "@perspective-dev/react": "workspace:^", + "@perspective-dev/server": "workspace:^", + "@perspective-dev/test": "workspace:^", + "@perspective-dev/viewer": "workspace:^", + "@perspective-dev/viewer-charts": "workspace:^", + "@perspective-dev/viewer-datagrid": "workspace:^", + "@perspective-dev/workspace": "workspace:^", + "@playwright/experimental-ct-react": "catalog:", + "@playwright/test": "catalog:", + "chalk": "catalog:", + "dotenv": "catalog:", + "eslint": "catalog:", + "husky": "catalog:", + "npm-run-all": "catalog:", + "prettier": "catalog:", + "tsx": "catalog:", + "typescript-eslint": "catalog:" + }, + "pnpm": { + "overrides": { + "d3-array": ">=3 <4", + "d3-dispatch": ">=3 <4", + "d3-format": ">=3 <4", + "d3-scale": ">=4 <5", + "d3-selection": ">=3 <4", + "d3-transition": ">=3 <4" + } + }, + "scripts": { + "preinstall": "npx only-allow pnpm", + "postinstall": "npm-run-all postinstall:*", + "postinstall:emsdk": "node tools/scripts/install_emsdk.mjs", + "postinstall:playwright": "npx playwright install --with-deps chromium", + "postinstall:vscode": "cp -n ./.vscode/settings.default.json ./.vscode/settings.json || true", + "install_llvm": "node tools/scripts/install_llvm.mjs", + "install_pyodide": "node tools/scripts/install_pyodide.mjs", + "build,test": "npm run --silent build && npm run --silent test", + "build_js": "node tools/scripts/build.mjs", + "build": "node tools/scripts/build.mjs", + "build_python": "node tools/scripts/build.mjs", + "bench": "node tools/scripts/bench.mjs", + "setup": "node tools/scripts/setup.mjs", + "once": "PSP_ONCE=1 node tools/scripts/setup.mjs", + "docs": "node tools/scripts/docs.mjs", + "test": "node tools/scripts/test.mjs", + "test:jupyter": "pnpm run --recursive --filter @perspective-dev/jupyterlab test:jupyter", + "test_js": "node tools/scripts/test_js.mjs", + "test_python": "node tools/scripts/test_python.mjs", + "clean": "node tools/scripts/clean.mjs", + "start": "npm run start --workspace", + "prepush": "node tools/scripts/prepush.mjs && npm run lint", + "prepare": "husky install", + "lint": "node tools/scripts/lint.mjs", + "fix": "node tools/scripts/fix.mjs", + "version": "node tools/scripts/version.mjs", + "jlab_link": "pip3 install ./python/perspective --no-build-isolation" + } +} diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..24af42d --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,24 @@ +# Perspective CLI + +```bash +Usage: perspective [options] [command] + +A convenient command-line client for Perspective.js. Can convert between Perspective supported format, or host a local web server. + +Options: + -V, --version output the version number + -h, --help output usage information + +Commands: + convert [options] [filename] Convert a file into a new format. Reads from STDIN if no filename is provided + Options: + -f, --format Which output format to use: arrow, csv, columns, json. + -o, --output Filename to write to. If not supplied, writes to STDOUT + -h, --help output usage information + + host [options] [filename] Host a file on a local Websocket/HTTP server using a server-side Perspective. Reads from STDIN if no filename is provided + Options: + -p, --port Which port to bind to (default: 8080) + -o, --open Open a browser automagically + -h, --help output usage information +``` \ No newline at end of file diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..d5e577c --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,36 @@ +{ + "name": "@perspective-dev/cli", + "version": "4.5.2", + "description": "Perspective.js CLI", + "main": "src/js/index.js", + "publishConfig": { + "access": "public" + }, + "type": "module", + "files": [ + "src/**/*", + "perspective" + ], + "types": "index.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/perspective-dev/perspective" + }, + "author": "", + "license": "Apache-2.0", + "bin": { + "perspective": "perspective" + }, + "dependencies": { + "@perspective-dev/client": "workspace:", + "@perspective-dev/viewer": "workspace:", + "@perspective-dev/viewer-charts": "workspace:", + "@perspective-dev/viewer-datagrid": "workspace:", + "@perspective-dev/workspace": "workspace:", + "commander": "catalog:", + "puppeteer": "catalog:" + }, + "devDependencies": { + "@perspective-dev/test": "workspace:" + } +} diff --git a/packages/cli/perspective b/packages/cli/perspective new file mode 100755 index 0000000..682aed6 --- /dev/null +++ b/packages/cli/perspective @@ -0,0 +1,3 @@ +#!/usr/bin/env node --max-old-space-size=8192 + +require("@perspective-dev/cli"); diff --git a/packages/cli/src/html/index.html b/packages/cli/src/html/index.html new file mode 100644 index 0000000..fbc6614 --- /dev/null +++ b/packages/cli/src/html/index.html @@ -0,0 +1,55 @@ + + + + + + Perspective CLI + + + + + + + + + + + + + + + + + + diff --git a/packages/cli/src/js/index.js b/packages/cli/src/js/index.js new file mode 100755 index 0000000..d09b6b2 --- /dev/null +++ b/packages/cli/src/js/index.js @@ -0,0 +1,178 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { WebSocketServer, table } from "@perspective-dev/client"; +import { read_stdin, open_browser } from "./utils.js"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { program } from "commander"; +import puppeteer from "puppeteer"; +import { createRequire } from "node:module"; +import * as url from "node:url"; + +const __dirname = url.fileURLToPath(new URL(".", import.meta.url)).slice(0, -1); + +const _require = createRequire(import.meta.url); + +/** + * Convert data from one format to another. + * + * @param {*} filename + * @param {*} options + */ +async function convert(filename, options) { + let file; + if (filename) { + file = fs.readFileSync(filename).toString(); + } else { + file = await read_stdin(); + } + try { + file = JSON.parse(file); + } catch {} + let tbl = await table(file); + let view = await tbl.view(); + let out; + options.format = options.format || "arrow"; + if (options.format === "csv") { + out = await view.to_csv(); + } else if (options.format === "columns") { + out = JSON.stringify(await view.to_columns()); + } else if (options.format === "json") { + out = JSON.stringify(await view.to_json()); + } else { + out = await view.to_arrow(); + } + if (options.format === "arrow") { + if (options.output) { + fs.writeFileSync(options.output, Buffer.from(out), "binary"); + } else { + console.log(Buffer.from(out).toString()); + } + } else { + if (options.output) { + fs.writeFileSync(options.output, out); + } else { + console.log(out); + } + } + view.delete(); + tbl.delete(); +} + +/** + * Host a Perspective on a web server. + * + * @param {*} filename + * @param {*} options + */ +export async function host(filename, options) { + let files = [process.cwd(), path.join(__dirname, "..", "html")]; + if (options.assets) { + files = [options.assets, ...files]; + } + const server = new WebSocketServer({ + assets: files, + port: options.port, + }); + + let file; + if (filename) { + file = await table(fs.readFileSync(filename).toString(), { + name: "data_source_one", + }); + } else { + file = await read_stdin(); + } + if (options.open) { + const browser = await puppeteer.launch({ + headless: false, + defaultViewport: null, + args: [ + "--incognito", + "--noerrdialogs", + "--disable-translate", + "--no-first-run", + "--fast", + "--fast-start", + "--disable-infobars", + "--window-size=1200,800", + "--disable-features=TranslateUI", + "--disk-cache-dir=/dev/null", + `--app=http://localhost:${options.port}/`, + ], + }); + + const pages = await browser.pages(); + + const page = pages[0]; + page.on("close", () => { + browser.close(); + process.exit(0); + }); + } + + return server; +} + +program + .version( + JSON.parse( + fs + .readFileSync(path.join(__dirname, "..", "..", "package.json")) + .toString(), + ).version, + ) + .description( + "A convenient command-line client for Perspective.js. Can convert between Perspective supported format, or host a local web server.", + ) + .action(() => program.help()); + +program + .command("convert [filename]") + .description( + "Convert a file into a new format. Reads from STDIN if no filename is provided.", + ) + .option( + "-f, --format ", + "Which output format to use: arrow, csv, columns, json.", + /^(arrow|json|columns|csv)$/i, + "arrow", + ) + .option( + "-o, --output ", + "Filename to write to. If not supplied, writes to STDOUT.", + ) + .action(convert); + +program + .command("host [filename]") + .description( + "Host a file on a local Websocket/HTTP server using a server-side Perspective. Reads from STDIN if no filename is provided", + ) + .option( + "-p, --port ", + "Which port to bind to.", + (x) => parseInt(x), + 8080, + ) + .option("-a, --assets ", "Host from a working directory") + .option("-o, --open", "Open a browser automagically.") + .action(host); + +if (_require.main.path.endsWith("perspective-cli")) { + if (!process.argv.slice(2).length) { + program.help(); + } else { + program.parse(process.argv); + } +} diff --git a/packages/cli/src/js/utils.js b/packages/cli/src/js/utils.js new file mode 100644 index 0000000..4b279b7 --- /dev/null +++ b/packages/cli/src/js/utils.js @@ -0,0 +1,74 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { exec } from "child_process"; +import { table } from "@perspective-dev/client"; + +const OPEN = (port) => ` +if which xdg-open > /dev/null +then + xdg-open http://localhost:${port}/ +elif which gnome-open > /dev/null +then + gnome-open http://localhost:${port}/ +elif which open > /dev/null +then + open http://localhost:${port}/ +fi`; + +export function infer_table(buffer) { + if (buffer.slice(0, 6).toString() === "ARROW1") { + console.log("Loaded Arrow"); + return table(buffer.buffer); + } else { + let text = buffer.toString(); + try { + let json = JSON.parse(text); + console.log("Loaded JSON"); + return table(json); + } catch (e) { + console.log("Loaded CSV"); + return table(text); + } + } +} + +export const read_stdin = function read_stdin() { + const ret = []; + let len = 0; + return new Promise((resolve) => { + process.stdin + .on("readable", function () { + let chunk; + while ((chunk = process.stdin.read())) { + ret.push(Buffer.from(chunk)); + len += chunk.length; + } + }) + .on("end", function () { + const buffer = Buffer.concat(ret, len); + resolve(infer_table(buffer)); + }); + }); +}; + +export const execute = function execute(command, callback) { + exec(command, function (error, stdout) { + if (callback) { + callback(stdout); + } + }); +}; + +export const open_browser = function open_browser(port) { + module.exports.execute(OPEN(port), console.log); +}; diff --git a/packages/cli/test/csv/test.csv b/packages/cli/test/csv/test.csv new file mode 100644 index 0000000..dfdb4d9 --- /dev/null +++ b/packages/cli/test/csv/test.csv @@ -0,0 +1,4 @@ +x,y +1,2 +3,4 +5,6 \ No newline at end of file diff --git a/packages/cli/test/js/superstore.spec.js b/packages/cli/test/js/superstore.spec.js new file mode 100644 index 0000000..4bdfb2c --- /dev/null +++ b/packages/cli/test/js/superstore.spec.js @@ -0,0 +1,51 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import * as path from "node:path"; +import { host } from "../../src/js/index.js"; +import * as url from "url"; + +const __dirname = url.fileURLToPath(new URL(".", import.meta.url)).slice(0, -1); + +test.describe("CLI", function () { + let server, port; + test.beforeAll(async () => { + const options = { port: 0 }; + server = await host(path.join(__dirname, "../csv/test.csv"), options); + port = server._server.address().port; + }); + + test.afterAll(async (options) => { + await server.close(); + }); + + test("Tests something", async ({ page }) => { + await page.goto(`http://localhost:${port}/`); + await page.waitForSelector( + "perspective-viewer perspective-viewer-datagrid", + ); + + const json = await page.evaluate(async function () { + const viewer = document.querySelector("perspective-viewer"); + await viewer.flush(); + const view = await viewer.getView(); + return await view.to_json(); + }); + + expect(json).toEqual([ + { x: 1, y: 2 }, + { x: 3, y: 4 }, + { x: 5, y: 6 }, + ]); + }); +}); diff --git a/packages/jupyterlab/DEVELOPMENT.md b/packages/jupyterlab/DEVELOPMENT.md new file mode 100644 index 0000000..cc16dc5 --- /dev/null +++ b/packages/jupyterlab/DEVELOPMENT.md @@ -0,0 +1,16 @@ +## Build + +As of March 2025, the build process is a little different from the jupyterlab +extension cookiecutter template. The build works as follows: + +### labextension + +1. esbuild produces a bundle in `dist/esm` with `src/index.js` as its entrypoint +2. `jupyter labextension build` packages that bundle, which is read from the + `main` field in `package.json`, into `dist/cjs` +3. the `dist/cjs` output is copied to the perspective-python package's `.data` + directory. + +This means running `jupyter labextension build` or `watch` out-of-band from the +build script won't rebuild the labextension on its own; the `labextension` step +runs on the output of the esbuild step. diff --git a/packages/jupyterlab/README.md b/packages/jupyterlab/README.md new file mode 100644 index 0000000..4c3eabf --- /dev/null +++ b/packages/jupyterlab/README.md @@ -0,0 +1,21 @@ +# Perspective JupyterLab Extension + +This extension allows in-lining perspective based charts in jupyterlab +notebooks. + +[Examples](https://github.com/perspective-dev/perspective/tree/master/examples/jupyter-notebooks) + +## Installation + +### From npm + +```bash +jupyter labextension install @jupyter-widgets/jupyterlab-manager +jupyter labextension install @perspective-dev/jupyterlab +``` + +### PIP + +```bash +pip install perspective-python +``` diff --git a/packages/jupyterlab/build.mjs b/packages/jupyterlab/build.mjs new file mode 100644 index 0000000..9b46035 --- /dev/null +++ b/packages/jupyterlab/build.mjs @@ -0,0 +1,142 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { WasmPlugin } from "@perspective-dev/esbuild-plugin/wasm.js"; +import { WorkerPlugin } from "@perspective-dev/esbuild-plugin/worker.js"; +import { build } from "@perspective-dev/esbuild-plugin/build.js"; +import * as path from "node:path"; +import { bundleAsync as bundleCss, composeVisitors } from "lightningcss"; +import * as fs from "node:fs"; +import * as url from "node:url"; +// import { createRequire } from "node:module"; +import { execSync } from "node:child_process"; +import { + resolveNPM, + inlineUrlVisitor, +} from "@perspective-dev/viewer/tools.mjs"; + +// const _require = createRequire(import.meta.url); +const __dirname = url.fileURLToPath(new URL(".", import.meta.url)).slice(0, -1); + +const NBEXTENSION_PATH = path.resolve( + __dirname, + "..", + "..", + "python", + "perspective", + "perspective", + "nbextension", + "static", +); + +const TEST_BUILD = { + entryPoints: ["src/js/psp_widget.js"], + define: { + global: "window", + }, + plugins: [WasmPlugin(true), WorkerPlugin({ inline: true })], + globalName: "PerspectiveLumino", + format: "esm", + loader: { + ".html": "text", + ".ttf": "file", + ".css": "text", + }, + outfile: "dist/esm/lumino.js", +}; + +const LAB_BUILD = { + entryPoints: ["src/js/index.js"], + define: { + global: "window", + }, + plugins: [WasmPlugin(true), WorkerPlugin({ inline: true })], + external: ["@jupyter*", "@lumino*"], + format: "esm", + loader: { + ".css": "text", + ".html": "text", + ".ttf": "file", + }, + outfile: "dist/esm/perspective-jupyterlab.js", +}; + +const NB_BUILDS = [ + // { + // entryPoints: ["src/js/notebook/extension.js"], + // define: { + // global: "window", + // }, + // plugins: [ + // WasmPlugin(true), + // WorkerPlugin({ inline: true }), + // AMDLoader([]), + // ], + // loader: { + // ".ttf": "file", + // ".css": "text", + // }, + // external: ["@jupyter*", "@lumino*"], + // format: "cjs", + // outfile: path.join(NBEXTENSION_PATH, "extension.js"), + // }, + // { + // entryPoints: ["src/js/notebook/index.js"], + // define: { + // global: "window", + // }, + // plugins: [ + // WasmPlugin(true), + // WorkerPlugin({ inline: true }), + // AMDLoader(["@jupyter-widgets/base"]), + // ], + // external: ["@jupyter*"], + // format: "cjs", + // loader: { + // ".ttf": "file", + // ".css": "text", + // }, + // outfile: path.join(NBEXTENSION_PATH, "index.js"), + // }, +]; + +const IS_TEST = process.argv.some((x) => x === "--test"); +const BUILD = IS_TEST + ? [LAB_BUILD, ...NB_BUILDS, TEST_BUILD] + : [LAB_BUILD, ...NB_BUILDS]; + +async function build_all() { + fs.mkdirSync("dist/css", { recursive: true }); + const filename = path.resolve(__dirname, "src/css/index.css"); + const { code } = await bundleCss({ + filename, + minify: true, + visitor: inlineUrlVisitor(filename), + resolver: resolveNPM(import.meta.url), + }); + + fs.writeFileSync("dist/css/perspective-jupyterlab.css", code); + await Promise.all(BUILD.map(build)).catch(() => process.exit(1)); + fs.cpSync("src/css", "dist/css/src", { recursive: true }); + execSync("jupyter labextension build .", { + stdio: "inherit", + }); + + const pkg = JSON.parse(fs.readFileSync("../../package.json").toString()); + const labext_dest = `../../rust/perspective-python/perspective_python-${pkg.version}.data/data/share/jupyter/labextensions/@perspective-dev/jupyterlab`; + fs.cpSync("dist/cjs", labext_dest, { recursive: true }); + if (IS_TEST) { + fs.cpSync("test/arrow", "dist/esm", { recursive: true }); + } +} + +build_all(); diff --git a/packages/jupyterlab/clean.mjs b/packages/jupyterlab/clean.mjs new file mode 100644 index 0000000..26c832f --- /dev/null +++ b/packages/jupyterlab/clean.mjs @@ -0,0 +1,24 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 "node:fs"; + +fs.rmSync("dist", { recursive: true, force: true }); +fs.rmSync("lib", { recursive: true, force: true }); +fs.rmSync("../../rust/perspective-python/perspective/labextension", { + recursive: true, + force: true, +}); +fs.rmSync("../../rust/perspective-python/perspective/nbextension/static", { + recursive: true, + force: true, +}); diff --git a/packages/jupyterlab/install.json b/packages/jupyterlab/install.json new file mode 100644 index 0000000..388db88 --- /dev/null +++ b/packages/jupyterlab/install.json @@ -0,0 +1,5 @@ +{ + "packageManager": "python", + "packageName": "perspective-python", + "uninstallInstructions": "Use your Python package manager (pip, conda, etc.) to uninstall the package perspective-python" +} diff --git a/packages/jupyterlab/package.json b/packages/jupyterlab/package.json new file mode 100644 index 0000000..0af8807 --- /dev/null +++ b/packages/jupyterlab/package.json @@ -0,0 +1,65 @@ +{ + "name": "@perspective-dev/jupyterlab", + "version": "4.5.2", + "description": "A Jupyterlab extension for the Perspective library, designed to be used with perspective-python.", + "files": [ + "dist/**/*", + "src/**/*" + ], + "type": "commonjs", + "main": "dist/esm/perspective-jupyterlab.js", + "style": "dist/css/perspective-jupyterlab.css", + "directories": { + "dist": "dist/" + }, + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "node ./build.mjs", + "clean": "node ./clean.mjs", + "test:jupyter": "__JUPYTERLAB_PORT__=6538 npx playwright test --config ../../tools/test/playwright.config.ts -- --jupyter", + "test:jupyter:build": "node ./build.mjs --test" + }, + "dependencies": { + "@perspective-dev/viewer-charts": "workspace:", + "@perspective-dev/viewer-datagrid": "workspace:", + "@perspective-dev/viewer": "workspace:", + "@perspective-dev/client": "workspace:", + "@perspective-dev/server": "workspace:", + "@jupyter-widgets/base": ">2 <5", + "@jupyterlab/application": ">2 <5", + "@lumino/application": "<3", + "@lumino/widgets": "<3" + }, + "devDependencies": { + "@perspective-dev/esbuild-plugin": "workspace:", + "@perspective-dev/test": "workspace:", + "@jupyterlab/builder": "^4", + "lightningcss": "catalog:", + "copy-webpack-plugin": "~12", + "zx": "^8.1.8" + }, + "jupyterlab": { + "webpackConfig": "./webpack.config.js", + "extension": true, + "outputDir": "./dist/cjs", + "sharedPackages": { + "@jupyter-widgets/base": { + "bundled": false, + "singleton": true + } + }, + "discovery": { + "server": { + "base": { + "name": "perspective-python" + }, + "managers": [ + "pip" + ] + } + } + } +} diff --git a/packages/jupyterlab/src/css/index.css b/packages/jupyterlab/src/css/index.css new file mode 100644 index 0000000..c17514c --- /dev/null +++ b/packages/jupyterlab/src/css/index.css @@ -0,0 +1,54 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ 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-dev/viewer/dist/css/themes.css"; + +div.PSPContainer { + overflow: auto; + padding-right: 5px; + padding-bottom: 5px; + height: 100%; + width: 100%; + flex: 1; +} + +.jp-Notebook div.PSPContainer { + resize: vertical; +} + +/* Widget height for Jupyterlab */ +.jp-NotebookPanel-notebook div.PSPContainer { + height: 520px; +} + +/* Widget height for Jupyter Notebook */ +.jupyter-widgets-view div.PSPContainer { + height: 520px; +} + +/* Widget height for Voila */ +/* Widget height for VS Code */ +body[data-voila="voila"] .jp-OutputArea-output div.PSPContainer, +body[data-vscode-theme-id] .cell-output-ipywidget-background div.PSPContainer { + min-height: 520px; + height: 520px; +} + +div.PSPContainer perspective-viewer[theme="Pro Light"] { + --psp-plugin--border: 1px solid #e0e0e0; +} + +div.PSPContainer perspective-viewer { + display: block; + height: 100%; +} diff --git a/packages/jupyterlab/src/js/index.js b/packages/jupyterlab/src/js/index.js new file mode 100644 index 0000000..0f1ea99 --- /dev/null +++ b/packages/jupyterlab/src/js/index.js @@ -0,0 +1,43 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 from "@perspective-dev/client"; +import perspective_viewer from "@perspective-dev/viewer"; + +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_viewer.init_client(client_wasm), + perspective.init_server(server_wasm), +]); + +export * from "./model"; +export * from "./version"; +export * from "./view"; +export * from "./widget"; + +import "@perspective-dev/viewer-datagrid"; +import "@perspective-dev/viewer-charts"; + +// NOTE: only expose the widget here +import { PerspectiveJupyterPlugin } from "./plugin"; + +let plugins = [PerspectiveJupyterPlugin]; + +// Conditionally import renderers if running in jupyterlab only +if (window && window._JUPYTERLAB) { + const { PerspectiveRenderers } = await import("./renderer"); + plugins.push(PerspectiveRenderers); +} + +export default plugins; diff --git a/packages/jupyterlab/src/js/model.js b/packages/jupyterlab/src/js/model.js new file mode 100644 index 0000000..9e84bc3 --- /dev/null +++ b/packages/jupyterlab/src/js/model.js @@ -0,0 +1,43 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { DOMWidgetModel } from "@jupyter-widgets/base"; +import { PERSPECTIVE_VERSION } from "./version"; + +/** + * TODO: document + */ +export class PerspectiveModel extends DOMWidgetModel { + defaults() { + return { + ...super.defaults(), + _model_name: PerspectiveModel.model_name, + _model_module: PerspectiveModel.model_module, + _model_module_version: PerspectiveModel.model_module_version, + _view_name: PerspectiveModel.view_name, + _view_module: PerspectiveModel.view_module, + _view_module_version: PerspectiveModel.view_module_version, + }; + } +} + +PerspectiveModel.serializers = { + ...DOMWidgetModel.serializers, + // Add any extra serializers here +}; + +PerspectiveModel.model_name = "PerspectiveModel"; +PerspectiveModel.model_module = "@perspective-dev/jupyterlab"; +PerspectiveModel.model_module_version = PERSPECTIVE_VERSION; +PerspectiveModel.view_name = "PerspectiveView"; +PerspectiveModel.view_module = "@perspective-dev/jupyterlab"; +PerspectiveModel.view_module_version = PERSPECTIVE_VERSION; diff --git a/packages/jupyterlab/src/js/notebook/css.js b/packages/jupyterlab/src/js/notebook/css.js new file mode 100644 index 0000000..047af32 --- /dev/null +++ b/packages/jupyterlab/src/js/notebook/css.js @@ -0,0 +1,20 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 THEMES from "../../../dist/css/perspective-jupyterlab.css"; + +// Export the required load_ipython_extension +exports.load_css = () => { + const style = document.createElement("style"); + style.textContent = THEMES; + document.head.appendChild(style); +}; diff --git a/packages/jupyterlab/src/js/notebook/extension.js b/packages/jupyterlab/src/js/notebook/extension.js new file mode 100644 index 0000000..28fa544 --- /dev/null +++ b/packages/jupyterlab/src/js/notebook/extension.js @@ -0,0 +1,32 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/* eslint-disable no-underscore-dangle */ +import { load_css } from "./css"; + +// This file contains the javascript that is run when the notebook is loaded. +// It contains some requirejs configuration and the `load_ipython_extension` +// which is required for any notebook extension. +if (window.require) { + window.require.config({ + map: { + "*": { + "@perspective-dev/jupyterlab": + "nbextensions/@perspective-dev/jupyterlab/index", + }, + }, + }); +} + +exports.load_ipython_extension = () => { + load_css(); +}; diff --git a/packages/jupyterlab/src/js/notebook/index.js b/packages/jupyterlab/src/js/notebook/index.js new file mode 100644 index 0000000..2ee1078 --- /dev/null +++ b/packages/jupyterlab/src/js/notebook/index.js @@ -0,0 +1,26 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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-dev/viewer-datagrid"; +import "@perspective-dev/viewer-charts"; + +import { load_css } from "./css"; +import { PerspectiveView } from "../view"; +import { PerspectiveModel } from "../model"; + +exports.PerspectiveModel = PerspectiveModel; +exports.PerspectiveView = PerspectiveView; + +// Run if in vs-code +if (window.vscIPyWidgets) { + load_css(); +} diff --git a/packages/jupyterlab/src/js/plugin.js b/packages/jupyterlab/src/js/plugin.js new file mode 100644 index 0000000..b2646bb --- /dev/null +++ b/packages/jupyterlab/src/js/plugin.js @@ -0,0 +1,38 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { IJupyterWidgetRegistry } from "@jupyter-widgets/base"; +import { PerspectiveModel } from "./model"; +import { PerspectiveView } from "./view"; +import { PERSPECTIVE_VERSION } from "./version"; +const EXTENSION_ID = "@perspective-dev/jupyterlab"; + +/** + * PerspectiveJupyterPlugin Defines the Jupyterlab plugin, and registers `PerspectiveModel` and `PerspectiveView` + * to be called on initialization. + */ +export const PerspectiveJupyterPlugin = { + id: EXTENSION_ID, + // @ts-ignore + requires: [IJupyterWidgetRegistry], + activate: (app, registry) => { + registry.registerWidget({ + name: EXTENSION_ID, + version: PERSPECTIVE_VERSION, + exports: { + PerspectiveModel: PerspectiveModel, + PerspectiveView: PerspectiveView, + }, + }); + }, + autoStart: true, +}; diff --git a/packages/jupyterlab/src/js/psp_widget.js b/packages/jupyterlab/src/js/psp_widget.js new file mode 100644 index 0000000..5a37a61 --- /dev/null +++ b/packages/jupyterlab/src/js/psp_widget.js @@ -0,0 +1,199 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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-dev/viewer"; +import { Widget } from "@lumino/widgets"; +import { MIME_TYPE, PSP_CLASS, PSP_CONTAINER_CLASS } from "./utils"; + +let _increment = 0; + +/** + * Class for perspective lumino widget. + * + * @class PerspectiveWidget (name) TODO: document + */ +export class PerspectiveWidget extends Widget { + constructor(name = "Perspective", elem, bindingMode) { + super({ + node: elem || document.createElement("div"), + }); + + this.bindingMode = bindingMode; + this._viewer = PerspectiveWidget.createNode(this.node); + this.title.label = name; + this.title.caption = `${name}`; + this.id = `${name}-` + _increment; + _increment += 1; + } + + /**********************/ + /* Lumino Overrides */ + /**********************/ + /** + * Lumino: after visible + * + */ + + onAfterShow(msg) { + this.viewer.resize(true); + super.onAfterShow(msg); + } + + onActivateRequest(msg) { + if (this.isAttached) { + this.viewer.focus(); + } + super.onActivateRequest(msg); + } + + async toggleConfig() { + await this.viewer.toggleConfig(); + } + + async save() { + return await this.viewer.save(); + } + + async restore(config) { + return await this.viewer.restore(config); + } + + /** + * Load a `perspective.table` into the viewer. + * + * @param table A `perspective.table` object. + */ + + async load(table) { + await this.viewer.load(table); + this._load_complete = true; + } + + /** + * Update the viewer with new data. + * + * @param data + */ + + async _update(data) { + const table = await this.viewer.getTable(true); + await table.update(data); + } + + /** + * Removes all rows from the viewer's table. Does not reset viewer state. + */ + + async clear() { + const table = await this.viewer.getTable(true); + await table.clear(); + } + + /** + * Replaces the data of the viewer's table with new data. New data must + * conform to the schema of the Table. + * + * @param data + */ + + async replace(data) { + const table = await this.viewer.getTable(true); + await table.replace(data); + } + + /** + * Deletes this element's data and clears it's internal state (but not its + * user state). This (or the underlying `perspective.table`'s equivalent + * method) must be called in order for its memory to be reclaimed. + */ + + async delete() { + await this.viewer.delete(); + } + + /** + * Returns a promise that resolves to the element's edit port ID, used + * internally when edits are made using datagrid in client/server mode. + */ + + async getEditPort() { + return await this.viewer.getEditPort(); + } + + async getTable() { + return await this.viewer.getTable(); + } + + /*************************************************************************** + * + * Getters + * + */ + /** + * Returns the underlying `PerspectiveViewer` instance. + * + * @returns {PerspectiveViewer} The widget's viewer instance. + */ + + get viewer() { + return this._viewer; + } + + /** + * Returns the name of the widget. + * + * @returns {string} the widget name - "Perspective" if not set by the user. + */ + + get name() { + return this.title.label; + } + + get selectable() { + return this.viewer.hasAttribute("selectable"); + } + + set selectable(row_selection) { + if (row_selection) { + this.viewer.setAttribute("selectable", ""); + } else { + this.viewer.removeAttribute("selectable"); + } + } + + static createNode(node) { + node.classList.add("p-Widget"); + node.classList.add(PSP_CONTAINER_CLASS); + const viewer = document.createElement("perspective-viewer"); + viewer.classList.add(PSP_CLASS); + viewer.setAttribute("type", MIME_TYPE); + while (node.lastChild) { + node.removeChild(node.lastChild); + } + + node.appendChild(viewer); + + // allow perspective's event handlers to do their work + viewer.addEventListener( + "contextmenu", + (event) => event.stopPropagation(), + false, + ); + + const div = document.createElement("div"); + div.style.setProperty("display", "flex"); + div.style.setProperty("flex-direction", "row"); + node.appendChild(div); + + return viewer; + } +} diff --git a/packages/jupyterlab/src/js/renderer.js b/packages/jupyterlab/src/js/renderer.js new file mode 100644 index 0000000..09d7d05 --- /dev/null +++ b/packages/jupyterlab/src/js/renderer.js @@ -0,0 +1,374 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { ActivityMonitor } from "@jupyterlab/coreutils"; +import { ILayoutRestorer } from "@jupyterlab/application"; +import { + IThemeManager, + WidgetTracker, + Dialog, + showDialog, +} from "@jupyterlab/apputils"; + +import { ABCWidgetFactory, DocumentWidget } from "@jupyterlab/docregistry"; +import { PerspectiveWidget } from "./psp_widget"; + +import perspective from "@perspective-dev/client"; + +/** + * The name of the factories that creates widgets. + */ +const FACTORY_CSV = "Perspective-CSV"; +const FACTORY_JSON = "Perspective-JSON"; +const FACTORY_ARROW = "Perspective-Arrow"; +const RENDER_TIMEOUT = 1000; + +// create here to reuse for exception handling +const baddialog = () => { + showDialog({ + body: "Perspective could not render the data", + buttons: [ + Dialog.okButton({ + label: "Dismiss", + }), + ], + focusNodeSelector: "input", + title: "Error", + }); +}; + +export class PerspectiveDocumentWidget extends DocumentWidget { + constructor(options, type = "csv") { + super({ + content: new PerspectiveWidget("Perspective"), + context: options.context, + reveal: options.reveal, + }); + + this._monitor = null; + this._psp = this.content; + this._type = type; + this._context = options.context; + this._context.ready.then(() => { + this._update(); + this._monitor = new ActivityMonitor({ + signal: this.context.model.contentChanged, + timeout: RENDER_TIMEOUT, + }); + this._monitor.activityStopped.connect(this._update, this); + }); + } + + async _update() { + try { + let data; + if (this._type === "csv") { + // load csv directly + data = this._context.model.toString(); + } else if (this._type === "arrow") { + // load arrow directly + data = Uint8Array.from( + atob(this._context.model.toString()), + (c) => c.charCodeAt(0), + ).buffer; + } else if (this._type === "json") { + data = this._context.model.toJSON(); + if (Array.isArray(data) && data.length > 0) { + // already is records form, load directly + data = data; + } else { + // Column-oriented or single records JSON + // don't handle for now, just need to implement + // a simple transform but we can't handle all + // cases + throw "Not handled"; + } + } else { + // don't handle other mimetypes for now + throw "Not handled"; + } + try { + const table = await this._psp.viewer.getTable(); + table.replace(data); + } catch (e) { + // construct new table + this.worker = await perspective.worker(); + const table_promise = this.worker.table(data); + + // load data + await this._psp.viewer.load(table_promise); + const table = await this._psp.viewer.getTable(); + + // create a flat view + const view = await table.view(); + view.on_update(async () => { + if (this._type === "csv") { + const result = await view.to_csv(); + this.context.model.fromString(result); + this.context.save(); + } else if (this._type === "arrow") { + const result = await view.to_arrow(); + const resultAsB64 = btoa( + new Uint8Array(result).reduce( + (acc, i) => + (acc += String.fromCharCode.apply(null, [ + i, + ])), + "", + ), + ); + this.context.model.fromString(resultAsB64); + this.context.save(); + } else if (this._type === "json") { + const result = await view.to_json(); + this.context.model.fromJSON(result); + this.context.save(); + } + }); + } + } catch (e) { + baddialog(); + throw e; + } + + // pickup theme from env + this._psp.theme = + document.body.getAttribute("data-jp-theme-light") === "false" + ? "Pro Light" + : "Pro Dark"; + } + + dispose() { + if (this._monitor) { + this._monitor.dispose(); + } + + this.worker.terminate(); + super.dispose(); + } + + get psp() { + return this._psp; + } +} + +/** + * A widget factory for CSV widgets. + */ +export class PerspectiveCSVFactory extends ABCWidgetFactory { + createNewWidget(context) { + return new PerspectiveDocumentWidget( + { + context, + }, + "csv", + ); + } +} + +/** + * A widget factory for JSON widgets. + */ +export class PerspectiveJSONFactory extends ABCWidgetFactory { + createNewWidget(context) { + return new PerspectiveDocumentWidget( + { + context, + }, + "json", + ); + } +} + +/** + * A widget factory for arrow widgets. + */ +export class PerspectiveArrowFactory extends ABCWidgetFactory { + createNewWidget(context) { + return new PerspectiveDocumentWidget( + { + context, + }, + "arrow", + ); + } +} + +/** + * Activate cssviewer extension for CSV files + */ + +function activate(app, restorer, themeManager) { + const factorycsv = new PerspectiveCSVFactory({ + name: FACTORY_CSV, + fileTypes: ["csv"], + defaultFor: ["csv"], + readOnly: true, + }); + + const factoryjson = new PerspectiveJSONFactory({ + name: FACTORY_JSON, + fileTypes: ["json", "jsonl"], + defaultFor: ["json", "jsonl"], + readOnly: true, + }); + + try { + app.docRegistry.addFileType({ + name: "arrow", + displayName: "arrow", + extensions: [".arrow"], + mimeTypes: ["application/octet-stream"], + contentType: "file", + fileFormat: "base64", + }); + } catch (_a) { + // do nothing + } + + const factoryarrow = new PerspectiveArrowFactory({ + name: FACTORY_ARROW, + fileTypes: ["arrow"], + defaultFor: ["arrow"], + readOnly: true, + modelName: "base64", + }); + + const trackercsv = new WidgetTracker({ + namespace: "csvperspective", + }); + + const trackerjson = new WidgetTracker({ + namespace: "jsonperspective", + }); + + const trackerarrow = new WidgetTracker({ + namespace: "arrowperspective", + }); + + if (restorer) { + // Handle state restoration. + void restorer.restore(trackercsv, { + command: "docmanager:open", + args: (widget) => ({ + path: widget.context.path, + factory: FACTORY_CSV, + }), + name: (widget) => widget.context.path, + }); + + void restorer.restore(trackerjson, { + command: "docmanager:open", + args: (widget) => ({ + path: widget.context.path, + factory: FACTORY_JSON, + }), + name: (widget) => widget.context.path, + }); + + void restorer.restore(trackerarrow, { + command: "docmanager:open", + args: (widget) => ({ + path: widget.context.path, + factory: FACTORY_ARROW, + }), + name: (widget) => widget.context.path, + }); + } + + app.docRegistry.addWidgetFactory(factorycsv); + app.docRegistry.addWidgetFactory(factoryjson); + app.docRegistry.addWidgetFactory(factoryarrow); + const ftcsv = app.docRegistry.getFileType("csv"); + const ftjson = app.docRegistry.getFileType("json"); + const ftarrow = app.docRegistry.getFileType("arrow"); + factorycsv.widgetCreated.connect((sender, widget) => { + // Track the widget. + void trackercsv.add(widget); + + // Notify the widget tracker if restore data needs to update. + widget.context.pathChanged.connect(() => { + void trackercsv.save(widget); + }); + + if (ftcsv) { + widget.title.iconClass = ftcsv.iconClass || ""; + widget.title.iconLabel = ftcsv.iconLabel || ""; + } + }); + + factoryjson.widgetCreated.connect((sender, widget) => { + // Track the widget. + void trackerjson.add(widget); + + // Notify the widget tracker if restore data needs to update. + widget.context.pathChanged.connect(() => { + void trackerjson.save(widget); + }); + + if (ftjson) { + widget.title.iconClass = ftjson.iconClass || ""; + widget.title.iconLabel = ftjson.iconLabel || ""; + } + }); + + factoryarrow.widgetCreated.connect((sender, widget) => { + // Track the widget. + void trackerarrow.add(widget); + + // Notify the widget tracker if restore data needs to update. + widget.context.pathChanged.connect(() => { + void trackerarrow.save(widget); + }); + + if (ftarrow) { + widget.title.iconClass = ftarrow.iconClass || ""; + widget.title.iconLabel = ftarrow.iconLabel || ""; + } + }); + + // Keep the themes up-to-date. + const updateThemes = () => { + const isLight = + themeManager && themeManager.theme + ? themeManager.isLight(themeManager.theme) + : true; + + const theme = isLight ? "Pro Light" : "Pro Dark"; + trackercsv.forEach((pspDocWidget) => { + pspDocWidget.psp.theme = theme; + }); + + trackerjson.forEach((pspDocWidget) => { + pspDocWidget.psp.theme = theme; + }); + + trackerarrow.forEach((pspDocWidget) => { + pspDocWidget.psp.theme = theme; + }); + }; + + if (themeManager) { + themeManager.themeChanged.connect(updateThemes); + } +} + +/** + * The perspective extension for files + */ +export const PerspectiveRenderers = { + activate: activate, + id: "@perspective-dev/jupyterlab-renderers", + requires: [], + optional: [ILayoutRestorer, IThemeManager], + autoStart: true, +}; diff --git a/packages/jupyterlab/src/js/utils.js b/packages/jupyterlab/src/js/utils.js new file mode 100644 index 0000000..87e3212 --- /dev/null +++ b/packages/jupyterlab/src/js/utils.js @@ -0,0 +1,15 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +export const MIME_TYPE = "application/psp+json"; +export const PSP_CLASS = "PSPViewer"; +export const PSP_CONTAINER_CLASS = "PSPContainer"; diff --git a/packages/jupyterlab/src/js/version.js b/packages/jupyterlab/src/js/version.js new file mode 100644 index 0000000..9cadda3 --- /dev/null +++ b/packages/jupyterlab/src/js/version.js @@ -0,0 +1,14 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +const pkg_json = require("../../package.json"); +export const PERSPECTIVE_VERSION = pkg_json.version; diff --git a/packages/jupyterlab/src/js/view.js b/packages/jupyterlab/src/js/view.js new file mode 100644 index 0000000..8351262 --- /dev/null +++ b/packages/jupyterlab/src/js/view.js @@ -0,0 +1,420 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { DOMWidgetView } from "@jupyter-widgets/base"; +import { PerspectiveJupyterWidget } from "./widget"; + +import perspective from "@perspective-dev/client"; + +function isEqual(a, b) { + if (a === b) return true; + if (typeof a != "object" || typeof b != "object" || a == null || b == null) + return false; + + let keysA = Object.keys(a), + keysB = Object.keys(b); + + if (keysA.length != keysB.length) return false; + for (let key of keysA) { + if (!keysB.includes(key)) return false; + if (typeof a[key] === "function" || typeof b[key] === "function") { + if (a[key].toString() != b[key].toString()) return false; + } else { + if (!isEqual(a[key], b[key])) return false; + } + } + + return true; +} + +async function get_psp_wasm_module() { + let elem = customElements.get("perspective-viewer"); + if (!elem) { + await customElements.whenDefined("perspective-viewer"); + elem = customElements.get("perspective-viewer"); + } + + return elem.__wasm_module__; +} + +/** + * `PerspectiveView` defines the plugin's DOM and how the plugin interacts with + * the DOM. + */ +export class PerspectiveView extends DOMWidgetView { + #psp_client_id = `${Math.random()}`; + + _createElement() { + const bindingMode = this.model.get("binding_mode"); + this.luminoWidget = new PerspectiveJupyterWidget( + undefined, + this, + bindingMode, + ); + + // set up perspective_client + get_psp_wasm_module().then(async (wasm_module) => { + this.send({ + type: "connect", + client_id: this.psp_client_id, + }); + + const { Client } = wasm_module; + // Responses are fed to the client in the widget's msg:custom handler + this.perspective_client = new Client( + async (binary_msg) => { + const buffer = binary_msg.slice().buffer; + this.send( + { type: "binary_msg", client_id: this.psp_client_id }, + [buffer], + ); + }, + () => { + this.send({ + type: "hangup", + client_id: this.psp_client_id, + }); + }, + ); + + const tableName = this.model.get("table_name"); + if (!tableName) throw new Error("table_name not set in model"); + const table = this.perspective_client + .open_table(tableName) + .then(async (table) => { + if (bindingMode === "client-server") { + // TODO make this a global lazy singleton + const client = await perspective.worker(); + const remote_view = await table.view(); + const local_table = await client.table(remote_view); + return local_table; + } else if (bindingMode === "server") { + return table; + } else { + throw new Error(`unknown binding mode: ${bindingMode}`); + } + }); + + this.luminoWidget.load(table); + this._restore_from_model(); + }); + this._synchronize_state_dbg = (event) => { + console.log("perspective-config-update event", event); + this._synchronize_state(); + }; + this._synchronize_state = this._synchronize_state.bind(this); + + // add event handler to synchronize traitlet values + this.luminoWidget.viewer.addEventListener( + "perspective-config-update", + this._synchronize_state_dbg, + ); + + // bind toggle_editable to this + this._toggle_editable = this._toggle_editable.bind(this); + + // return the node against witch pWidget is bound + return this.luminoWidget.node; + } + + _setElement(el) { + if (this.el || el !== this.luminoWidget.node) { + // Do not allow the view to be reassigned to a different element. + throw new Error("Cannot reset the DOM element."); + } + this.el = this.luminoWidget.node; + } + + /** + * When state changes on the viewer DOM, apply it to the widget state. + * + * @param mutations + */ + + async _synchronize_state(event) { + if (!this.luminoWidget._load_complete) { + return; + } + + const config = await this.luminoWidget.viewer.save(); + + for (const name of Object.keys(config)) { + let new_value = config[name]; + + const current_value = this.model.get(name); + if (typeof new_value === "undefined") { + continue; + } + + if ( + new_value && + typeof new_value === "string" && + name !== "plugin" && + name !== "theme" && + name !== "title" && + name !== "version" + ) { + new_value = JSON.parse(new_value); + } + + if (new_value === null && name === "plugin_config") { + new_value = {}; + } + + if (!isEqual(new_value, current_value)) { + this.model.set(name, new_value); + } + } + + // propagate changes back to Python + this.touch(); + } + + get psp_client_id() { + return this.#psp_client_id; + } + + /** + * Attach event handlers to the model for state changes in order to + * reflect them back to the DOM. + */ + + render() { + super.render(); + this.model.on("msg:custom", this._handle_message, this); + this.model.on("change:plugin", this.plugin_changed, this); + this.model.on("change:columns", this.columns_changed, this); + this.model.on("change:group_by", this.group_by_changed, this); + this.model.on("change:split_by", this.split_by_changed, this); + this.model.on("change:aggregates", this.aggregates_changed, this); + this.model.on("change:sort", this.sort_changed, this); + this.model.on("change:filter", this.filter_changed, this); + this.model.on("change:expressions", this.expressions_changed, this); + this.model.on("change:plugin_config", this.plugin_config_changed, this); + this.model.on("change:theme", this.theme_changed, this); + this.model.on("change:settings", this.settings_changed, this); + this.model.on("change:title", this.title_changed, this); + this.model.on("change:table_name", this.table_name_changed, this); + } + + /** + * Handle messages from the Python PerspectiveViewer instance. + */ + _handle_message(msg, buffers) { + if (msg.type === "binary_msg" && msg.client_id === this.psp_client_id) { + const [dataview] = buffers; + this.perspective_client.handle_response(dataview.buffer); + } + } + + get client_worker() { + if (!this._client_worker) { + this._client_worker = perspective.worker(); + } + return this._client_worker; + } + + async _restore_from_model() { + await this.luminoWidget.restore({ + plugin: this.model.get("plugin"), + columns: this.model.get("columns"), + group_by: this.model.get("group_by"), + split_by: this.model.get("split_by"), + aggregates: this.model.get("aggregates"), + sort: this.model.get("sort"), + filter: this.model.get("filter"), + expressions: this.model.get("expressions"), + plugin_config: this.model.get("plugin_config"), + theme: this.model.get("theme"), + settings: this.model.get("settings"), + title: this.model.get("title"), + version: this.model.get("version"), + }); + } + + // XXX(tom): haven't looked at this, needs testing. used in client-server mode + async _toggle_editable() { + // Need to await the table and get the instance + // separately as load() only takes a promise + // to a table and not the instance itself. + const table = await this.luminoWidget.getTable(); + + // Setup ports in advance + if (!this._client_edit_port) { + this._client_edit_port = await this.luminoWidget.getEditPort(); + } + + // if (!this._kernel_edit_port) { + // this._kernel_edit_port = await this._kernel_table.make_port(); + // } + + const { plugin_config } = await this.luminoWidget.viewer.save(); + if (plugin_config?.editable) { + // TODO only evaluated during initial load. + // Toggling from python after initial load won't + // cause edits to propagate + + // Allow edits from the client Perspective to + // feed back to the kernel. + + // When the client updates, if the update + // comes through the edit port then forward + // it to the server. + this._client_view_update_callback = (updated) => { + if (updated.port_id === this._client_edit_port) { + this._kernel_table.update(updated.delta, { + port_id: this._kernel_edit_port, + }); + } + }; + + // If the server updates, and the edit is + // not coming from the server edit port, + // then synchronize state with the client. + this._kernel_view_update_callback = (updated) => { + if (updated.port_id !== this._kernel_edit_port) { + table.update(updated.delta); // any port, we dont care + } + }; + } else { + // ignore + this._client_view_update_callback = () => {}; + + // Load the table and mirror updates from the + // kernel. + this._kernel_view_update_callback = (updated) => + table.update(updated.delta); + } + + if (this._client_view) { + // NOTE: if `plugin_config_changed` called before + // `_handle_load_message`, this will be undefined + // Ignore, as `_handle_load_message` is sure to + // follow. + this._client_view.on_update( + (updated) => this._client_view_update_callback(updated), + { mode: "row" }, + ); + } + + // this._kernel_view.on_update( + // (updated) => this._kernel_view_update_callback(updated), + // { mode: "row" } + // ); + } + + /** + * When the View is removed after the widget terminates, clean up the + * client viewer and Web Worker. + */ + + remove() { + // Delete the but do not terminate the shared + // worker as it is shared across other widgets. + this.perspective_client.terminate(); // invokes the close callback we wired up in constructor + this.luminoWidget.delete(); + this.luminoWidget.viewer.removeEventListener( + "perspective-config-update", + this._synchronize_state_dbg, + ); + } + + /** + * When traitlets are updated in python, update the corresponding value on + * the front-end viewer. `client` and `server` are not included, as they + * are not properties in ``. + */ + + plugin_changed() { + this.luminoWidget.restore({ + plugin: this.model.get("plugin"), + }); + } + + columns_changed() { + this.luminoWidget.restore({ + columns: this.model.get("columns"), + }); + } + + group_by_changed() { + this.luminoWidget.restore({ + group_by: this.model.get("group_by"), + }); + } + + split_by_changed() { + this.luminoWidget.restore({ + split_by: this.model.get("split_by"), + }); + } + + aggregates_changed() { + this.luminoWidget.restore({ + aggregates: this.model.get("aggregates"), + }); + } + + sort_changed() { + this.luminoWidget.restore({ + sort: this.model.get("sort"), + }); + } + + filter_changed() { + this.luminoWidget.restore({ + filter: this.model.get("filter"), + }); + } + + expressions_changed() { + this.luminoWidget.restore({ + expressions: this.model.get("expressions"), + }); + } + + plugin_config_changed() { + this.luminoWidget.restore({ + plugin_config: this.model.get("plugin_config"), + }); + this._toggle_editable(); + } + + theme_changed() { + this.luminoWidget.restore({ + theme: this.model.get("theme"), + }); + } + + settings_changed() { + this.luminoWidget.restore({ + settings: this.model.get("settings"), + }); + } + + title_changed() { + this.luminoWidget.restore({ + title: this.model.get("title"), + }); + } + + version_changed() { + this.luminoWidget.restore({ + version: this.model.get("version"), + }); + } + + table_name_changed() { + // nop + // XXX(tom): we may want to re-load the viewer in this instance + } +} diff --git a/packages/jupyterlab/src/js/widget.js b/packages/jupyterlab/src/js/widget.js new file mode 100644 index 0000000..afb24f0 --- /dev/null +++ b/packages/jupyterlab/src/js/widget.js @@ -0,0 +1,54 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { PerspectiveWidget } from "./psp_widget"; + +/** + * PerspectiveJupyterWidget is the ipywidgets front-end for the Perspective Jupyterlab plugin. + */ +export class PerspectiveJupyterWidget extends PerspectiveWidget { + constructor(name = "Perspective", view, bindingMode) { + super(name, view.el, bindingMode); + this._view = view; + } + + /** + * Process the lumino message. + * + * Any custom lumino widget used inside a Jupyter widget should override + * the processMessage function like this. + */ + + processMessage(msg) { + super.processMessage(msg); + this._view.processLuminoMessage(msg); + } + + /** + * Dispose the widget. + * + * This causes the view to be destroyed as well with 'remove' + */ + + dispose() { + if (this.isDisposed) { + return; + } + + super.dispose(); + if (this._view) { + this._view.remove(); + } + + this._view = null; + } +} diff --git a/packages/jupyterlab/test/arrow/test.arrow b/packages/jupyterlab/test/arrow/test.arrow new file mode 100644 index 0000000000000000000000000000000000000000..cc4b5882093f8b0cb13b5de807161adc50cea06c GIT binary patch literal 3680 zcmds3O>7%Q6n;q@$4#9kMQy22Qmhq}3Pp+SIB8Px<1~$N)1+~V6PlEgIFIh7(% zO{Rp5B2Zn7z-vi4DoIci+C`@V*ODojJwd061gZ&fMIumLk-%$7IVwp|6WT?m0@so$ znSCLriUg_&aYZ6fU6H_RNjWM>P!rlkrvlfKNjp6v32HLwOfKPAkS$PC%4zP>j#FpL zxO47ofm%{?xm>3DIVn`DOTjyp6k1DWu~?{9k)S40%9``JWQyrKb#il;Q}ac}Ybjw} zvaa2^&M8#WQt*EjDw|=61*biclBx;~7 z!S^u#FpP6>H{ zAHFjB*3zw~w`@FnO{|L#AOCLD=f;m)J>3%gdZ)Ztk7(P9Q(YMBEgM&_pVv;#cn2Kj zwp;g8=bRV!5fk%N=L0ut<5uUjZkkSj!`z~ctIsR;IC@{^<$A8!xYc<)ilU$7h{3JXxO0ChHW`CeKQuW_u2vdz@tO&vWmx=AN(T2HCO) z1g?dfmkuG$Cor#-l^R^uyLKDzv1rZk#d(X*Xq&+K{u^bKyAIn}A1a>GS8?U5IOnuC zCY}n{-E9|tH(q=R zd+{(--FxV6*^4h^8!IxmgD~c`!tRCbhjIG@-_`x-d47Mv_%|5iNBIwp=aQRRmwSXu zkIMeI?7zo#+|+yhlW@x(8UOIdhi@k@|NJ=IOIsfetfRmB*n1C#(TAV@^Umk!^XG1K zucx;^d3VFTcYP-E>`OQ4o^baA`+x5!91S07zxhh;%Z~7^`(Awe*A{QM1sABs-|-RH zBd~E8ze|y^fq}E}iO|_6&X0~f85)nB9*T~QobM0BCxT4Xyxu}zWM6xJ?x91p Q`SEuzPpr1`G~ssXA3X{Ah5!Hn literal 0 HcmV?d00001 diff --git a/packages/jupyterlab/test/config/jupyter/globalSetup.ts b/packages/jupyterlab/test/config/jupyter/globalSetup.ts new file mode 100644 index 0000000..37bea29 --- /dev/null +++ b/packages/jupyterlab/test/config/jupyter/globalSetup.ts @@ -0,0 +1,26 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { start_jlab, kill_jlab } from "./jlab_start.ts"; + +async function globalSetup() { + // Start Jupyterlab in the background + await start_jlab(); + + // At this point, Jupyterlab has already been started by the main test + // runner, so all we need to do is set up the signal listeners to + // clean up the Jupyter process if we Ctrl-C. + process.on("SIGINT", kill_jlab); + process.on("SIGABRT", kill_jlab); +} + +export default globalSetup; diff --git a/packages/jupyterlab/test/config/jupyter/globalTeardown.ts b/packages/jupyterlab/test/config/jupyter/globalTeardown.ts new file mode 100644 index 0000000..a842060 --- /dev/null +++ b/packages/jupyterlab/test/config/jupyter/globalTeardown.ts @@ -0,0 +1,19 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { kill_jlab } from "./jlab_start.ts"; + +async function globalTeardown() { + await kill_jlab(); +} + +export default globalTeardown; diff --git a/packages/jupyterlab/test/config/jupyter/jlab_start.ts b/packages/jupyterlab/test/config/jupyter/jlab_start.ts new file mode 100644 index 0000000..0bea012 --- /dev/null +++ b/packages/jupyterlab/test/config/jupyter/jlab_start.ts @@ -0,0 +1,118 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 path from "path"; +import { get } from "http"; +import { spawn } from "child_process"; +import "zx/globals"; + +const PACKAGE_ROOT = path.join(__dirname, "..", "..", ".."); + +/** + * Kill the Jupyterlab process created by the tests. + */ +export const kill_jlab = () => { + console.log("\n-- Cleaning up Jupyterlab process"); + $`ps aux | grep -i '[j]upyter-lab --no-browser' | awk '{print $2}' | xargs kill -9 && echo "[perspective-jupyterlab] JupyterLab process terminated"`; +}; + +/** + * Block until the Jupyterlab server is ready. + */ +const wait_for_jlab = async function () { + let num_errors = 0; + let loaded = false; + + while (!loaded) { + get( + `http://127.0.0.1:${process.env.__JUPYTERLAB_PORT__}/lab?`, + (res) => { + if (res.statusCode !== 200) { + throw new Error(`${res.statusCode} not 200!`); + } + + console.log( + `Jupyterlab server has started on ${process.env.__JUPYTERLAB_PORT__}`, + ); + loaded = true; + }, + ).on("error", (err) => { + if (num_errors > 50) { + kill_jlab(); + throw new Error(`Could not launch Jupyterlab: ${err}`); + } + + num_errors++; + }); + + await new Promise((resolve) => setTimeout(resolve, 500)); + } +}; + +export function start_jlab() { + /* + * Spawn the Jupyterlab server. + */ + try { + // Does not alter the global env, only the env for this process + process.env.JUPYTER_CONFIG_DIR = path.join( + PACKAGE_ROOT, + "test", + "config", + "jupyter", + ); + process.env.JUPYTERLAB_SETTINGS_DIR = path.join( + PACKAGE_ROOT, + "test", + "config", + "jupyter", + "user_settings", + ); + + // Start jupyterlab with a root to dist/esm where the notebooks will be. + process.chdir(path.join(PACKAGE_ROOT, "dist", "esm")); + + console.log("Spawning Jupyterlab process"); + + // Jupyterlab is spawned with the default $PYTHONPATH of the shell it + // is running in. For local testing during devlopment you may need to + // run it with the $PYTHONPATH set to ./python/perspective + const proc = spawn( + "jupyter", + [ + "lab", + "--no-browser", + "--log-level=CRITICAL", + `--port=${process.env.__JUPYTERLAB_PORT__}`, + `--config=${process.env.JUPYTER_CONFIG_DIR}/jupyter_notebook_config.json`, + ], + { + env: { + ...process.env, + + // https://github.com/microsoft/playwright/issues/24516 + NODE_OPTIONS: undefined, + }, + // stdio: "inherit", + }, + ); + + // Wait for Jupyterlab to start up + return wait_for_jlab().then(() => { + return proc; + }); + } catch (e) { + console.error(e); + kill_jlab(); + process.exit(1); + } +} diff --git a/packages/jupyterlab/test/config/jupyter/jupyter_notebook_config.json b/packages/jupyterlab/test/config/jupyter/jupyter_notebook_config.json new file mode 100644 index 0000000..df8b301 --- /dev/null +++ b/packages/jupyterlab/test/config/jupyter/jupyter_notebook_config.json @@ -0,0 +1,18 @@ +{ + "NotebookApp": { + "token": "", + "password": "", + "default_url": "/lab?reset", + "allow_remote_access": true, + "disable_check_xsrf": true, + "enable_mathjax": false + }, + "LabApp": { + "tornado_settings": { + "page_config_data": { + "buildCheck": false, + "buildAvailable": false + } + } + } +} \ No newline at end of file diff --git a/packages/jupyterlab/test/config/jupyter/jupyter_server_config.json b/packages/jupyterlab/test/config/jupyter/jupyter_server_config.json new file mode 100644 index 0000000..893c579 --- /dev/null +++ b/packages/jupyterlab/test/config/jupyter/jupyter_server_config.json @@ -0,0 +1,10 @@ +{ + "ServerApp": { + "token": "", + "password": "", + "answer_yes": true, + "allow_remote_access": true, + "disable_check_xsrf": true, + "allow_password_change": false + } +} \ No newline at end of file diff --git a/packages/jupyterlab/test/config/jupyter/user_settings/@jupyterlab/shortcuts-extension/shortcuts.jupyterlab-settings b/packages/jupyterlab/test/config/jupyter/user_settings/@jupyterlab/shortcuts-extension/shortcuts.jupyterlab-settings new file mode 100644 index 0000000..a19ed6a --- /dev/null +++ b/packages/jupyterlab/test/config/jupyter/user_settings/@jupyterlab/shortcuts-extension/shortcuts.jupyterlab-settings @@ -0,0 +1,12 @@ +{ + "shortcuts": [ + { + "command": "runmenu:run-all", + "keys": [ + "R", + "R" + ], + "selector": "[data-jp-kernel-user]:focus" + } + ] +} \ No newline at end of file diff --git a/packages/jupyterlab/test/html/resize.html b/packages/jupyterlab/test/html/resize.html new file mode 100644 index 0000000..70aacd9 --- /dev/null +++ b/packages/jupyterlab/test/html/resize.html @@ -0,0 +1,26 @@ + + + + + + + + + + +
    hello
    + + diff --git a/packages/jupyterlab/test/js/resize.spec.mjs b/packages/jupyterlab/test/js/resize.spec.mjs new file mode 100644 index 0000000..7d85434 --- /dev/null +++ b/packages/jupyterlab/test/js/resize.spec.mjs @@ -0,0 +1,114 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test } from "@perspective-dev/test"; +import { compareContentsToSnapshot } from "@perspective-dev/test"; + +test.beforeEach(async ({ page }) => { + await page.goto( + "/node_modules/@perspective-dev/jupyterlab/test/html/resize.html", + ); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); +}); + +test.describe("JupyterLab resize", () => { + test("Config should be hidden by default", async ({ page }) => { + // Snapshot is viewer contents + const contents = await page.evaluate(async () => { + await window.__WIDGET__.viewer.getTable(); + await window.__WIDGET__.viewer.flush(); + + // Linux returns ever-so-slightly different auto width + // column values so we need to strip these. + for (const elem of document.querySelectorAll( + "perspective-viewer *", + )) { + elem.removeAttribute("style"); + } + + return window.__WIDGET__.viewer.innerHTML; + }); + + await compareContentsToSnapshot(contents, [ + "jupyterlab-resize-config-hidden.txt", + ]); + }); + + test("Resize the container causes the widget to resize", async ({ + page, + }) => { + await page.evaluate(async () => { + await document.querySelector("perspective-viewer").toggleConfig(); + await document.querySelector("perspective-viewer").getTable(); + }); + + await page.evaluate(async () => { + document + .querySelector(".PSPContainer") + .setAttribute( + "style", + "position:absolute;top:0;left:0;width:300px;height:300px", + ); + + await document.querySelector("perspective-viewer").resize(); + }); + + // Snapshot is viewer contents + const contents = await page.evaluate(async () => { + document.querySelector(".PSPContainer").style = + "position:absolute;top:0;left:0;width:800px;height:600px"; + await document.querySelector("perspective-viewer").resize(); + + for (const elem of document.querySelectorAll( + "perspective-viewer *", + )) { + elem.removeAttribute("style"); + } + + return window.__WIDGET__.viewer.innerHTML; + }); + + await compareContentsToSnapshot(contents, [ + "jupyterlab-resize-config-shown.txt", + ]); + }); + + test("group_by traitlet works", async ({ page }) => { + await page.evaluate(async () => { + await document.querySelector("perspective-viewer").toggleConfig(); + await document.querySelector("perspective-viewer").getTable(); + await document.querySelector("perspective-viewer").flush(); + }); + + // Snapshot is datagrid contents + const contents = await page.evaluate(async () => { + await window.__WIDGET__.restore({ group_by: ["State"] }); + for (const elem of document.querySelectorAll( + "perspective-viewer *", + )) { + elem.removeAttribute("style"); + } + const datagrid = window.__WIDGET__.viewer.querySelector( + "perspective-viewer-datagrid", + ); + return datagrid.shadowRoot.innerHTML; + }); + + await compareContentsToSnapshot(contents, [ + "jupyterlab-resize-group-by-traitlet.txt", + ]); + }); +}); diff --git a/packages/jupyterlab/test/jupyter/notebook_template.json b/packages/jupyterlab/test/jupyter/notebook_template.json new file mode 100644 index 0000000..39fc64e --- /dev/null +++ b/packages/jupyterlab/test/jupyter/notebook_template.json @@ -0,0 +1,24 @@ +{ + "cells": [], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.9" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/packages/jupyterlab/test/jupyter/utils.mjs b/packages/jupyterlab/test/jupyter/utils.mjs new file mode 100644 index 0000000..8783d3f --- /dev/null +++ b/packages/jupyterlab/test/jupyter/utils.mjs @@ -0,0 +1,202 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@playwright/test"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { dirname } from "path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const notebook_template = JSON.parse( + fs.readFileSync(__dirname + "/notebook_template.json", { + encoding: "utf-8", + }), +); + +const DIST_ROOT = path.join(__dirname, "..", "..", "dist", "esm"); +const TEST_CONFIG_ROOT = path.join(__dirname, "..", "config", "jupyter"); + +const remove_jupyter_artifacts = () => { + fs.rmSync(path.join(TEST_CONFIG_ROOT, "lab"), { + recursive: true, + force: true, + }); + + fs.rmSync(path.join(DIST_ROOT, ".ipynb_checkpoints"), { + recursive: true, + force: true, + }); +}; + +/** + * Generate a new Jupyter notebook using the standard JSON template, and + * save it into dist/esm so that the tests can use the resulting notebook. + * + * @param {String} notebook_name + * @param {Array} cells + */ +const generate_notebook = (notebook_name, cells) => { + const notebook_path = path.join(DIST_ROOT, notebook_name); + + // deepcopy the notebook template so we are not modifying a shared object + const nb = JSON.parse(JSON.stringify(notebook_template)); + + // import perspective, set up test data etc. + nb["cells"] = [ + { + cell_type: "code", + metadata: {}, + execution_count: null, + outputs: [], + source: [ + "import perspective\n", + "import perspective.widget\n", + "import pandas as pd\n", + "import numpy as np\n", + "arrow_data = None\n", + "with open('test.arrow', 'rb') as arrow: \n arrow_data = arrow.read()", + ], + }, + ]; + + // Cells defined in the test as an array of arrays - each inner array + // is a new cell to be added to the notebook. + for (const cell of cells) { + nb["cells"].push({ + cell_type: "code", + execution_count: null, + metadata: {}, + outputs: [], + source: cell, + }); + } + + // Write the notebook to dist/esm, which acts as the working directory + // for the Jupyterlab test server. + fs.writeFileSync(notebook_path, JSON.stringify(nb)); +}; + +// Add Jupyterlab-specific bindings to the global Jest objects +export function describe_jupyter(body, { name, root } = {}) { + // Remove the automatically generated workspaces directory, as it + // will try to redirect single-document URLs to the last URL opened. + test.beforeEach(remove_jupyter_artifacts); + test.afterAll(remove_jupyter_artifacts); + + // URL is null because each test.capture_jupyterlab will have its own + // unique notebook generated. + return test.describe(`Blank Notebook`, body); +} + +/** + * Execute body() on a Jupyter notebook without taking any screenshots. + * + * @param {*} name + * @param {*} cells + * @param {*} body + */ +export function test_jupyter(name, cells, body) { + const notebook_name = `${name.replace(/[ \.']/g, "_")}.ipynb`; + generate_notebook(notebook_name, cells); + const url = `doc/tree/${notebook_name}`; + test(name, async ({ page }) => { + await page.goto( + `http://127.0.0.1:${process.env.__JUPYTERLAB_PORT__}/${url}`, + { waitUntil: "domcontentloaded" }, + ); + await body({ page }); + }); +} + +export async function default_body(page) { + await execute_all_cells(page); + const viewer = await page.waitForSelector( + ".jp-OutputArea-output perspective-viewer", + { visible: true }, + ); + await viewer.evaluate(async (viewer) => await viewer.flush()); + return viewer; +} +export async function execute_all_cells(page) { + await page.waitForFunction(async () => !!document.title); + await page.waitForSelector(".lm-Widget", { visible: true }); + await page.waitForSelector(".jp-NotebookPanel-toolbar", { + visible: true, + }); + + // wait for a cell to be active + try { + await page.waitForSelector( + '.jp-Notebook-ExecutionIndicator:not([data-status="idle"])', + { timeout: 1000 }, + ); + } catch (e) {} + // await new Promise((x) => setTimeout(x, 2000)); + + await page.waitForSelector( + '.jp-Notebook-ExecutionIndicator[data-status="idle"]', + ); + + // Use our custom keyboard shortcut to run all cells + await page.keyboard.press("R"); + await page.keyboard.press("R"); + await page.evaluate(() => (document.scrollTop = 0)); +} + +export async function add_and_execute_cell(page, cell_content) { + // wait for a code cell to be visible + await page.waitForSelector(".jp-CodeCell", { + visible: true, + }); + + // find and click the a cell in the notebook + await page.click(".jp-CodeCell"); + await new Promise((x) => setTimeout(x, 100)); + // find and click the "new cell" button + await page.click('jp-button[data-command="notebook:insert-cell-below"]'); + await new Promise((x) => setTimeout(x, 100)); + // after clicking new cell, the document will auto + // focus the new cell, so lets grab it + const el = await page.evaluateHandle(() => document.activeElement); + await el.type(cell_content); + + await new Promise((x) => setTimeout(x, 100)); + // now while the element is still focused, click the run cell button + await page.click( + 'jp-button[data-command="notebook:run-cell-and-select-next"]', + ); + await new Promise((x) => setTimeout(x, 100)); + // wait for kernel to stop running + // await page.waitForSelector( + // "//div.jp-InputPrompt[contains(text(),'[*]:')]", + // { + // hidden: true, + // } + // ); +} +export async function assert_no_error_in_cell(page, cell_content) { + // run the cell + await add_and_execute_cell(page, cell_content); + + // wait for jupyter to render any frontend exceptions + return await Promise.race([ + page + .waitForSelector( + 'div[data-mime-type="application/vnd.jupyter.stderr"]', + ) + .then(() => false), + page + .waitForSelector("//div//pre[contains(text(),\"'Passed'\")]") + .then(() => true), + ]); +} diff --git a/packages/jupyterlab/test/jupyter/widget.spec.mjs b/packages/jupyterlab/test/jupyter/widget.spec.mjs new file mode 100644 index 0000000..69c01f5 --- /dev/null +++ b/packages/jupyterlab/test/jupyter/widget.spec.mjs @@ -0,0 +1,598 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { API_VERSION, expect } from "@perspective-dev/test"; +import path from "path"; +import { + default_body, + add_and_execute_cell, + assert_no_error_in_cell, + execute_all_cells, + test_jupyter, + describe_jupyter, +} from "./utils.mjs"; + +import { fileURLToPath } from "url"; +import { dirname } from "path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const getEditMode = async (viewer) => { + return await viewer.evaluate(async (viewer) => { + return (await viewer.save()).plugin_config.edit_mode; + }); +}; + +// utils.with_jupyterlab(process.env.__JUPYTERLAB_PORT__, () => { +describe_jupyter( + () => { + test_jupyter( + "Open arrow and csv from file browser", + [], + async ({ page }) => { + await page.locator("#tab-key-1-7").click(); + await page + .locator(`.jp-DirListing-item[data-file-type="arrow"]`) + .click({ button: "right" }); + + await page.hover(`.lm-Menu .lm-Menu-item[data-type="submenu"]`); + await page + .locator(`.lm-Menu-item[data-command="filebrowser:open"]`) + .click(); + + const num_columns = await page + .locator("regular-table thead tr") + .first() + .evaluate((tr) => tr.childElementCount); + + expect(num_columns).toEqual(14); + await expect( + page.locator("regular-table tbody tr"), + ).toHaveCount(5); + }, + ); + + // Basics + test_jupyter( + "Loads data", + [ + "w = perspective.widget.PerspectiveWidget(arrow_data, columns=['f64', 'str', 'datetime'])", + "w", + ], + async ({ page }) => { + await default_body(page); + const num_columns = await page + .locator("regular-table thead tr") + .first() + .evaluate((tr) => tr.childElementCount); + + expect(num_columns).toEqual(3); + await expect( + page.locator("regular-table tbody tr"), + ).toHaveCount(5); + }, + ); + + test_jupyter( + "Loads updates", + [ + [ + "server = perspective.Server()", + "client = server.new_local_client()", + "table = client.table(arrow_data)", + "w = perspective.widget.PerspectiveWidget(table, columns=['f64', 'str', 'datetime'])", + ].join("\n"), + "w", + "table.update(arrow_data)", + ], + async ({ page }) => { + await default_body(page); + const num_columns = await page + .locator("regular-table thead tr") + .first() + .evaluate((tr) => tr.childElementCount); + + expect(num_columns).toEqual(3); + + await expect( + page.locator("regular-table tbody tr"), + ).toHaveCount(10); + }, + ); + + test_jupyter( + "Loads a table", + [ + [ + "server = perspective.Server()", + "client = server.new_local_client()", + "table = client.table(arrow_data)", + "w = perspective.widget.PerspectiveWidget(table, columns=['f64', 'str', 'datetime'])", + ].join("\n"), + "w", + ], + async ({ page }) => { + await default_body(page); + + const num_columns = await page + .locator("regular-table thead tr") + .first() + .evaluate((tr) => tr.childElementCount); + + expect(num_columns).toEqual(3); + + await expect( + page.locator("regular-table tbody tr"), + ).toHaveCount(5); + }, + ); + + test_jupyter( + "Loads AsyncTable", + [ + ` +import asyncio +server = perspective.Server() +sync_client = server.new_local_client() +sync_client.table({"Income": [5,4,3,2,1], "Expense": [4,3,2,1,1], "Profit": [1,1,1,1,0]}, name="Microstore") +proxy_sess = perspective.ProxySession(sync_client, lambda msg: asyncio.create_task(async_client.handle_response(msg))) + +async_client = perspective.AsyncClient(proxy_sess.handle_request_async) +async_table = await async_client.open_table("Microstore")`, + "w = perspective.widget.PerspectiveWidget(async_table)", + "w", + ], + async ({ page }) => { + await default_body(page); + const num_columns = await page + .locator("regular-table thead tr") + .first() + .evaluate((tr) => tr.childElementCount); + + expect(num_columns).toEqual(3); + await expect( + page.locator("regular-table tbody tr"), + ).toHaveCount(5); + }, + ); + + test_jupyter( + "Loads updates to AsyncTable", + [ + [ + ` +import asyncio +server = perspective.Server() +sync_client = server.new_local_client() +sync_table = sync_client.table(arrow_data) +proxy_sess = perspective.ProxySession(sync_client, lambda msg: asyncio.create_task(async_client.handle_response(msg))) + +async_client = perspective.AsyncClient(proxy_sess.handle_request_async) +async_table = await async_client.open_table(sync_table.get_name())`, + "w = perspective.widget.PerspectiveWidget(async_table, columns=['f64', 'str', 'datetime'])", + ].join("\n"), + "w", + "sync_table.update(arrow_data)", + ], + async ({ page }) => { + await default_body(page); + const num_columns = await page + .locator("regular-table thead tr") + .first() + .evaluate((tr) => tr.childElementCount); + + expect(num_columns).toEqual(3); + + await expect( + page.locator("regular-table tbody tr"), + ).toHaveCount(10); + }, + ); + // Restore + + test_jupyter( + "Loads with settings=False", + [ + [ + "server = perspective.Server()", + "client = server.new_local_client()", + "table = client.table(arrow_data)", + "w = perspective.widget.PerspectiveWidget(table, columns=['f64', 'str', 'datetime'], settings=False)", + ].join("\n"), + "w", + ], + async ({ page }) => { + const viewer = await default_body(page); + const settings = await viewer.evaluate(async (viewer) => { + return (await viewer.save()).settings; + }); + + expect(settings).toEqual(false); + }, + ); + + test_jupyter( + "Loads with edit_mode=EDIT", + [ + [ + "server = perspective.Server()", + "client = server.new_local_client()", + "table = client.table(arrow_data)", + "w = perspective.widget.PerspectiveWidget(table, plugin_config={'edit_mode': 'EDIT'})", + ].join("\n"), + "w", + ], + async ({ page }) => { + const viewer = await default_body(page); + const edit_mode = await getEditMode(viewer); + expect(edit_mode).toEqual("EDIT"); + }, + ); + + test_jupyter( + "Editable Toggle - from Python", + [ + [ + "server = perspective.Server()", + "client = server.new_local_client()", + "table = client.table(arrow_data)", + "w = perspective.widget.PerspectiveWidget(table)", + ].join("\n"), + "w", + ], + async ({ page }) => { + const viewer = await default_body(page); + let edit_mode = await getEditMode(viewer); + expect(edit_mode).toEqual("READ_ONLY"); + + await add_and_execute_cell( + page, + 'w.plugin_config = {"edit_mode": "EDIT"}', + ); + + edit_mode = await getEditMode(viewer); + expect(edit_mode).toEqual("EDIT"); + }, + ); + + test_jupyter( + "Editable Toggle - from JS", + [ + [ + "server = perspective.Server()", + "client = server.new_local_client()", + "table = client.table(arrow_data)", + "w = perspective.widget.PerspectiveWidget(table)", + ].join("\n"), + "w", + ], + async ({ page }) => { + const viewer = await default_body(page); + let edit_mode = await getEditMode(viewer); + expect(edit_mode).toEqual("READ_ONLY"); + + await viewer.evaluate(async (viewer) => { + const edit = + viewer.children[1].shadowRoot.querySelector( + "span#edit_mode", + ); + edit.click(); + }); + + edit_mode = await getEditMode(viewer); + expect(edit_mode).toEqual("EDIT"); + }, + ); + + test_jupyter( + "Everything Else - Toggle from Python", + [ + [ + "server = perspective.Server()", + "client = server.new_local_client()", + "table = client.table(arrow_data)", + "w = perspective.widget.PerspectiveWidget(table)", + ].join("\n"), + "w", + ], + async ({ page }) => { + const viewer = await default_body(page); + let config = await viewer.evaluate(async (viewer) => { + return await viewer.save(); + }); + + // Check default config + expect(config).toEqual({ + version: API_VERSION, + columns_config: {}, + aggregates: {}, + columns: [ + "ui8", + "i8", + "ui16", + "i16", + "ui32", + "i32", + "ui64", + "i64", + "f32", + "f64", + "bool", + "str", + "date", + "datetime", + ], + expressions: {}, + filter: [], + group_by: [], + plugin: "Datagrid", + plugin_config: { + columns: {}, + edit_mode: "READ_ONLY", + scroll_lock: false, + }, + settings: true, + sort: [], + split_by: [], + theme: "Pro Light", + title: null, + }); + + await add_and_execute_cell( + page, + ` +w.plugin = "X Bar" +w.columns = ["ui8"] +w.filter = [["i8", "<", 50]] +w.group_by = ["date"] +w.split_by = ["bool"] +w.sort = [["date", "asc"]] +w.theme = "Pro Dark"`, + ); + + // grab the config again + config = await viewer.evaluate(async (viewer) => { + return await viewer.save(); + }); + + // and check it + expect(config).toEqual({ + version: API_VERSION, + columns_config: {}, + aggregates: {}, + columns: ["ui8"], + expressions: {}, + filter: [["i8", "<", 50]], + group_by: ["date"], + plugin: "X Bar", + plugin_config: {}, + settings: true, + sort: [["date", "asc"]], + split_by: ["bool"], + theme: "Pro Dark", + title: null, + }); + }, + ); + + test_jupyter( + "Everything Else - Toggle from JS", + [ + [ + "server = perspective.Server()", + "client = server.new_local_client()", + "table = client.table(arrow_data)", + "w = perspective.widget.PerspectiveWidget(table)", + ].join("\n"), + "w", + ], + async ({ page }) => { + const viewer = await default_body(page); + const config = await viewer.evaluate(async (viewer) => { + return await viewer.save(); + }); + + // Check default config + expect(config).toEqual({ + version: API_VERSION, + columns_config: {}, + aggregates: {}, + columns: [ + "ui8", + "i8", + "ui16", + "i16", + "ui32", + "i32", + "ui64", + "i64", + "f32", + "f64", + "bool", + "str", + "date", + "datetime", + ], + expressions: {}, + filter: [], + group_by: [], + plugin: "Datagrid", + plugin_config: { + columns: {}, + scroll_lock: false, + edit_mode: "READ_ONLY", + }, + settings: true, + sort: [], + split_by: [], + theme: "Pro Light", + title: null, + }); + + await viewer.evaluate(async (viewer, version) => { + viewer.restore({ + version, + columns: ["ui8"], + filter: [["i8", "<", "50"]], + group_by: ["date"], + plugin: "X Bar", + settings: false, + sort: [["date", "asc"]], + split_by: ["bool"], + theme: "Pro Dark", + title: null, + }); + + return ""; + }, API_VERSION); + + const error_cells_dont_exist = await assert_no_error_in_cell( + page, + ` +assert w.plugin == "X Bar" +assert w.columns == ["ui8"] +assert w.filter == [["i8", "<", "50"]] +assert w.group_by == ["date"] +assert w.split_by == ["bool"] +assert w.plugin_config == {} +assert w.settings == False +assert w.sort == [["date", "asc"]] +assert w.theme == "Pro Dark" +"Passed"`, + ); + expect(error_cells_dont_exist).toBe(true); + }, + ); + + test_jupyter( + "Edit from frontend - end to end", + [ + 'w = perspective.widget.PerspectiveWidget({"a": [True, False, True], "b": ["abc", "def", "ghi"]}, index="b", plugin_config={"edit_mode": "EDIT"})', + "w", + ], + async ({ page }) => { + const viewer = await default_body(page); + + // assert in python or else + let error_cells_dont_exist = await assert_no_error_in_cell( + page, + [ + `assert w.table.view().to_columns() == {'a': [True, False, True], 'b': ['abc', 'def', 'ghi']}`, + `"Passed"`, + ].join("\n"), + ); + expect(error_cells_dont_exist).toBe(true); + + // Toggle some values in the frontend + const bools = await page.$$(".psp-bool-type"); + + // do synchronous + for (let bool of bools) { + await bool.click(); + } + + // now check again + error_cells_dont_exist = await assert_no_error_in_cell( + page, + [ + `assert w.table.view().to_columns() == {'a': [False, True, False], 'b': ['abc', 'def', 'ghi']}`, + `"Passed"`, + ].join("\n"), + ); + + expect(error_cells_dont_exist).toBe(true); + }, + ); + + test_jupyter("Restores from saved config", [], async ({ page }) => { + await execute_all_cells(page); + let errored = await assert_no_error_in_cell( + page, + ` +server = perspective.Server() +client = server.new_local_client() +table = client.table(arrow_data) +w = perspective.widget.PerspectiveWidget(table) +config = w.save() +perpsective.PerspectiveWidget(df, **config) + `, + ); + expect(errored).toBe(false); + }); + + test_jupyter( + "Toggles to datagrid and back regression", + [ + "w = perspective.widget.PerspectiveWidget(arrow_data, columns=['f64', 'str', 'datetime'])", + "w", + ], + async ({ page }) => { + await default_body(page); + const num_columns = await page + .locator("regular-table thead tr") + .first() + .evaluate((tr) => tr.childElementCount); + + async function toggle(plugin) { + await page.locator(".plugin-select-item").click(); + await page + .locator("#plugin_selector_container.open") + .waitFor(); + + await page + .locator(`[data-plugin=${plugin}].plugin-select-item`) + .click(); + + await page + .locator("#plugin_selector_container:not(.open)") + .waitFor(); + + await page.evaluate(async () => { + await document + .querySelector("perspective-viewer") + .flush(); + }); + } + + await toggle('"X/Y Line"'); + await toggle("Datagrid"); + await toggle('"X/Y Line"'); + await toggle("Datagrid"); + + // expect(num_columns).toEqual(3); + await expect( + page.locator("regular-table tbody tr"), + ).toHaveCount(5); + }, + ); + + // ************************* + // UTILS + // ************************* + test_jupyter( + "Run in Cell - Assert in Cell working", + [], + async ({ page }) => { + await execute_all_cells(page); + + // assert_no_error_in_cell runs add_and_execute_cell internally so only need to check one + const error_cells_dont_exist = await assert_no_error_in_cell( + page, + "raise Exception('anything')", + ); + expect(error_cells_dont_exist).toBe(false); + }, + ); + }, + { name: "Simple", root: path.join(__dirname, "..", "..") }, +); +// }); diff --git a/packages/jupyterlab/webpack.config.js b/packages/jupyterlab/webpack.config.js new file mode 100644 index 0000000..8cb8152 --- /dev/null +++ b/packages/jupyterlab/webpack.config.js @@ -0,0 +1,24 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +const CopyPlugin = require("copy-webpack-plugin"); + +module.exports = { + experiments: { + topLevelAwait: true, + }, + plugins: [ + new CopyPlugin({ + patterns: [{ from: "./install.json", to: "../install.json" }], + }), + ], +}; diff --git a/packages/react/build.mjs b/packages/react/build.mjs new file mode 100644 index 0000000..babd5c5 --- /dev/null +++ b/packages/react/build.mjs @@ -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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { NodeModulesExternal } from "@perspective-dev/esbuild-plugin/external.js"; +import { build } from "@perspective-dev/esbuild-plugin/build.js"; +import "zx/globals"; + +const BUILD = [ + { + define: { + global: "window", + }, + entryPoints: ["src/index.tsx"], + format: "esm", + loader: {}, + outfile: "dist/esm/index.js", + plugins: [NodeModulesExternal()], + }, +]; + +async function build_all() { + await Promise.all(BUILD.map(build)).catch(() => process.exit(1)); + try { + await $`tsc --project ./tsconfig.json`.stdio( + "inherit", + "inherit", + "inherit", + ); + } catch (e) { + console.error(e); + process.exit(1); + } +} + +build_all(); diff --git a/packages/react/clean.mjs b/packages/react/clean.mjs new file mode 100644 index 0000000..49a5253 --- /dev/null +++ b/packages/react/clean.mjs @@ -0,0 +1,15 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 "node:fs"; + +fs.rmSync("dist", { recursive: true, force: true }); diff --git a/packages/react/docs.mjs b/packages/react/docs.mjs new file mode 100644 index 0000000..3abf82f --- /dev/null +++ b/packages/react/docs.mjs @@ -0,0 +1,15 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 "zx/globals"; + +await $`typedoc --tsconfig tsconfig.json --out ../../docs/static/react`; diff --git a/packages/react/package.json b/packages/react/package.json new file mode 100644 index 0000000..7c7b1ec --- /dev/null +++ b/packages/react/package.json @@ -0,0 +1,51 @@ +{ + "name": "@perspective-dev/react", + "version": "4.5.2", + "description": "React component wrappers for `` and ``", + "keywords": [ + "perspective", + "react", + "data", + "analytics", + "visualization" + ], + "homepage": "https://perspective-dev.github.io", + "repository": { + "type": "git", + "url": "https://github.com/perspective-dev/perspective" + }, + "license": "Apache-2.0", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js", + "default": "./dist/esm/index.js" + }, + "./package.json": "./package.json" + }, + "type": "module", + "scripts": { + "build": "node ./build.mjs", + "clean": "node ./clean.mjs", + "docs": "node ./docs.mjs" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@perspective-dev/client": "workspace:", + "@perspective-dev/viewer": "workspace:", + "@perspective-dev/workspace": "workspace:", + "@types/react": "catalog:", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@perspective-dev/esbuild-plugin": "workspace:", + "superstore-arrow": "catalog:", + "typescript": "catalog:", + "typedoc": "catalog:", + "zx": "catalog:" + } +} diff --git a/packages/react/src/index.tsx b/packages/react/src/index.tsx new file mode 100644 index 0000000..379c7f3 --- /dev/null +++ b/packages/react/src/index.tsx @@ -0,0 +1,24 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * + * # See Also + * + * [`react-example`](https://github.com/perspective-dev/perspective/tree/master/examples/react-example) + * project from the Perspective GitHub repo. + * + * @module + */ + +export * from "./viewer"; +export * from "./workspace"; diff --git a/packages/react/src/utils.tsx b/packages/react/src/utils.tsx new file mode 100644 index 0000000..2d81653 --- /dev/null +++ b/packages/react/src/utils.tsx @@ -0,0 +1,27 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 React from "react"; + +export function usePspListener
    ( + el: HTMLElement | undefined | null, + event: string, + cb?: (x: A) => void, +) { + React.useEffect(() => { + if (!cb || !el) return; + const ctx = new AbortController(); + const callback = (e: Event) => cb((e as CustomEvent).detail); + el?.addEventListener(event, callback, { signal: ctx.signal }); + return () => ctx.abort(); + }, [el, cb]); +} diff --git a/packages/react/src/viewer.tsx b/packages/react/src/viewer.tsx new file mode 100644 index 0000000..dc96315 --- /dev/null +++ b/packages/react/src/viewer.tsx @@ -0,0 +1,86 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 React from "react"; +import type * as psp from "@perspective-dev/client"; +import type * as pspViewer from "@perspective-dev/viewer"; +import { usePspListener } from "./utils"; + +function PerspectiveViewerImpl(props: PerspectiveViewerProps) { + const [viewer, setViewer] = + React.useState(null); + + React.useEffect(() => { + return () => { + viewer?.delete(); + }; + }, [viewer]); + + React.useEffect(() => { + if (props.client) { + viewer?.load(props.client); + } else { + viewer?.eject(); + } + }, [viewer, props.client]); + + React.useEffect(() => { + if (props.client && props.config) { + viewer?.restore(props.config); + } + }, [viewer, props.client, JSON.stringify(props.config)]); + + usePspListener(viewer, "perspective-click", props.onClick); + usePspListener(viewer, "perspective-select", props.onSelect); + usePspListener(viewer, "perspective-config-update", props.onConfigUpdate); + + return ( +
    ` + * next to the GL canvas, and applies cursor changes to the canvas's + * own `style.cursor`. Host-only — depends on `document` / + * `getComputedStyle`. + */ +export class DomHostSink implements HostSink { + private _glCanvas: HTMLCanvasElement; + private _parent: HTMLElement; + private _div: HTMLDivElement | null = null; + + constructor(glCanvas: HTMLCanvasElement, parent: HTMLElement) { + this._glCanvas = glCanvas; + this._parent = parent; + } + + pin( + lines: string[], + pos: { px: number; py: number }, + bounds: CssBounds, + ): void { + this.dismiss(); + const div = document.createElement("div"); + + div.className = "webgl-tooltip"; + div.style.maxHeight = `${Math.round(bounds.cssHeight * 0.6)}px`; + div.textContent = lines.join("\n"); + + if (getComputedStyle(this._parent).position === "static") { + this._parent.style.position = "relative"; + } + + div.style.left = "-9999px"; + div.style.top = "0px"; + this._parent.appendChild(div); + this._div = div; + const divW = div.getBoundingClientRect().width; + const divH = div.getBoundingClientRect().height; + let tx = pos.px + 12; + let ty = pos.py - divH - 8; + if (tx + divW > bounds.cssWidth) { + tx = pos.px - divW - 12; + } + + if (tx < 0) { + tx = 4; + } + + if (ty < 0) { + ty = pos.py + 12; + } + + if (ty + divH > bounds.cssHeight) { + ty = bounds.cssHeight - divH - 4; + } + + div.style.left = `${tx}px`; + div.style.top = `${ty}px`; + } + + dismiss(): void { + if (this._div) { + this._div.remove(); + this._div = null; + } + } + + setCursor(cursor: string): void { + this._glCanvas.style.cursor = cursor; + } +} diff --git a/packages/viewer-charts/src/ts/interaction/host-sink-message.ts b/packages/viewer-charts/src/ts/interaction/host-sink-message.ts new file mode 100644 index 0000000..ad5fd97 --- /dev/null +++ b/packages/viewer-charts/src/ts/interaction/host-sink-message.ts @@ -0,0 +1,86 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { + DismissTooltipMsg, + PinTooltipMsg, + SetCursorMsg, + UserClickMsg, + UserSelectMsg, +} from "../transport/protocol"; +import type { + CssBounds, + HostSink, + UserClickPayload, + UserSelectPayload, +} from "./tooltip-controller"; + +/** + * The subset of `WorkerMsg`s that flow chart → host through a + * `MessageHostSink`. Identical to the worker-side post payloads so the + * sink can ship them straight to `WorkerRenderer.post` with no + * intermediate translation. + */ +export type HostSinkEnvelope = + | PinTooltipMsg + | DismissTooltipMsg + | SetCursorMsg + | UserClickMsg + | UserSelectMsg; + +/** + * `HostSink` that posts pin / dismiss / setCursor / user-event intents + * over a `postMessage`-style channel as `WorkerMsg`s. The host-side + * transport listens for these and drives a `DomHostSink` for + * pin/dismiss and dispatches `CustomEvent`s on the viewer for user + * events. + */ +export class MessageHostSink implements HostSink { + private _send: (msg: HostSinkEnvelope) => void; + + constructor(send: (msg: HostSinkEnvelope) => void) { + this._send = send; + } + + pin( + lines: string[], + pos: { px: number; py: number }, + bounds: CssBounds, + ): void { + this._send({ kind: "pinTooltip", lines, pos, bounds }); + } + + dismiss(): void { + this._send({ kind: "dismissTooltip" }); + } + + setCursor(cursor: string): void { + this._send({ kind: "setCursor", cursor }); + } + + emitUserClick(payload: UserClickPayload): void { + // `UserClickPayload` is structurally identical to + // `PerspectiveClickDetail`; the cast carries the `config` + // field's looser inner shape without affecting runtime data. + this._send({ kind: "userClick", detail: payload as any }); + } + + emitUserSelect(payload: UserSelectPayload): void { + this._send({ + kind: "userSelect", + selected: payload.selected, + row: payload.row, + column_names: payload.column_names, + insertConfig: payload.insertConfig as any, + }); + } +} diff --git a/packages/viewer-charts/src/ts/interaction/lazy-tooltip.ts b/packages/viewer-charts/src/ts/interaction/lazy-tooltip.ts new file mode 100644 index 0000000..aeb40ba --- /dev/null +++ b/packages/viewer-charts/src/ts/interaction/lazy-tooltip.ts @@ -0,0 +1,102 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Serial-checked async tooltip cache. + * + * Every chart family that paints tooltip text from a lazy row fetch + * needs the same dance: each hover (or pin) bumps a serial; the async + * line-build resolves later; if the user has moved on by then the + * resolved lines must be discarded so we don't paint stale text. + * + * Hit-test logic stays per-chart — that genuinely diverges (spatial + * grid for cartesian, walk-visible-nodes for treemap, angular for + * sunburst). The serial discipline is what was getting copy-pasted. + * + * `Target` is whatever identity the chart uses for hover/pin (flat + * slot index, node id, etc.); it's stored on `hoveredTarget` so the + * render path can tell whether cached `lines` belong to the currently + * hovered entity (some charts paint tooltip text only when both + * agree). + */ +export class LazyTooltip { + /** + * Cached lines for the latest committed hover, or `null`. + */ + lines: string[] | null = null; + + /** + * Identity of the entity `lines` describe. `null` when cleared. + */ + hoveredTarget: Target | null = null; + + private _hoverSerial = 0; + private _pinSerial = 0; + + /** + * Begin a new hover. Call this only when the hovered entity has + * actually changed. Clears the cached lines, records the new + * target, bumps the hover serial, and returns the new value — pass + * it back to {@link commitHover} from your async resolver to gate + * the write. + */ + beginHover(target: Target): number { + this.lines = null; + this.hoveredTarget = target; + return ++this._hoverSerial; + } + + /** + * Commit a freshly-resolved line list for `serial`. Returns true + * when the write happened (caller should repaint), false when the + * serial was stale. + */ + commitHover(serial: number, lines: string[]): boolean { + if (serial !== this._hoverSerial) { + return false; + } + + this.lines = lines; + return true; + } + + /** + * Clear hover state (mouse left, view changed, etc.). + */ + clearHover(): void { + this.lines = null; + this.hoveredTarget = null; + this._hoverSerial++; + } + + /** + * Begin a pin operation. Returns the serial; pass it to + * {@link isPinFresh} from your async resolver. + */ + beginPin(): number { + return ++this._pinSerial; + } + + /** + * True when `serial` still names the latest pin attempt. + */ + isPinFresh(serial: number): boolean { + return serial === this._pinSerial; + } + + /** + * Bump the pin serial without starting a new pin (e.g. dismiss). + */ + invalidatePin(): void { + this._pinSerial++; + } +} diff --git a/packages/viewer-charts/src/ts/interaction/raw-event-forwarder.ts b/packages/viewer-charts/src/ts/interaction/raw-event-forwarder.ts new file mode 100644 index 0000000..e1be297 --- /dev/null +++ b/packages/viewer-charts/src/ts/interaction/raw-event-forwarder.ts @@ -0,0 +1,173 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { InteractionEvent } from "../transport/protocol"; + +/** + * Captures wheel / pointer events on the GL canvas, normalizes coords + * to canvas-relative CSS pixels, and emits semantic + * {@link InteractionEvent}s to the Renderer over its transport. The + * Renderer owns the `ZoomController`s and runs the actual hit-test + + * apply logic — see `applyWheel` / `applyPan` in `zoom-router.ts`. + * + * Pointer capture is set on `pointerdown` and released on `pointerup` + * so drags continue to deliver `pointermove` events when the cursor + * leaves the canvas. + */ +export class RawEventForwarder { + private _element: HTMLElement | null = null; + private _emit: ((e: InteractionEvent) => void) | null = null; + private _pointerId: number | null = null; + + private _onWheel: ((e: WheelEvent) => void) | null = null; + private _onPointerDown: ((e: PointerEvent) => void) | null = null; + private _onPointerMove: ((e: PointerEvent) => void) | null = null; + private _onPointerUp: ((e: PointerEvent) => void) | null = null; + private _onPointerLeave: (() => void) | null = null; + private _onClick: ((e: MouseEvent) => void) | null = null; + private _onDblClick: ((e: MouseEvent) => void) | null = null; + + attach(element: HTMLElement, emit: (e: InteractionEvent) => void): void { + this.detach(); + this._element = element; + this._emit = emit; + + this._onWheel = (e: WheelEvent) => { + const rect = element.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + + // `preventDefault` is fired unconditionally so the page + // does not scroll while the chart is hovered — the worker + // may still no-op if the cursor is outside any facet's + // plot rect. + e.preventDefault(); + emit({ type: "wheel", mx, my, deltaY: e.deltaY }); + }; + + this._onPointerDown = (e: PointerEvent) => { + const rect = element.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + element.setPointerCapture(e.pointerId); + this._pointerId = e.pointerId; + emit({ type: "pointerdown", mx, my, pointerId: e.pointerId }); + }; + + this._onPointerMove = (e: PointerEvent) => { + const rect = element.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + emit({ type: "pointermove", mx, my }); + }; + + this._onPointerUp = (e: PointerEvent) => { + if (this._pointerId !== null) { + try { + element.releasePointerCapture(this._pointerId); + } catch { + // capture may have already been released + } + + this._pointerId = null; + } + + void e; + emit({ type: "pointerup" }); + }; + + // Pointerleave gives the renderer a single signal that the + // cursor truly exited the canvas — used for tooltip dismissal. + // Hover + drag both ride the `pointermove` stream above, so no + // parallel `mousemove` channel exists. + this._onPointerLeave = () => { + emit({ type: "pointerleave" }); + }; + + this._onClick = (e: MouseEvent) => { + const rect = element.getBoundingClientRect(); + emit({ + type: "click", + mx: e.clientX - rect.left, + my: e.clientY - rect.top, + }); + }; + + this._onDblClick = (e: MouseEvent) => { + const rect = element.getBoundingClientRect(); + emit({ + type: "dblclick", + mx: e.clientX - rect.left, + my: e.clientY - rect.top, + }); + }; + + element.addEventListener("wheel", this._onWheel, { passive: false }); + element.addEventListener("pointerdown", this._onPointerDown); + element.addEventListener("pointermove", this._onPointerMove); + element.addEventListener("pointerup", this._onPointerUp); + element.addEventListener("pointerleave", this._onPointerLeave); + element.addEventListener("click", this._onClick); + element.addEventListener("dblclick", this._onDblClick); + } + + detach(): void { + if (this._element) { + if (this._onWheel) { + this._element.removeEventListener("wheel", this._onWheel); + } + + if (this._onPointerDown) { + this._element.removeEventListener( + "pointerdown", + this._onPointerDown, + ); + } + + if (this._onPointerMove) { + this._element.removeEventListener( + "pointermove", + this._onPointerMove, + ); + } + + if (this._onPointerUp) { + this._element.removeEventListener( + "pointerup", + this._onPointerUp, + ); + } + + if (this._onPointerLeave) { + this._element.removeEventListener( + "pointerleave", + this._onPointerLeave, + ); + } + + if (this._onClick) { + this._element.removeEventListener("click", this._onClick); + } + + if (this._onDblClick) { + this._element.removeEventListener("dblclick", this._onDblClick); + } + } + + this._element = null; + this._emit = null; + this._pointerId = null; + this._onPointerLeave = null; + this._onClick = null; + this._onDblClick = null; + } +} diff --git a/packages/viewer-charts/src/ts/interaction/spatial-grid.ts b/packages/viewer-charts/src/ts/interaction/spatial-grid.ts new file mode 100644 index 0000000..75848d1 --- /dev/null +++ b/packages/viewer-charts/src/ts/interaction/spatial-grid.ts @@ -0,0 +1,100 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +export class SpatialGrid { + private _cells: Map = new Map(); + private _xMin: number; + private _yMin: number; + private _cellSize: number; + private _cols: number; + + constructor( + xMin: number, + xMax: number, + yMin: number, + yMax: number, + cellSize: number, + ) { + this._xMin = xMin; + this._yMin = yMin; + this._cellSize = cellSize; + this._cols = Math.max(1, Math.ceil((xMax - xMin) / cellSize)); + } + + private _cellKey(cx: number, cy: number): number { + return cy * this._cols + cx; + } + + insert(index: number, x: number, y: number): void { + const cx = Math.floor((x - this._xMin) / this._cellSize); + const cy = Math.floor((y - this._yMin) / this._cellSize); + const key = this._cellKey(cx, cy); + let cell = this._cells.get(key); + if (!cell) { + cell = []; + this._cells.set(key, cell); + } + + cell.push(index); + } + + /** + * Find the nearest point to (dataX, dataY) within the given radius, + * measured in pixel distance using the provided scale factors. + */ + query( + dataX: number, + dataY: number, + radiusPx: number, + pxPerDataX: number, + pxPerDataY: number, + xData: Float32Array, + yData: Float32Array, + ): number { + const cellRadiusX = Math.ceil(radiusPx / pxPerDataX / this._cellSize); + const cellRadiusY = Math.ceil(radiusPx / pxPerDataY / this._cellSize); + const centerCX = Math.floor((dataX - this._xMin) / this._cellSize); + const centerCY = Math.floor((dataY - this._yMin) / this._cellSize); + + let bestIdx = -1; + let bestDistSq = radiusPx * radiusPx; + + for ( + let cy = centerCY - cellRadiusY; + cy <= centerCY + cellRadiusY; + cy++ + ) { + for ( + let cx = centerCX - cellRadiusX; + cx <= centerCX + cellRadiusX; + cx++ + ) { + const cell = this._cells.get(this._cellKey(cx, cy)); + if (!cell) { + continue; + } + + for (const i of cell) { + const dx = (xData[i] - dataX) * pxPerDataX; + const dy = (yData[i] - dataY) * pxPerDataY; + const distSq = dx * dx + dy * dy; + if (distSq < bestDistSq) { + bestDistSq = distSq; + bestIdx = i; + } + } + } + } + + return bestIdx; + } +} diff --git a/packages/viewer-charts/src/ts/interaction/tooltip-controller.ts b/packages/viewer-charts/src/ts/interaction/tooltip-controller.ts new file mode 100644 index 0000000..3580d65 --- /dev/null +++ b/packages/viewer-charts/src/ts/interaction/tooltip-controller.ts @@ -0,0 +1,407 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { Canvas2D, Context2D } from "../charts/canvas-types"; +import type { PlotLayout } from "../layout/plot-layout"; +import type { Theme } from "../theme/theme"; + +/** + * Minimal positioning input — PlotLayout satisfies this. + */ +export interface CssBounds { + cssWidth: number; + cssHeight: number; +} + +export interface TooltipCallbacks { + /** + * RAF-throttled mouse position in CSS pixels, relative to the GL + * canvas (host already subtracted `getBoundingClientRect`). + */ + onHover(mx: number, my: number): void; + + /** + * Fires on mouseleave; skipped while a pinned tooltip is active. + */ + onLeave(): void; + + /** + * Fires on click with mouse position. Return true to consume the click + * (skipping the default pin/dismiss flow — used for legend clicks). + */ + onClickPre?(mx: number, my: number): boolean; + + /** + * Fires when a click should pin the current hover target. + */ + onPin?(mx: number, my: number): void; + + /** + * Fires on dblclick (treemap drill-up gesture). Optional — charts + * that don't bind a handler simply ignore the event. + */ + onDblClick?(mx: number, my: number): void; + + /** + * Fires when an active pin is dismissed by a click on the + * already-pinned target (the "click again to unpin" gesture in + * `dispatchClick`). Chart impls hook this to emit a + * `perspective-global-filter` with `selected: false`. Does *not* + * fire on the implicit dismiss inside `pin()` that replaces an + * existing pin — that path is followed by a fresh `onPin` which + * emits its own `selected: true`. + */ + onUnpin?(): void; +} + +export interface RenderTooltipOptions { + /** + * Draw a dashed crosshair at `pos`. Used by scatter/line. + */ + crosshair?: boolean; + + /** + * Draw a ring of `radius` CSS pixels at `pos`. Used to highlight a + * hovered point. Omit for bars (where the bar itself highlights). + */ + highlightRadius?: number; +} + +/** + * Side-channel from the chart back to the host's DOM. The chart calls + * into a sink rather than touching the DOM itself; the host + * materializes the actual visual (`
    ` for pinned tooltip, cursor + * mutation on the GL canvas). + * + * - `MessageHostSink` (in worker/) — forwards calls over a + * `postMessage`-shaped channel back to + * the host. + * - `DomHostSink` (in host transport) — receives the matching + * envelopes and applies them to the DOM. + * + * The controller's `_pinned` flag is the source of truth for whether + * hover updates are gated; the sink only owns the visual artifact. + */ +export interface HostSink { + pin( + lines: string[], + pos: { px: number; py: number }, + bounds: CssBounds, + ): void; + dismiss(): void; + setCursor(cursor: string): void; + + /** + * Forward a `perspective-click` to the host. Optional — only the + * worker-bound `MessageHostSink` implements it; `DomHostSink` (the + * host-side consumer of pin/dismiss) never sees user-event calls, + * so omits the implementation. + */ + emitUserClick?(detail: UserClickPayload): void; + + /** + * Forward a `perspective-global-filter` to the host with the + * `selected: true` / `selected: false` semantics. The host owns the + * `removeConfigs` history (mirrors datagrid's + * `model._last_insert_configs`); the sink only ships the new state. + */ + emitUserSelect?(payload: UserSelectPayload): void; +} + +/** + * Plain-object payload for `HostSink.emitUserClick`. Matches + * `PerspectiveClickDetail` byte-for-byte; defined locally to avoid a + * cycle through `event-detail.ts`. + */ +export interface UserClickPayload { + row: Record; + column_names: string[]; + config: { filter?: unknown[] }; +} + +/** + * Plain-object payload for `HostSink.emitUserSelect`. The host + * transport reconstructs a `PerspectiveSelectDetail` class instance + * from this plus its cached `_lastInsertConfig`. + */ +export interface UserSelectPayload { + selected: boolean; + row: Record; + column_names: string[]; + insertConfig: { filter?: unknown[] }; +} + +/** + * Owns the hover/click/dblclick state machine and the pinned-tooltip + * lifecycle. The renderer drives this purely through + * `dispatchHover` / `dispatchLeave` / `dispatchClick` / + * `dispatchDblClick` — the host's `RawEventForwarder` captures DOM + * events on the GL canvas and posts them as `InteractionEvent`s. + * + * Pinning + cursor changes go through a {@link HostSink} so the actual + * DOM mutations happen host-side regardless of where the chart runs. + */ +export class TooltipController { + private _callbacks: TooltipCallbacks | null = null; + private _hoverRAFId = 0; + private _hoverTimeoutId: ReturnType | null = null; + private _host: HostSink | null = null; + private _pinned = false; + + get isPinned(): boolean { + return this._pinned; + } + + /** + * Replace the active host sink. Dismisses any existing pin via the + * prior sink so we never leak a pinned artifact across resets — + * though in practice each chart instance uses one sink for its + * lifetime. + */ + setHost(sink: HostSink): void { + if (this._pinned) { + this._host?.dismiss(); + this._pinned = false; + } + + this._host = sink; + } + + /** + * Forward a cursor change to the host. No-op when no host sink is + * installed (chart constructed without a transport). + */ + setCursor(cursor: string): void { + this._host?.setCursor(cursor); + } + + /** + * Install the chart's tooltip callbacks. The renderer drives the + * controller via `dispatchHover` / `dispatchLeave` / + * `dispatchClick` / `dispatchDblClick`; this controller never + * touches the DOM directly. + */ + attach(callbacks: TooltipCallbacks): void { + this.detach(); + this._callbacks = callbacks; + } + + detach(): void { + if (this._hoverRAFId) { + cancelAnimationFrame(this._hoverRAFId); + this._hoverRAFId = 0; + } + + if (this._hoverTimeoutId !== null) { + clearTimeout(this._hoverTimeoutId); + this._hoverTimeoutId = null; + } + + this._callbacks = null; + } + + /** + * Schedule an `onHover` callback for the given canvas-relative + * coords. Coalesces multiple calls within one animation frame so + * pointer streams don't backlog the chart's hit-test path. + * + * Workers ship with `requestAnimationFrame` (DedicatedWorkerGlobalScope + * exposes it for OffscreenCanvas painting), so the same coalescer + * works in both modes. We fall back to setTimeout if RAF is missing + * (e.g. node tests without a polyfill). + */ + dispatchHover(mx: number, my: number): void { + if (this._pinned || !this._callbacks) { + return; + } + + if (this._hoverRAFId || this._hoverTimeoutId !== null) { + return; + } + + const fire = () => { + this._hoverRAFId = 0; + this._hoverTimeoutId = null; + this._callbacks?.onHover(mx, my); + }; + + if (typeof requestAnimationFrame === "function") { + this._hoverRAFId = requestAnimationFrame(fire); + } else { + this._hoverTimeoutId = setTimeout(fire, 16); + } + } + + dispatchLeave(): void { + if (this._pinned || !this._callbacks) { + return; + } + + this._callbacks.onLeave(); + } + + dispatchClick(mx: number, my: number): void { + if (!this._callbacks) { + return; + } + + if (this._callbacks.onClickPre?.(mx, my)) { + return; + } + + if (this._pinned) { + const cb = this._callbacks; + this.dismiss(); + cb.onUnpin?.(); + return; + } + + this._callbacks.onPin?.(mx, my); + } + + dispatchDblClick(mx: number, my: number): void { + this._callbacks?.onDblClick?.(mx, my); + } + + /** + * Pin a tooltip (or replace an active one). Forwards through the + * configured sink and flips the controller's pinned flag so hover + * dispatch is suppressed until dismissal. + */ + pin( + lines: string[], + pos: { px: number; py: number }, + bounds: CssBounds, + ): void { + if (lines.length === 0) { + return; + } + + this._host?.pin(lines, pos, bounds); + this._pinned = true; + } + + dismiss(): void { + this._host?.dismiss(); + this._pinned = false; + } +} + +/** + * Paint a canvas tooltip (crosshair, highlight ring, box + text) onto + * `canvas`. The helper normalizes the 2D context to a DPR-scaled + * identity transform on entry and restores prior state on exit, so it + * composes cleanly with other chrome painters that may have already + * called `initCanvas` on the same canvas — re-applying `scale(dpr,dpr)` + * blind would double-scale in that case, misplacing the tooltip + * proportionally to its distance from the origin. + */ +export function renderCanvasTooltip( + canvas: Canvas2D | null, + pos: { px: number; py: number }, + lines: string[], + layout: PlotLayout, + theme: Theme, + dpr: number, + options: RenderTooltipOptions = {}, +): void { + if (!canvas) { + return; + } + + const ctx = canvas.getContext("2d") as Context2D | null; + if (!ctx) { + return; + } + + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.scale(dpr, dpr); + ctx.font = `11px ${theme.fontFamily}`; + const lineHeight = 16; + const padding = 8; + let maxWidth = 0; + for (const line of lines) { + const w = ctx.measureText(line).width; + if (w > maxWidth) { + maxWidth = w; + } + } + + const boxW = maxWidth + padding * 2; + const boxH = lines.length * lineHeight + padding * 2 - 4; + let tx = pos.px + 12; + let ty = pos.py - boxH - 8; + if (tx + boxW > layout.cssWidth) { + tx = pos.px - boxW - 12; + } + + if (ty < 0) { + ty = pos.py + 12; + } + + if (ty + boxH > layout.cssHeight) { + ty = layout.cssHeight - boxH - 4; + } + + const hasLines = lines.length > 0; + + // Crosshair + if (options.crosshair) { + ctx.strokeStyle = theme.tickColor; + ctx.globalAlpha = 0.3; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.moveTo(pos.px, layout.plotRect.y); + ctx.lineTo(pos.px, layout.plotRect.y + layout.plotRect.height); + ctx.moveTo(layout.plotRect.x, pos.py); + ctx.lineTo(layout.plotRect.x + layout.plotRect.width, pos.py); + ctx.stroke(); + ctx.setLineDash([]); + ctx.globalAlpha = 1.0; + } + + // Highlight ring + if (options.highlightRadius && options.highlightRadius > 0) { + ctx.strokeStyle = theme.tickColor; + ctx.globalAlpha = 0.8; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(pos.px, pos.py, options.highlightRadius, 0, Math.PI * 2); + ctx.stroke(); + ctx.globalAlpha = 1.0; + } + + // Box + text are only drawn when we have content. Callers pass an + // empty `lines` array while a lazy row fetch is still in flight — + // the crosshair / highlight ring above paint immediately so the + // hover remains visible, but the tooltip chrome waits for data. + if (hasLines) { + ctx.fillStyle = theme.tooltipBg; + ctx.strokeStyle = theme.tooltipBorder; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(tx, ty, boxW, boxH, 4); + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = theme.tooltipText; + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + for (let i = 0; i < lines.length; i++) { + ctx.fillText(lines[i], tx + padding, ty + padding + i * lineHeight); + } + } + + ctx.restore(); +} diff --git a/packages/viewer-charts/src/ts/interaction/zoom-controller.ts b/packages/viewer-charts/src/ts/interaction/zoom-controller.ts new file mode 100644 index 0000000..00e1c28 --- /dev/null +++ b/packages/viewer-charts/src/ts/interaction/zoom-controller.ts @@ -0,0 +1,505 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { PlotLayout } from "../layout/plot-layout"; + +export interface ZoomState { + scaleX: number; + scaleY: number; + + // Translate as fraction of base domain range (0 = centered, ±0.5 = edge) + normTranslateX: number; + normTranslateY: number; +} + +/** + * Runtime config for `ZoomController`. Not part of `ZoomState` — wired + * by the owning chart at construction, not serialized with zoom level. + * + * `lockAxis` pins one axis at `scale=1, translate=0` and suppresses + * wheel / pan updates on that axis, leaving the other axis fully + * zoomable. Categorical axes are the typical candidates. + * + * `lockAspect` keeps `dataPerPixel` equal on both axes by padding the + * narrower axis of the rendered domain to match the plot rect's aspect + * ratio. Required by map plugins (Mercator preserves local angle, so + * map glyphs distort under independent X/Y zoom), and useful for any + * cartesian view where stretching points along one axis would lie + * about the data. + */ +export interface ZoomConfig { + lockAxis?: "x" | "y" | null; + lockAspect?: boolean; +} + +export const MAX_ZOOM = 100_000; +export const MIN_ZOOM = 1; + +/** + * Pad the narrower axis of `domain` so its aspect ratio matches + * `plotRect`. Preserves the center of each axis and the longer axis's + * extent — only the shorter axis grows. Returns the input unmodified + * if either dimension is non-positive. + * + * Used by `lockAspect` mode to keep `dataPerPixel` equal on both axes, + * which is what map plugins (Mercator) and any "square pixel" view + * require. + */ +function applyAspectLock( + domain: { xMin: number; xMax: number; yMin: number; yMax: number }, + plotRect: { width: number; height: number }, +): { xMin: number; xMax: number; yMin: number; yMax: number } { + if (plotRect.width <= 0 || plotRect.height <= 0) { + return domain; + } + + const xRange = domain.xMax - domain.xMin; + const yRange = domain.yMax - domain.yMin; + if (xRange <= 0 || yRange <= 0) { + return domain; + } + + const plotAspect = plotRect.width / plotRect.height; + const dataAspect = xRange / yRange; + + if (dataAspect < plotAspect) { + const cx = (domain.xMin + domain.xMax) / 2; + const newX = (yRange * plotAspect) / 2; + return { + xMin: cx - newX, + xMax: cx + newX, + yMin: domain.yMin, + yMax: domain.yMax, + }; + } else { + const cy = (domain.yMin + domain.yMax) / 2; + const newY = xRange / plotAspect / 2; + return { + xMin: domain.xMin, + xMax: domain.xMax, + yMin: cy - newY, + yMax: cy + newY, + }; + } +} + +export class ZoomController { + private _scaleX = 1; + private _scaleY = 1; + + // Normalized translate: fraction of base domain range + private _normTX = 0; + private _normTY = 0; + + private _baseXMin = 0; + private _baseXMax = 1; + private _baseYMin = 0; + private _baseYMax = 1; + + private _lockAxis: "x" | "y" | null = null; + private _lockAspect = false; + + // Opt-in "pin" flags. When set, `setBaseDomain` preserves the + // axis's *absolute* visible center across a base swap — the + // pre-existing rebase math. Default-cleared: data updates "follow" + // (preserve normalized translate, so the visible window tracks the + // data fractionally). Wire to a paused-frame review feature when + // one exists; no current caller flips these. + private _xPinned = false; + private _yPinned = false; + + private _element: HTMLElement | null = null; + private _layout: PlotLayout | null = null; + private _onUpdate: (() => void) | null = null; + + private _pointerDown = false; + private _lastPointerX = 0; + private _lastPointerY = 0; + + private _onWheel: ((e: WheelEvent) => void) | null = null; + private _onPointerDown: ((e: PointerEvent) => void) | null = null; + private _onPointerMove: ((e: PointerEvent) => void) | null = null; + private _onPointerUp: ((e: PointerEvent) => void) | null = null; + + // Per-controller mutators used by `applyWheel` / `applyPan` + // (zoom-router.ts) for the facet-aware zoom path. + get lockedAxis(): "x" | "y" | null { + return this._lockAxis; + } + get scaleX(): number { + return this._scaleX; + } + get scaleY(): number { + return this._scaleY; + } + set scaleX(v: number) { + this._scaleX = v; + } + set scaleY(v: number) { + this._scaleY = v; + } + get normTranslateX(): number { + return this._normTX; + } + get normTranslateY(): number { + return this._normTY; + } + set normTranslateX(v: number) { + this._normTX = v; + } + set normTranslateY(v: number) { + this._normTY = v; + } + get baseXRange(): number { + return this._baseXMax - this._baseXMin; + } + get baseYRange(): number { + return this._baseYMax - this._baseYMin; + } + + /** + * Update the base (full-data) domain. Each axis is handled + * independently: + * + * - If the axis is at default (no user pan or zoom on this axis), + * just swap the new base in — no further math needed. + * - If the axis has been explicitly *pinned* (`pinAxis("x" | "y")`), + * preserve its *absolute* visible center across the swap: + * re-solve `_normT` so the data-space center is unchanged. + * This is paused-frame-review semantics — the user has marked a + * region of interest and wants it to stay put even as new data + * flows in. No current caller pins, but the API is here so the + * default rule below doesn't bake the choice in. + * - Otherwise (the default — "follow"): keep `_normT` as-is and + * just swap the new base. The visible window's *fractional* + * position is preserved, so sliding windows slide with the data + * and extending windows grow proportionally with the data. + * + * Per-axis handling matters because `_scaleX/_normTX` and + * `_scaleY/_normTY` are independent. A user who panned X to scroll + * through time should not force Y onto the rebase path — Y data + * updates should still flow through cleanly. + */ + setBaseDomain( + xMin: number, + xMax: number, + yMin: number, + yMax: number, + ): void { + const newRangeX = xMax - xMin; + if (this.isXDefault() || !this._xPinned) { + this._baseXMin = xMin; + this._baseXMax = xMax; + } else { + const oldRangeX = this._baseXMax - this._baseXMin; + const oldCx = + (this._baseXMin + this._baseXMax) / 2 + + this._normTX * oldRangeX; + this._baseXMin = xMin; + this._baseXMax = xMax; + this._normTX = + newRangeX > 0 ? (oldCx - (xMin + xMax) / 2) / newRangeX : 0; + } + + const newRangeY = yMax - yMin; + if (this.isYDefault() || !this._yPinned) { + this._baseYMin = yMin; + this._baseYMax = yMax; + } else { + const oldRangeY = this._baseYMax - this._baseYMin; + const oldCy = + (this._baseYMin + this._baseYMax) / 2 + + this._normTY * oldRangeY; + this._baseYMin = yMin; + this._baseYMax = yMax; + this._normTY = + newRangeY > 0 ? (oldCy - (yMin + yMax) / 2) / newRangeY : 0; + } + } + + /** + * Mark an axis as "pinned" so subsequent `setBaseDomain` calls + * preserve its *absolute* visible center (paused-frame-review + * semantics). Default-cleared on construction; both axes follow + * data growth fractionally until explicitly pinned. + */ + pinAxis(axis: "x" | "y"): void { + if (axis === "x") { + this._xPinned = true; + } else { + this._yPinned = true; + } + } + + unpinAxis(axis: "x" | "y"): void { + if (axis === "x") { + this._xPinned = false; + } else { + this._yPinned = false; + } + } + + /** + * Apply config. Called once by the chart during `setZoomController`. + * Locking an axis snaps its `scale`/`translate` to identity so any + * pre-existing state on that axis is cleared; subsequent wheel / + * pan events leave the locked axis alone. + */ + configure(config: ZoomConfig): void { + this._lockAxis = config.lockAxis ?? null; + this._lockAspect = config.lockAspect ?? false; + if (this._lockAxis === "x") { + this._scaleX = 1; + this._normTX = 0; + } else if (this._lockAxis === "y") { + this._scaleY = 1; + this._normTY = 0; + } + } + + isXDefault(): boolean { + return this._scaleX === 1 && this._normTX === 0; + } + + isYDefault(): boolean { + return this._scaleY === 1 && this._normTY === 0; + } + + isDefault(): boolean { + return this.isXDefault() && this.isYDefault(); + } + + getVisibleDomain(): { + xMin: number; + xMax: number; + yMin: number; + yMax: number; + } { + const bxRange = this._baseXMax - this._baseXMin; + const byRange = this._baseYMax - this._baseYMin; + const vxRange = bxRange / this._scaleX; + const vyRange = byRange / this._scaleY; + + // Center = base midpoint + normalized translate * base range + const cx = + (this._baseXMin + this._baseXMax) / 2 + this._normTX * bxRange; + const cy = + (this._baseYMin + this._baseYMax) / 2 + this._normTY * byRange; + + const domain = { + xMin: cx - vxRange / 2, + xMax: cx + vxRange / 2, + yMin: cy - vyRange / 2, + yMax: cy + vyRange / 2, + }; + + if (this._lockAspect && this._layout) { + return applyAspectLock(domain, this._layout.plotRect); + } + + return domain; + } + + attach( + element: HTMLElement, + layout: PlotLayout, + onUpdate: () => void, + ): void { + this.detach(); + this._element = element; + this._layout = layout; + this._onUpdate = onUpdate; + + this._onWheel = (e: WheelEvent) => { + e.preventDefault(); + const rect = element.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + const plot = this._layout!.plotRect; + + if ( + mouseX < plot.x || + mouseX > plot.x + plot.width || + mouseY < plot.y || + mouseY > plot.y + plot.height + ) { + return; + } + + // Cursor position as a fraction of the plot rect — the + // anchor point that should stay visually fixed across the + // zoom mutation below. `fracY` is 0 at top, 1 at bottom + // (screen coords); Y data axis is inverted, hence the + // `0.5 - fracY` form below. + const fracX = (mouseX - plot.x) / plot.width; + const fracY = (mouseY - plot.y) / plot.height; + const oldScaleX = this._scaleX; + const oldScaleY = this._scaleY; + + // Zoom factor — skip the locked axis so its scale stays + // pinned at 1. + const factor = Math.pow(1.1, -e.deltaY / 100); + if (this._lockAxis !== "x") { + this._scaleX = Math.max( + MIN_ZOOM, + Math.min(MAX_ZOOM, this._scaleX * factor), + ); + } + + if (this._lockAxis !== "y") { + this._scaleY = Math.max( + MIN_ZOOM, + Math.min(MAX_ZOOM, this._scaleY * factor), + ); + } + + // Cursor-anchored zoom: keep the data point under the + // cursor visually fixed. Derivation, for X: + // dataX(scale) = mid + normTX*baseRange + // + (fracX - 0.5) * baseRange/scale + // After mutating scale (normTX unchanged), the cursor data + // coord shifts. Re-anchoring is `normTX += + // (oldDataX - newDataX) / baseRange`; the `baseRange` + // terms cancel to: + // normTXDelta = (fracX - 0.5) * (1/oldScale - 1/newScale) + // Base-independent — a concurrent `setBaseDomain` mid- + // handler can't corrupt the anchor math. Y axis is screen- + // inverted, hence `(0.5 - fracY)`. + if (this._lockAxis !== "x" && oldScaleX !== this._scaleX) { + this._normTX += + (fracX - 0.5) * (1 / oldScaleX - 1 / this._scaleX); + } + + if (this._lockAxis !== "y" && oldScaleY !== this._scaleY) { + this._normTY += + (0.5 - fracY) * (1 / oldScaleY - 1 / this._scaleY); + } + + this._onUpdate!(); + }; + + this._onPointerDown = (e: PointerEvent) => { + const rect = element.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + const plot = this._layout!.plotRect; + + if ( + mouseX >= plot.x && + mouseX <= plot.x + plot.width && + mouseY >= plot.y && + mouseY <= plot.y + plot.height + ) { + this._pointerDown = true; + this._lastPointerX = e.clientX; + this._lastPointerY = e.clientY; + element.setPointerCapture(e.pointerId); + } + }; + + this._onPointerMove = (e: PointerEvent) => { + if (!this._pointerDown) { + return; + } + + const dx = e.clientX - this._lastPointerX; + const dy = e.clientY - this._lastPointerY; + this._lastPointerX = e.clientX; + this._lastPointerY = e.clientY; + + // Pan as a fraction is `dx / (plotWidth * scaleX)` — + // independent of the current base range. The chained form + // (`pixels → data delta → fraction-of-base`) cancels the + // `baseRange` terms algebraically; computing the cancelled + // form directly means a concurrent `setBaseDomain` swap + // mid-gesture cannot corrupt the pan math. + const plot = this._layout!.plotRect; + if (this._lockAxis !== "x" && plot.width > 0) { + this._normTX -= dx / (plot.width * this._scaleX); + } + + if (this._lockAxis !== "y" && plot.height > 0) { + this._normTY += dy / (plot.height * this._scaleY); + } + + this._onUpdate!(); + }; + + this._onPointerUp = () => { + this._pointerDown = false; + }; + + element.addEventListener("wheel", this._onWheel, { passive: false }); + element.addEventListener("pointerdown", this._onPointerDown); + element.addEventListener("pointermove", this._onPointerMove); + element.addEventListener("pointerup", this._onPointerUp); + } + + updateLayout(layout: PlotLayout): void { + this._layout = layout; + } + + detach(): void { + if (this._element) { + if (this._onWheel) { + this._element.removeEventListener("wheel", this._onWheel); + } + + if (this._onPointerDown) { + this._element.removeEventListener( + "pointerdown", + this._onPointerDown, + ); + } + + if (this._onPointerMove) { + this._element.removeEventListener( + "pointermove", + this._onPointerMove, + ); + } + + if (this._onPointerUp) { + this._element.removeEventListener( + "pointerup", + this._onPointerUp, + ); + } + } + + this._element = null; + this._onUpdate = null; + } + + reset(): void { + this._scaleX = 1; + this._scaleY = 1; + this._normTX = 0; + this._normTY = 0; + } + + serialize(): ZoomState { + return { + scaleX: this._scaleX, + scaleY: this._scaleY, + normTranslateX: this._normTX, + normTranslateY: this._normTY, + }; + } + + restore(state: ZoomState): void { + this._scaleX = state.scaleX; + this._scaleY = state.scaleY; + this._normTX = state.normTranslateX; + this._normTY = state.normTranslateY; + } +} diff --git a/packages/viewer-charts/src/ts/interaction/zoom-router.ts b/packages/viewer-charts/src/ts/interaction/zoom-router.ts new file mode 100644 index 0000000..1a2abe7 --- /dev/null +++ b/packages/viewer-charts/src/ts/interaction/zoom-router.ts @@ -0,0 +1,107 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { PlotLayout } from "../layout/plot-layout"; +import { MAX_ZOOM, MIN_ZOOM, type ZoomController } from "./zoom-controller"; + +/** + * Resolver that maps a cursor position to `{ controller, layout }` — + * returns `null` when the cursor is not inside any facet. In + * independent-zoom mode the facet under the cursor owns its events; in + * shared-zoom mode the resolver always returns the same controller for + * every plot rect in the grid. + */ +export interface ZoomTarget { + controller: ZoomController; + layout: PlotLayout; +} + +/** + * Apply a wheel-zoom delta to the resolved target. The worker + * dispatches interaction events forwarded from the host's + * `RawEventForwarder` and calls this directly. + */ +export function applyWheel( + target: ZoomTarget, + mouseX: number, + mouseY: number, + deltaY: number, +): void { + const { controller, layout } = target; + const plot = layout.plotRect; + + const domain = controller.getVisibleDomain(); + const dataX = + domain.xMin + + ((mouseX - plot.x) / plot.width) * (domain.xMax - domain.xMin); + const dataY = + domain.yMax - + ((mouseY - plot.y) / plot.height) * (domain.yMax - domain.yMin); + + const factor = Math.pow(1.1, -deltaY / 100); + const locked = controller.lockedAxis; + if (locked !== "x") { + controller.scaleX = Math.max( + MIN_ZOOM, + Math.min(MAX_ZOOM, controller.scaleX * factor), + ); + } + + if (locked !== "y") { + controller.scaleY = Math.max( + MIN_ZOOM, + Math.min(MAX_ZOOM, controller.scaleY * factor), + ); + } + + const newDomain = controller.getVisibleDomain(); + const newDataX = + newDomain.xMin + + ((mouseX - plot.x) / plot.width) * (newDomain.xMax - newDomain.xMin); + const newDataY = + newDomain.yMax - + ((mouseY - plot.y) / plot.height) * (newDomain.yMax - newDomain.yMin); + + const bxRange = controller.baseXRange; + const byRange = controller.baseYRange; + if (locked !== "x" && bxRange > 0) { + controller.normTranslateX += (dataX - newDataX) / bxRange; + } + + if (locked !== "y" && byRange > 0) { + controller.normTranslateY += (dataY - newDataY) / byRange; + } +} + +/** + * Apply a drag-pan delta to the resolved target. Exported for the + * same reason as {@link applyWheel}: worker-mode interaction + * dispatch reuses the math without owning DOM listeners. + */ +export function applyPan(target: ZoomTarget, dx: number, dy: number): void { + const { controller, layout } = target; + const domain = controller.getVisibleDomain(); + const plot = layout.plotRect; + const dataPerPixelX = (domain.xMax - domain.xMin) / plot.width; + const dataPerPixelY = (domain.yMax - domain.yMin) / plot.height; + + const locked = controller.lockedAxis; + const bxRange = controller.baseXRange; + const byRange = controller.baseYRange; + if (locked !== "x" && bxRange > 0) { + controller.normTranslateX -= (dx * dataPerPixelX) / bxRange; + } + + if (locked !== "y" && byRange > 0) { + controller.normTranslateY += (dy * dataPerPixelY) / byRange; + } +} diff --git a/packages/viewer-charts/src/ts/layout/facet-grid.ts b/packages/viewer-charts/src/ts/layout/facet-grid.ts new file mode 100644 index 0000000..42e7694 --- /dev/null +++ b/packages/viewer-charts/src/ts/layout/facet-grid.ts @@ -0,0 +1,346 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { PlotLayout, type PlotRect } from "./plot-layout"; + +/** + * Tri-state axis mode. + * + * - `"outer"` — one shared axis band reserved at the grid edge; + * `outerXAxisRect` / `outerYAxisRect` populated, per-cell gutter + * collapsed to 0 on that side. Caller paints the shared axis + * once per frame using the grid's outer rect. + * - `"cell"` — every cell reserves its own gutter on that side; + * caller paints one axis per cell. Outer rect is undefined. + * - `"none"` — no gutter anywhere on that side: neither an outer + * band nor a per-cell reservation. Intended for chart types with + * no numeric axis at all (treemap, sunburst). When BOTH axes are + * `"none"` cells are also made flush on the right so adjacent + * plot rects share a boundary. + * + * Defaults to `"cell"` when undefined. + */ +export type AxisMode = "outer" | "cell" | "none"; + +export interface FacetGridOptions { + cssWidth: number; + cssHeight: number; + + /** + * See {@link AxisMode}. Default `"cell"`. + */ + xAxis?: AxisMode; + + /** + * See {@link AxisMode}. Default `"cell"`. + */ + yAxis?: AxisMode; + + /** + * Reserve a right gutter for a single shared legend. + */ + hasLegend?: boolean; + + /** Axis-label allowance (consumed only when the corresponding axis + * mode produces a gutter — outer band or per-cell). */ + hasXLabel?: boolean; + hasYLabel?: boolean; + + /** + * Per-facet title strip height (px). 0 disables. + */ + titleBand?: number; + + /** + * Pixel gap between adjacent cells. Carved out of the grid + * interior before cell sizing; outer edges of the leftmost / + * rightmost columns and top / bottom rows are unaffected. Default + * 0 (flush cells). + */ + gap?: number; +} + +export interface FacetCell { + index: number; + label: string; + + /** + * Sub-plot layout. Every cell in a grid has *identical* + * `plotRect.width` and `plotRect.height` — cell internal margins + * do not vary by edge position. Shared-axis gutters live in + * `FacetGrid.outerXAxisRect` / `outerYAxisRect` instead, painted + * once per frame by the caller. + */ + layout: PlotLayout; + + /** + * Title strip above the facet's plot rect, if `titleBand > 0`. + */ + titleRect?: PlotRect; + isLeftEdge: boolean; + isBottomEdge: boolean; +} + +export interface FacetGrid { + cells: FacetCell[]; + + /** + * Right-gutter rect for the shared legend. + */ + legendRect?: PlotRect; + + /** + * Outer band reserved for the shared X axis (ticks + label). Only + * set when `xAxis === "outer"`. Spans the grid interior's + * horizontal extent and sits immediately below the bottom row of + * cells. + */ + outerXAxisRect?: PlotRect; + + /** + * Outer band reserved for the shared Y axis (ticks + label). Only + * set when `yAxis === "outer"`. Spans the grid interior's + * vertical extent and sits immediately left of the leftmost + * column of cells. + */ + outerYAxisRect?: PlotRect; +} + +/** + * Collect the bottom-row cells' `PlotLayout`s — i.e. the cells that + * sit on the grid's bottom edge. Shared-X axis renderers paint X + * ticks aligned to each of these. Empty when the grid has zero cells. + */ +export function bottomRowLayouts(grid: FacetGrid): PlotLayout[] { + return grid.cells.filter((c) => c.isBottomEdge).map((c) => c.layout); +} + +/** + * Collect the left-column cells' `PlotLayout`s — symmetric to + * {@link bottomRowLayouts} for the shared-Y axis path. + */ +export function leftColumnLayouts(grid: FacetGrid): PlotLayout[] { + return grid.cells.filter((c) => c.isLeftEdge).map((c) => c.layout); +} + +// Per-cell internal gutter defaults mirror `PlotLayout`'s constants so +// that a cell with `leftExtra: undefined` reserves the same space the +// outer band would reserve when the axis is shared. Keep these in sync +// with `plot-layout.ts`. +const CELL_LEFT_GUTTER = 55; +const CELL_BOTTOM_GUTTER = 24; +const AXIS_LABEL_W = 16; +const AXIS_LABEL_H = 18; + +const TITLE_BAND_DEFAULT = 18; +const LEGEND_GUTTER = 96; + +/** + * Pick `(cols, rows)` so that each resulting cell's aspect ratio is as + * close to 1 as possible given the grid interior. Sweeps `cols ∈ [1, + * count]` with `rows = ceil(count / cols)` and minimizes + * `max(cellW/cellH, cellH/cellW)`. Ties break toward fewer total cells + * (less unused grid area). + */ +function pickGridShape( + count: number, + gridW: number, + gridH: number, + gap: number, +): { cols: number; rows: number } { + if (count <= 1) { + return { cols: 1, rows: 1 }; + } + + let bestCols = 1; + let bestRows = count; + let bestCost = Infinity; + let bestTotal = count; + for (let cols = 1; cols <= count; cols++) { + const rows = Math.ceil(count / cols); + const cellW = Math.max(1, (gridW - (cols - 1) * gap) / cols); + const cellH = Math.max(1, (gridH - (rows - 1) * gap) / rows); + const aspect = cellW / cellH; + const cost = Math.max(aspect, 1 / aspect); + const total = cols * rows; + if (cost < bestCost || (cost === bestCost && total < bestTotal)) { + bestCols = cols; + bestRows = rows; + bestCost = cost; + bestTotal = total; + } + } + + return { cols: bestCols, rows: bestRows }; +} + +/** + * Arrange `labels.length` sub-plots in a row-major grid sized to fit + * `(cssWidth, cssHeight)`. + * + * Grid shape is chosen to minimize cell aspect distance from square + * given the container's grid interior: `cols ∈ [1, count]`, + * `rows = ceil(count / cols)`, tie-broken toward fewer total cells. + * + * **Invariant:** every `cells[i].layout.plotRect` has the same + * `width` and `height`. Shared-axis gutters are carved out of the + * outer canvas BEFORE cell sizing, so a cell's edge position never + * affects its internal margins. This lets per-facet draws reuse the + * same projection scale and lets shared ticks line up with the + * interior cell boundaries exactly. + * + * Axis modes — see {@link AxisMode}: + * - `"outer"` → outer band rect is populated; per-cell gutter is 0. + * - `"cell"` → outer band is undefined; each cell owns its own gutter. + * - `"none"` → no gutter anywhere on that side; used by axis-less + * chart types. + * + * Because all cells are identical in size, callers can sample *any* + * cell's layout (e.g. `cells[0].layout`) for tick / scale + * computations. + */ +export function buildFacetGrid( + labels: string[], + opts: FacetGridOptions, +): FacetGrid { + const count = labels.length; + const { cssWidth, cssHeight } = opts; + + if (count <= 0 || cssWidth <= 0 || cssHeight <= 0) { + return { cells: [] }; + } + + const titleBand = opts.titleBand ?? TITLE_BAND_DEFAULT; + const legendW = opts.hasLegend ? LEGEND_GUTTER : 0; + + const xMode: AxisMode = opts.xAxis ?? "cell"; + const yMode: AxisMode = opts.yAxis ?? "cell"; + + // Axis-less chart types (trees) benefit from fully-flush cells — + // no per-cell breathing on the right either, so adjacent plot + // rects share a boundary instead of leaving a 16 px seam. + const cellsFlush = xMode === "none" && yMode === "none"; + + // Outer margins: shared-axis gutters + legend gutter live OUTSIDE + // the per-cell rects. + const outerLeft = + yMode === "outer" + ? CELL_LEFT_GUTTER + (opts.hasYLabel ? AXIS_LABEL_W : 0) + : 0; + const outerBottom = + xMode === "outer" + ? CELL_BOTTOM_GUTTER + (opts.hasXLabel ? AXIS_LABEL_H : 0) + : 0; + const outerTop = 0; + const outerRight = legendW; + + const gridX = outerLeft; + const gridY = outerTop; + const gridW = Math.max(1, cssWidth - outerLeft - outerRight); + const gridH = Math.max(1, cssHeight - outerTop - outerBottom); + + // Carve the total inter-cell gap out of the grid interior before + // sizing cells so every cell remains identical in size (the + // grid-uniform invariant). Gaps only sit BETWEEN neighbors — not + // against the outer edges. + const gap = Math.max(0, opts.gap ?? 0); + const { cols, rows } = pickGridShape(count, gridW, gridH, gap); + const totalGapX = Math.max(0, cols - 1) * gap; + const totalGapY = Math.max(0, rows - 1) * gap; + const cellW = Math.max(1, (gridW - totalGapX) / cols); + const cellH = Math.max(1, (gridH - totalGapY) / rows); + + const cells: FacetCell[] = []; + for (let i = 0; i < count; i++) { + const row = Math.floor(i / cols); + const col = i - row * cols; + const isBottomEdge = row === rows - 1 || i + cols >= count; + const isLeftEdge = col === 0; + + const cellX = gridX + col * (cellW + gap); + const cellY = gridY + row * (cellH + gap); + + // Carve a title strip from the top of each cell. The remaining + // rect becomes the per-cell `PlotLayout`. + const plotTop = cellY + titleBand; + const plotLeft = cellX; + const plotWidth = cellW; + const plotHeight = Math.max(1, cellH - titleBand); + + // Per-cell gutters: + // - "cell" → keep `PlotLayout` default (undefined). + // - "outer" / "none" → collapse to 0 (no internal gutter). + // Per-cell labels only paint when the axis is per-cell. + const layout = new PlotLayout(cssWidth, cssHeight, { + hasXLabel: xMode === "cell" && opts.hasXLabel === true, + hasYLabel: yMode === "cell" && opts.hasYLabel === true, + hasLegend: false, + leftExtra: yMode === "cell" ? undefined : 0, + bottomExtra: xMode === "cell" ? undefined : 0, + rightExtra: cellsFlush ? 0 : undefined, + originX: plotLeft, + originY: plotTop, + cellWidth: plotWidth, + cellHeight: plotHeight, + }); + + const titleRect: PlotRect | undefined = + titleBand > 0 + ? { + x: plotLeft, + y: cellY, + width: plotWidth, + height: titleBand, + } + : undefined; + + cells.push({ + index: i, + label: labels[i], + layout, + titleRect, + isLeftEdge, + isBottomEdge, + }); + } + + const legendRect: PlotRect | undefined = opts.hasLegend + ? { + x: gridX + gridW, + y: outerTop, + width: legendW, + height: gridH, + } + : undefined; + + const outerXAxisRect: PlotRect | undefined = + xMode === "outer" + ? { + x: gridX, + y: gridY + gridH, + width: gridW, + height: outerBottom, + } + : undefined; + + const outerYAxisRect: PlotRect | undefined = + yMode === "outer" + ? { + x: 0, + y: gridY, + width: outerLeft, + height: gridH, + } + : undefined; + + return { cells, legendRect, outerXAxisRect, outerYAxisRect }; +} diff --git a/packages/viewer-charts/src/ts/layout/plot-layout.ts b/packages/viewer-charts/src/ts/layout/plot-layout.ts new file mode 100644 index 0000000..ee93876 --- /dev/null +++ b/packages/viewer-charts/src/ts/layout/plot-layout.ts @@ -0,0 +1,277 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +export interface PlotMargins { + top: number; + right: number; + bottom: number; + left: number; +} + +export interface PlotRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface PlotLayoutOptions { + hasXLabel: boolean; + hasYLabel: boolean; + hasLegend: boolean; + + /** + * Additional CSS-pixel height reserved at the bottom of the plot for a + * hierarchical / rotated categorical X axis. Overrides the default 24px + * tick band. The axis-label allowance from `hasXLabel` is preserved. + */ + bottomExtra?: number; + + /** + * Total CSS-pixel width reserved at the left of the plot for a + * hierarchical categorical Y axis. Overrides the default `55 + + * hasYLabel*16` left gutter. The axis-label allowance from `hasYLabel` + * is preserved. + */ + leftExtra?: number; + + /** + * Total CSS-pixel width reserved at the right of the plot. Overrides + * the default (`80` when `hasLegend`, else `16`). Faceted cells + * without axes (treemap / sunburst in grid mode) pass `0` to make + * adjacent cell plot rects flush; axis-bearing charts leave it + * unset to keep the default breathing-room margin. + */ + rightExtra?: number; + + /** + * Absolute canvas-coordinate offset for this layout's plot origin. + * When set, `cssWidth` / `cssHeight` describe the *outer* canvas, and + * `originX` / `originY` name the top-left corner of the cell this + * layout represents. The cell's own width/height come from + * `cellWidth` / `cellHeight`. `margins` are computed relative to the + * cell then shifted into canvas-absolute space so projection + * matrices, scissor, and `dataToPixel` all operate in full-canvas + * coordinates without branching per-facet. + * + * When any of these fields is unset, the layout is single-plot: the + * cell occupies the whole canvas and `originX` / `originY` default + * to 0. + */ + originX?: number; + originY?: number; + cellWidth?: number; + cellHeight?: number; +} + +/** + * Coordinates margins and coordinate transforms between WebGL and Canvas2D. + * All measurements are in CSS pixels (not physical/DPR-scaled pixels). + */ +export class PlotLayout { + readonly margins: PlotMargins; + readonly plotRect: PlotRect; + readonly cssWidth: number; + readonly cssHeight: number; + + // Padded domain set by buildProjectionMatrix, used by dataToPixel + // and pixelToData for tooltip hit-testing. + paddedXMin = 0; + paddedXMax = 1; + paddedYMin = 0; + paddedYMax = 1; + + constructor( + cssWidth: number, + cssHeight: number, + options: PlotLayoutOptions, + ) { + this.cssWidth = cssWidth; + this.cssHeight = cssHeight; + + const baseLeft = options.leftExtra ?? 55; + const left = baseLeft + (options.hasYLabel ? 16 : 0); + const baseBottom = options.bottomExtra ?? 24; + const bottom = baseBottom + (options.hasXLabel ? 18 : 0); + const top = 0; + const right = options.rightExtra ?? (options.hasLegend ? 80 : 16); + + // Facet cells: the sub-plot lives at `(originX, originY)` within a + // larger canvas of size `(cssWidth, cssHeight)`. Its own bounds are + // `cellWidth × cellHeight`. The gutters above are then interpreted + // inside that cell, and `margins` / `plotRect` are shifted into + // canvas-absolute coordinates. Single-plot layouts leave these + // unset, in which case `originX / originY = 0` and the cell + // occupies the whole canvas — identical to pre-facet semantics. + const originX = options.originX ?? 0; + const originY = options.originY ?? 0; + const cellW = options.cellWidth ?? cssWidth; + const cellH = options.cellHeight ?? cssHeight; + + const marginLeftAbs = originX + left; + const marginTopAbs = originY + top; + const plotW = Math.max(1, cellW - left - right); + const plotH = Math.max(1, cellH - top - bottom); + const marginRightAbs = cssWidth - (marginLeftAbs + plotW); + const marginBottomAbs = cssHeight - (marginTopAbs + plotH); + + this.margins = { + top: marginTopAbs, + right: marginRightAbs, + bottom: marginBottomAbs, + left: marginLeftAbs, + }; + + this.plotRect = { + x: marginLeftAbs, + y: marginTopAbs, + width: plotW, + height: plotH, + }; + } + + /** + * Build an orthographic projection matrix that maps data coordinates + * [xMin..xMax, yMin..yMax] to the plot area sub-region of clip space [-1, 1]. + * + * The matrix bakes margin offsets into the transform so that gl.viewport + * remains full-canvas and no scissor/sub-viewport is needed. + * + * `clamp`, when set, names the axis that carries the *value* (as + * opposed to categorical / positional) data. Today it only affects + * `requireZero`; both axes always receive symmetric `padRatio` padding. + * + * `requireZero`, when true, guarantees that the unpadded value `0` + * falls inside the clamped axis's final domain. For all-positive + * data the axis minimum is pinned at `0` (the baseline sits on the + * axis line); for all-negative data the maximum is pinned at `0`; + * for data that already straddles zero, nothing changes. Pairs with + * `clamp`, and is a no-op when `clamp` is unset. + * + * `padRatio` controls the symmetric cosmetic pad on both axes + * (default 2%). Charts that want plot edges flush with the axes + * (e.g. heatmap, whose cell rects already span the exact domain) + * pass `0`. + */ + buildProjectionMatrix( + xMin: number, + xMax: number, + yMin: number, + yMax: number, + clamp?: "x" | "y", + requireZero?: boolean, + padRatio: number = 0.02, + xOrigin: number = 0, + yOrigin: number = 0, + ): Float32Array { + // Symmetric cosmetic padding on both axes (default 2%). + let xRange = xMax - xMin; + let yRange = yMax - yMin; + if (xRange === 0) { + xRange = 1; + } + + if (yRange === 0) { + yRange = 1; + } + + const xPad = xRange * padRatio; + const yPad = yRange * padRatio; + + // Evaluate the zero-snap condition against the *pre-pad* + // values so that an exact-zero boundary (e.g. bar pipelines + // that snap `valMin` to 0) still qualifies — otherwise the + // padding step would tip the boundary slightly negative and + // the snap branch below would miss. Inclusive comparison is + // deliberate. + const snapYMin = requireZero && clamp === "y" && yMin >= 0; + const snapYMax = requireZero && clamp === "y" && yMax <= 0; + const snapXMin = requireZero && clamp === "x" && xMin >= 0; + const snapXMax = requireZero && clamp === "x" && xMax <= 0; + + xMin -= xPad; + xMax += xPad; + yMin -= yPad; + yMax += yPad; + + // Pin the snapped boundary to exactly zero and give the + // opposite boundary a second pad for visual headroom above the + // tallest bar. No-op when data straddles zero (neither flag + // set) so no boundary collapses onto an in-range value. + if (snapYMin) { + yMin = 0; + yMax += yPad; + } else if (snapYMax) { + yMax = 0; + yMin -= yPad; + } + + if (snapXMin) { + xMin = 0; + xMax += xPad; + } else if (snapXMax) { + xMax = 0; + xMin -= xPad; + } + + // Store padded domain for dataToPixel + this.paddedXMin = xMin; + this.paddedXMax = xMax; + this.paddedYMin = yMin; + this.paddedYMax = yMax; + + // Clip-space bounds for the plot area + const clipLeft = (2 * this.margins.left) / this.cssWidth - 1; + const clipRight = 1 - (2 * this.margins.right) / this.cssWidth; + const clipBottom = (2 * this.margins.bottom) / this.cssHeight - 1; + const clipTop = 1 - (2 * this.margins.top) / this.cssHeight; + + // Scale and translate: rebased data [(min-origin), (max-origin)] + // → clip [clipMin, clipMax]. Callers that ship rebased values to + // the GPU pass the origin they used; for ordinary numeric data + // both origins default to 0 and the math collapses to the + // legacy `tx = clipLeft - sx*xMin` form. With a datetime axis + // the origin lifts ~1.7e12 out of `tx` before the f32 + // narrowing in the matrix below, keeping shader cancellation + // precision-safe down to sub-millisecond granularity. + const sx = (clipRight - clipLeft) / (xMax - xMin); + const sy = (clipTop - clipBottom) / (yMax - yMin); + const tx = clipLeft - sx * (xMin - xOrigin); + const ty = clipBottom - sy * (yMin - yOrigin); + + // Column-major 4x4 matrix + // prettier-ignore + return new Float32Array([ + sx, 0, 0, 0, + 0, sy, 0, 0, + 0, 0, -1, 0, + tx, ty, 0, 1, + ]); + } + + /** + * Convert data coordinates to CSS pixel coordinates on the overlay canvas. + * Uses the padded domain from the last `buildProjectionMatrix` call so + * that pixel positions align exactly with the WebGL projection. + */ + dataToPixel(dataX: number, dataY: number): { px: number; py: number } { + const { x, y, width, height } = this.plotRect; + const tx = + (dataX - this.paddedXMin) / (this.paddedXMax - this.paddedXMin); + const ty = + (dataY - this.paddedYMin) / (this.paddedYMax - this.paddedYMin); + return { + px: x + tx * width, + py: y + (1 - ty) * height, // Y is flipped (CSS Y goes down) + }; + } +} diff --git a/packages/viewer-charts/src/ts/layout/ticks.ts b/packages/viewer-charts/src/ts/layout/ticks.ts new file mode 100644 index 0000000..4c8ef9a --- /dev/null +++ b/packages/viewer-charts/src/ts/layout/ticks.ts @@ -0,0 +1,168 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +function niceNum(value: number, round: boolean): number { + const exp = Math.floor(Math.log10(value)); + const frac = value / Math.pow(10, exp); + let nice: number; + if (round) { + if (frac < 1.5) { + nice = 1; + } else if (frac < 3) { + nice = 2; + } else if (frac < 7) { + nice = 5; + } else { + nice = 10; + } + } else { + if (frac <= 1) { + nice = 1; + } else if (frac <= 2) { + nice = 2; + } else if (frac <= 5) { + nice = 5; + } else { + nice = 10; + } + } + + return nice * Math.pow(10, exp); +} + +/** + * Generate an array of "nice" tick values spanning [min, max]. + * @param min - Domain minimum + * @param max - Domain maximum + * @param targetCount - Desired number of ticks (approximate) + */ +export function computeNiceTicks( + min: number, + max: number, + targetCount: number, +): number[] { + if (targetCount < 1) { + targetCount = 1; + } + + const range = niceNum(max - min, false); + const step = niceNum(range / targetCount, true); + const tickMin = Math.ceil(min / step) * step; + const tickMax = Math.floor(max / step) * step; + + const ticks: number[] = []; + + // Use epsilon to avoid floating point overshoot + for (let t = tickMin; t <= tickMax + step * 0.001; t += step) { + ticks.push(t); + } + + return ticks; +} + +/** + * Format a numeric tick value for display. + * Uses K/M/B suffixes for large numbers, fixed decimals for small. + */ +export function formatTickValue(val: number): string { + const abs = Math.abs(val); + if (abs === 0) { + return "0"; + } + + if (abs >= 1e9) { + return (val / 1e9).toFixed(1) + "B"; + } + + if (abs >= 1e6) { + return (val / 1e6).toFixed(1) + "M"; + } + + if (abs >= 1e3) { + return (val / 1e3).toFixed(1) + "K"; + } + + if (Number.isInteger(val)) { + return val.toString(); + } + + if (abs >= 1) { + return val.toFixed(1); + } + + return val.toFixed(2); +} + +/** + * Format a timestamp (ms since epoch) as a human-readable date/time label. + * Adapts precision based on the tick spacing. + */ +export function formatDateTickValue(val: number, stepMs?: number): string { + const d = new Date(val); + if (isNaN(d.getTime())) { + return formatTickValue(val); + } + + // If step is provided, choose precision based on tick interval + if (stepMs !== undefined && stepMs > 0) { + const DAY = 86_400_000; + const HOUR = 3_600_000; + const MINUTE = 60_000; + + if (stepMs >= DAY * 28) { + // Monthly or longer — show year-month + return d.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + }); + } + + if (stepMs >= DAY) { + // Daily — show month and day + return d.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); + } + + if (stepMs >= HOUR) { + // Hourly + return d.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + }); + } + + if (stepMs >= MINUTE) { + // Minutes + return d.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + }); + } + + // Sub-minute + return d.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + second: "2-digit", + }); + } + + // Default: show date only + return d.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} diff --git a/packages/viewer-charts/src/ts/map/mercator.ts b/packages/viewer-charts/src/ts/map/mercator.ts new file mode 100644 index 0000000..4483fe0 --- /dev/null +++ b/packages/viewer-charts/src/ts/map/mercator.ts @@ -0,0 +1,204 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Web Mercator projection helpers and XYZ tile-pyramid math. Pure + * functions, no side effects, safe to call from any thread (including + * the renderer worker). + * + * Mercator output is in *meters*, not the normalized [0, 1] form that + * some libraries use. We feed the meter values straight into the + * cartesian projection matrix in `plot-layout.ts`, so the upstream + * domain stays in physical units and per-pixel ground resolution is a + * straightforward division. + */ + +/** + * WGS84 equatorial radius in meters. Matches what every standard tile + * provider (OSM, CartoDB, Mapbox, ...) uses for Web Mercator. + */ +export const EARTH_RADIUS_M = 6378137; + +/** + * Half the world extent in Mercator meters: π · R ≈ 20037508.34. The + * full Mercator square is `[-WORLD_HALF, +WORLD_HALF]` on both axes. + */ +export const WORLD_HALF = Math.PI * EARTH_RADIUS_M; + +/** + * Maximum absolute latitude representable in Web Mercator. Beyond this + * the projection diverges to ±∞; tile providers don't ship tiles + * outside [-MAX_LAT, +MAX_LAT]. Computed as + * `atan(sinh(π)) · 180 / π ≈ 85.0511287798°`. + */ +export const MAX_LAT = 85.0511287798066; + +/** + * Project (longitude, latitude) in degrees to Web Mercator meters. + * + * Latitudes outside ±MAX_LAT return `[NaN, NaN]` so callers in the + * cartesian build path (which already has a post-`projectPoint` NaN + * guard) discard those rows without special-casing. + */ +export function lonLatToMercator(lon: number, lat: number): [number, number] { + if (lat > MAX_LAT || lat < -MAX_LAT) { + return [NaN, NaN]; + } + + const x = (lon * Math.PI * EARTH_RADIUS_M) / 180; + const latRad = (lat * Math.PI) / 180; + const y = EARTH_RADIUS_M * Math.log(Math.tan(Math.PI / 4 + latRad / 2)); + return [x, y]; +} + +/** + * Inverse: Mercator meters → (lon, lat) in degrees. Used by tooltip + * formatting and any UI that surfaces the cursor position to the user. + */ +export function mercatorToLonLat(x: number, y: number): [number, number] { + const lon = (x * 180) / (Math.PI * EARTH_RADIUS_M); + const lat = (Math.atan(Math.exp(y / EARTH_RADIUS_M)) * 360) / Math.PI - 90; + return [lon, lat]; +} + +/** + * A single XYZ tile address. + */ +export interface TileId { + z: number; + x: number; + y: number; +} + +/** + * Mercator extent in meters of one XYZ tile. Y is in the Mercator + * convention (north positive), not the tile-pyramid convention (y=0 + * at the top); the conversion is done inside the helper. + */ +export interface TileExtent { + xMin: number; + yMin: number; + xMax: number; + yMax: number; +} + +/** + * Return the Mercator-meter bounds of an XYZ tile. Tile (0, 0) at + * zoom 0 covers `[-WORLD_HALF, +WORLD_HALF]` on both axes — the whole + * world. Each zoom level subdivides into `2^z × 2^z` equal squares. + */ +export function tileExtent(z: number, x: number, y: number): TileExtent { + const n = 1 << z; + const tileSize = (2 * WORLD_HALF) / n; + const xMin = -WORLD_HALF + x * tileSize; + const xMax = xMin + tileSize; + // Tile y=0 sits at the *top* of the pyramid (north), so flip. + const yMax = WORLD_HALF - y * tileSize; + const yMin = yMax - tileSize; + return { xMin, yMin, xMax, yMax }; +} + +/** + * Pick the integer zoom level whose tile pixel resolution best matches + * the requested Mercator-meters-per-pixel. Snaps to the next coarser + * level so we never undersample (a finer level would fetch tiles only + * to scale them down). + * + * `targetResolutionMpp` is meters per *device pixel*; the caller + * computes it as `(domain.xMax - domain.xMin) / plotRect.width`. + */ +export function pickZoom( + targetResolutionMpp: number, + tileSizePx = 256, + maxZoom = 19, +): number { + if (!isFinite(targetResolutionMpp) || targetResolutionMpp <= 0) { + return 0; + } + + // Resolution at zoom z = (2·WORLD_HALF) / (tileSizePx · 2^z). + // Solve for z; floor so we stay at the next coarser level when in + // between two levels. + const z = Math.log2((2 * WORLD_HALF) / (tileSizePx * targetResolutionMpp)); + return Math.max(0, Math.min(maxZoom, Math.floor(z))); +} + +/** + * Enumerate every visible tile at a single zoom level that intersects + * the given Mercator extent. Returned in left-to-right, top-to-bottom + * order so the layer's render loop produces a deterministic draw + * sequence (helps with debugging tile-load races). + * + * Tiles outside the world's `[0, 2^z)` X range are *not* wrapped — + * antimeridian wraparound is a v2 feature. Callers see a gap when + * panning past ±180° lon; tiles inside the valid range still render. + */ +export function tilesForExtent(extent: TileExtent, z: number): TileId[] { + const n = 1 << z; + const tileSize = (2 * WORLD_HALF) / n; + + // Tile X grows east; tile Y grows south. Convert the extent's + // bounds accordingly. + const xMinTile = Math.floor((extent.xMin + WORLD_HALF) / tileSize); + const xMaxTile = Math.floor((extent.xMax + WORLD_HALF) / tileSize); + const yMinTile = Math.floor((WORLD_HALF - extent.yMax) / tileSize); + const yMaxTile = Math.floor((WORLD_HALF - extent.yMin) / tileSize); + + const result: TileId[] = []; + for (let ty = yMinTile; ty <= yMaxTile; ty++) { + if (ty < 0 || ty >= n) { + continue; + } + + for (let tx = xMinTile; tx <= xMaxTile; tx++) { + if (tx < 0 || tx >= n) { + continue; + } + + result.push({ z, x: tx, y: ty }); + } + } + + return result; +} + +/** + * Return the parent tile (one zoom level coarser) of the given tile, + * along with the [0, 1] UV sub-rect that this tile occupies inside + * its parent. Used by the layer's "draw what we have" fallback while + * a missing target tile is in-flight — the parent texture is sampled + * with the sub-rect so the visible region keeps tile-aligned content + * instead of flashing blank. + * + * Returns `null` for zoom-0 tiles (no parent). + */ +export function parentTile(tile: TileId): { + parent: TileId; + uvMin: [number, number]; + uvMax: [number, number]; +} | null { + if (tile.z <= 0) { + return null; + } + + const parent: TileId = { + z: tile.z - 1, + x: tile.x >> 1, + y: tile.y >> 1, + }; + + const u = tile.x & 1; + const v = tile.y & 1; + const uvMin: [number, number] = [u * 0.5, v * 0.5]; + const uvMax: [number, number] = [uvMin[0] + 0.5, uvMin[1] + 0.5]; + return { parent, uvMin, uvMax }; +} diff --git a/packages/viewer-charts/src/ts/map/tile-cache.ts b/packages/viewer-charts/src/ts/map/tile-cache.ts new file mode 100644 index 0000000..439b6a2 --- /dev/null +++ b/packages/viewer-charts/src/ts/map/tile-cache.ts @@ -0,0 +1,96 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Bounded LRU of `WebGLTexture` keyed by tile cache key (see + * `tile-loader.tileKey`). Evicts least-recently-touched entries on + * insert past `capacity`, deleting their GPU textures. + * + * Default capacity of 256 is enough to cover a full screen worth of + * tiles at zoom-1 jumps either side of the current viewport plus the + * parent fallbacks — at 256×256 RGBA that's ~64 MB of texture memory, + * which sits comfortably under the 256 MB headroom most browsers + * grant to a tab. + */ +export class TileCache { + private _entries = new Map(); + private readonly _capacity: number; + + constructor(capacity = 256) { + this._capacity = capacity; + } + + /** + * Fetch a texture by key. Touching an existing entry moves it to + * the LRU tail so it survives the next eviction sweep. + */ + get(key: string): WebGLTexture | undefined { + const tex = this._entries.get(key); + if (tex !== undefined) { + // Re-insert to push the entry to the tail. + this._entries.delete(key); + this._entries.set(key, tex); + } + + return tex; + } + + /** + * Insert a texture under `key`. If the cache is at capacity, evict + * the oldest entry first (calling `gl.deleteTexture` on its GPU + * resource). + */ + set( + gl: WebGL2RenderingContext | WebGLRenderingContext, + key: string, + texture: WebGLTexture, + ): void { + if (this._entries.has(key)) { + const old = this._entries.get(key)!; + gl.deleteTexture(old); + this._entries.delete(key); + } + + this._entries.set(key, texture); + while (this._entries.size > this._capacity) { + const oldestKey = this._entries.keys().next().value; + if (oldestKey === undefined) { + break; + } + + const tex = this._entries.get(oldestKey)!; + gl.deleteTexture(tex); + this._entries.delete(oldestKey); + } + } + + /** + * Whether a key is resident. Used to gate the "kick off a fetch" + * branch in the layer's render loop. + */ + has(key: string): boolean { + return this._entries.has(key); + } + + /** + * Release every texture. Called on chart destroy. Safe to call + * with a stale `gl` reference (no-op if `deleteTexture` rejects), + * but in practice the caller passes the still-live worker context. + */ + dispose(gl: WebGL2RenderingContext | WebGLRenderingContext): void { + for (const tex of this._entries.values()) { + gl.deleteTexture(tex); + } + + this._entries.clear(); + } +} diff --git a/packages/viewer-charts/src/ts/map/tile-layer.ts b/packages/viewer-charts/src/ts/map/tile-layer.ts new file mode 100644 index 0000000..0e49c7d --- /dev/null +++ b/packages/viewer-charts/src/ts/map/tile-layer.ts @@ -0,0 +1,382 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { WebGLContextManager } from "../webgl/context-manager"; +import type { PlotLayout } from "../layout/plot-layout"; +import { pickZoom, tilesForExtent, tileExtent, type TileId } from "./mercator"; +import { TileCache } from "./tile-cache"; +import { TileLoader, tileKey } from "./tile-loader"; +import type { TileSource } from "./tile-source"; +import tileVert from "../shaders/tile.vert.glsl"; +import tileFrag from "../shaders/tile.frag.glsl"; +import { compileProgram } from "../webgl/program-cache"; + +type GL = WebGL2RenderingContext | WebGLRenderingContext; + +interface TileProgramCache { + program: WebGLProgram; + u_projection: WebGLUniformLocation | null; + u_extent_min: WebGLUniformLocation | null; + u_extent_max: WebGLUniformLocation | null; + u_uv_min: WebGLUniformLocation | null; + u_uv_max: WebGLUniformLocation | null; + u_tile: WebGLUniformLocation | null; + u_alpha: WebGLUniformLocation | null; + a_corner: number; +} + +/** + * Renders an XYZ raster tile basemap into the chart's webgl canvas + * inside the plot rect's scissor. Used by `MapChart` from inside + * `renderInPlotFrame`, before the glyph draw, so chart glyphs (point, + * line, density) composite naturally on top of the basemap. + * + * The layer owns: + * - The tile shader program (compiled once per WebGL context). + * - A unit-quad corner buffer (one 2-byte attribute, reused per + * tile). + * - A `TileCache` (LRU of `WebGLTexture`). + * - A `TileLoader` (async fetch + dedup). + * + * On each render: pick the integer zoom that matches the requested + * meters-per-pixel, enumerate visible tiles at that zoom, and draw + * each one — either with the loaded texture or with a parent + * texture's sub-rect while the target is in flight. + */ +export class TileLayer { + private _program: TileProgramCache | null = null; + private _cornerBuffer: WebGLBuffer | null = null; + private _cache = new TileCache(); + private _loader = new TileLoader(); + private _source: TileSource | null = null; + private _alpha = 1.0; + private _onTileLoad: () => void = () => {}; + + /** + * Hook the "tile arrived" notification through to the chart's + * render scheduler. Called once when the layer is constructed + * by `MapChart`. + */ + setOnTileLoad(cb: () => void): void { + this._onTileLoad = cb; + this._loader.setOnLoad(cb); + } + + /** + * Swap the tile source (e.g. light ↔ dark theme). Drops the cache + * because the cached textures came from the prior source's URLs. + */ + setSource(gl: GL, source: TileSource): void { + if (this._source?.id === source.id) { + return; + } + + this._cache.dispose(gl); + this._loader.cancelAll(); + this._source = source; + } + + setAlpha(alpha: number): void { + this._alpha = Math.max(0, Math.min(1, alpha)); + } + + get source(): TileSource | null { + return this._source; + } + + /** + * Render the basemap for the current visible Mercator extent. + * Caller is responsible for binding the chart's main framebuffer + * (the plot-frame scissor is already in place by the time we get + * here). The same `projection` matrix the glyph draw uses is + * passed straight through. + */ + render( + glManager: WebGLContextManager, + layout: PlotLayout, + projection: Float32Array, + domain: { xMin: number; xMax: number; yMin: number; yMax: number }, + xOrigin: number, + yOrigin: number, + ): void { + const source = this._source; + if (!source) { + return; + } + + this._ensureProgram(glManager); + const prog = this._program; + const cornerBuf = this._cornerBuffer; + if (!prog || !cornerBuf) { + return; + } + + const gl = glManager.gl; + const dpr = glManager.dpr; + const plotWidth = Math.max(1, layout.plotRect.width * dpr); + const xRange = domain.xMax - domain.xMin; + if (!isFinite(xRange) || xRange <= 0) { + return; + } + + const mpp = xRange / plotWidth; + const z = pickZoom(mpp, source.tileSize, source.maxZoom); + const visible = tilesForExtent(domain, z); + + // Cancel any in-flight fetches for old zooms / off-screen + // tiles. We compute the live key set up-front so the loader + // doesn't keep tickling `requestRender` after the user pans + // past a slow-loading region. + const liveKeys = new Set(); + for (const t of visible) { + liveKeys.add(tileKey(source.id, t.z, t.x, t.y)); + } + + this._loader.cancelExcept(liveKeys); + + // Setup the shader program + static unit-quad buffer once per + // frame. Inside the loop only the per-tile uniforms change. + gl.useProgram(prog.program); + gl.uniformMatrix4fv(prog.u_projection, false, projection); + gl.uniform1f(prog.u_alpha, this._alpha); + gl.bindBuffer(gl.ARRAY_BUFFER, cornerBuf); + gl.enableVertexAttribArray(prog.a_corner); + gl.vertexAttribPointer(prog.a_corner, 2, gl.FLOAT, false, 0, 0); + gl.activeTexture(gl.TEXTURE0); + gl.uniform1i(prog.u_tile, 0); + + // Tiles are opaque; use the simplest blend mode so the + // glyph layer (drawn next in `_fullRender`) lands on top + // naturally without weird premultiplied tricks. + const wasBlend = gl.isEnabled(gl.BLEND); + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + + for (const tile of visible) { + this._drawTile(gl, prog, source, tile, xOrigin, yOrigin); + } + + if (!wasBlend) { + gl.disable(gl.BLEND); + } + } + + /** + * Free every GPU resource. Called from `MapChart.destroyInternal`. + */ + destroy(gl: GL): void { + this._loader.cancelAll(); + this._cache.dispose(gl); + if (this._cornerBuffer) { + gl.deleteBuffer(this._cornerBuffer); + this._cornerBuffer = null; + } + + this._program = null; + } + + private _drawTile( + gl: GL, + prog: TileProgramCache, + source: TileSource, + tile: TileId, + xOrigin: number, + yOrigin: number, + ): void { + // Subtract the chart's rebase origin so the position matches + // the convention glyphs use (`_xData = absX - xOrigin`). The + // shared projection matrix bakes in `xOrigin/yOrigin` and + // would otherwise shift tiles by `sx*xOrigin` clip units — + // for Mercator-scale origins (~1e7 m), well off-screen. + const rawExtent = tileExtent(tile.z, tile.x, tile.y); + const extent = { + xMin: rawExtent.xMin - xOrigin, + xMax: rawExtent.xMax - xOrigin, + yMin: rawExtent.yMin - yOrigin, + yMax: rawExtent.yMax - yOrigin, + }; + const key = tileKey(source.id, tile.z, tile.x, tile.y); + + const tex = this._cache.get(key); + if (tex) { + this._issueDraw(gl, prog, tex, extent, [0, 0], [1, 1]); + return; + } + + // Cache miss: kick off async load (idempotent — loader dedups). + // No await — we paint a fallback this frame and the chart will + // re-render when the texture arrives. + this._kickLoad(gl, source, tile); + + // Walk up the pyramid up to 6 levels looking for any loaded + // ancestor. Each level halves the UV sub-rect that the target + // tile occupies inside the ancestor; the math is direct from + // the tile coordinate bit-shift, no recursive accumulation. + for (let dz = 1; dz <= 6; dz++) { + const az = tile.z - dz; + if (az < 0) { + return; + } + + const ax = tile.x >> dz; + const ay = tile.y >> dz; + const ancestorKey = tileKey(source.id, az, ax, ay); + const ancestorTex = this._cache.get(ancestorKey); + if (!ancestorTex) { + continue; + } + + const n = 1 << dz; + const localX = tile.x - ax * n; + const localY = tile.y - ay * n; + const span = 1 / n; + const uvMin: [number, number] = [localX * span, localY * span]; + const uvMax: [number, number] = [uvMin[0] + span, uvMin[1] + span]; + this._issueDraw(gl, prog, ancestorTex, extent, uvMin, uvMax); + return; + } + } + + private _issueDraw( + gl: GL, + prog: TileProgramCache, + tex: WebGLTexture, + extent: { xMin: number; yMin: number; xMax: number; yMax: number }, + uvMin: [number, number], + uvMax: [number, number], + ): void { + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.uniform2f(prog.u_extent_min, extent.xMin, extent.yMin); + gl.uniform2f(prog.u_extent_max, extent.xMax, extent.yMax); + gl.uniform2f(prog.u_uv_min, uvMin[0], uvMin[1]); + gl.uniform2f(prog.u_uv_max, uvMax[0], uvMax[1]); + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); + } + + private _kickLoad(gl: GL, source: TileSource, tile: TileId): void { + const key = tileKey(source.id, tile.z, tile.x, tile.y); + if (this._cache.has(key)) { + return; + } + + this._loader.load(source, tile.z, tile.x, tile.y).then((bmp) => { + if (!bmp) { + return; + } + + // The chart may have switched sources between launch and + // resolve; drop the bitmap if so. + if (this._source?.id !== source.id) { + bmp.close(); + return; + } + + const tex = gl.createTexture(); + if (!tex) { + bmp.close(); + return; + } + + // Anchor the upload to a known texture unit. Without + // this the upload binds to whatever unit was last + // active (gradient LUT lives at TEXTURE2, etc.), which + // is harmless for the upload itself but easy to confuse + // with the sampling path during debugging. + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri( + gl.TEXTURE_2D, + gl.TEXTURE_WRAP_S, + gl.CLAMP_TO_EDGE, + ); + gl.texParameteri( + gl.TEXTURE_2D, + gl.TEXTURE_WRAP_T, + gl.CLAMP_TO_EDGE, + ); + + // Pin pixel-store flags. Workers and main threads start + // with the WebGL spec defaults, but other parts of the + // chart (or future extensions) may flip + // `UNPACK_PREMULTIPLY_ALPHA_WEBGL` or + // `UNPACK_FLIP_Y_WEBGL` and not restore them. Set them + // explicitly so the upload result is deterministic. + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + gl.RGBA, + gl.UNSIGNED_BYTE, + bmp, + ); + + // Do *not* `bmp.close()` here. The WebGL spec says + // `texImage2D(ImageBitmap)` consumes the source at call + // time, but several browser/driver combinations defer + // the actual GPU copy until the next draw; closing the + // bitmap before that copy lands leaves the texture + // valid-but-empty and the sampler returns (0,0,0,1) — + // i.e. solid black — for every tile after the first one + // or two whose upload happened to drain in time. The + // ImageBitmap is small (≤256×256×4 ≈ 256 KB) and will + // be garbage-collected once the .then closure is + // released. + this._cache.set(gl, key, tex); + this._onTileLoad(); + }); + } + + private _ensureProgram(glManager: WebGLContextManager): void { + if (this._program && this._cornerBuffer) { + return; + } + + const gl = glManager.gl; + this._program = compileProgram( + glManager, + "map-tile", + tileVert, + tileFrag, + [ + "u_projection", + "u_extent_min", + "u_extent_max", + "u_uv_min", + "u_uv_max", + "u_tile", + "u_alpha", + ], + ["a_corner"], + ); + + const buf = gl.createBuffer(); + if (!buf) { + return; + } + + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + // Unit-quad corners in TRIANGLE_STRIP order: + // (0,0) (1,0) (0,1) (1,1) + // Stretched into Mercator space by `u_extent_*` uniforms in + // the vertex shader; UV picked by `u_uv_*`. + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), + gl.STATIC_DRAW, + ); + this._cornerBuffer = buf; + } +} diff --git a/packages/viewer-charts/src/ts/map/tile-loader.ts b/packages/viewer-charts/src/ts/map/tile-loader.ts new file mode 100644 index 0000000..401e922 --- /dev/null +++ b/packages/viewer-charts/src/ts/map/tile-loader.ts @@ -0,0 +1,143 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { TileSource } from "./tile-source"; + +/** + * One in-flight tile fetch. The same key (z/x/y under one source) only + * launches one fetch; concurrent requesters share the promise. + */ +interface InFlight { + promise: Promise; + abort: AbortController; +} + +/** + * Async tile fetcher with in-flight dedup and abort-on-zoom-change. + * + * Runs inside the renderer worker (the chart's host process). `fetch` + * and `createImageBitmap` are both available in workers, and the + * resulting `ImageBitmap` can be uploaded straight to a WebGL2 texture + * via `gl.texImage2D(..., ImageBitmap)` — no main-thread bounce. + * + * The loader does not own GPU resources. Upload to texture happens in + * the tile layer once an `ImageBitmap` resolves; the layer then drops + * the bitmap (calling `close()` to free the decoded pixels). + */ +export class TileLoader { + private _inFlight = new Map(); + private _onLoad: () => void = () => {}; + + /** + * Register the "tile arrived" callback. The layer wires this to + * `chart.requestRender(glManager)` so a newly-loaded tile triggers + * exactly one extra frame. + */ + setOnLoad(cb: () => void): void { + this._onLoad = cb; + } + + /** + * Kick off a fetch (or return the in-flight one). The same + * (source.id, z, x, y) tuple only ever has one outstanding + * fetch; multiple callers share the result. Rejected fetches + * (network error, abort) resolve to `null` so callers can skip + * the tile without try/catch noise on every miss. + */ + load( + source: TileSource, + z: number, + x: number, + y: number, + ): Promise { + const key = tileKey(source.id, z, x, y); + const existing = this._inFlight.get(key); + if (existing) { + return existing.promise; + } + + const abort = new AbortController(); + const url = source.urlFor(z, x, y); + const promise = this._fetchAndDecode(url, abort.signal) + .then((bmp) => { + this._inFlight.delete(key); + if (bmp) { + this._onLoad(); + } + + return bmp; + }) + .catch(() => { + this._inFlight.delete(key); + return null; + }); + + this._inFlight.set(key, { promise, abort }); + return promise; + } + + /** + * Abort every in-flight fetch. Called on view teardown / chart + * destroy. Fetches whose `Response` has already arrived but + * haven't yet decoded will still complete the decode and resolve + * to `null` (because `_onLoad` is replaced or the cache is gone) + * — harmless, no resource leak. + */ + cancelAll(): void { + for (const entry of this._inFlight.values()) { + entry.abort.abort(); + } + + this._inFlight.clear(); + } + + /** + * Abort just the fetches whose key isn't in the supplied set. + * The layer calls this on every render with the currently-visible + * tile set so old-zoom requests don't keep arriving and triggering + * spurious re-renders after the user has moved on. + */ + cancelExcept(liveKeys: Set): void { + for (const [key, entry] of this._inFlight) { + if (!liveKeys.has(key)) { + entry.abort.abort(); + this._inFlight.delete(key); + } + } + } + + private async _fetchAndDecode( + url: string, + signal: AbortSignal, + ): Promise { + const resp = await fetch(url, { signal }); + if (!resp.ok) { + return null; + } + + const blob = await resp.blob(); + return await createImageBitmap(blob); + } +} + +/** + * Stable cache key for a tile under a given source. Embedded source id + * so swapping sources (light/dark) doesn't surface stale tiles. + */ +export function tileKey( + sourceId: string, + z: number, + x: number, + y: number, +): string { + return `${sourceId}/${z}/${x}/${y}`; +} diff --git a/packages/viewer-charts/src/ts/map/tile-source.ts b/packages/viewer-charts/src/ts/map/tile-source.ts new file mode 100644 index 0000000..bf68fe8 --- /dev/null +++ b/packages/viewer-charts/src/ts/map/tile-source.ts @@ -0,0 +1,156 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * A tile source describes *where* to fetch raster XYZ tiles and what + * attribution text the renderer must display in the chrome canvas. + * Implementations are stateless — the tile loader handles caching and + * in-flight dedup. + */ +export interface TileSource { + /** + * Build the URL for tile (z, x, y). Implementations typically + * substitute a template like `{z}/{x}/{y}` and may rotate + * subdomains for browsers that throttle concurrent connections + * per host. + */ + urlFor(z: number, x: number, y: number): string; + + /** + * Plain-text attribution shown in the bottom-right of the chrome + * canvas. Required by every major tile provider's terms of use — + * do not suppress it without provider-side opt-out. + */ + readonly attribution: string; + + /** + * Side length of one tile in pixels. Tile providers ship 256 by + * default; a few offer 512 (`@2x`) variants. Used by the zoom- + * level picker to convert meters-per-pixel into a zoom level. + */ + readonly tileSize: number; + + /** + * Maximum zoom level the provider serves. Tiles requested above + * this fall back to the deepest available level with sub-tile + * UVs — same trick used during async loads. + */ + readonly maxZoom: number; + + /** + * Stable identifier for caching. Two `TileSource` instances with + * the same `id` share a tile cache; switching source (e.g. theme + * change) invalidates and re-fetches. + */ + readonly id: string; +} + +/** + * Subdomain-rotated URL template. Replaces `{s}` with one of the + * provided subdomains hashed by `(x + y)`, and `{z}`, `{x}`, `{y}` + * with the tile address. Most major tile providers fit this shape. + */ +export class TemplatedTileSource implements TileSource { + constructor( + readonly id: string, + private readonly template: string, + readonly attribution: string, + readonly tileSize = 256, + readonly maxZoom = 19, + private readonly subdomains: readonly string[] = [], + ) {} + + urlFor(z: number, x: number, y: number): string { + let url = this.template + .replace("{z}", String(z)) + .replace("{x}", String(x)) + .replace("{y}", String(y)); + if (this.subdomains.length > 0) { + const idx = (x + y) % this.subdomains.length; + url = url.replace("{s}", this.subdomains[idx]); + } + + return url; + } +} + +/** + * Identifier of the default tile providers shipped with `viewer-charts`. + * Surfaced as the `map_tile_provider` PluginConfig enum so users can + * pick light vs. dark vs. labels-only without writing a custom source. + */ +export type TileProviderId = + | "carto-positron" + | "carto-dark-matter" + | "carto-voyager"; + +/** + * CartoDB's "Positron" basemap — light, low-contrast, designed to sit + * behind a chart overlay. Default for light themes. + */ +function cartoPositron(): TileSource { + return new TemplatedTileSource( + "carto-positron", + "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", + "© OpenStreetMap contributors © CARTO", + 256, + 19, + ["a", "b", "c", "d"], + ); +} + +/** + * CartoDB's "Dark Matter" basemap — dark, low-contrast. Default for + * dark themes. + */ +function cartoDarkMatter(): TileSource { + return new TemplatedTileSource( + "carto-dark-matter", + "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png", + "© OpenStreetMap contributors © CARTO", + 256, + 19, + ["a", "b", "c", "d"], + ); +} + +/** + * CartoDB's "Voyager" basemap — full color, more land/water contrast + * than Positron. Good when the chart glyphs are translucent. + */ +function cartoVoyager(): TileSource { + return new TemplatedTileSource( + "carto-voyager", + "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png", + "© OpenStreetMap contributors © CARTO", + 256, + 19, + ["a", "b", "c", "d"], + ); +} + +/** + * Resolve a `TileProviderId` (from PluginConfig) to a concrete + * `TileSource`. Unknown ids fall back to Positron so a misconfigured + * `_pluginConfig` never produces a blank map. + */ +export function tileSourceFor(id: TileProviderId | string): TileSource { + switch (id) { + case "carto-dark-matter": + return cartoDarkMatter(); + case "carto-voyager": + return cartoVoyager(); + case "carto-positron": + default: + return cartoPositron(); + } +} diff --git a/packages/viewer-charts/src/ts/plugin/charts.ts b/packages/viewer-charts/src/ts/plugin/charts.ts new file mode 100644 index 0000000..a983086 --- /dev/null +++ b/packages/viewer-charts/src/ts/plugin/charts.ts @@ -0,0 +1,286 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { PluginConfig } from "../charts/chart"; + +export type ChartType = "bar" | "line" | "scatter" | "area"; + +export type PluginChartType = ChartType | "candlestick" | "ohlc"; + +/** + * Subset of `PluginConfig` keys that a chart impl actually consumes. + * Drives `plugin_config_schema()` filtering — the host only renders + * the controls listed here, and `plugin.restore({ plugin_config })` + * still hands the full struct to the worker (other keys are inert). + */ +export type PluginConfigField = keyof PluginConfig; + +export interface ChartTypeConfig { + name: string; + tag: string; + category: string; + selectMode: "select" | "toggle"; + initial: { + count: number; + names: string[]; + }; + max_cells: number; + max_columns: number; + default_chart_type?: PluginChartType; + + /** + * Plugin-config keys this chart type renders controls for. Empty + * for plugins with no global settings (heatmap / treemap / + * sunburst). See {@link PluginConfig} for field semantics. + */ + applicable_plugin_fields: readonly PluginConfigField[]; + + /** + * Per-chart-type overrides for `DEFAULT_PLUGIN_CONFIG`. Used when a + * field's sensible default differs by chart family — currently + * `include_zero` (true for Y Bar / Y Area / X Bar, false for line + * / scatter / cartesian / financial). Applied at schema generation + * and at `restore({})` so the effective default matches the + * surfaced UI default. + */ + plugin_field_defaults?: Partial; +} + +const SERIES = "Series Charts"; +const CART = "Cartesian Charts"; +const HIER = "Hierarchical Charts"; +const FIN = "Financial Charts"; +const MAP = "Map Charts"; +const X_AXIS = ["X Axis"]; +const Y_AXIS = ["Y Axis"]; +const SELECT = "select"; +const TOGGLE = "toggle"; + +const DEFAULT_MAX_CELLS = 2_000_000; +const DEFAULT_MAX_COLUMNS = 10_000; + +// Plugin-config field sets, by chart family. +// +// Series charts paint bars / lines / scatter / area glyphs (selected +// per-column via `chart_type`), so the union covers every glyph that +// might appear. `auto_alt_y_axis` + `series_zoom_mode` are Series-only. +const SERIES_FIELDS: readonly PluginConfigField[] = [ + "auto_alt_y_axis", + "facet_mode", + "series_zoom_mode", + "include_zero", + "domain_mode", + "line_width_px", + "point_size_px", + "band_inner_frac", + "bar_inner_pad", +]; + +// Bar / area series glyphs grow from the zero baseline, so the value +// axis must enclose 0 to render correctly. Line / scatter glyphs have +// no such constraint — their default `include_zero` stays `false`. +const ZERO_ANCHORED_DEFAULTS: Partial = { include_zero: true }; + +// Pure Cartesian (X/Y Scatter, X/Y Line) — no categorical axis, so no +// band geometry; gets the facet-routing variant of zoom_mode. +const CARTESIAN_FIELDS: readonly PluginConfigField[] = [ + "facet_mode", + "facet_zoom_mode", + "domain_mode", + "line_width_px", + "point_size_px", +]; + +// Candlestick/OHLC share the categorical-X build pipeline (band slots) +// and add their two stroke widths. +const FIN_FIELDS: readonly PluginConfigField[] = [ + "facet_mode", + "series_zoom_mode", + "domain_mode", + "band_inner_frac", + "bar_inner_pad", + "wick_width_px", + "ohlc_line_width_px", +]; + +// Hierarchical — none of the listed fields apply. +const NO_FIELDS: readonly PluginConfigField[] = []; + +// Heatmap +const HEATMAP_FIELDS: readonly PluginConfigField[] = ["facet_zoom_mode"]; + +// Map — reuses the cartesian build pipeline with a Mercator +// projection hook. Carries the basemap controls (`map_tile_provider`, +// `map_tile_alpha`) plus the relevant glyph-styling fields the +// underlying chart type already uses. Density-on-map adds the four +// gradient knobs on top. +const MAP_BASE_FIELDS: readonly PluginConfigField[] = [ + "facet_mode", + "facet_zoom_mode", + "domain_mode", + "map_tile_provider", + "map_tile_alpha", +]; +const MAP_SCATTER_FIELDS: readonly PluginConfigField[] = [ + ...MAP_BASE_FIELDS, + "point_size_px", +]; +const MAP_LINE_FIELDS: readonly PluginConfigField[] = [ + ...MAP_BASE_FIELDS, + "line_width_px", +]; +const MAP_DENSITY_FIELDS: readonly PluginConfigField[] = [ + ...MAP_BASE_FIELDS, + "gradient_color_mode", + "gradient_radius_px", + "gradient_intensity", + "gradient_heat_max", +]; + +// Density — shares the cartesian build pipeline (X/Y numeric +// with an optional Color column), then routes through the density-field +// glyph. Reuses the cartesian facet/zoom controls and adds the three +// shader-specific knobs. +const DENSITY_FIELDS: readonly PluginConfigField[] = [ + "facet_mode", + "facet_zoom_mode", + "domain_mode", + "gradient_color_mode", + "gradient_radius_px", + "gradient_intensity", + "gradient_heat_max", +]; + +function make( + name: string, + tag: string, + category: string, + selectMode: "select" | "toggle", + count: number, + names: readonly string[], + applicable_plugin_fields: readonly PluginConfigField[], + overrides?: Partial< + Pick< + ChartTypeConfig, + | "max_cells" + | "max_columns" + | "default_chart_type" + | "plugin_field_defaults" + > + >, +): ChartTypeConfig { + return { + name, + tag, + category, + selectMode, + initial: { count, names: names as string[] }, + max_cells: overrides?.max_cells ?? DEFAULT_MAX_CELLS, + max_columns: overrides?.max_columns ?? DEFAULT_MAX_COLUMNS, + applicable_plugin_fields, + ...(overrides?.default_chart_type + ? { default_chart_type: overrides.default_chart_type } + : {}), + ...(overrides?.plugin_field_defaults + ? { plugin_field_defaults: overrides.plugin_field_defaults } + : {}), + }; +} + +const FIN_NAMES = ["Open", "Close", "High", "Low", "Tooltip"]; +const HIER_NAMES = ["Size", "Color", "Tooltip"]; + +const CHARTS: ChartTypeConfig[] = [ + make("X Bar", "x-bar", SERIES, SELECT, 1, X_AXIS, SERIES_FIELDS, { + default_chart_type: "bar", + plugin_field_defaults: ZERO_ANCHORED_DEFAULTS, + }), + make("Y Bar", "y-bar", SERIES, SELECT, 1, Y_AXIS, SERIES_FIELDS, { + default_chart_type: "bar", + plugin_field_defaults: ZERO_ANCHORED_DEFAULTS, + }), + make("Y Line", "y-line", SERIES, SELECT, 1, Y_AXIS, SERIES_FIELDS, { + default_chart_type: "line", + }), + make("Y Scatter", "y-scatter", SERIES, SELECT, 1, Y_AXIS, SERIES_FIELDS, { + default_chart_type: "scatter", + }), + make("Y Area", "y-area", SERIES, SELECT, 1, Y_AXIS, SERIES_FIELDS, { + default_chart_type: "area", + plugin_field_defaults: ZERO_ANCHORED_DEFAULTS, + }), + make( + "X/Y Scatter", + "scatter", + CART, + TOGGLE, + 2, + ["X Axis", "Y Axis", "Color", "Size", "Label", "Tooltip"], + CARTESIAN_FIELDS, + ), + make( + "X/Y Line", + "line", + CART, + SELECT, + 2, + ["X Axis", "Y Axis", "Tooltip"], + CARTESIAN_FIELDS, + ), + make( + "Density", + "density", + CART, + TOGGLE, + 2, + ["X Axis", "Y Axis", "Color", "Tooltip"], + DENSITY_FIELDS, + ), + make("Treemap", "treemap", HIER, TOGGLE, 1, HIER_NAMES, NO_FIELDS), + make("Sunburst", "sunburst", HIER, TOGGLE, 1, HIER_NAMES, NO_FIELDS), + make("Heatmap", "heatmap", HIER, SELECT, 1, ["Color"], HEATMAP_FIELDS), + make("Candlestick", "candlestick", FIN, TOGGLE, 1, FIN_NAMES, FIN_FIELDS, { + default_chart_type: "candlestick", + }), + make("OHLC", "ohlc", FIN, TOGGLE, 1, FIN_NAMES, FIN_FIELDS, { + default_chart_type: "ohlc", + }), + make( + "Map Scatter", + "map-scatter", + MAP, + TOGGLE, + 2, + ["Longitude", "Latitude", "Color", "Size", "Label", "Tooltip"], + MAP_SCATTER_FIELDS, + ), + make( + "Map Line", + "map-line", + MAP, + SELECT, + 2, + ["Longitude", "Latitude", "Tooltip"], + MAP_LINE_FIELDS, + ), + make( + "Map Density", + "map-density", + MAP, + TOGGLE, + 2, + ["Longitude", "Latitude", "Color", "Tooltip"], + MAP_DENSITY_FIELDS, + ), +]; + +export default CHARTS; diff --git a/packages/viewer-charts/src/ts/plugin/plugin.ts b/packages/viewer-charts/src/ts/plugin/plugin.ts new file mode 100644 index 0000000..815cb91 --- /dev/null +++ b/packages/viewer-charts/src/ts/plugin/plugin.ts @@ -0,0 +1,648 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { View } from "@perspective-dev/client"; +import type { + HTMLPerspectiveViewerElement, + IPerspectiveViewerPlugin, + PluginStaticConfig, +} from "@perspective-dev/viewer"; +import { ChartTypeConfig, PluginConfigField } from "./charts"; +import style from "../../css/perspective-viewer-charts.css"; +import { + DEFAULT_FACET_CONFIG, + DEFAULT_PLUGIN_CONFIG, + type FacetConfig, + type PluginConfig, +} from "../charts/chart"; +import { RawEventForwarder } from "../interaction/raw-event-forwarder"; +import { RendererTransport } from "../transport/renderer-transport"; +import { RENDER_BLIT_MODE } from "../config"; + +/** + * Facet-rendering defaults shared by every chart. Per-chart overrides + * arrive through `plugin_config` (`facet_mode` + `facet_zoom_mode`); + * the remaining fields (`shared_x_axis`, `shared_y_axis`, + * `coordinated_tooltip`, `facet_padding`) are not yet user-configurable + * — flip the defaults in `DEFAULT_FACET_CONFIG` to change globally. + */ +const FACET_CONFIG_DEFAULTS: FacetConfig = { ...DEFAULT_FACET_CONFIG }; + +/** + * Static UI-control spec per `plugin_config` field. Mirrors the shape + * `column_config_schema` already returns (datagrid). The runtime default + * is sourced separately from the chart-type-effective defaults at + * `fieldSpec` call time so per-chart overrides like + * `include_zero=true` for Y Bar / Y Area / X Bar surface in the UI. + */ +type FieldSpec = + | { kind: "Bool" } + | { + kind: "Enum"; + variants: ReadonlyArray<{ value: string; label: string }>; + } + | { kind: "Number"; min: number; max: number; step?: number }; + +const FIELD_SCHEMAS: Record = { + auto_alt_y_axis: { kind: "Bool" }, + include_zero: { kind: "Bool" }, + domain_mode: { + kind: "Enum", + variants: [ + { value: "fit", label: "Fit" }, + { value: "expand", label: "Expand" }, + ], + }, + facet_mode: { + kind: "Enum", + variants: [ + { value: "grid", label: "Grid" }, + { value: "overlay", label: "Overlay" }, + ], + }, + facet_zoom_mode: { + kind: "Enum", + variants: [ + { value: "shared", label: "Shared" }, + { value: "independent", label: "Independent" }, + ], + }, + series_zoom_mode: { + kind: "Enum", + variants: [ + { value: "dynamic", label: "Dynamic" }, + { value: "fixed", label: "Fixed" }, + ], + }, + line_width_px: { kind: "Number", min: 0.5, step: 0.5, max: 16 }, + point_size_px: { kind: "Number", min: 1, max: 32 }, + band_inner_frac: { kind: "Number", min: 0.1, max: 1, step: 0.01 }, + bar_inner_pad: { kind: "Number", min: 0, max: 0.9, step: 0.01 }, + wick_width_px: { kind: "Number", min: 0.5, step: 0.5, max: 8 }, + ohlc_line_width_px: { kind: "Number", min: 0.5, step: 0.5, max: 8 }, + gradient_radius_px: { kind: "Number", min: 2, step: 1, max: 256 }, + gradient_intensity: { kind: "Number", min: 0.05, step: 0.05, max: 4 }, + gradient_heat_max: { kind: "Number", min: 0.1, step: 0.1, max: 64 }, + gradient_color_mode: { + kind: "Enum", + variants: [ + { value: "mean", label: "Mean (density-weighted)" }, + { value: "density", label: "Density only" }, + { value: "extreme", label: "Extremes" }, + { value: "signed", label: "Signed sum" }, + ], + }, + map_tile_provider: { + kind: "Enum", + variants: [ + { value: "carto-positron", label: "Light (Positron)" }, + { value: "carto-dark-matter", label: "Dark Matter" }, + { value: "carto-voyager", label: "Voyager" }, + ], + }, + map_tile_alpha: { kind: "Number", min: 0, max: 1, step: 0.05 }, +}; + +function fieldSpec( + key: PluginConfigField, + defaults: PluginConfig, +): Record & { kind: string } { + return { ...FIELD_SCHEMAS[key], key, default: defaults[key] }; +} + +const GLOBAL_STYLES = (() => { + const sheet = new CSSStyleSheet(); + sheet.replaceSync(style); + return [sheet]; +})(); + +/** + * Process-global GL presentation strategy, shared by *every* chart-type + * plugin in this renderer. Seeded from the build-time {@link RENDER_BLIT_MODE} + * and overridable at runtime via + * {@link HTMLPerspectiveViewerWebGLPluginElement.setBlitMode}. + * + * Module scope (not per-instance) on purpose: blit-vs-direct is a + * whole-renderer decision — in `"blit"` mode the worker shares a pool of + * GL contexts across all charts ([webgl/context-pool.ts]), so a page + * can't sensibly mix strategies per chart. Every per-chart-type subclass + * registered in [index.ts] reads this one value when it builds its + * renderer, so setting it once (before the first chart renders) applies + * to all of them. + */ +let BLIT_MODE: "direct" | "blit" = RENDER_BLIT_MODE; + +export class HTMLPerspectiveViewerWebGLPluginElement + extends HTMLElement + implements IPerspectiveViewerPlugin +{ + declare _chartType: ChartTypeConfig; + + private _initialized = false; + private _glCanvas!: HTMLCanvasElement; + private _gridlineCanvas!: HTMLCanvasElement; + private _chromeCanvas!: HTMLCanvasElement; + private _renderer: RendererTransport | null = null; + private _rendererPromise: Promise | null = null; + private _rawEventForwarder: RawEventForwarder | null = null; + private _generation = 0; + private _resetClickAbort: AbortController | null = null; + + /** + * Plugin-scoped global config. Seeded lazily from + * `_effectiveDefaults()` (which folds + * `_chartType.plugin_field_defaults` over `DEFAULT_PLUGIN_CONFIG`) + * because base-class field initializers run before the subclass + * `_chartType` assignment. `restore({ plugin_config })` merges + * incoming values on top of the same effective defaults so fields + * the host omits fall back to the chart-type default + * (`include_zero = true` for Y Bar / Y Area / X Bar, `false` + * elsewhere). Held on the element (not just inside the worker) so + * a `_buildRenderer` triggered after a `restore` ships the + * resolved values in the `InitMsg`. + */ + private _pluginConfigStore: PluginConfig | null = null; + + private get _pluginConfig(): PluginConfig { + if (!this._pluginConfigStore) { + this._pluginConfigStore = this._effectiveDefaults(); + } + + return this._pluginConfigStore; + } + + private set _pluginConfig(value: PluginConfig) { + this._pluginConfigStore = value; + } + + private _effectiveDefaults(): PluginConfig { + return { + ...DEFAULT_PLUGIN_CONFIG, + ...(this._chartType.plugin_field_defaults ?? {}), + }; + } + + connectedCallback() { + if (!this._initialized) { + this.attachShadow({ mode: "open" }); + for (const sheet of GLOBAL_STYLES) { + this.shadowRoot!.adoptedStyleSheets.push(sheet); + } + + this.shadowRoot!.innerHTML = + `
    ` + + `
    ` + + `` + + `
    ` + + `
    `; + + this._initialized = true; + } + + if (!this._glCanvas?.isConnected) { + this._buildCanvasStack(); + } + } + + private _buildCanvasStack(): void { + const container = this.shadowRoot!.querySelector(".webgl-container")!; + container.insertAdjacentHTML( + "afterbegin", + `` + + `` + + ``, + ); + + this._glCanvas = + container.querySelector(".webgl-canvas")!; + this._gridlineCanvas = + container.querySelector(".webgl-gridlines")!; + this._chromeCanvas = + container.querySelector(".webgl-chrome")!; + } + + private _clearCanvasStack(): void { + const container = this.shadowRoot?.querySelector(".webgl-container"); + if (container) { + for (const c of Array.from(container.querySelectorAll("canvas"))) { + c.remove(); + } + } + + this._glCanvas = null!; + this._gridlineCanvas = null!; + this._chromeCanvas = null!; + } + + /** + * Fires when the host (``) removes this plugin + * from the DOM on chart-type switch — see + * `renderer/activate.rs::remove_inactive_plugin`. Without this, + * inactive plugin instances retain their `RendererTransport` + * (worker + WebGL context + compiled shader programs) until the + * entire viewer is torn down, so a user cycling all 12 chart kinds + * holds 12 GL contexts per viewer and routinely exceeds the + * browser's per-page context cap (~16) in workspaces. + */ + disconnectedCallback() { + this.delete(); + this._clearCanvasStack(); + } + + /** + * Lazy renderer construction. Memoizes the in-flight `init()` + * promise so concurrent `draw()` calls during async setup await + * the same initialization rather than racing. + */ + private _ensureRenderer(view: View): Promise { + if (!this._initialized) { + this.connectedCallback(); + } + + if (this._rendererPromise) { + return this._rendererPromise; + } + + // `_buildRenderer` is async — it awaits `getClient`, `getTable`, + // and a full worker handshake — so a `disconnectedCallback` + // (toggle / chart-type switch) frequently lands *before* this + // `.then` runs. At that point `this._renderer` is still null, + // so `delete()`'s `if (this._renderer)` teardown is skipped and + // the freshly-built transport (and its WebGL context) would be + // assigned to a detached element and leak — one context per + // raced toggle, until the browser evicts the oldest. + // + // `delete()` sets `_rendererPromise = null`, so promise identity + // is the dispose signal: if `this._rendererPromise` no longer + // points at *this* build when it resolves, the element was + // deleted (or a reconnect started a newer build) and we destroy + // the orphan instead of adopting it. Promise identity — not + // `_generation`, which every `draw`/`update` bumps — is the + // right token: a rapid draw→update must NOT tear down the + // single in-flight build it shares via this memoized promise. + const p: Promise = this._buildRenderer(view).then( + (r) => { + if (this._rendererPromise !== p) { + r.destroy(); + throw new Error("renderer disposed during init"); + } + + this._renderer = r; + this._setupInteraction(r); + return r; + }, + ); + + // Swallow the dispose rejection so it doesn't surface as an + // unhandled rejection; `_drawImpl` catches it and bails. + p.catch(() => {}); + this._rendererPromise = p; + return p; + } + + /** + * Capture raw DOM events on the GL canvas with `RawEventForwarder` + * and post them over the control channel. The renderer dispatches + * them through its own resolver + `applyWheel` / `applyPan` for + * zoom/pan, and through `TooltipController` virtual dispatch for + * hover/click; `zoomChanged` updates push back so the reset-zoom + * button visibility tracks the renderer-side state. + * + * The `zoomChanged` callback was wired at `RendererTransport` + * construction time; here we just attach the event forwarder and + * the reset-button click handler. + */ + private _setupInteraction(renderer: RendererTransport): void { + if (this._rawEventForwarder) { + return; + } + + const zoomControls = this.shadowRoot!.querySelector( + ".zoom-controls", + ) as HTMLDivElement | null; + + this._rawEventForwarder = new RawEventForwarder(); + this._rawEventForwarder.attach(this._glCanvas, (event) => { + renderer.forwardInteraction(event); + }); + + const resetBtn = this.shadowRoot!.querySelector(".zoom-reset"); + if (resetBtn) { + this._resetClickAbort = new AbortController(); + resetBtn.addEventListener( + "click", + () => { + renderer.resetAllZooms(); + if (zoomControls) { + zoomControls.classList.remove("visible"); + } + }, + { signal: this._resetClickAbort.signal }, + ); + } + } + + private async _buildRenderer(view: View): Promise { + const viewer = this.parentElement as HTMLPerspectiveViewerElement; + const client = await viewer.getClient(); + const viewer_class = customElements.get("perspective-viewer"); + const clientWasm = viewer_class.get_wasm_module(); + const clientWorkerURL = viewer_class.get_worker_url(); + const table = await viewer?.getTable?.(); + const tableName: string | undefined = table + ? await table.get_name() + : undefined; + + const zoomControls = this.shadowRoot!.querySelector( + ".zoom-controls", + ) as HTMLDivElement | null; + + const transport = new RendererTransport({ + client, + view, + tableName, + clientWorkerURL, + clientWasm, + chartTag: this._chartType.tag, + maxCells: this._chartType.max_cells, + precompileShaders: true, + onZoomChanged: (isDefault: boolean) => { + if (zoomControls) { + zoomControls.classList.toggle("visible", !isDefault); + } + }, + }); + + await transport.init({ + gl: this._glCanvas, + gridlines: this._gridlineCanvas, + chrome: this._chromeCanvas, + facetConfig: { + ...FACET_CONFIG_DEFAULTS, + facet_mode: this._pluginConfig.facet_mode, + zoom_mode: this._pluginConfig.facet_zoom_mode, + }, + pluginConfig: this._pluginConfig, + defaultChartType: this._chartType.default_chart_type, + renderBlitMode: BLIT_MODE, + }); + + return transport; + } + + /** + * Select the GL presentation strategy for *all* chart-type plugins + * in this renderer. Static + process-global: the value is shared by + * every per-chart-type subclass, so it must be set once before the + * charts that should use it build their renderers (a renderer reads + * {@link BLIT_MODE} at construction in `_buildRenderer`; charts + * already built keep their mode until torn down and rebuilt). + * + * - `"direct"` — each chart owns a GL context 1:1 with its visible + * canvas (lowest latency; bounded by the browser's ~16-context + * cap). + * - `"blit"` — charts render off-screen and share a pool of GL + * contexts ([webgl/context-pool.ts]), so a page can exceed the cap. + */ + static setBlitMode(mode: "direct" | "blit") { + BLIT_MODE = mode; + } + + get_static_config(): PluginStaticConfig { + return { + name: this._chartType.name, + category: this._chartType.category, + select_mode: this._chartType.selectMode, + min_config_columns: this._chartType.initial.count, + config_column_names: this._chartType.initial.names, + max_cells: this._chartType.max_cells, + max_columns: this._chartType.max_columns, + group_rollup_modes: ["flat"], + priority: 0, + can_render_column_styles: + !!this._chartType.default_chart_type || + this._chartType.category === "Cartesian Charts", + }; + } + + column_config_schema( + column_type: string, + _group: string | undefined, + _column_name: string, + current_value: Record | null, + _viewer_config?: { group_by?: string[]; group_rollup_mode?: string }, + ) { + const fields: Array & { kind: string }> = []; + + // Y-series plugins expose the per-column chart_type picker; non-Y + // plugins leave `default_chart_type` unset. + const def = this._chartType.default_chart_type; + if (def && (column_type === "integer" || column_type === "float")) { + fields.push({ + kind: "Enum", + key: "chart_type", + default: def, + variants: [ + { value: "bar", label: "Bar" }, + { value: "line", label: "Line" }, + { value: "scatter", label: "Scatter" }, + { value: "area", label: "Area" }, + ], + }); + + const effective_chart_type = + (current_value?.chart_type as string | undefined) ?? def; + + const supports_stack = + effective_chart_type === "bar" || + effective_chart_type === "area"; + + if (supports_stack) { + fields.push({ + kind: "Bool", + key: "stack", + default: supports_stack, + }); + } + + const is_series_glyph = + def === "bar" || + def === "line" || + def === "scatter" || + def === "area"; + + if (is_series_glyph) { + fields.push({ + kind: "Bool", + key: "alt_axis", + default: false, + }); + } + + // Line / area glyphs can bridge interior nulls by linear + // interpolation. Bar / scatter ignore the flag. + const supports_interpolate = + effective_chart_type === "line" || + effective_chart_type === "area"; + + if (supports_interpolate) { + const variants = + effective_chart_type === "area" + ? [ + { value: "skip", label: "Skip" }, + { value: "solid", label: "Solid" }, + ] + : [ + { value: "skip", label: "Skip" }, + { value: "solid", label: "Solid" }, + { value: "transparent", label: "Transparent" }, + ]; + fields.push({ + kind: "Enum", + key: "interpolate", + default: "solid", + variants, + }); + } + } + + // Per-column formatter widgets. Surfaced for every chart type so + // axes / tooltips / legends honor the user's format choice. + if (column_type === "integer" || column_type === "float") { + fields.push({ kind: "NumberFormat" }); + } else if (column_type === "date" || column_type === "datetime") { + fields.push({ kind: "DatetimeFormat" }); + } + + return { fields }; + } + + plugin_config_schema(_view_config?: { + group_by?: string[]; + group_rollup_mode?: string; + }) { + const defaults = this._effectiveDefaults(); + const fields = this._chartType.applicable_plugin_fields.map((key) => + fieldSpec(key, defaults), + ); + + return { fields }; + } + + async draw(view: View): Promise { + // `draw` always indicates a view-level change (pivots, columns, + // filters, sorts, schema, …) — invalidate the `domain_mode: + // "expand"` accumulator so the new view's extent starts fresh. + // `update` (data-only redraw on the same view) shares + // `_drawImpl` but skips this reset. + this._renderer?.resetExpandedDomain(); + this._renderer?.resetAllZooms(); + return this._drawImpl(view); + } + + async update(view: View): Promise { + return this._drawImpl(view); + } + + private async _drawImpl(view: View): Promise { + const gen = ++this._generation; + let renderer: RendererTransport; + try { + renderer = await this._ensureRenderer(view); + } catch { + // Renderer was disposed mid-init (element disconnected + // during the async build) — nothing to draw. + return; + } + + if (this._generation !== gen) { + return; + } + + renderer.setView(view); + renderer.setBufferMaxCapacity(this._chartType.max_cells); + const viewer = this + .parentElement as HTMLPerspectiveViewerElement | null; + const viewerConfig = (await viewer?.getViewConfig?.()) ?? {}; + if (this._generation !== gen) { + return; + } + + await renderer.loadAndRender({ + viewerConfig: { + group_by: viewerConfig?.group_by ?? [], + split_by: viewerConfig?.split_by ?? [], + columns: viewerConfig?.columns ?? [], + }, + options: { float32: true }, + }); + } + + async clear(): Promise { + this._generation++; + this._renderer?.clear(); + } + + async resize(): Promise { + this._renderer?.resize(); + } + + restyle() { + this._renderer?.invalidateTheme(); + return 5; + } + + async render(view: View): Promise { + await this._ensureRenderer(view); + await this.draw(view); + return this._renderer!.snapshotPng(); + } + + restore(config: any, columns_config?: Record) { + if (config?.zoom) { + this._renderer?.restoreZoom(config.zoom); + } + + // Merge incoming plugin_config on top of the `chart_type` + // effective defaults so a partial restore (UI emits only + // changed fields) keeps untouched defaults in place — and + // chart-type overrides (e.g. `include_zero=true` for Y Bar / + // Y Area / X Bar) survive when the host elides their values. + this._pluginConfig = { + ...this._effectiveDefaults(), + ...config, + }; + + this._renderer?.setPluginConfig(this._pluginConfig); + this._renderer?.setColumnsConfig(columns_config ?? {}); + } + + delete() { + this._generation++; + if (this._rawEventForwarder) { + this._rawEventForwarder.detach(); + this._rawEventForwarder = null; + } + + if (this._resetClickAbort) { + this._resetClickAbort.abort(); + this._resetClickAbort = null; + } + + if (this._renderer) { + this._renderer.destroy(); + this._renderer = null; + } + + this._rendererPromise = null; + } +} diff --git a/packages/viewer-charts/src/ts/render/scheduler.ts b/packages/viewer-charts/src/ts/render/scheduler.ts new file mode 100644 index 0000000..649e44f --- /dev/null +++ b/packages/viewer-charts/src/ts/render/scheduler.ts @@ -0,0 +1,422 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { WebGLContextManager } from "../webgl/context-manager"; + +/** + * Module-level render scheduler. The single entry point for driving a + * chart frame. Every render-triggering caller — upload chunks, zoom / + * pan, resize, theme invalidation, host-driven redraws — calls + * `requestRender(glManager, fullRender)` and awaits the returned + * promise. + * + * ## Guarantees + * + * 1. **At most one RAF queued globally.** The first request kicks + * off `requestAnimationFrame(drain)`; subsequent requests during + * that window enqueue without scheduling another callback. + * + * 2. **Coalesced per `glManager`.** The pending map is keyed by + * `WebGLContextManager`, so concurrent requests for the same + * chart share an entry — there is exactly one `_fullRender` call + * per glManager per RAF, regardless of how many requests landed. + * + * 3. **Promise resolves after the entry's own present.** Each + * waiter resolves when its entry's `_fullRender` + + * `awaitGpuFence` + `endFrame` chain completes. Independent + * glManagers run their fence waits in parallel (Phase 2 below), + * so a fast chart's waiters do not block on a slow chart in the + * same frame. + * + * 4. **At most one `_fullRender` per glManager per RAF.** GL + * contexts are not re-entered. `transferToImageBitmap` is called + * exactly once per glManager per frame, so the host blitter + * receives one bitmap per frame per chart and never an empty + * one. + * + * ## Drain ordering + * + * - **Phase 1 (synchronous):** iterate the pending snapshot and call + * each entry's `fullRender()` in one un-yielded loop. This pushes + * all GL command buffers to all contexts before any fence wait + * begins, letting per-context GPU work overlap. + * + * - **Phase 2 (parallel):** `Promise.all(snapshot.map(present))` + * where `present` does `await awaitGpuFence(); endFrame(); + * resolve waiters`. Each entry's waiters resolve as soon as its + * own present completes — independent of other entries. + * + * ## Failure modes + * + * - A throw from `fullRender()` rejects that entry's waiters and + * drops the entry; other entries continue to drain normally. + * + * - A rejection from `awaitGpuFence` calls `endFrame` anyway (so + * canvas state stays consistent — `transferToImageBitmap` clears + * the offscreen even on the error path) and rejects that entry's + * waiters. + * + * ## Snapshot bypass + * + * The scheduler always pairs `_fullRender` with `endFrame()`, which + * calls `transferToImageBitmap` and clears the offscreen. PNG + * export needs `gl.readPixels` against an intact backbuffer, so + * `snapshotPng` deliberately calls `_fullRender` directly and skips + * `endFrame`. That is the only sanctioned bypass; everything else + * goes through `requestRender`. + */ + +interface Entry { + glManager: WebGLContextManager; + fullRender: () => void; + waiters: PromiseWithResolvers[]; +} + +const pending = new Map(); +let rafId = 0; + +/** + * Set of `glManager`s currently in `present()` (Phase 2 — between + * Phase 1 paint and `endFrame`). Mutations to these canvases must be + * deferred until Phase 2 completes, otherwise they corrupt the bitmap + * that `transferToImageBitmap` ships: + * + * - `glManager.resize` sets `canvas.width = N`, which the spec + * mandates clears the drawing buffer immediately (out-of-band + * from the GL command queue). + * - `glManager.clear` queues `gl.clear` after Phase 1's draw + * commands but before the fence; if it executes before + * `transferToImageBitmap`, the canvas is wiped. + * + * Either path produces a blank frame on the host. `deferIfDraining` + * is the gate; sibling message handlers (resize, clear) wrap their + * canvas-mutating bodies in it. + */ +const inFlight = new Set(); +const deferred = new Map void)[]>(); + +/** + * Request a coalesced render of `glManager` whose body is + * `fullRender`. Returns a promise that resolves when this entry's + * Phase 2 (`awaitGpuFence` + `endFrame`) completes. + * + * If a request is already pending for the same glManager, the new + * call's `fullRender` closure replaces the prior one (latest call + * wins; closures read chart state lazily so this is functionally a + * no-op, but keeps the closure fresh) and the returned promise + * resolves alongside the existing waiters. + */ +export function requestRender( + glManager: WebGLContextManager, + fullRender: () => void, +): Promise { + let entry = pending.get(glManager); + if (entry) { + entry.fullRender = fullRender; + } else { + entry = { glManager, fullRender, waiters: [] }; + pending.set(glManager, entry); + } + + const waiter = Promise.withResolvers(); + entry.waiters.push(waiter); + + if (!rafId) { + rafId = scheduleFrame(drain); + } + + return waiter.promise; +} + +/** + * Run `op` synchronously if no drain `present()` is currently active + * for `glManager`. Otherwise queue `op` to run as soon as that + * glManager's in-flight `present()` completes (after `endFrame`, + * after the resolved/rejected waiters). + * + * Used by canvas-mutating callers — `WorkerRenderer.resize`, + * `WorkerRenderer.clear` — to avoid wiping the offscreen between + * Phase 1 paint and Phase 2 `endFrame`. `glManager.resize` setting + * `canvas.width = N` clears the drawing buffer immediately + * (per the WebGL spec, out-of-band from the GL command queue), and + * a clear that lands in Phase 2's fence-wait yield window corrupts + * the bitmap that `transferToImageBitmap` ships, producing a blank + * frame on the host. + * + * Deferred ops execute in `present()`'s `finally` clause, so they + * land *after* the in-flight drain's bitmap has been shipped and + * before the next drain starts. If a deferred op itself triggers a + * `requestRender`, the resulting entry queues into `pending` and + * the drain's tail check (`pending.size > 0 → scheduleFrame(drain)`) + * picks it up for the next RAF. + */ +export function deferIfDraining( + glManager: WebGLContextManager, + op: () => void, +): void { + if (!inFlight.has(glManager)) { + op(); + return; + } + + let ops = deferred.get(glManager); + if (!ops) { + ops = []; + deferred.set(glManager, ops); + } + + ops.push(op); +} + +/** + * Drop every scheduler reference to `glManager` — called from + * `WebGLContextManager`'s owning renderer at teardown, *before* + * `glManager.destroy()` loses the GL context. + * + * Without this, a frame queued for the next RAF (an `Entry` in + * `pending`) or a `deferIfDraining` op still parked in `deferred` + * would survive the destroy and, on the next `drain()`, drive + * `fullRender` / `present` against a context-lost manager. In blit + * mode that surfaces as `endFrame`'s `transferToImageBitmap` throwing + * "Cannot transfer to ImageBitmap because WebGL context is lost" — + * logged as "scheduler: present failed". In direct mode it's a wasted + * paint against a dead context. + * + * Outstanding waiters for a destroyed manager are resolved (not + * rejected): the caller asked to tear the chart down, so its awaited + * `draw()` should observe a clean no-op rather than an error it would + * have to suppress. An in-flight `present()` (manager in `inFlight`) + * is left to unwind on its own — its `finally` clause will not find a + * `deferred` entry to flush, and the manager is already gone from + * `pending`, so it cannot re-enqueue. + */ +export function unregister(glManager: WebGLContextManager): void { + const entry = pending.get(glManager); + if (entry) { + pending.delete(glManager); + for (const w of entry.waiters) { + w.resolve(); + } + } + + deferred.delete(glManager); + inFlight.delete(glManager); +} + +/** + * Test-only: clear pending state. Production callers must not use + * this — outstanding waiters are silently dropped. + */ +export function _resetForTest(): void { + if (rafId) { + cancelFrame(rafId); + rafId = 0; + } + + pending.clear(); + inFlight.clear(); + deferred.clear(); +} + +// async function drain(): Promise { +// rafId = 0; + +// // Snapshot the pending set up front so requests that arrive during +// // the drain (in microtasks between Phase 2 awaits, or in tasks +// // unblocked by `awaitGpuFence`'s yields) queue into the next RAF +// // rather than mutating this drain's working set. +// const snapshot = Array.from(pending.values()); +// pending.clear(); + +// // Phase 1: synchronously queue GL commands for every entry. One +// // un-yielded loop so all contexts have their commands submitted +// // before any fence wait begins; otherwise fence waits serialize +// // behind each subsequent `_fullRender`'s draw submissions. +// const ready: Entry[] = []; +// for (const entry of snapshot) { +// try { +// entry.fullRender(); +// ready.push(entry); +// } catch (err) { +// console.error("scheduler: fullRender threw", err); +// for (const w of entry.waiters) { +// w.reject(err); +// } +// } +// } + +// // Phase 2: run each entry's fence + endFrame + waiter-resolve as +// // its own async task. `Promise.all` joins for the drain wall +// // time, but per-entry waiters resolve as soon as their entry's +// // present completes — a fast chart in this frame is not held up +// // by a slow chart. +// await Promise.all(ready.map(present)); +// } + +async function drain(): Promise { + const snapshot = Array.from(pending.values()); + pending.clear(); + + // Group ready entries by the GL context behind each manager. Under + // pooled blit mode many managers share one context (one drawing + // buffer): their renders MUST serialize — interleaving two charts' + // paints into one canvas would corrupt the bitmap the first ships. + // In the default 1:1 mode every manager has its own backend, so + // every group has exactly one entry and all groups run in parallel, + // exactly as before pooling. + const groups = new Map(); + for (const entry of snapshot) { + // The browser force-loses the oldest context when a page + // exceeds its per-agent WebGL context cap (~16). A frame queued + // before that eviction would paint + present against a dead + // context; skip it and settle its waiters cleanly rather than + // letting `endFrame`'s `transferToImageBitmap` throw. + if (entry.glManager.isContextLost()) { + for (const w of entry.waiters) { + w.resolve(); + } + + continue; + } + + const key = entry.glManager.backendId; + const group = groups.get(key); + if (group) { + group.push(entry); + } else { + groups.set(key, [entry]); + } + } + + await Promise.all(Array.from(groups.values()).map(presentGroup)); + + // Now (and only now) clear rafId. If new requests landed during + // this drain, schedule the next RAF. + rafId = 0; + if (pending.size > 0) { + rafId = scheduleFrame(drain); + } +} + +/** + * Render every entry sharing one GL context, sequentially: each chart + * gets the shared drawing buffer to itself for a full + * `beginFrame` → `fullRender` → `awaitGpuFence` → `endFrame` (present) + * cycle before the next chart touches it. Groups for *different* + * contexts run concurrently (via `Promise.all` in `drain`), so K pooled + * contexts give K-way parallelism and the 1:1 default keeps full + * cross-chart overlap. + * + * The synchronous prefix of each group (`beginFrame` + `fullRender`) + * runs before its first `awaitGpuFence` yields, so when groups run + * concurrently all first-chart draws are still submitted before any + * fence wait — preserving the GPU overlap the old two-phase drain had. + */ +async function presentGroup(entries: Entry[]): Promise { + for (const entry of entries) { + await present(entry); + } +} + +async function present(entry: Entry): Promise { + // Mark this glManager as in-flight *synchronously*, before the first + // await, so sibling message handlers' canvas mutations (resize, + // clear) route through `deferIfDraining` into `deferred` until the + // `finally` flushes them. + inFlight.add(entry.glManager); + try { + // Apply the dimension change and (in pooled mode) reset shared + // GL state in the same un-yielded step as the paint that fills + // the buffer. Pairing the canvas-clearing `canvas.width = N` + // with `_fullRender` keeps the compositor from ever observing a + // cleared-but-unpainted canvas (visible flicker in direct/in- + // process modes, where the visible canvas IS the GL canvas). + entry.glManager.beginFrame(); + entry.fullRender(); + } catch (err) { + console.error("scheduler: fullRender threw", err); + for (const w of entry.waiters) { + w.reject(err); + } + + flushDeferred(entry.glManager); + return; + } + + try { + await entry.glManager.awaitGpuFence(); + entry.glManager.endFrame(); + for (const w of entry.waiters) { + w.resolve(); + } + } catch (err) { + console.error("scheduler: present failed", err); + // Still call `endFrame` so the canvas state is consistent — + // `transferToImageBitmap` clears the offscreen, and skipping + // it would leave a stale image bound to a context the host + // already considers presented. + try { + entry.glManager.endFrame(); + } catch { + // Swallow: already in a failure path. + } + + for (const w of entry.waiters) { + w.reject(err); + } + } finally { + flushDeferred(entry.glManager); + } +} + +/** + * Re-open a glManager's canvas to mutations and flush any ops deferred + * during its present, in arrival order. Deferred ops may call + * `requestRender`; the resulting entry queues into `pending` and the + * drain's tail check picks it up for the next RAF. + */ +function flushDeferred(glManager: WebGLContextManager): void { + inFlight.delete(glManager); + const ops = deferred.get(glManager); + if (ops) { + deferred.delete(glManager); + for (const op of ops) { + try { + op(); + } catch (err) { + console.error("scheduler: deferred op threw", err); + } + } + } +} + +/** + * RAF in worker scope is exposed by `DedicatedWorkerGlobalScope` for + * `OffscreenCanvas` painting and is the same primitive as on the + * main thread. Fall back to `setTimeout(16)` for environments + * without RAF (jsdom, headless tests without a polyfill). + */ +function scheduleFrame(cb: () => void): number { + if (typeof requestAnimationFrame === "function") { + return requestAnimationFrame(cb); + } + + return setTimeout(cb, 16) as unknown as number; +} + +function cancelFrame(id: number): void { + if (typeof cancelAnimationFrame === "function") { + cancelAnimationFrame(id); + } else { + clearTimeout(id); + } +} diff --git a/packages/viewer-charts/src/ts/shaders/area.frag.glsl b/packages/viewer-charts/src/ts/shaders/area.frag.glsl new file mode 100644 index 0000000..61a8735 --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/area.frag.glsl @@ -0,0 +1,20 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +precision highp float; + +uniform vec3 u_color; +uniform float u_opacity; + +void main() { + gl_FragColor = vec4(u_color, u_opacity); +} diff --git a/packages/viewer-charts/src/ts/shaders/area.vert.glsl b/packages/viewer-charts/src/ts/shaders/area.vert.glsl new file mode 100644 index 0000000..55026f0 --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/area.vert.glsl @@ -0,0 +1,19 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +attribute vec2 a_position; + +uniform mat4 u_projection; + +void main() { + gl_Position = u_projection * vec4(a_position, 0.0, 1.0); +} diff --git a/packages/viewer-charts/src/ts/shaders/bar.frag.glsl b/packages/viewer-charts/src/ts/shaders/bar.frag.glsl new file mode 100644 index 0000000..7c81409 --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/bar.frag.glsl @@ -0,0 +1,25 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +precision highp float; + +varying vec3 v_color; +varying float v_hover; +varying vec2 v_local; + +void main() { + vec3 color = v_color; + if (v_hover > 0.5) { + color = mix(color, vec3(1.0), 0.25); + } + gl_FragColor = vec4(color, 1.0); +} diff --git a/packages/viewer-charts/src/ts/shaders/bar.vert.glsl b/packages/viewer-charts/src/ts/shaders/bar.vert.glsl new file mode 100644 index 0000000..1cc302a --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/bar.vert.glsl @@ -0,0 +1,60 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// Per-vertex (divisor=0): unit quad corner in [0,1]^2 order +// 0=(0,0) bottom-left, 1=(1,0) bottom-right, 2=(0,1) top-left, 3=(1,1) top-right +attribute vec2 a_corner; + +// Per-instance (divisor=1) bar attributes +attribute float a_x_center; +attribute float a_half_width; +attribute float a_y0; +attribute float a_y1; +attribute vec3 a_color; +attribute float a_series_id; +attribute float a_axis; + +uniform mat4 u_proj_left; +uniform mat4 u_proj_right; +uniform float u_hover_series; +// 0 = vertical bars (categorical X, numeric Y). +// 1 = horizontal bars (numeric X, categorical Y). Instance attributes +// stay in "logical" form — x_center + halfWidth on the categorical axis, +// y0/y1 on the value axis — and we transpose at projection time. +uniform float u_horizontal; + +varying vec3 v_color; +varying float v_hover; +varying vec2 v_local; + +void main() { + vec2 pos; + if (u_horizontal > 0.5) { + pos = vec2( + mix(a_y0, a_y1, a_corner.x), + a_x_center + (a_corner.y - 0.5) * 2.0 * a_half_width + ); + } else { + pos = vec2( + a_x_center + (a_corner.x - 0.5) * 2.0 * a_half_width, + mix(a_y0, a_y1, a_corner.y) + ); + } + + // Branch on per-instance axis flag. Both matrices are set each frame. + mat4 proj = a_axis < 0.5 ? u_proj_left : u_proj_right; + gl_Position = proj * vec4(pos, 0.0, 1.0); + + v_color = a_color; + v_hover = abs(a_series_id - u_hover_series) < 0.5 ? 1.0 : 0.0; + v_local = a_corner; +} diff --git a/packages/viewer-charts/src/ts/shaders/candlestick-body.frag.glsl b/packages/viewer-charts/src/ts/shaders/candlestick-body.frag.glsl new file mode 100644 index 0000000..84f0bcc --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/candlestick-body.frag.glsl @@ -0,0 +1,19 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +precision highp float; + +varying vec3 v_color; + +void main() { + gl_FragColor = vec4(v_color, 1.0); +} diff --git a/packages/viewer-charts/src/ts/shaders/candlestick-body.vert.glsl b/packages/viewer-charts/src/ts/shaders/candlestick-body.vert.glsl new file mode 100644 index 0000000..761f411 --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/candlestick-body.vert.glsl @@ -0,0 +1,34 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// Filled-rectangle shader for candlestick bodies. A trimmed copy of +// the bar shader without the hover-highlight / dual-axis plumbing that +// candlestick doesn't need. + +attribute vec2 a_corner; // per-vertex, divisor=0: (0|1, 0|1) + +attribute float a_x_center; // per-instance +attribute float a_half_width; +attribute float a_y0; +attribute float a_y1; +attribute vec3 a_color; + +uniform mat4 u_projection; + +varying vec3 v_color; + +void main() { + float x = a_x_center + (a_corner.x - 0.5) * 2.0 * a_half_width; + float y = mix(a_y0, a_y1, a_corner.y); + gl_Position = u_projection * vec4(x, y, 0.0, 1.0); + v_color = a_color; +} diff --git a/packages/viewer-charts/src/ts/shaders/density-extreme.frag.glsl b/packages/viewer-charts/src/ts/shaders/density-extreme.frag.glsl new file mode 100644 index 0000000..e8bb875 --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/density-extreme.frag.glsl @@ -0,0 +1,30 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +precision highp float; + +varying vec2 v_uv; +varying float v_color_t; + +void main() { + float r = length(v_uv); + if(r >= 1.0) { + discard; + } + + // Signed deviation from the gradient midpoint, in [-1, +1]. Split + // into two non-negative channels so the FBO format stays in [0, 1] + // and so the `MAX` blend at the call site keeps the strongest + // contributor of each sign separately. + float dev = (v_color_t - 0.5) * 2.0; + gl_FragColor = vec4(max(0.0, dev), max(0.0, -dev), 0.0, 0.0); +} diff --git a/packages/viewer-charts/src/ts/shaders/density-mrt.frag.glsl b/packages/viewer-charts/src/ts/shaders/density-mrt.frag.glsl new file mode 100644 index 0000000..2d7adf1 --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/density-mrt.frag.glsl @@ -0,0 +1,44 @@ +#version 300 es +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// MRT variant of the splat fragment used by `extreme` mode when the +// running context advertises `OES_draw_buffers_indexed`. One pass +// writes to both targets: +// +// - location 0 (heat FBO, ADD blend): same payload as +// density-splat.frag.glsl — `(w, w·t, 0, 0)`. +// - location 1 (extreme FBO, MAX blend): same payload as +// density-extreme.frag.glsl — split signed deviation. + +precision highp float; + +uniform float u_intensity; + +in vec2 v_uv; +in float v_color_t; + +layout(location = 0) out vec4 outHeat; +layout(location = 1) out vec4 outExtreme; + +void main() { + float r = length(v_uv); + float w = max(0.0f, 1.0f - r); + w = w * w * u_intensity; + if(w <= 0.0f) { + discard; + } + + outHeat = vec4(w, w * v_color_t, 0.0f, 0.0f); + float dev = (v_color_t - 0.5f) * 2.0f; + outExtreme = vec4(max(0.0f, dev), max(0.0f, -dev), 0.0f, 0.0f); +} diff --git a/packages/viewer-charts/src/ts/shaders/density-mrt.vert.glsl b/packages/viewer-charts/src/ts/shaders/density-mrt.vert.glsl new file mode 100644 index 0000000..fd2c748 --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/density-mrt.vert.glsl @@ -0,0 +1,48 @@ +#version 300 es +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// GLSL ES 3.00 variant of `density-splat.vert.glsl`, mirroring its math +// 1:1 in the modern dialect (`in`/`out` instead of `attribute`/ +// `varying`). Paired with `density-mrt.frag.glsl` for the MRT fast +// path used by `extreme` mode on WebGL2 — the program's vertex and +// fragment shaders must share a GLSL version, so the WebGL1-style +// splat vert can't be linked against a 300 ES MRT frag. + +in vec2 a_corner; +in vec2 a_position; +in float a_color_value; + +uniform mat4 u_projection; +uniform vec2 u_radius_ndc; +uniform vec2 u_color_range; + +out vec2 v_uv; +out float v_color_t; + +void main() { + vec4 center = u_projection * vec4(a_position, 0.0, 1.0); + gl_Position = center + vec4(a_corner * u_radius_ndc * center.w, 0.0, 0.0); + + v_uv = a_corner; + + float cmin = u_color_range.x; + float cmax = u_color_range.y; + if(cmax <= cmin) { + v_color_t = 0.5; + } else if(cmin < 0.0 && cmax > 0.0) { + float denom = max(-cmin, cmax); + v_color_t = clamp(0.5 + 0.5 * (a_color_value / denom), 0.0, 1.0); + } else { + v_color_t = clamp((a_color_value - cmin) / (cmax - cmin), 0.0, 1.0); + } +} diff --git a/packages/viewer-charts/src/ts/shaders/density-resolve.frag.glsl b/packages/viewer-charts/src/ts/shaders/density-resolve.frag.glsl new file mode 100644 index 0000000..0f87d56 --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/density-resolve.frag.glsl @@ -0,0 +1,89 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +precision highp float; + +uniform sampler2D u_heat; +uniform sampler2D u_extreme; +uniform sampler2D u_gradient_lut; + +// Saturation knob shared by every mode. `density` maps it onto hue, +// `mean` / `extreme` map it onto alpha, `signed` uses it as the +// signed-sum denominator. +uniform float u_heat_max; + +// 0 = density-only (color column ignored) +// 1 = mean (density-weighted average of color-t) +// 2 = extreme (sign-aware max-blended deviation) +// 3 = signed (net positive vs net negative accumulation) +uniform int u_color_mode; + +varying vec2 v_uv; + +void main() { + vec4 sd = texture2D(u_heat, v_uv); + float density = sd.r; + float weighted = sd.g; + + if(density <= 0.0) { + discard; + } + + float t; + float alpha; + float n = clamp(density / max(u_heat_max, 1e-4), 0.0, 1.0); + + if(u_color_mode == 0) { + // Density only: gamma-curve clamped density into the hue + // axis. Alpha follows raw density so a single splat fades + // smoothly into the plot background. + t = pow(n, 0.6); + alpha = clamp(density, 0.0, 1.0); + } else if(u_color_mode == 2) { + // Extreme: signed max of `t - 0.5` is split across R (positive + // deviation) and G (negative deviation magnitude); whichever + // is larger wins the sign. Falls back to neutral if neither + // contributed at this pixel. + vec4 ext = texture2D(u_extreme, v_uv); + float pos = ext.r; + float neg = ext.g; + float winner = max(pos, neg); + if(winner <= 0.0) { + t = 0.5; + } else if(pos >= neg) { + t = clamp(0.5 + pos * 0.5, 0.5, 1.0); + } else { + t = clamp(0.5 - neg * 0.5, 0.0, 0.5); + } + alpha = n; + } else if(u_color_mode == 3) { + // Signed sum: each splat additively contributes `w * t`, so + // the per-pixel signed sum (relative to the neutral midpoint) + // is `Σ w(t-0.5) = G - 0.5·R`. Sign drives which half of the + // LUT we land in, magnitude/heat_max drives both saturation + // and alpha. + float signed_sum = weighted - 0.5 * density; + float mag = clamp(abs(signed_sum) / max(u_heat_max, 1e-4), 0.0, 1.0); + float dir = signed_sum >= 0.0 ? 1.0 : -1.0; + t = clamp(0.5 + 0.5 * dir * mag, 0.0, 1.0); + alpha = mag; + } else { + // Mean (mode 1, default): density-weighted average of per- + // point color-t, with density driving alpha so sparse + // pixels fade out. + t = clamp(weighted / density, 0.0, 1.0); + alpha = n; + } + + vec4 color = texture2D(u_gradient_lut, vec2(t, 0.5)); + gl_FragColor = vec4(color.rgb, color.a * alpha); +} diff --git a/packages/viewer-charts/src/ts/shaders/density-resolve.vert.glsl b/packages/viewer-charts/src/ts/shaders/density-resolve.vert.glsl new file mode 100644 index 0000000..3422fdc --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/density-resolve.vert.glsl @@ -0,0 +1,23 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// Fullscreen triangle covering NDC `[-1, 1]^2`. Drawn with +// `drawArrays(TRIANGLES, 0, 3)`; the lone triangle's overscan rasterizes +// every pixel exactly once and replaces the standard two-triangle quad. +attribute vec2 a_corner; + +varying vec2 v_uv; + +void main() { + v_uv = a_corner * 0.5 + 0.5; + gl_Position = vec4(a_corner, 0.0, 1.0); +} diff --git a/packages/viewer-charts/src/ts/shaders/density-splat.frag.glsl b/packages/viewer-charts/src/ts/shaders/density-splat.frag.glsl new file mode 100644 index 0000000..911e391 --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/density-splat.frag.glsl @@ -0,0 +1,34 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +precision highp float; + +uniform float u_intensity; + +varying vec2 v_uv; +varying float v_color_t; + +void main() { + // Radial falloff. `length(v_uv)` is `1.0` at the disk edge. + // `pow(...)^2` matches the WGSL reference splat shape. + float r = length(v_uv); + float w = max(0.0, 1.0 - r); + w = w * w * u_intensity; + if(w <= 0.0) { + discard; + } + + // R: accumulated density. G: density-weighted color-t. The resolve + // pass divides G/R to recover the per-pixel weighted-average t. + // Blend func at the call site is `(gl.ONE, gl.ONE)` (additive). + gl_FragColor = vec4(w, w * v_color_t, 0.0, 0.0); +} diff --git a/packages/viewer-charts/src/ts/shaders/density-splat.vert.glsl b/packages/viewer-charts/src/ts/shaders/density-splat.vert.glsl new file mode 100644 index 0000000..bc40919 --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/density-splat.vert.glsl @@ -0,0 +1,52 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// Per-vertex (divisor 0): unit-quad corner in `[(-1,-1), (1,-1), (-1,1), (1,1)]`. +attribute vec2 a_corner; + +// Per-instance (divisor 1): data-space xy of the splat center. +attribute vec2 a_position; + +// Per-instance (divisor 1): raw color column value. Folded into a +// sign-aware `t` matching the scatter glyph for cross-chart parity. +attribute float a_color_value; + +// Splat radius expressed in NDC. Computed CPU-side as +// `(radius_px * dpr * 2) / plot_pixel_width` so the disk maintains a +// fixed pixel footprint as the user zooms. +uniform vec2 u_radius_ndc; + +uniform mat4 u_projection; +uniform vec2 u_color_range; +varying vec2 v_uv; +varying float v_color_t; + +void main() { + // Project the data-space center into clip space, then offset by the + // unit-quad corner scaled by `u_radius_ndc`. This keeps splats + // axis-aligned to the screen regardless of zoom level. + vec4 center = u_projection * vec4(a_position, 0.0, 1.0); + gl_Position = center + vec4(a_corner * u_radius_ndc * center.w, 0.0, 0.0); + + v_uv = a_corner; + + float cmin = u_color_range.x; + float cmax = u_color_range.y; + if(cmax <= cmin) { + v_color_t = 0.5; + } else if(cmin < 0.0 && cmax > 0.0) { + float denom = max(-cmin, cmax); + v_color_t = clamp(0.5 + 0.5 * (a_color_value / denom), 0.0, 1.0); + } else { + v_color_t = clamp((a_color_value - cmin) / (cmax - cmin), 0.0, 1.0); + } +} diff --git a/packages/viewer-charts/src/ts/shaders/gridline.frag.glsl b/packages/viewer-charts/src/ts/shaders/gridline.frag.glsl new file mode 100644 index 0000000..7076e81 --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/gridline.frag.glsl @@ -0,0 +1,18 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +precision mediump float; +uniform vec4 u_color; + +void main() { + gl_FragColor = u_color; +} diff --git a/packages/viewer-charts/src/ts/shaders/gridline.vert.glsl b/packages/viewer-charts/src/ts/shaders/gridline.vert.glsl new file mode 100644 index 0000000..e0e95b5 --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/gridline.vert.glsl @@ -0,0 +1,18 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +attribute vec2 a_position; +uniform mat4 u_projection; + +void main() { + gl_Position = u_projection * vec4(a_position, 0.0, 1.0); +} diff --git a/packages/viewer-charts/src/ts/shaders/heatmap.frag.glsl b/packages/viewer-charts/src/ts/shaders/heatmap.frag.glsl new file mode 100644 index 0000000..533ffd7 --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/heatmap.frag.glsl @@ -0,0 +1,23 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +precision highp float; + +uniform sampler2D u_gradient_lut; + +varying float v_color_t; + +void main() { + float t = clamp(v_color_t, 0.0, 1.0); + vec4 rgba = texture2D(u_gradient_lut, vec2(t, 0.5)); + gl_FragColor = vec4(rgba.rgb, 1.0); +} diff --git a/packages/viewer-charts/src/ts/shaders/heatmap.vert.glsl b/packages/viewer-charts/src/ts/shaders/heatmap.vert.glsl new file mode 100644 index 0000000..1b96c0c --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/heatmap.vert.glsl @@ -0,0 +1,42 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// Unit-quad corner per vertex: (0,0) bottom-left → (1,1) top-right. +attribute vec2 a_corner; + +// Per-instance: cell center (data units) + normalized color t in [0, 1]. +// In category mode the center is the integer index `(xIdx, yIdx)`; in +// numeric mode the JS uploader pre-multiplies into real data values +// (e.g. ms-since-epoch). +attribute vec2 a_cell; +attribute float a_color_t; +uniform mat4 u_projection; + +// Inset applied in data-space so the shader can carve a pixel-accurate +// gap between neighbouring cells regardless of plot size. +uniform vec2 u_cell_inset; + +// Cell size in data units. `(1.0, 1.0)` for the integer category grid; +// in numeric mode the bandWidth derived from min adjacent delta. +uniform vec2 u_cell_size; +varying float v_color_t; + +void main() { + // Cell `c` occupies [c - half, c + half] on each axis. Inset shrinks + // the rendered rect inward by `u_cell_inset` on all sides. + vec2 half_size = 0.5 * u_cell_size; + vec2 span = u_cell_size - 2.0 * u_cell_inset; + float x = a_cell.x - half_size.x + u_cell_inset.x + a_corner.x * span.x; + float y = a_cell.y - half_size.y + u_cell_inset.y + a_corner.y * span.y; + gl_Position = u_projection * vec4(x, y, 0.0, 1.0); + v_color_t = a_color_t; +} diff --git a/packages/viewer-charts/src/ts/shaders/line-uniform.frag.glsl b/packages/viewer-charts/src/ts/shaders/line-uniform.frag.glsl new file mode 100644 index 0000000..e494238 --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/line-uniform.frag.glsl @@ -0,0 +1,27 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +precision highp float; + +uniform vec4 u_color; +uniform float u_line_width; + +varying float v_edge_dist; +varying float v_seg_alpha; + +void main() { + float dist = abs(v_edge_dist); + float coreEdge = u_line_width / (u_line_width + 1.5); + float alpha = 1.0 - smoothstep(coreEdge, 1.0, dist); + + gl_FragColor = vec4(u_color.rgb, u_color.a * alpha * v_seg_alpha); +} diff --git a/packages/viewer-charts/src/ts/shaders/line-uniform.vert.glsl b/packages/viewer-charts/src/ts/shaders/line-uniform.vert.glsl new file mode 100644 index 0000000..0839043 --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/line-uniform.vert.glsl @@ -0,0 +1,73 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// Uniform-color line shader used by bar's line-glyph overlay. The +// LineChart pipeline uses `line.vert.glsl` instead, which drives color +// from a per-point series-id + gradient LUT. Bar pre-computes one +// `s.color` per series from its own palette resolver, so a uniform +// is the right fit here. +attribute vec2 a_start; +attribute vec2 a_end; + +// Per-vertex attribute (advance every vertex, divisor=0) +// 0 = start+left, 1 = start+right, 2 = end+left, 3 = end+right +attribute float a_corner; + +// Per-segment "is endpoint a real source-data cell" flags. Both vertices +// of a segment's quad see the same instance values, so `v_seg_alpha` +// below is constant across the quad — no gradient fade. Read from the +// per-cell real-flag buffer with offset 0 / 1 (same overlap trick as +// the bar-line glyph's segment-position attributes). +attribute float a_real_start; +attribute float a_real_end; + +uniform mat4 u_projection; +uniform vec2 u_resolution; +uniform float u_line_width; + +// Alpha multiplier applied to any segment whose endpoints are not both +// real. Set per draw based on the series' interpolate mode: +// 0.0 = skip (gaps at synthesized cells) +// 1.0 = solid (no visible difference; default for non-line) +// 0.5 = transparent (50% opacity on segments touching synthesized cells) +uniform float u_interp_alpha; + +varying float v_edge_dist; +varying float v_seg_alpha; + +void main() { + vec4 clipStart = u_projection * vec4(a_start, 0.0, 1.0); + vec4 clipEnd = u_projection * vec4(a_end, 0.0, 1.0); + + vec2 pixelStart = clipStart.xy * u_resolution * 0.5; + vec2 pixelEnd = clipEnd.xy * u_resolution * 0.5; + + vec2 dir = pixelEnd - pixelStart; + float segLen = length(dir); + dir = segLen > 0.001 ? dir / segLen : vec2(1.0, 0.0); + + vec2 normal = vec2(-dir.y, dir.x); + + float isEnd = step(1.5, a_corner); + float side = 1.0 - mod(a_corner, 2.0) * 2.0; + + vec4 clipPos = mix(clipStart, clipEnd, isEnd); + + float halfWidth = (u_line_width + 1.5) * 0.5; + vec2 clipOffset = (normal * side * halfWidth) / (u_resolution * 0.5); + + gl_Position = clipPos + vec4(clipOffset, 0.0, 0.0); + v_edge_dist = side; + + float bothReal = a_real_start * a_real_end; + v_seg_alpha = mix(u_interp_alpha, 1.0, step(0.5, bothReal)); +} diff --git a/packages/viewer-charts/src/ts/shaders/line.frag.glsl b/packages/viewer-charts/src/ts/shaders/line.frag.glsl new file mode 100644 index 0000000..057255e --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/line.frag.glsl @@ -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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +precision highp float; + +uniform float u_line_width; + +varying float v_edge_dist; +varying vec3 v_color; + +void main() { + // |v_edge_dist| ranges from 0 (line centre) to 1 (outer AA fringe edge). + // The solid core of the line extends to coreEdge; beyond that we fade out. + float dist = abs(v_edge_dist); + float coreEdge = u_line_width / (u_line_width + 1.5); + float alpha = 1.0 - smoothstep(coreEdge, 1.0, dist); + + gl_FragColor = vec4(v_color, alpha); +} diff --git a/packages/viewer-charts/src/ts/shaders/line.vert.glsl b/packages/viewer-charts/src/ts/shaders/line.vert.glsl new file mode 100644 index 0000000..01c7543 --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/line.vert.glsl @@ -0,0 +1,87 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// Per-instance attributes (advance once per segment via divisor=1). +// Both `a_start`/`a_end` and `a_color_start`/`a_color_end` read from +// the same flat buffers using overlapping offsets: instance i reads +// vertex[i] into `*_start` and vertex[i+1] into `*_end`. Each per- +// series draw call binds into its own slot range, so segments never +// cross series boundaries — the CPU-side rebinding in `drawLineSeries` +// is the safeguard, so the shader contains no discard branch. +// +// `a_color_start` / `a_color_end` carry the segment endpoints' raw +// color values (numeric data value for gradient, dictionary index for +// categorical). The gradient LUT is sampled using the same mapping the +// scatter shader uses — `(v - cmin) / (cmax - cmin)` with sign-aware +// handling for zero-crossing domains. The two endpoints' colors are +// averaged so the segment reads as a single chord in gradient space. +attribute vec2 a_start; +attribute vec2 a_end; +attribute float a_color_start; +attribute float a_color_end; + +// Per-vertex attribute (advance every vertex, divisor=0) +// 0 = start+left, 1 = start+right, 2 = end+left, 3 = end+right +attribute float a_corner; + +uniform mat4 u_projection; +uniform vec2 u_resolution; +uniform float u_line_width; +uniform vec2 u_color_range; +uniform sampler2D u_gradient_lut; + +varying float v_edge_dist; +varying vec3 v_color; + +float colorT(float v, float cmin, float cmax) { + if(cmax <= cmin) { + return 0.5; + } else if(cmin < 0.0 && cmax > 0.0) { + float denom = max(-cmin, cmax); + return clamp(0.5 + 0.5 * (v / denom), 0.0, 1.0); + } + return clamp((v - cmin) / (cmax - cmin), 0.0, 1.0); +} + +void main() { + // Average the two endpoints so the segment takes one color — keeps + // the fragment shader cheap (no interpolated t sample) and matches + // the single-tone feel of the old per-series palette. + float cmin = u_color_range.x; + float cmax = u_color_range.y; + float avgVal = 0.5 * (a_color_start + a_color_end); + float t = colorT(avgVal, cmin, cmax); + v_color = texture2D(u_gradient_lut, vec2(t, 0.5)).rgb; + + vec4 clipStart = u_projection * vec4(a_start, 0.0, 1.0); + vec4 clipEnd = u_projection * vec4(a_end, 0.0, 1.0); + + vec2 pixelStart = clipStart.xy * u_resolution * 0.5; + vec2 pixelEnd = clipEnd.xy * u_resolution * 0.5; + + vec2 dir = pixelEnd - pixelStart; + float segLen = length(dir); + dir = segLen > 0.001 ? dir / segLen : vec2(1.0, 0.0); + + vec2 normal = vec2(-dir.y, dir.x); + + float isEnd = step(1.5, a_corner); + float side = 1.0 - mod(a_corner, 2.0) * 2.0; + + vec4 clipPos = mix(clipStart, clipEnd, isEnd); + + float halfWidth = (u_line_width + 1.5) * 0.5; + vec2 clipOffset = (normal * side * halfWidth) / (u_resolution * 0.5); + + gl_Position = clipPos + vec4(clipOffset, 0.0, 0.0); + v_edge_dist = side; +} diff --git a/packages/viewer-charts/src/ts/shaders/scatter.frag.glsl b/packages/viewer-charts/src/ts/shaders/scatter.frag.glsl new file mode 100644 index 0000000..4ce0742 --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/scatter.frag.glsl @@ -0,0 +1,39 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +precision highp float; + +varying float v_color_t; +varying float v_point_size; + +uniform sampler2D u_gradient_lut; + +void main() { + // Distance from center of point sprite in [0, 0.5] space + vec2 coord = gl_PointCoord - vec2(0.5); + float dist = length(coord); + + // Discard fragments clearly outside the circle + if(dist > 0.5) { + discard; + } + + // Anti-alias: smooth falloff over ~1.5 screen pixels at the edge. + // In point-coord space, 1 pixel = 1/v_point_size. + float pixelWidth = 1.5 / max(v_point_size, 1.0); + float alpha = 1.0 - smoothstep(0.5 - pixelWidth, 0.5, dist); + + // LUT lookup — t is already sign-aware (pre-baked CPU-side via + // `colorValueToT`), so the shader is a pure sampler. + vec4 color = texture2D(u_gradient_lut, vec2(clamp(v_color_t, 0.0, 1.0), 0.5)); + gl_FragColor = vec4(color.rgb, color.a * alpha); +} diff --git a/packages/viewer-charts/src/ts/shaders/scatter.vert.glsl b/packages/viewer-charts/src/ts/shaders/scatter.vert.glsl new file mode 100644 index 0000000..d652aa7 --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/scatter.vert.glsl @@ -0,0 +1,67 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +attribute vec2 a_position; +attribute float a_color_value; +attribute float a_size_value; + +uniform mat4 u_projection; +uniform float u_point_size; +// Data extents of the color column. The vertex shader folds these into a +// sign-aware `t` whose 50% stop is always the sign pivot, matching the +// CPU-side `colorValueToT` helper used by heatmap and the Canvas2D +// legend / tooltip. +uniform vec2 u_color_range; +uniform vec2 u_size_range; +uniform vec2 u_point_size_range; + +varying float v_color_t; +varying float v_point_size; + +void main() { + // No unused-slot discard: tight per-series draws in `points.ts` + // already bound `gl.drawArrays(gl.POINTS, s*cap, count[s])` to + // valid rows, so the shader never sees a tail slot. A historical + // sentinel branch here culled `a_color_value < 0.0`, which under + // current dispatch silently dropped real numeric color data + // whenever the user's color column legitimately went negative + // (e.g., a diverging `Profit` column). + gl_Position = u_projection * vec4(a_position, 0.0, 1.0); + + float sizeRange = u_size_range.y - u_size_range.x; + if(sizeRange > 0.0) { + float size_t = clamp((a_size_value - u_size_range.x) / sizeRange, 0.0, 1.0); + gl_PointSize = mix(u_point_size_range.x, u_point_size_range.y, size_t); + } else { + gl_PointSize = u_point_size; + } + + v_point_size = gl_PointSize; + + // Color-t mapping. Linear across `[cmin, cmax]` for single-sign + // domains (which includes categorical `[0, N-1]` split / string + // indices, so the colors match `interpolatePalette`'s even sampling + // used by the legend). When the domain actually crosses zero we + // switch to sign-aware so the value 0 always lands at the 50% stop + // of the diverging gradient — matching heatmap and the Canvas2D + // tooltip paths. + float cmin = u_color_range.x; + float cmax = u_color_range.y; + if(cmax <= cmin) { + v_color_t = 0.5; + } else if(cmin < 0.0 && cmax > 0.0) { + float denom = max(-cmin, cmax); + v_color_t = clamp(0.5 + 0.5 * (a_color_value / denom), 0.0, 1.0); + } else { + v_color_t = clamp((a_color_value - cmin) / (cmax - cmin), 0.0, 1.0); + } +} diff --git a/packages/viewer-charts/src/ts/shaders/sunburst-arc.frag.glsl b/packages/viewer-charts/src/ts/shaders/sunburst-arc.frag.glsl new file mode 100644 index 0000000..4bbc5fa --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/sunburst-arc.frag.glsl @@ -0,0 +1,19 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +precision highp float; + +varying vec4 v_color; + +void main() { + gl_FragColor = v_color; +} diff --git a/packages/viewer-charts/src/ts/shaders/sunburst-arc.vert.glsl b/packages/viewer-charts/src/ts/shaders/sunburst-arc.vert.glsl new file mode 100644 index 0000000..58eac68 --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/sunburst-arc.vert.glsl @@ -0,0 +1,79 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// Per-vertex (divisor=0): position in the unit triangle-strip template. +// a_strip_t ∈ [0, 1] angular parameter along the arc +// a_side ∈ {0, 1} 0 = inner radius, 1 = outer radius +attribute float a_strip_t; +attribute float a_side; + +// Per-instance (divisor=1) arc geometry. +attribute vec2 a_angles; // (a0, a1) in radians +attribute vec2 a_radii; // (r0, r1) inner / outer pixel radius +attribute vec4 a_color; + +uniform vec2 u_center; // chart center in pixel space +uniform vec2 u_resolution; // viewport size in device pixels +uniform float u_border_px; // symmetric inset, in device pixels + +varying vec4 v_color; +varying vec2 v_edge; // (angular t, radial t) for optional AA fringe + +void main() { + // Symmetric inset: shrink the arc by half the border on every edge + // so adjacent arcs each give up half, producing a + // `u_border_px`-wide gap in pixel space. + // + // Radial inset is constant; angular inset is computed per-vertex + // based on the vertex's *actual* radius. This keeps the angular gap + // at exactly `u_border_px` pixels wide at every radial position — + // without it, the gap narrows toward the center (since the same + // dTheta corresponds to fewer pixels at smaller r). + // + // Side effect: the arc's side edges are slightly non-radial (they + // curve so that the outer endpoint sits inside the inner endpoint's + // wedge). At 1 px borders this is imperceptible; at very wide + // borders the arc looks like a thin curved parallelogram — which + // is, in fact, the shape with constant pixel-width gaps. + float half_border = u_border_px * 0.5; + + float adjR0 = a_radii.x + half_border; + float adjR1 = a_radii.y - half_border; + if(adjR1 <= adjR0) { + // Arc thinner than the border radially — nothing to draw. + gl_Position = vec4(2.0, 2.0, 2.0, 1.0); + v_color = vec4(0.0); + v_edge = vec2(0.0); + return; + } + + // Per-vertex radius + angular inset. dTheta scales with 1/r so the + // pixel-space gap is constant at every radial position. Clamp to + // at most `span / 2` so narrow arcs collapse their inner edge to + // a point at the midpoint angle instead of inverting (which would + // send inner vertices past outer ones and rasterize degenerate + // triangles across the screen). Every vertex of an instance takes + // the same clamp path, so the triangle strip stays well-formed. + float r = mix(adjR0, adjR1, a_side); + float dTheta = half_border / max(r, 1.0); + float span = a_angles.y - a_angles.x; + dTheta = min(dTheta, span * 0.5); + float angle = mix(a_angles.x + dTheta, a_angles.y - dTheta, a_strip_t); + + vec2 pixel = u_center + vec2(cos(angle), sin(angle)) * r; + // Pixel → clip: origin top-left, Y flipped to match 2D canvas conv. + vec2 clip = (pixel / u_resolution) * 2.0 - 1.0; + gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0); + + v_color = a_color; + v_edge = vec2(a_strip_t, a_side); +} diff --git a/packages/viewer-charts/src/ts/shaders/tile.frag.glsl b/packages/viewer-charts/src/ts/shaders/tile.frag.glsl new file mode 100644 index 0000000..cac33dc --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/tile.frag.glsl @@ -0,0 +1,27 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +precision highp float; + +varying vec2 v_uv; + +uniform sampler2D u_tile; + +// Optional alpha multiplier (0.0..1.0). Used to fade the tile basemap +// when the chart's theme prefers a subdued backdrop behind the glyph +// layer; defaults to 1.0 so callers can omit it. +uniform float u_alpha; + +void main() { + vec4 c = texture2D(u_tile, v_uv); + gl_FragColor = vec4(c.rgb, c.a * u_alpha); +} diff --git a/packages/viewer-charts/src/ts/shaders/tile.vert.glsl b/packages/viewer-charts/src/ts/shaders/tile.vert.glsl new file mode 100644 index 0000000..78d8737 --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/tile.vert.glsl @@ -0,0 +1,35 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// Unit-quad vertex shader for one map tile. The same static [0..1] +// corner buffer is bound for every tile in a frame; per-tile uniforms +// stretch the corner into Mercator space and pick the UV sub-rect. +// Sub-rect support is for the parent-tile fallback path (sample the +// loaded parent at the child's quadrant while the child fetches). +attribute vec2 a_corner; + +uniform mat4 u_projection; +uniform vec2 u_extent_min; +uniform vec2 u_extent_max; +uniform vec2 u_uv_min; +uniform vec2 u_uv_max; + +varying vec2 v_uv; + +void main() { + vec2 pos = mix(u_extent_min, u_extent_max, a_corner); + // Tiles ship north-up; the WebGL Y axis points up at clip + // coordinates so flip the V channel before sampling. + vec2 uv = mix(u_uv_min, u_uv_max, vec2(a_corner.x, 1.0 - a_corner.y)); + v_uv = uv; + gl_Position = u_projection * vec4(pos, 0.0, 1.0); +} diff --git a/packages/viewer-charts/src/ts/shaders/treemap.frag.glsl b/packages/viewer-charts/src/ts/shaders/treemap.frag.glsl new file mode 100644 index 0000000..4bbc5fa --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/treemap.frag.glsl @@ -0,0 +1,19 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +precision highp float; + +varying vec4 v_color; + +void main() { + gl_FragColor = v_color; +} diff --git a/packages/viewer-charts/src/ts/shaders/treemap.vert.glsl b/packages/viewer-charts/src/ts/shaders/treemap.vert.glsl new file mode 100644 index 0000000..3334d19 --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/treemap.vert.glsl @@ -0,0 +1,25 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +attribute vec2 a_position; +attribute vec4 a_color; + +uniform vec2 u_resolution; + +varying vec4 v_color; + +void main() { + vec2 clip = (a_position / u_resolution) * 2.0 - 1.0; + clip.y = -clip.y; + gl_Position = vec4(clip, 0.0, 1.0); + v_color = a_color; +} diff --git a/packages/viewer-charts/src/ts/shaders/y-scatter.frag.glsl b/packages/viewer-charts/src/ts/shaders/y-scatter.frag.glsl new file mode 100644 index 0000000..7c3813b --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/y-scatter.frag.glsl @@ -0,0 +1,30 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +precision highp float; + +varying vec3 v_color; +varying float v_point_size; + +void main() { + vec2 coord = gl_PointCoord - vec2(0.5); + float dist = length(coord); + + if(dist > 0.5) + discard; + + // Anti-alias at the circle edge. + float pixelWidth = 1.5 / max(v_point_size, 1.0); + float alpha = 1.0 - smoothstep(0.5 - pixelWidth, 0.5, dist); + + gl_FragColor = vec4(v_color, alpha); +} diff --git a/packages/viewer-charts/src/ts/shaders/y-scatter.vert.glsl b/packages/viewer-charts/src/ts/shaders/y-scatter.vert.glsl new file mode 100644 index 0000000..75ae509 --- /dev/null +++ b/packages/viewer-charts/src/ts/shaders/y-scatter.vert.glsl @@ -0,0 +1,31 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// Per-vertex: one point per (series, category) sample. Color is carried +// per-vertex so a single draw call can render every scatter series with +// its palette color, no gradient lookup. + +attribute vec2 a_position; +attribute vec3 a_color; + +uniform mat4 u_projection; +uniform float u_point_size; + +varying vec3 v_color; +varying float v_point_size; + +void main() { + gl_Position = u_projection * vec4(a_position, 0.0, 1.0); + gl_PointSize = u_point_size; + v_point_size = u_point_size; + v_color = a_color; +} diff --git a/packages/viewer-charts/src/ts/theme/gradient.ts b/packages/viewer-charts/src/ts/theme/gradient.ts new file mode 100644 index 0000000..067b65f --- /dev/null +++ b/packages/viewer-charts/src/ts/theme/gradient.ts @@ -0,0 +1,312 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { parseCSSColorToVec3 } from "../utils/css"; + +/** + * A single stop on a parsed CSS gradient. `offset` ∈ [0, 1]. + */ +export interface GradientStop { + offset: number; + color: [number, number, number, number]; // RGBA, each ∈ [0, 1] +} + +const DEFAULT_STOPS: GradientStop[] = [ + { offset: 0, color: [0x03 / 255, 0x66 / 255, 0xd6 / 255, 1] }, + { offset: 1, color: [0xff / 255, 0x7f / 255, 0x0e / 255, 1] }, +]; + +/** + * Parse a `linear-gradient(...)` CSS expression into ordered stops. Tolerates + * missing percentages (distributes linearly between known offsets, matching + * the CSS standard) and leading direction tokens (`to right`, `90deg`, etc.) + * which are simply skipped. + * + * Returns the default blue → orange two-stop on any parse failure so themes + * that never set the gradient still produce sane output. + */ +export function parseCssGradient( + src: string | null | undefined, +): GradientStop[] { + if (!src) { + return DEFAULT_STOPS.slice(); + } + + const trimmed = src.trim(); + if (!trimmed) { + return DEFAULT_STOPS.slice(); + } + + // Strip the `linear-gradient(` wrapper. Bail out if we don't find it. + const openIdx = trimmed.indexOf("("); + if (openIdx < 0) { + return DEFAULT_STOPS.slice(); + } + + if (!/^linear-gradient\s*\(/i.test(trimmed)) { + return DEFAULT_STOPS.slice(); + } + + const closeIdx = trimmed.lastIndexOf(")"); + if (closeIdx <= openIdx) { + return DEFAULT_STOPS.slice(); + } + + const body = trimmed.substring(openIdx + 1, closeIdx); + + // Split on commas at depth 0 (respecting nested `rgb(...)` / `rgba(...)` / + // `hsl(...)` parens which also contain commas). + const parts: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + if (ch === "(") { + depth++; + } else if (ch === ")") { + depth--; + } else if (ch === "," && depth === 0) { + parts.push(body.substring(start, i)); + start = i + 1; + } + } + + parts.push(body.substring(start)); + + // First part may be a direction (`to right`, `90deg`, `to bottom right`) + // rather than a color-stop. Detect by: no matching color syntax. + const stops: Array<{ + color: [number, number, number]; + offset: number | null; + }> = []; + let startIdx = 0; + if (parts.length > 0) { + const firstNorm = parts[0].trim().toLowerCase(); + if ( + firstNorm.startsWith("to ") || + /^[-\d.]+(deg|rad|grad|turn)/.test(firstNorm) + ) { + startIdx = 1; + } + } + + for (let i = startIdx; i < parts.length; i++) { + const piece = parts[i].trim(); + if (!piece) { + continue; + } + + // Peel off an optional trailing `%` or `px`. + const pctMatch = piece.match(/\s([\-\d.]+)%\s*$/); + const color = pctMatch + ? piece.substring(0, pctMatch.index).trim() + : piece; + const offset = pctMatch ? parseFloat(pctMatch[1]) / 100 : null; + try { + const rgb = parseCSSColorToVec3(color); + stops.push({ color: rgb, offset }); + } catch { + // skip unparseable stop + } + } + + if (stops.length === 0) { + return DEFAULT_STOPS.slice(); + } + + if (stops.length === 1) { + // Single stop → solid color. Duplicate across [0, 1] so sampling works. + const [r, g, b] = stops[0].color; + return [ + { offset: 0, color: [r, g, b, 1] }, + { offset: 1, color: [r, g, b, 1] }, + ]; + } + + // Fill in missing offsets by linear interpolation of neighbours with + // known positions (CSS implicit-position semantics). + if (stops[0].offset === null) { + stops[0].offset = 0; + } + + if (stops[stops.length - 1].offset === null) { + stops[stops.length - 1].offset = 1; + } + + for (let i = 1; i < stops.length - 1; i++) { + if (stops[i].offset !== null) { + continue; + } + + // Find next known offset. + let j = i + 1; + while (j < stops.length && stops[j].offset === null) { + j++; + } + + const before = stops[i - 1].offset!; + const after = stops[j].offset!; + const span = j - (i - 1); + for (let k = i; k < j; k++) { + stops[k].offset = + before + ((k - (i - 1)) / span) * (after - before); + } + + i = j - 1; + } + + // Clamp offsets to [0, 1] and ensure non-decreasing order. + let prev = 0; + const result: GradientStop[] = stops.map((s) => { + const off = Math.max(prev, Math.min(1, s.offset!)); + prev = off; + return { + offset: off, + color: [s.color[0], s.color[1], s.color[2], 1], + }; + }); + + return result; +} + +/** + * Piecewise-linear color sample at `t ∈ [0, 1]`. Returns RGBA in [0, 1]. + * Clamps `t` to the gradient's first/last stop outside `[0, 1]`. + */ +export function sampleGradient( + stops: GradientStop[], + t: number, +): [number, number, number, number] { + if (stops.length === 0) { + return [0, 0, 0, 1]; + } + + if (t <= stops[0].offset) { + return stops[0].color.slice() as [number, number, number, number]; + } + + const last = stops[stops.length - 1]; + if (t >= last.offset) { + return last.color.slice() as [number, number, number, number]; + } + + // Bisect for the interval containing `t`. + let lo = 0; + let hi = stops.length - 1; + while (hi - lo > 1) { + const mid = (lo + hi) >> 1; + if (stops[mid].offset <= t) { + lo = mid; + } else { + hi = mid; + } + } + + const a = stops[lo]; + const b = stops[hi]; + const span = b.offset - a.offset; + const u = span > 0 ? (t - a.offset) / span : 0; + return [ + a.color[0] + (b.color[0] - a.color[0]) * u, + a.color[1] + (b.color[1] - a.color[1]) * u, + a.color[2] + (b.color[2] - a.color[2]) * u, + a.color[3] + (b.color[3] - a.color[3]) * u, + ]; +} + +/** + * Sign-aware normalization. Returns `t ∈ [0, 1]` where the 50% stop is + * always the sign pivot: + * - crosses zero → `[-maxAbs, maxAbs]` stretched symmetrically; 0 → 0.5. + * - all-positive → `[0, colorMax]` occupies top half `[0.5, 1]`. + * - all-negative → `[colorMin, 0]` occupies bottom half `[0, 0.5]`. + * - degenerate → 0.5 (single colour at the midpoint). + */ +export function colorValueToT( + value: number, + colorMin: number, + colorMax: number, +): number { + if (!isFinite(value) || colorMin === colorMax) { + return 0.5; + } + + let denom: number; + if (colorMin >= 0) { + denom = colorMax; + } else if (colorMax <= 0) { + denom = -colorMin; + } else { + denom = Math.max(-colorMin, colorMax); + } + + if (denom <= 0) { + return 0.5; + } + + const t = 0.5 + 0.5 * (value / denom); + return t < 0 ? 0 : t > 1 ? 1 : t; +} + +/** + * Convert a discrete series palette (from `--psp-charts--series-N--color`) + * into a `GradientStop[]` with stops at `i / (N - 1)`. The resulting + * stops can feed `buildGradientLUT` / `ensureGradientTexture` / any + * other code path that already accepts a gradient — so categorical + * coloring and numeric gradients share one LUT pipeline. Integer idx + * sampling via `t = idx / (N - 1)` lands exactly on a palette color; + * the linear blend between stops is only hit by non-integer samples + * (which categorical data doesn't produce). + */ +export function paletteToStops( + palette: [number, number, number][], +): GradientStop[] { + if (palette.length === 0) { + return DEFAULT_STOPS.slice(); + } + + if (palette.length === 1) { + const [r, g, b] = palette[0]; + return [ + { offset: 0, color: [r, g, b, 1] }, + { offset: 1, color: [r, g, b, 1] }, + ]; + } + + const denom = palette.length - 1; + return palette.map(([r, g, b], i) => ({ + offset: i / denom, + color: [r, g, b, 1], + })); +} + +/** + * Bake a sampled LUT for GPU upload as RGBA8 (`size × 1`). Default 256 + * samples — visually indistinguishable from a denser sample at typical + * viewport sizes and keeps the texture tiny (1 KB). + */ +export function buildGradientLUT( + stops: GradientStop[], + size: number = 256, +): Uint8Array { + const out = new Uint8Array(size * 4); + for (let i = 0; i < size; i++) { + const t = size === 1 ? 0 : i / (size - 1); + const c = sampleGradient(stops, t); + out[i * 4] = Math.round(c[0] * 255); + out[i * 4 + 1] = Math.round(c[1] * 255); + out[i * 4 + 2] = Math.round(c[2] * 255); + out[i * 4 + 3] = Math.round(c[3] * 255); + } + + return out; +} diff --git a/packages/viewer-charts/src/ts/theme/palette.ts b/packages/viewer-charts/src/ts/theme/palette.ts new file mode 100644 index 0000000..ac72a72 --- /dev/null +++ b/packages/viewer-charts/src/ts/theme/palette.ts @@ -0,0 +1,61 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { sampleGradient, type GradientStop } from "./gradient"; + +export type Vec3 = [number, number, number]; + +/** + * Build a series palette of length `count` by sampling the theme gradient + * at evenly-spaced offsets. For count == 1 returns the 50% stop. + */ +function interpolatePalette(stops: GradientStop[], count: number): Vec3[] { + if (count <= 0) { + return []; + } + + const out: Vec3[] = new Array(count); + for (let i = 0; i < count; i++) { + const t = count === 1 ? 0.5 : i / (count - 1); + const c = sampleGradient(stops, t); + out[i] = [c[0], c[1], c[2]]; + } + + return out; +} + +/** + * Resolve a series palette: use the discrete `--psp-charts--series-N--color` + * palette when available, otherwise fall back to evenly-spaced samples of + * the theme gradient. + */ +export function resolvePalette( + discrete: Vec3[], + stops: GradientStop[], + count: number, +): Vec3[] { + if (discrete.length > 0) { + if (discrete.length >= count) { + return discrete.slice(0, count); + } + + // Cycle through the discrete palette for overflow indices. + const out: Vec3[] = new Array(count); + for (let i = 0; i < count; i++) { + out[i] = discrete[i % discrete.length]; + } + + return out; + } + + return interpolatePalette(stops, count); +} diff --git a/packages/viewer-charts/src/ts/theme/theme-snapshot.ts b/packages/viewer-charts/src/ts/theme/theme-snapshot.ts new file mode 100644 index 0000000..fd08d02 --- /dev/null +++ b/packages/viewer-charts/src/ts/theme/theme-snapshot.ts @@ -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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { ThemeSnapshot } from "./theme"; + +/** + * CSS variables (and one inherited property) the chart renderer reads. + * Host-only: workers can't call `getComputedStyle`, so this list (and + * `snapshotThemeVars`) doesn't need to ship in the worker bundle. + */ +const THEME_VARS = [ + "--psp-charts--font-family", + "font-family", + "--psp--background-color", + "--psp-charts--axis-ticks--color", + "--psp--color", + "--psp-charts--axis-lines--color", + "--psp-charts--gridline--color", + "--psp-charts--gradient--background", + "--psp-charts--full-gradient--background", + "--psp-charts--legend--color", + "--psp-charts--legend-border--color", + "--psp-charts--tooltip--background", + "--psp-charts--tooltip--color", + "--psp-charts--tooltip--border-color", + "--psp-charts--area--opacity", + "--psp-charts--heatmap-gap--px", + "--psp-charts--sunburst-gap--px", +]; + +/** + * Capture every CSS variable the renderer cares about into a + * structured-cloneable map. Series-palette colours are walked until the + * first missing var. + */ +export function snapshotThemeVars(el: Element): ThemeSnapshot { + const style = getComputedStyle(el); + const out: ThemeSnapshot = {}; + for (const v of THEME_VARS) { + const raw = style.getPropertyValue(v).trim(); + if (raw) { + out[v] = raw; + } + } + + for (let i = 1; ; i++) { + const key = `--psp-charts--series-${i}--color`; + const raw = style.getPropertyValue(key).trim(); + if (!raw) { + break; + } + + out[key] = raw; + } + + return out; +} diff --git a/packages/viewer-charts/src/ts/theme/theme.ts b/packages/viewer-charts/src/ts/theme/theme.ts new file mode 100644 index 0000000..d89d462 --- /dev/null +++ b/packages/viewer-charts/src/ts/theme/theme.ts @@ -0,0 +1,166 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { parseCSSColorToVec3 } from "../utils/css"; +import { parseCssGradient, type GradientStop } from "./gradient"; + +export type { GradientStop } from "./gradient"; + +function clampOpacity(v: number): number { + if (!isFinite(v)) { + return 0.5; + } + + if (v < 0) { + return 0; + } + + if (v > 1) { + return 1; + } + + return v; +} + +function clampGap(v: number): number { + if (!isFinite(v) || v < 0) { + return 1; + } + + return v; +} + +export interface Theme { + fontFamily: string; + tickColor: string; + labelColor: string; + axisLineColor: string; + gridlineColor: string; + backgroundColor: string; + gradientStops: GradientStop[]; + legendText: string; + legendBorder: string; + tooltipBg: string; + tooltipText: string; + tooltipBorder: string; + + /** + * Fill opacity for area glyphs. `--psp-charts--area--opacity`. + */ + areaOpacity: number; + + /** + * Pixel gap between heatmap cells. Controls the inset applied in the + * heatmap vertex shader so neighbouring cells remain visually + * distinguishable. `--psp-charts--heatmap-gap--px`. + */ + heatmapGapPx: number; + + /** + * Pixel gap between sunburst arcs (both radial — between ring + * levels — and angular — between siblings). Works the same way as + * `heatmapGapPx`: a symmetric inset in the vertex shader so the + * transparent background shows through as a border. + * `--psp-charts--sunburst-gap--px`. + */ + sunburstGapPx: number; + + /** + * Discrete series palette read from `--psp-charts--series-N--color` + * (N = 1, 2, …). Empty when no palette is defined — callers should + * fall back to `gradientStops` sampling in that case. + */ + seriesPalette: [number, number, number][]; +} + +/** + * Plain map of CSS variable name → resolved value. Produced on the + * main thread via `snapshotThemeVars` and shipped to the worker + * Renderer (which has no DOM and can't call `getComputedStyle`). + */ +export type ThemeSnapshot = Record; + +/** + * Decode a `ThemeSnapshot` into the parsed `Theme` the renderer + * consumes. Workers reach this from a serialized snapshot; host code + * snapshots from the live DOM via `theme-snapshot.ts` and feeds it + * through here. + */ +export function resolveThemeFromVars(vars: ThemeSnapshot): Theme { + const get = (prop: string, fallback: string): string => + vars[prop] || fallback; + + const gradientSrc = + vars["--psp-charts--gradient--background"] || + vars["--psp-charts--full-gradient--background"] || + "linear-gradient(#0366d6 0%, #ff7f0e 100%)"; + + const gradientStops = parseCssGradient(gradientSrc); + const seriesPalette: [number, number, number][] = []; + for (let i = 1; ; i++) { + const raw = vars[`--psp-charts--series-${i}--color`]; + if (!raw) { + break; + } + + seriesPalette.push(parseCSSColorToVec3(raw)); + } + + return { + fontFamily: get( + "--psp-charts--font-family", + get("font-family", "monospace"), + ), + backgroundColor: get( + "--psp--background-color", + "rgba(255, 255, 255, 1)", + ), + tickColor: get( + "--psp-charts--axis-ticks--color", + "rgba(160, 0, 0, 0.8)", + ), + labelColor: get("--psp--color", "rgba(180, 0, 0, 0.9)"), + axisLineColor: get( + "--psp-charts--axis-lines--color", + "rgba(160, 0, 0, 0.4)", + ), + gridlineColor: get( + "--psp-charts--gridline--color", + "rgba(255, 0, 0, 1)", + ), + gradientStops, + legendText: get( + "--psp-charts--legend--color", + "rgba(180, 180, 180, 0.9)", + ), + legendBorder: get( + "--psp-charts--legend-border--color", + "rgba(128,128,128,0.3)", + ), + tooltipBg: get( + "--psp-charts--tooltip--background", + "rgba(155,155,155,0.8)", + ), + tooltipText: get("--psp-charts--tooltip--color", "#161616"), + tooltipBorder: get("--psp-charts--tooltip--border-color", "#fff"), + areaOpacity: clampOpacity( + parseFloat(get("--psp-charts--area--opacity", "0.85")), + ), + heatmapGapPx: clampGap( + parseFloat(get("--psp-charts--heatmap-gap--px", "0")), + ), + sunburstGapPx: clampGap( + parseFloat(get("--psp-charts--sunburst-gap--px", "1")), + ), + seriesPalette, + }; +} diff --git a/packages/viewer-charts/src/ts/transport/protocol.ts b/packages/viewer-charts/src/ts/transport/protocol.ts new file mode 100644 index 0000000..331df24 --- /dev/null +++ b/packages/viewer-charts/src/ts/transport/protocol.ts @@ -0,0 +1,492 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { FacetConfig, PluginConfig } from "../charts/chart"; +import type { PerspectiveClickDetail } from "../event-detail"; +import type { ThemeSnapshot } from "../theme/theme"; +import type { ViewConfig } from "@perspective-dev/client"; + +export type { ThemeSnapshot }; + +/** + * Worker-mode control-channel messages. Distinct from the perspective + * `ProxySession` channel that the worker's `Client` uses to talk to the + * host's real `Client` — that's pure protobuf bytes; this one is the + * chart's own renderer control plane. + */ +export type ControlMsg = + | InitMsg + | SetViewByNameMsg + | SetColumnsConfigMsg + | SetPluginConfigMsg + | SetBufferMaxCapacityMsg + | LoadAndRenderMsg + | RedrawMsg + | ResizeMsg + | ClearMsg + | InvalidateThemeMsg + | RestoreZoomMsg + | ResetAllZoomsMsg + | ResetExpandedDomainMsg + | SaveZoomReqMsg + | SnapshotPngReqMsg + | InteractionMsg + | DestroyMsg; + +export type WorkerMsg = + | ReadyMsg + | ZoomChangedMsg + | SaveZoomReplyMsg + | SnapshotPngReplyMsg + | PinTooltipMsg + | DismissTooltipMsg + | SetCursorMsg + | UserClickMsg + | UserSelectMsg + | LoadAndRenderAckMsg + | FrameBitmapMsg + | ErrorMsg; + +/** + * Session-tagged envelopes for the shared-worker transport. Every + * message between the host and the shared `Worker` carries a numeric + * `sessionId` that addresses a specific `WorkerRenderer` slot in the + * worker's `RENDERERS` map. + * + * In-process mode bypasses these — its `MessageChannel` is per- + * transport, so messages are already private and routing isn't + * needed. Only worker-mode `RendererTransport` and the worker-scope + * message handler wrap / unwrap envelopes. + */ +export interface ControlEnvelope { + sessionId: number; + msg: ControlMsg; +} + +export interface WorkerEnvelope { + sessionId: number; + msg: WorkerMsg; +} + +export interface InitMsg { + kind: "init"; + + /** + * GL canvas display strategy. `"direct"` means `glCanvas` below is + * the host's `.webgl-canvas` transferred via + * `transferControlToOffscreen` and the renderer paints straight + * into it. `"blit"` means `glCanvas` is omitted; the renderer + * allocates its own internal `OffscreenCanvas`, renders into it, + * and posts each completed frame back as a `FrameBitmapMsg` for + * the host to draw into a 2D-context display canvas. + */ + renderMode: "direct" | "blit"; + + /** + * Transferred via `transferControlToOffscreen` on the host. Present + * iff `renderMode === "direct"`. In blit mode the renderer + * constructs its own offscreen surface from `cssWidth`/`cssHeight`/ + * `dpr` and there is no host-side GL drawing buffer. + */ + glCanvas?: OffscreenCanvas; + gridlinesCanvas: OffscreenCanvas; + chromeCanvas: OffscreenCanvas; + + /** + * `MessagePort` to the host's `ProxySession`. Worker mode only — + * the worker bootstraps a fresh `Client` and bridges it through + * this port. In-process mode uses the host's `Client` directly + * (handed in via `bootstrapInProcess`'s `client` option), so no + * proxy bridge is needed. + */ + proxyPort?: MessagePort; + + /** + * Compiled perspective-js client wasm forwarded from the host. + * Worker mode only — passed to `module.initSync(...)` after the + * worker dynamic-imports `clientWorkerURL`. In-process mode + * inherits the host's already-bound wasm via the supplied + * `Client` instance. + */ + clientWasm?: WebAssembly.Module; + + /** + * URL the worker uses to dynamic-import the perspective-viewer + * wasm-bindgen JS module. Worker mode only — required because + * the worker scope can't share module instances with the host. + * In-process mode uses the host's already-loaded module via + * the supplied `Client` instance. + */ + clientWorkerURL?: URL; + + /** + * `ChartTypeConfig.tag` — selects which `ChartImplementation` to + * construct in the worker. The worker has its own copy of the + * chart class registry. + */ + chartTag: string; + + /** + * Server-assigned `View` name for `client.__unsafe_open_view(name)`. + */ + viewName: string; + + /** + * `Table` name for the worker to resolve via `client.open_table(...)` + * once at bootstrap. Used for source-schema lookups (group-by level + * types) on the render path so the host doesn't have to await + * `table.schema()` on every draw. May be omitted if the host viewer + * has no table loaded yet — `loadAndRender` falls back to an empty + * source schema in that case. + */ + tableName?: string; + facetConfig: FacetConfig; + + /** + * Initial plugin-scoped global config. Seeds the chart impl's + * `_pluginConfig` before the first `loadAndRender` so the build + * pipeline (`auto_alt_y_axis`, `band_inner_frac`, `bar_inner_pad`) + * and render-path uniforms see correct values on the very first + * frame. The host's later `restore({ plugin_config })` arrives as + * a `setPluginConfig` control msg. + */ + pluginConfig: PluginConfig; + defaultChartType?: string; + + /** + * Pre-resolved CSS-variable theme snapshot from the host. + */ + themeVars: ThemeSnapshot; + + /** + * `@font-face` rules captured from the host document, to be + * re-loaded into the worker's `self.fonts` set before first + * paint. Worker mode only — workers don't share `FontFaceSet` + * with the document, so any font referenced via `font-family` + * must be reloaded there. In-process mode shares + * `document.fonts` with the host so the array is empty / unused. + * See `snapshotFontFaces()` for CORS / scope caveats. + */ + fontFaces: FontFaceDescriptor[]; + + /** + * Initial CSS size + DPR; subsequent resizes arrive as `resize`. + */ + cssWidth: number; + cssHeight: number; + dpr: number; + + /** + * `ChartTypeConfig.max_cells` for the buffer pool. + */ + bufferMaxCapacity: number; + + /** + * If `true`, the `WebGLContextManager` constructed on the + * renderer side compiles + links every shader in + * `SHADER_MANIFEST` during construction. Trades a known init-time + * cost for elimination of inline compile latency on first frame. + * Default behavior (when undefined) is lazy compilation as a + * side effect of each glyph's first `getOrCreate` call. + */ + precompileShaders?: boolean; +} + +/** + * Plain-object form of an `@font-face` rule, structured-cloneable for + * `postMessage`. The worker reconstitutes a `FontFace` via + * `new FontFace(family, src, descriptors)`, awaits its load, and + * registers it in `self.fonts`. + * + * `src` is the raw CSS `src:` value (e.g. + * `url(https://…/foo.woff2) format("woff2")`), with every `url(...)` + * already absolutized on the host against the parent stylesheet's + * `href` — the worker's script URL is a Blob URL, so relative URLs + * would otherwise fail to resolve. + */ +export interface FontFaceDescriptor { + family: string; + src: string; + style?: string; + weight?: string; + stretch?: string; + unicodeRange?: string; + variant?: string; + featureSettings?: string; + display?: string; +} + +export interface SetViewByNameMsg { + kind: "setViewByName"; + name: string; +} + +export interface SetColumnsConfigMsg { + kind: "setColumnsConfig"; + cfg: Record; +} + +/** + * Host → worker: replace the chart impl's `_pluginConfig` with a new + * snapshot. Sent on every `plugin.restore({ plugin_config })`. The + * chart re-syncs derived state (`_facetConfig.facet_mode`, + * `_facetConfig.zoom_mode`, `_autoFitValue`) in `setPluginConfig` and + * the host posts a `redraw` so render-path uniform changes (line + * widths, point size) take effect on the next frame. Build-time + * fields (`auto_alt_y_axis`, `band_inner_frac`, `bar_inner_pad`) take + * effect on the next `loadAndRender`. + */ +export interface SetPluginConfigMsg { + kind: "setPluginConfig"; + cfg: PluginConfig; +} + +export interface SetBufferMaxCapacityMsg { + kind: "setBufferMaxCapacity"; + n: number; +} + +/** + * Host → worker: trigger a full data-fetch + render cycle. The worker + * resolves all schema / row-count metadata against its own `View` and + * `Table` (no host-side `Client`/`Table`/`View` await on the render + * path), runs `view.with_typed_arrays`, and uploads the resulting + * column data straight into the chart impl on the same thread — + * eliminating the prior cross-boundary `postMessage` of column buffers. + * + * `viewerConfig` ships the bits the worker can't read for itself + * (`` element APIs, not `Client`/`Table`/`View`). + * + * Mid-flight cancellation: each call increments a worker-side + * generation counter. A newer `loadAndRender` arriving while one is in + * flight causes the older call's `with_typed_arrays` callback to throw + * a sentinel before any chart mutation, unwinding the wasm Arrow + * buffer release before the new call proceeds. Both calls reply with + * `loadAndRenderAck` so the host promise resolves either way. + */ +export interface LoadAndRenderMsg { + kind: "loadAndRender"; + msgId: number; + viewerConfig: { + group_by: string[]; + split_by: string[]; + columns: (string | null)[]; + }; + options: { + float32: boolean; + }; +} + +/** + * Worker → host reply to a `LoadAndRenderMsg`. Always sent — including + * on stale-generation drop — so the host's awaited promise resolves + * deterministically. + */ +export interface LoadAndRenderAckMsg { + kind: "loadAndRenderAck"; + msgId: number; +} + +export interface RedrawMsg { + kind: "redraw"; +} + +export interface ResizeMsg { + kind: "resize"; + cssWidth: number; + cssHeight: number; + dpr: number; +} + +export interface ClearMsg { + kind: "clear"; +} + +export interface InvalidateThemeMsg { + kind: "invalidateTheme"; + + /** + * Fresh CSS-variable snapshot — the worker can't read DOM. + */ + themeVars: ThemeSnapshot; +} + +export interface RestoreZoomMsg { + kind: "restoreZoom"; + state: any; +} + +export interface ResetAllZoomsMsg { + kind: "resetAllZooms"; +} + +/** + * Host → worker: clear the chart's `domain_mode: "expand"` accumulator + * so the next data load starts from a fresh extent. Sent at the head + * of every `plugin.draw()` (which always indicates a view-level + * change). `plugin.update()` does not send this — same view, more + * data, the accumulator should keep growing. + */ +export interface ResetExpandedDomainMsg { + kind: "resetExpandedDomain"; +} + +export interface SaveZoomReqMsg { + kind: "saveZoom"; + requestId: number; +} + +/** + * Host → worker: ask the renderer to flush a frame and return a PNG of + * the composited canvas stack. The reply ships a `Blob` correlated to + * the request via `requestId` (allocated by the host's + * `RendererTransport.snapshotPng()`). + */ +export interface SnapshotPngReqMsg { + kind: "snapshotPng"; + requestId: number; +} + +/** + * Worker → host reply to a `SnapshotPngReqMsg`. Resolves the + * corresponding host-side promise with the encoded `Blob`. + */ +export interface SnapshotPngReplyMsg { + kind: "snapshotPngReply"; + requestId: number; + blob: Blob; +} + +export interface DestroyMsg { + kind: "destroy"; +} + +/** + * Raw pointer / wheel event forwarded from the host's + * `RawEventForwarder` to the renderer. Coordinates are canvas-relative + * CSS pixels (host already subtracted `getBoundingClientRect`). + * + * `pointerdown` carries `pointerId` so the host can drive + * `setPointerCapture` while the corresponding `pointermove` / + * `pointerup` events fire even when the cursor leaves the canvas. + * + * `pointermove` drives both pan (when a drag is active) and tooltip + * hover (when not). `pointerleave` drives tooltip leave. `click` / + * `dblclick` drive tooltip click handling. One channel per cursor + * stream — no parallel `mouse*` mirror. + */ +export type InteractionEvent = + | { type: "wheel"; mx: number; my: number; deltaY: number } + | { type: "pointerdown"; mx: number; my: number; pointerId: number } + | { type: "pointermove"; mx: number; my: number } + | { type: "pointerup" } + | { type: "pointerleave" } + | { type: "click"; mx: number; my: number } + | { type: "dblclick"; mx: number; my: number }; + +export interface InteractionMsg { + kind: "interaction"; + event: InteractionEvent; +} + +export interface ReadyMsg { + kind: "ready"; +} + +export interface ZoomChangedMsg { + kind: "zoomChanged"; + isDefault: boolean; +} + +export interface SaveZoomReplyMsg { + kind: "saveZoomReply"; + requestId: number; + state: any; +} + +export interface ErrorMsg { + kind: "error"; + message: string; +} + +/** + * Worker-side request to render a pinned tooltip on the host. The + * worker has no DOM, so the persistent tooltip `
    ` is materialized + * by `RendererTransport` (via a `DomHostSink`) on receipt. `bounds` ships + * the chart's CSS size so the host can clamp the tooltip without + * reading the canvas geometry itself. + */ +export interface PinTooltipMsg { + kind: "pinTooltip"; + lines: string[]; + pos: { px: number; py: number }; + bounds: { cssWidth: number; cssHeight: number }; +} + +export interface DismissTooltipMsg { + kind: "dismissTooltip"; +} + +/** + * Renderer → host: set the GL canvas's `style.cursor`. The renderer + * has no DOM (worker mode) — `cursor` is a CSS cursor value + * (`"pointer"`, `"default"`, etc.) applied directly by the host on + * receipt. + */ +export interface SetCursorMsg { + kind: "setCursor"; + cursor: string; +} + +/** + * Renderer → host: a user click landed on a chart glyph. Host + * re-dispatches as `CustomEvent` on the + * `` ancestor. Payload is a plain object so it + * survives `postMessage` without losing the class prototype. + */ +export interface UserClickMsg { + kind: "userClick"; + detail: PerspectiveClickDetail; +} + +/** + * Renderer → host: a user click pinned or unpinned a chart target. + * Host materializes a `PerspectiveSelectDetail` from this payload plus + * its own cached previous-insert config and dispatches as + * `CustomEvent` (`perspective-global-filter`). + * `removeConfigs` is computed host-side — not sent. + */ +export interface UserSelectMsg { + kind: "userSelect"; + selected: boolean; + row: Record; + column_names: string[]; + insertConfig: Partial; +} + +/** + * Worker → host: a completed GL frame, materialized as an + * `ImageBitmap` from the renderer's internal offscreen canvas via + * `transferToImageBitmap()`. Sent only in `renderMode === "blit"`, + * after each `_fullRender` completes. The host draws the bitmap into + * its 2D-context display canvas and calls `bitmap.close()` to release + * the GPU-backed surface. + * + * The bitmap MUST appear in the postMessage transfer list — the + * underlying surface is moved, not copied, so failing to transfer + * renders the host's drawImage a no-op (or worse, a Safari crash on + * older WebKits). + */ +export interface FrameBitmapMsg { + kind: "frameBitmap"; + bitmap: ImageBitmap; +} diff --git a/packages/viewer-charts/src/ts/transport/renderer-transport.ts b/packages/viewer-charts/src/ts/transport/renderer-transport.ts new file mode 100644 index 0000000..6c9a4dc --- /dev/null +++ b/packages/viewer-charts/src/ts/transport/renderer-transport.ts @@ -0,0 +1,788 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { Client, View, ViewConfig } from "@perspective-dev/client"; +import type { FacetConfig, PluginConfig } from "../charts/chart"; +import type { + ControlMsg, + InitMsg, + InteractionEvent, + LoadAndRenderMsg, + WorkerEnvelope, + WorkerMsg, +} from "./protocol"; +import { + PerspectiveSelectDetail, + type PerspectiveClickDetail, +} from "../event-detail"; +import { snapshotThemeVars } from "../theme/theme-snapshot"; +import { snapshotFontFaces } from "../utils/font-snapshot"; +import { DomHostSink } from "../interaction/host-sink-dom"; +import { RUNTIME_MODE } from "../config"; + +// @ts-ignore — resolved at build time by `@perspective-dev/esbuild-plugin/worker` +import getWorkerURL from "../worker/renderer.worker.js"; + +/** + * Module-level shared `Worker` for every `RendererTransport` running + * in worker mode. One worker process hosts one `WorkerRenderer` per + * active `sessionId` — N chart instances share startup costs (wasm + * `initSync`, font loads, JS module parse) instead of paying them N + * times. + * + * Lazy: created on first `transport.init()` in worker mode. Lives + * until page teardown — no refcount, no termination logic. Pages + * with no charts never spawn one. Per-session memory still scales + * with N (each session retains its own WebGL context, buffer pool, + * chart impl, view, client); the browser's ~16-context-per-worker + * cap is the new ceiling on simultaneous worker-mode charts. + * + * In-process mode bypasses this entirely — each transport gets its + * own `MessageChannel` + in-thread `WorkerRenderer`. + */ +let SHARED_WORKER: Promise | null = null; + +/** + * Per-session message handlers, keyed by the host-allocated + * `sessionId`. The shared worker's response listener demultiplexes + * incoming envelopes into here. + */ +const HOST_LISTENERS = new Map void>(); + +let NEXT_SESSION_ID = 0; + +async function getSharedWorker(): Promise { + if (SHARED_WORKER) { + return SHARED_WORKER; + } + + SHARED_WORKER = (async () => { + const url = await getWorkerURL(); + const w = new Worker(url, { type: "module", name: "viewer-charts" }); + w.addEventListener("message", (e: MessageEvent) => { + const env = e.data as WorkerEnvelope; + HOST_LISTENERS.get(env.sessionId)?.(env.msg); + }); + return w; + })(); + + return SHARED_WORKER; +} + +interface RendererHandle { + post(msg: any, transfer: Transferable[]): void; + addMessageListener(cb: (msg: any) => void): void; + terminate(): void; +} + +type PendingRenderType = "saveZoom" | "loadAndRender" | "snapshotPng"; +interface PendingRenderRequest { + kind: PendingRenderType; + resolve: (v: any) => void; + reject: (e: Error) => void; +} + +/** + * Unified host-side driver for the chart renderer. Owns one of two + * handle shapes: + * + * - **Worker mode**: a real `Worker` running the same module. The + * handle posts `ControlMsg`s over `Worker.postMessage`. + * - **In-process mode**: a `MessageChannel` whose `port2` is owned + * by an in-thread `WorkerRenderer` instantiated via + * `await import(workerURL)`. Same module bytes, different host. + * + * Both modes go through the same control channel, the same + * `ProxySession` proxy port, and the same `OffscreenCanvas` transfer + * — `MessageChannel` and `transferControlToOffscreen` work in-realm + * just as well as cross-thread. The only branching is at construction + * (handle creation) and bootstrap (worker scope sets up its own + * `Client`; in-process reuses the host's). + */ +export class RendererTransport { + private _handle: RendererHandle | null = null; + private _proxyChannel: MessageChannel | null = null; + private _proxySession: any = null; + private _client: Client; + private _view: View; + private _tableName: string | undefined; + private _clientWorkerURL: URL; + private _clientWasm: WebAssembly.Module; + private _chartTag: string; + private _maxCells: number; + private _precompileShaders: boolean; + private _ready: Promise; + private _resolveReady!: () => void; + private _rejectReady!: (err: Error) => void; + + /** + * Pending request/reply promises across all worker round-trips — + * `saveZoom`, `uploadChunk` ACKs, and `snapshotPng`. Each entry + * carries its `kind` so `destroy()` can apply per-kind teardown + * semantics (uploadChunk resolves silently, the rest reject with + * a teardown error). + * + * Keyed by a single monotonic counter; the worker's reply messages + * carry that id back verbatim. One counter for all kinds is safe + * because the host's switch already keys on `msg.kind` before + * resolving. + */ + private _pending = new Map(); + + private _pendingCounter = 0; + private _onZoomChanged: ((isDefault: boolean) => void) | null = null; + + /** + * Cached zoom-default flag pushed by the renderer after each zoom + * mutation. Surfaced sync via `allZoomsDefault()`; updates between + * calls are best-effort. + */ + private _allZoomsDefault = true; + private _hostGlCanvas: HTMLCanvasElement | null = null; + + /** + * Blit-mode only: the visible `.webgl-canvas`'s 2D context. The + * worker emits each completed GL frame as a `FrameBitmapMsg`; on + * receipt we `drawImage` the bitmap into this context and `close()` + * it to release the GPU surface. Null in direct mode (the visible + * canvas's drawing buffer is the worker's transferred GL canvas). + */ + private _displayCtx: CanvasRenderingContext2D | null = null; + + /** + * Host-side sink for tooltip + cursor side-effects. The chart + * inside the renderer calls into a `MessageHostSink` that posts + * `pinTooltip` / `dismissTooltip` / `setCursor` over the control + * channel; this sink applies them to the DOM. Initialized lazily + * on first signal so we don't pay for the parent-style lookup + * unless a user interacts. + */ + private _hostSink: DomHostSink | null = null; + + /** + * Last `insertConfig` accepted by a `userSelect { selected: true }` + * message. Used to populate `removeConfigs` on the next + * `selected: false` (unpin / drill-up / view-change) — mirrors + * datagrid's `model._last_insert_configs` so coordinated-filter + * consumers can roll back the previous select when a new one + * supplants it. + */ + private _lastInsertConfig: Partial | undefined = undefined; + + constructor(opts: { + client: Client; + view: View; + tableName?: string; + clientWasm: WebAssembly.Module; + clientWorkerURL: URL; + chartTag: string; + maxCells: number; + precompileShaders?: boolean; + onZoomChanged?: (isDefault: boolean) => void; + }) { + this._client = opts.client; + this._view = opts.view; + this._tableName = opts.tableName; + this._clientWorkerURL = opts.clientWorkerURL; + this._clientWasm = opts.clientWasm; + this._chartTag = opts.chartTag; + this._maxCells = opts.maxCells; + this._precompileShaders = opts.precompileShaders ?? false; + this._onZoomChanged = opts.onZoomChanged ?? null; + this._ready = new Promise((resolve, reject) => { + this._resolveReady = resolve; + this._rejectReady = reject; + }); + } + + async init(opts: { + gl: HTMLCanvasElement; + gridlines: HTMLCanvasElement; + chrome: HTMLCanvasElement; + facetConfig: FacetConfig; + pluginConfig: PluginConfig; + defaultChartType?: string; + renderBlitMode: "blit" | "direct"; + }): Promise { + this._hostGlCanvas = opts.gl; + const workerURL: string = await getWorkerURL(); + + // Worker mode: bridge the worker's fresh `Client` (instantiated + // in `bootstrapWorker` from `clientWasm` + `clientWorkerURL`) + // back to the host's real `Client` via a `ProxySession` over a + // dedicated `MessageChannel`. + // + // In-process mode skips this entirely — `bootstrapInProcess` + // is handed the host's `Client` directly, so there's no + // worker-side `Client` to bridge. The proxy port would just + // dangle. + if (RUNTIME_MODE === "worker") { + this._proxyChannel = new MessageChannel(); + this._proxySession = (this._client as any).new_proxy_session( + (bytes: Uint8Array) => { + const buf = bytes.slice().buffer; + this._proxyChannel!.port1.postMessage(buf, [buf]); + }, + ); + + this._proxyChannel.port1.addEventListener( + "message", + (e: MessageEvent) => { + this._proxySession.handle_request(new Uint8Array(e.data)); + }, + ); + + this._proxyChannel.port1.start(); + } + + // Blit mode keeps the visible `.webgl-canvas` main-thread with + // a 2D context — the renderer paints into its own internal + // `OffscreenCanvas` and ships each completed frame back as an + // `ImageBitmap`. Direct mode transfers the visible canvas's + // drawing buffer to the renderer so GL paints straight to + // screen. + let glOC: OffscreenCanvas | undefined; + if (opts.renderBlitMode === "blit") { + this._displayCtx = opts.gl.getContext("2d"); + } else { + glOC = opts.gl.transferControlToOffscreen(); + } + + const gridlinesOC = opts.gridlines.transferControlToOffscreen(); + const chromeOC = opts.chrome.transferControlToOffscreen(); + const rect = opts.gl.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + const themeVars = snapshotThemeVars(opts.gl); + + // Worker mode forwards `@font-face` rules so the worker's + // separate `FontFaceSet` can resolve `ctx.font` family names. + // In-process mode shares `document.fonts` with the host — + // omit the descriptors entirely. + const fontFaces = RUNTIME_MODE === "worker" ? snapshotFontFaces() : []; + const clientWasm = + RUNTIME_MODE === "worker" ? this._clientWasm : undefined; + + const clientWorkerURL = + RUNTIME_MODE === "worker" ? this._clientWorkerURL : undefined; + + const proxyPort = + RUNTIME_MODE === "worker" ? this._proxyChannel!.port2 : undefined; + + const initMsg: InitMsg = { + kind: "init", + renderMode: opts.renderBlitMode, + glCanvas: glOC, + gridlinesCanvas: gridlinesOC, + chromeCanvas: chromeOC, + proxyPort, + clientWorkerURL, + clientWasm, + chartTag: this._chartTag, + viewName: this._view.__unsafe_get_name(), + tableName: this._tableName, + facetConfig: opts.facetConfig, + pluginConfig: opts.pluginConfig, + defaultChartType: opts.defaultChartType, + themeVars, + fontFaces, + cssWidth: rect.width, + cssHeight: rect.height, + dpr, + bufferMaxCapacity: 0, + precompileShaders: this._precompileShaders, + }; + + this._handle = await this._createHandle(workerURL, initMsg); + this._handle.addMessageListener((msg) => + this._handleRendererMsg(msg as WorkerMsg), + ); + + if (RUNTIME_MODE === "worker") { + // Worker mode: the bootstrap is triggered by posting the + // init message into the worker's scope (which the + // `if (IS_WORKER_SCOPE)` block in `renderer.worker.ts` + // listens for). `glOC` is omitted in blit mode (the + // renderer allocates its own offscreen) — only include the + // GL canvas in the transfer list when present. + const transfer: Transferable[] = [ + gridlinesOC, + chromeOC, + this._proxyChannel!.port2, + ]; + if (glOC) { + transfer.unshift(glOC); + } + + this._handle.post(initMsg, transfer); + } + + // In-process mode: the handle's `_createHandle` already kicked + // off `bootstrapInProcess` with the init msg directly, no + // postMessage needed. + + await this._ready; + } + + /** + * Construct the underlying transport. Worker mode wraps the + * module-shared `Worker` (lazy, page-singleton) and tags every + * message with a unique `sessionId`. In-process mode pairs a + * `MessageChannel` with a dynamically-imported + * {@link bootstrapInProcess}. + */ + private async _createHandle( + workerURL: string, + initMsg: InitMsg, + ): Promise { + if (RUNTIME_MODE === "worker") { + const w = await getSharedWorker(); + const sessionId = ++NEXT_SESSION_ID; + return { + post: (msg, transfer) => + w.postMessage({ sessionId, msg }, transfer), + addMessageListener: (cb) => { + HOST_LISTENERS.set(sessionId, cb); + }, + terminate: () => { + HOST_LISTENERS.delete(sessionId); + // Don't terminate the underlying worker — other + // sessions may still be live. Worker-side + // `WorkerRenderer` cleanup is driven by the + // `destroy` ControlMsg posted by the transport + // before reaching here. + }, + }; + } + + // In-process: instantiate the renderer on this thread by + // dynamic-importing the same module the worker uses. The Blob + // URL (or file URL in debug builds) loads as ESM, so module + // dedup means only one copy of the chart code lives in + // memory regardless of how many host elements use this mode. + // `@vite-ignore` is harmless under esbuild (esbuild's parser + // ignores it); some downstream bundlers honor it to suppress + // a static-import warning on the dynamic URL. + // + // Hand the host's already-bound `Client` to the renderer via + // `bootstrapInProcess` — option B. The dynamically-imported + // module has its own copy of the perspective-viewer + // wasm-bindgen JS, but that copy stays unused: we never + // construct `new Client(...)` inside it; we only ever call + // methods on the host-supplied instance. + const mod: any = await import(/* @vite-ignore */ workerURL); + const channel = new MessageChannel(); + await mod.bootstrapInProcess({ + msg: initMsg, + client: this._client, + controlPort: channel.port2, + }); + + return { + post: (msg, transfer) => channel.port1.postMessage(msg, transfer), + addMessageListener: (cb) => { + // `addEventListener("message", …)` does NOT auto-start + // a `MessagePort` — only setting `onmessage` does. + // Without this explicit `start()` the renderer's + // `{ kind: "ready" }` would queue on `port1` forever + // and `init()` would hang on `await this._ready`. + channel.port1.addEventListener("message", (e: MessageEvent) => + cb(e.data), + ); + channel.port1.start(); + }, + terminate: () => { + channel.port1.close(); + channel.port2.close(); + }, + }; + } + + setView(view: View): void { + this._view = view; + this._post({ + kind: "setViewByName", + name: this._view.__unsafe_get_name(), + }); + } + + setColumnsConfig(cfg: Record): void { + this._post({ kind: "setColumnsConfig", cfg }); + } + + setPluginConfig(cfg: PluginConfig): void { + this._post({ kind: "setPluginConfig", cfg }); + } + + setBufferMaxCapacity(n: number): void { + this._post({ kind: "setBufferMaxCapacity", n }); + } + + /** + * Trigger a worker-side data fetch + render cycle. The worker + * resolves all schema / row-count metadata against its own `View` + * and `Table`, runs `view.with_typed_arrays`, and pipes the + * resulting `ColumnDataMap` directly into `chartImpl.uploadAndRender` + * — no host-side `Client`/`Table`/`View` await, no `postMessage` of + * column buffers. + * + * The returned promise resolves when the worker replies with + * `loadAndRenderAck`. Per the worker's "resolve on stale" + * contract, a mid-flight cancellation (a newer `loadAndRender` + * superseding this one) still acks — the host's awaiter just + * resolves quietly. + */ + loadAndRender(opts: { + viewerConfig: { + group_by: string[]; + split_by: string[]; + columns: (string | null)[]; + }; + options?: { float32?: boolean }; + }): Promise { + const { id, promise } = this._allocPending("loadAndRender"); + const msg: LoadAndRenderMsg = { + kind: "loadAndRender", + msgId: id, + viewerConfig: opts.viewerConfig, + options: { float32: opts.options?.float32 ?? true }, + }; + + this._post(msg); + return promise; + } + + redraw(): void { + this._post({ kind: "redraw" }); + } + + resize(): void { + if (!this._hostGlCanvas) { + return; + } + + const rect = this._hostGlCanvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + this._post({ + kind: "resize", + cssWidth: rect.width, + cssHeight: rect.height, + dpr, + }); + } + + clear() { + this._post({ kind: "clear" }); + } + + invalidateTheme() { + if (!this._hostGlCanvas) { + return; + } + + const themeVars = snapshotThemeVars(this._hostGlCanvas); + this._post({ kind: "invalidateTheme", themeVars }); + } + + async saveZoom() { + const { id } = this._allocPending("saveZoom"); + this._post({ kind: "saveZoom", requestId: id }); + } + + /** + * Allocate a pending request slot of the given `kind`. Returns the + * id (encoded into the outgoing `ControlMsg`) and a promise that + * resolves / rejects when the matching reply arrives or + * `destroy()` drains the table. + */ + private _allocPending(kind: PendingRenderType): { + id: number; + promise: Promise; + } { + const id = ++this._pendingCounter; + const promise = new Promise((resolve, reject) => { + this._pending.set(id, { kind, resolve, reject }); + }); + + return { id, promise }; + } + + restoreZoom(state: any): void { + this._post({ kind: "restoreZoom", state }); + } + + allZoomsDefault(): boolean { + return this._allZoomsDefault; + } + + resetAllZooms(): void { + this._post({ kind: "resetAllZooms" }); + } + + resetExpandedDomain(): void { + this._post({ kind: "resetExpandedDomain" }); + } + + /** + * Request a PNG snapshot of the current frame. The worker flushes a + * synchronous render across the GL + gridlines + chrome layers, + * composites them into a single `OffscreenCanvas`, fills the theme + * background, and replies with the `convertToBlob` result. + */ + snapshotPng(): Promise { + const { id, promise } = this._allocPending("snapshotPng"); + this._post({ kind: "snapshotPng", requestId: id }); + return promise; + } + + forwardInteraction(event: InteractionEvent): void { + this._post({ kind: "interaction", event }); + } + + destroy(): void { + this._post({ kind: "destroy" }); + if (this._proxySession) { + this._proxySession.close().catch(() => {}); + } + + if (this._proxyChannel) { + this._proxyChannel.port1.close(); + this._proxyChannel = null; + } + + if (this._handle) { + this._handle.terminate(); + this._handle = null; + } + + this._hostSink?.dismiss(); + this._hostSink = null; + + // The host's `` elements are torn down by the plugin + // element's `disconnectedCallback` after `destroy()` returns — + // null these refs now so any post-destroy code can't dereference + // them, and so the GPU-backed 2D context can release earlier. + this._hostGlCanvas = null; + this._displayCtx = null; + + // Drain pending request promises with kind-aware semantics: + // - `loadAndRender` resolves silently (the host's awaited draw + // observes a clean "no more work" rather than a teardown + // rejection it would otherwise have to suppress). + // - `saveZoom` / `snapshotPng` reject so the upstream promise + // chain doesn't hang. Any unanswered messages still in the + // worker's queue are abandoned along with the renderer when + // the `destroy` ControlMsg fires worker-side. + const teardownErr = new Error("RendererTransport destroyed"); + for (const entry of this._pending.values()) { + if (entry.kind === "loadAndRender") { + entry.resolve(undefined); + } else { + entry.reject(teardownErr); + } + } + + this._pending.clear(); + } + + private _post(msg: ControlMsg): void { + this._postRaw(msg, []); + } + + private _postRaw(msg: ControlMsg, transfer: Transferable[]): void { + if (!this._handle) { + return; + } + + this._handle.post(msg, transfer); + } + + private _handleRendererMsg(msg: WorkerMsg): void { + switch (msg.kind) { + case "ready": + this._resolveReady(); + break; + case "zoomChanged": + this._allZoomsDefault = msg.isDefault; + this._onZoomChanged?.(msg.isDefault); + break; + case "saveZoomReply": + this._resolvePending(msg.requestId, "saveZoom", msg.state); + break; + case "pinTooltip": + this._ensureHostSink()?.pin(msg.lines, msg.pos, msg.bounds); + break; + case "dismissTooltip": + this._hostSink?.dismiss(); + break; + case "setCursor": + this._ensureHostSink()?.setCursor(msg.cursor); + break; + case "userClick": + this._dispatchOnViewer( + new CustomEvent( + "perspective-click", + { + bubbles: true, + composed: true, + detail: msg.detail, + }, + ), + ); + break; + case "userSelect": { + const removeConfigs = this._lastInsertConfig + ? [this._lastInsertConfig] + : []; + const insertConfigs = msg.selected ? [msg.insertConfig] : []; + this._lastInsertConfig = msg.selected + ? msg.insertConfig + : undefined; + const detail = new PerspectiveSelectDetail( + msg.selected, + msg.row, + msg.column_names, + // `Partial` (what the chart emits) is + // structurally a `ViewConfigUpdate` for the + // `filter`-only patches we ship; the only + // incompatible field (`group_by_depth: number | + // null`) is never set by our emitters. + removeConfigs as any, + insertConfigs as any, + ); + this._dispatchOnViewer( + new CustomEvent( + "perspective-global-filter", + { + bubbles: true, + composed: true, + detail, + }, + ), + ); + break; + } + + case "frameBitmap": + this._drawFrameBitmap(msg.bitmap); + break; + case "error": + this._rejectReady(new Error(msg.message)); + break; + case "loadAndRenderAck": + this._resolvePending(msg.msgId, "loadAndRender", undefined); + break; + case "snapshotPngReply": + this._resolvePending(msg.requestId, "snapshotPng", msg.blob); + break; + } + } + + /** + * Look up a pending request by id, verify the recorded kind + * matches the inbound reply, resolve, and remove. Mismatches are + * silently dropped — they would only fire if the worker echoed + * the wrong kind for a given id, which would itself be a bug + * worth catching at the worker side. + */ + private _resolvePending( + id: number, + kind: PendingRenderType, + value: unknown, + ): void { + const entry = this._pending.get(id); + if (!entry || entry.kind !== kind) { + return; + } + + this._pending.delete(id); + entry.resolve(value); + } + + /** + * Blit-mode handler: draw a renderer-emitted frame into the + * visible 2D-context display canvas, then close the bitmap so its + * GPU-backed surface is released. Resizes the visible canvas's + * drawing buffer to the bitmap dimensions on first frame and + * after any worker-side resize — the host doesn't directly + * control GL canvas size in blit mode, so we follow whatever the + * renderer emits. + */ + private _drawFrameBitmap(bitmap: ImageBitmap): void { + if (this._displayCtx && this._hostGlCanvas) { + const w = bitmap.width; + const h = bitmap.height; + if (this._hostGlCanvas.width !== w) { + this._hostGlCanvas.width = w; + } + + if (this._hostGlCanvas.height !== h) { + this._hostGlCanvas.height = h; + } + + this._displayCtx.globalCompositeOperation = "copy"; + this._displayCtx.drawImage(bitmap, 0, 0); + } + + bitmap.close(); + } + + /** + * Dispatch a `CustomEvent` on the `` ancestor + * of this transport's GL canvas. Walks the parent chain so the + * event bubbles from the viewer (matching where datagrid + * dispatches its `perspective-click` / `perspective-global-filter` + * events). No-op when the canvas is detached or no viewer ancestor + * exists (test harnesses, snapshot mode). + */ + private _dispatchOnViewer(ev: CustomEvent): void { + if (!this._hostGlCanvas) { + return; + } + + let node: Node | null = this._hostGlCanvas; + while (node) { + if ( + node instanceof HTMLElement && + node.tagName === "PERSPECTIVE-VIEWER" + ) { + node.dispatchEvent(ev); + return; + } + + // Cross shadow-root boundaries — `parentNode` returns `null` + // at a ShadowRoot, so use `host` when present. + node = + (node as ShadowRoot).host ?? + (node as Element).parentNode ?? + null; + } + } + + /** + * Lazily construct a `DomHostSink` rooted at the host GL canvas + * (cursor mutations) and its parent (pinned-tooltip `
    `). + * Returns `null` if the canvas has been detached. + */ + private _ensureHostSink(): DomHostSink | null { + if (this._hostSink) { + return this._hostSink; + } + + const parent = this._hostGlCanvas?.parentElement; + if (!parent || !this._hostGlCanvas) { + return null; + } + + this._hostSink = new DomHostSink(this._hostGlCanvas, parent); + return this._hostSink; + } +} diff --git a/packages/viewer-charts/src/ts/utils/css.ts b/packages/viewer-charts/src/ts/utils/css.ts new file mode 100644 index 0000000..a08f180 --- /dev/null +++ b/packages/viewer-charts/src/ts/utils/css.ts @@ -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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +export function parseCSSColorToVec3( + colorStr: string, +): [number, number, number] { + const s = colorStr.trim(); + if (s.startsWith("#")) { + let hex = s.slice(1); + if (hex.length === 3) { + hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; + } + + return [ + parseInt(hex.slice(0, 2), 16) / 255, + parseInt(hex.slice(2, 4), 16) / 255, + parseInt(hex.slice(4, 6), 16) / 255, + ]; + } + + const m = s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); + if (m) { + return [+m[1] / 255, +m[2] / 255, +m[3] / 255]; + } + + return [0.5, 0.5, 0.5]; +} diff --git a/packages/viewer-charts/src/ts/utils/font-snapshot.ts b/packages/viewer-charts/src/ts/utils/font-snapshot.ts new file mode 100644 index 0000000..1252984 --- /dev/null +++ b/packages/viewer-charts/src/ts/utils/font-snapshot.ts @@ -0,0 +1,159 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { FontFaceDescriptor } from "../transport/protocol"; + +/** + * Walk every accessible `@font-face` rule in the document and collect a + * descriptor list ready for forwarding to a Web Worker, where each + * entry is reconstituted via `new FontFace(family, src, descriptors)` + * and registered in the worker's own `self.fonts` set. + * + * Background: a `Worker` has its own `FontFaceSet` distinct from the + * document's. Fonts loaded into `document.fonts` (by ``, + * `@font-face`, or programmatic `FontFace`) are *not* visible to the + * worker — Canvas2D `ctx.font` lookups inside the worker fall back to + * the platform default if the family isn't in `self.fonts`. This + * helper bridges that gap for the in-CSS case. + * + * # CORS / security caveats + * + * 1. **Cross-origin stylesheets without permissive CORS** throw + * `SecurityError` on `cssRules` access, and we silently skip them. + * Their `@font-face` rules are unreachable to JS regardless of + * where the worker would re-load them, so this is the same + * fundamental limitation the document itself has. + * + * 2. **Font URLs are absolutized against the parent stylesheet's + * `href`** before forwarding. The worker's own script URL is a + * Blob URL produced by `WorkerPlugin`, so relative URLs in the raw + * `src:` declaration would resolve against the Blob origin (i.e. + * fail). Callers should treat the absolute URLs as the canonical + * source. + * + * 3. **The actual font fetch issued by `face.load()` in the worker + * is a fresh cross-origin request from the worker scope.** The + * font server must respond with `Access-Control-Allow-Origin` + * (e.g. `*` or the page origin) and an appropriate + * `Access-Control-Allow-Headers` policy if any non-simple headers + * are involved. A "no-cors" / opaque response is *not* usable + * here: `FontFace.load()` rejects on opaque responses, and even + * if it didn't, painted glyphs would taint the canvas and break + * `getImageData`. Same-origin fonts (including `data:` URIs) + * sidestep this entirely. + * + * 4. **Programmatic fonts** (e.g. `document.fonts.add(new + * FontFace(name, source))`) are *not* captured by this walker — + * they don't appear in any stylesheet's `cssRules`. Iterating + * `document.fonts` directly would catch them, but `FontFace` + * instances don't expose their source URL or buffer post- + * construction, so there's no public path to forward them. If a + * test or app needs that, it must register the same fonts in the + * worker explicitly. + */ +export function snapshotFontFaces(): FontFaceDescriptor[] { + const out: FontFaceDescriptor[] = []; + for (const sheet of Array.from(document.styleSheets)) { + let rules: CSSRuleList; + try { + rules = sheet.cssRules; + } catch { + // Cross-origin stylesheet without permissive CORS — its + // `@font-face` rules are unreachable to JS. See caveat (1). + continue; + } + + const base = sheet.href ?? document.baseURI; + for (const rule of Array.from(rules)) { + if (!(rule instanceof CSSFontFaceRule)) { + continue; + } + + const style = rule.style; + const rawFamily = style.getPropertyValue("font-family").trim(); + const rawSrc = style.getPropertyValue("src").trim(); + if (!rawFamily || !rawSrc) { + continue; + } + + out.push({ + // CSSOM serializes multi-word family names with their + // CSS quotes preserved (e.g. `"Roboto Mono"`). The + // `FontFace` constructor *doesn't* unquote on parse — + // Chromium stores the literal input and re-quotes / + // escapes on `face.family` getter access, yielding + // `"\"Roboto Mono\""` and a name that no `ctx.font` + // lookup can match. Strip one layer of matching outer + // quotes here so the worker's font set keys align + // with how Canvas2D resolves family names. + family: stripOuterQuotes(rawFamily), + src: resolveSrcUrls(rawSrc, base), + style: optional(style, "font-style"), + weight: optional(style, "font-weight"), + stretch: optional(style, "font-stretch"), + unicodeRange: optional(style, "unicode-range"), + variant: optional(style, "font-variant"), + featureSettings: optional(style, "font-feature-settings"), + display: optional(style, "font-display"), + }); + } + } + + return out; +} + +function optional( + style: CSSStyleDeclaration, + prop: string, +): string | undefined { + const v = style.getPropertyValue(prop).trim(); + return v || undefined; +} + +/** + * Remove a single matching pair of outer `"…"` or `'…'` quotes from a + * family name. CSSOM serializes multi-word `font-family` descriptor + * values with their quotes preserved; `FontFace` then re-quotes on + * serialization, producing the literal `"\"Foo\""` double-quoting + * observed in `face.family`. Stripping one layer here aligns the + * stored family with what Canvas2D resolves at render time. + */ +function stripOuterQuotes(s: string): string { + if (s.length >= 2) { + const first = s.charCodeAt(0); + const last = s.charCodeAt(s.length - 1); + if (first === last && (first === 0x22 || first === 0x27)) { + return s.slice(1, -1); + } + } + + return s; +} + +/** + * Rewrite every `url(...)` token inside a CSS `src:` value to its + * absolute form. Multi-source declarations (`url(...) format(...), + * url(...) format(...)`) are handled by replacing each `url(...)` + * chunk independently. See caveat (2). + */ +function resolveSrcUrls(src: string, base: string): string { + return src.replace( + /url\(\s*(['"]?)([^'")]+)\1\s*\)/g, + (match, _quote, url) => { + try { + return `url(${new URL(url, base).href})`; + } catch { + return match; + } + }, + ); +} diff --git a/packages/viewer-charts/src/ts/webgl/buffer-pool.ts b/packages/viewer-charts/src/ts/webgl/buffer-pool.ts new file mode 100644 index 0000000..e0dcd84 --- /dev/null +++ b/packages/viewer-charts/src/ts/webgl/buffer-pool.ts @@ -0,0 +1,163 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { BUFFER_POOL_STRICT } from "../config"; + +type GL = WebGL2RenderingContext | WebGLRenderingContext; + +export interface ManagedBuffer { + buffer: WebGLBuffer; + byteCapacity: number; +} + +export class BufferPool { + private _gl: GL; + private _buffers: Map = new Map(); + private _totalCapacity = 0; + private _maxCapacity = 0; + + constructor(gl: GL) { + this._gl = gl; + } + + get totalCapacity(): number { + return this._totalCapacity; + } + + get maxCapacity() { + return this._maxCapacity; + } + + set maxCapacity(cap: number) { + this._maxCapacity = cap; + } + + ensureCapacity(totalRows: number): void { + this._totalCapacity = + this._maxCapacity > 0 + ? Math.min(totalRows, this._maxCapacity) + : totalRows; + } + + /** + * Read-only lookup by name. Returns the existing managed buffer, or + * `undefined` if no buffer has been allocated under that name yet. + * + * Render-path callers that bind buffers for `drawArrays` / + * `drawArraysInstanced` MUST use this rather than `getOrCreate`: + * the latter recreates (zero-initialized) when `_totalCapacity` has + * grown past the current `byteCapacity`. That recreate is desired + * during `upload` (where `bufferSubData` immediately writes the + * actual data) but catastrophic during render — it wipes the + * previous draw's vertex data, leaving `drawArrays` to issue + * against zeros and produce no visible glyphs (a one-frame blank + * plot area while gridlines/chrome remain correct). + * + * Specifically, `ensureBufferCapacity(totalRows)` from a pending + * draw updates `_totalCapacity` *before* its matching `uploadChunk` + * has run; a pan/zoom-induced render landing in that window would + * otherwise see `requiredBytes > byteCapacity` and recreate. + */ + peek(name: string): ManagedBuffer | undefined { + return this._buffers.get(name); + } + + getOrCreate( + name: string, + componentsPerVertex: number, + bytesPerElement: number, + ): ManagedBuffer { + const requiredBytes = + this._totalCapacity * componentsPerVertex * bytesPerElement; + let managed = this._buffers.get(name); + if (managed && managed.byteCapacity >= requiredBytes) { + return managed; + } + + const gl = this._gl; + if (managed) { + gl.deleteBuffer(managed.buffer); + } + + const buffer = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + gl.bufferData(gl.ARRAY_BUFFER, requiredBytes, gl.DYNAMIC_DRAW); + + managed = { buffer, byteCapacity: requiredBytes }; + this._buffers.set(name, managed); + return managed; + } + + /** + * Upload `data` into the named GPU buffer at `byteOffset`. The + * buffer is sized lazily by `getOrCreate` to + * `_totalCapacity × componentsPerVertex × data.BYTES_PER_ELEMENT`. + * Callers MUST keep `byteOffset + data.byteLength` within that + * capacity — `bufferSubData` raises `INVALID_VALUE` on overflow + * and the upload is silently dropped at the GL layer. + * + * Common pitfall: passing a module-level scratch typed array whose + * `length` exceeds the current frame's valid-data count. Scratch + * buffers in chart impls grow monotonically across frames; the + * GPU buffer is sized to the *current* `_totalCapacity`. After a + * renderer-session reset (e.g. plugin disconnect/reconnect) the + * GPU buffer is fresh while the scratch retains its historical- + * peak length, and the upload writes past the buffer end. Always + * pass `data.subarray(0, n × componentsPerVertex)` when uploading + * from a scratch. + * + * Set `BUFFER_POOL_STRICT = true` in `config.ts` to convert + * overflows from opaque GL errors into descriptive throws at the + * offending stack frame. + */ + upload( + name: string, + data: Float32Array | Int32Array, + byteOffset: number, + componentsPerVertex: number = 1, + ): WebGLBuffer { + const gl = this._gl; + const managed = this.getOrCreate( + name, + componentsPerVertex, + data.BYTES_PER_ELEMENT, + ); + + if (BUFFER_POOL_STRICT) { + const writeEnd = byteOffset + data.byteLength; + if (writeEnd > managed.byteCapacity) { + throw new Error( + `BufferPool.upload("${name}"): write ${byteOffset}..${writeEnd} ` + + `exceeds capacity ${managed.byteCapacity} ` + + `(_totalCapacity=${this._totalCapacity}, ` + + `components=${componentsPerVertex}, ` + + `bytes=${data.BYTES_PER_ELEMENT}). ` + + `Did you pass the full scratch buffer instead of a subarray(0, n)?`, + ); + } + } + + gl.bindBuffer(gl.ARRAY_BUFFER, managed.buffer); + gl.bufferSubData(gl.ARRAY_BUFFER, byteOffset, data); + + return managed.buffer; + } + + releaseAll(): void { + for (const managed of this._buffers.values()) { + this._gl.deleteBuffer(managed.buffer); + } + + this._buffers.clear(); + this._totalCapacity = 0; + } +} diff --git a/packages/viewer-charts/src/ts/webgl/context-manager.ts b/packages/viewer-charts/src/ts/webgl/context-manager.ts new file mode 100644 index 0000000..41ffb65 --- /dev/null +++ b/packages/viewer-charts/src/ts/webgl/context-manager.ts @@ -0,0 +1,420 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { ShaderRegistry } from "./shader-registry"; +import { BufferPool } from "./buffer-pool"; +import { GLContext, type WebGLCanvas } from "./gl-context"; + +export type { WebGLCanvas } from "./gl-context"; + +export interface WebGLContextManagerOptions { + /** + * If `true`, compile + link every shader in `SHADER_MANIFEST` + * during context construction (and again after a context-loss/ + * restore cycle). Trades ~30-100ms of init time for elimination of + * the compile/link cost on the first-frame path. Ignored when the + * manager is constructed against an already-built shared + * {@link GLContext} — that context owns the precompile decision. + * Default `false` keeps lazy-compile behavior. + */ + precompile?: boolean; +} + +/** + * Per-chart render handle. Owns the chart's GPU **buffers** and frame + * bookkeeping, but only *borrows* its GL **context** — either a private + * one it constructs (the default 1:1 mode, where the manager is handed a + * raw canvas) or a shared {@link GLContext} from the + * [ContextPool](./context-pool.ts) (pooled blit mode, where many + * managers share K contexts to stay under the browser's per-agent + * context cap). + * + * Buffers are always per-manager: {@link BufferPool} keys buffers by + * name, so two charts sharing one pool would stomp each other's `"x"` / + * `"y"` / `"color"`. Shaders, the GL context, and the canvas drawing + * buffer are shared per {@link GLContext}; the scheduler serializes + * renders of managers that share a context so the shared drawing buffer + * only ever holds one chart's frame at a time (see {@link beginFrame}). + */ +export class WebGLContextManager { + private _ctx: GLContext; + private _ownsCtx: boolean; + private _disposeRestore: () => void; + private _buffers: BufferPool; + private _uploadedCount = 0; + private _cssWidth = 0; + private _cssHeight = 0; + private _dpr = 1; + private _destroyed = false; + private _frameCallback: ((bitmap: ImageBitmap) => void) | null = null; + + /** + * Per-instance `MessageChannel` used by `_yieldToTask` to resume a + * polling `awaitGpuFence` loop on the next task. Allocated lazily. + * Must be per-instance: a module-level singleton races when two + * managers poll concurrently (the second `onmessage` assignment + * clobbers the first's resolver, hanging that fence wait). Now that + * fence waits across managers run in parallel within one scheduler + * drain, concurrent poll loops are the common case. + */ + private _yieldChannel: MessageChannel | null = null; + + private _pendingYieldResolve: (() => void) | null = null; + + /** + * @param source A raw canvas (manager constructs and **owns** a + * private {@link GLContext}) or an existing shared `GLContext` + * (manager borrows it; the pool owns its lifecycle). + */ + constructor( + source: WebGLCanvas | GLContext, + options: WebGLContextManagerOptions = {}, + ) { + if (source instanceof GLContext) { + this._ctx = source; + this._ownsCtx = false; + } else { + this._ctx = new GLContext(source, { + precompile: options.precompile ?? false, + }); + this._ownsCtx = true; + } + + this._buffers = new BufferPool(this._ctx.gl); + + // Rebuild this tenant's buffers after a context restore. The + // shared `GLContext` rebuilds the shader cache itself, once. + this._disposeRestore = this._ctx.onRestore(() => { + this._buffers.releaseAll(); + this._buffers = new BufferPool(this._ctx.gl); + this._uploadedCount = 0; + }); + } + + get gl(): WebGL2RenderingContext | WebGLRenderingContext { + return this._ctx.gl; + } + + /** + * Identifies the shared GL context behind this manager. The + * scheduler groups pending renders by this id: managers on the same + * backend serialize (they share one drawing buffer); managers on + * different backends render in parallel. In the default 1:1 mode + * every manager has its own backend, so every render is independent + * — identical to the pre-pooling behavior. + */ + get backendId(): number { + return this._ctx.id; + } + + /** + * True once this manager's context has been lost — its own + * {@link destroy}, or a browser eviction of the shared context. + */ + isContextLost(): boolean { + return this._destroyed || this._ctx.isContextLost(); + } + + get isWebGL2(): boolean { + return this._ctx.isWebGL2; + } + + get shaders(): ShaderRegistry { + return this._ctx.shaders; + } + + get bufferPool(): BufferPool { + return this._buffers; + } + + get uploadedCount(): number { + return this._uploadedCount; + } + + set uploadedCount(count: number) { + this._uploadedCount = count; + } + + /** + * Resize the GL canvas's bitmap to match the host's CSS layout. The + * host measures the DOM element and DPR — the manager never touches + * DOM, so the same path works for an `HTMLCanvasElement` + * (in-process), a transferred `OffscreenCanvas` (direct/worker), or + * a pool-shared `OffscreenCanvas` (pooled blit). + */ + resize(cssWidth: number, cssHeight: number, dpr: number): void { + this._cssWidth = cssWidth; + this._cssHeight = cssHeight; + this._dpr = dpr; + + const width = Math.round(cssWidth * dpr); + const height = Math.round(cssHeight * dpr); + const canvas = this._ctx.canvas; + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + this._ctx.gl.viewport(0, 0, width, height); + } + } + + /** + * Pending dimensions to apply at the start of the next render. + * `null` when no resize is queued. See {@link requestResize} / + * {@link applyPendingResize}. + */ + private _pendingResize: { + cssWidth: number; + cssHeight: number; + dpr: number; + } | null = null; + + /** + * Record a dimension change to be applied at the start of the next + * render's Phase 1, before `_fullRender` runs. The actual + * `canvas.width = N` assignment (which clears the drawing buffer per + * the WebGL spec) happens inside {@link applyPendingResize} / + * {@link beginFrame}, paired in the same synchronous task as the + * paint that fills the new buffer — eliminating the one-frame blank + * the compositor would otherwise show in direct/in-process modes + * (where the GL canvas IS the visible canvas). + * + * Multiple calls before the next render coalesce to last-write-wins. + */ + requestResize(cssWidth: number, cssHeight: number, dpr: number): void { + this._pendingResize = { cssWidth, cssHeight, dpr }; + } + + /** + * Apply any pending dimension change recorded by + * {@link requestResize}. Returns `true` when a resize was applied. + */ + applyPendingResize(): boolean { + if (!this._pendingResize) { + return false; + } + + const { cssWidth, cssHeight, dpr } = this._pendingResize; + this._pendingResize = null; + this.resize(cssWidth, cssHeight, dpr); + return true; + } + + /** + * Prepare the shared GL surface for *this* manager's render. Called + * by the scheduler immediately before `_fullRender` (and by the + * `snapshotPng` bypass). + * + * - **Own context (1:1):** identical to the old Phase-1 step — apply + * only a *pending* resize. The context isn't shared, so there is + * no sibling state to reset and no need to touch the canvas when + * dimensions are unchanged. + * - **Shared context (pooled):** a co-tenant chart rendered last and + * left the shared canvas at *its* dimensions with *its* GL state. + * Force the canvas to this manager's dimensions and reset the + * global GL state a sibling might have left dirty (bound + * framebuffer, viewport, scissor/depth enables) so frames can't + * bleed across charts. Per-frame draw state (blend, clear) is + * re-established by the chart's `clearAndSetupFrame`. + */ + beginFrame(): void { + if (this._ownsCtx) { + this.applyPendingResize(); + return; + } + + // Honor a queued `requestResize` (it carries the latest dims but + // hasn't touched the canvas yet), then force the shared canvas + // to this manager's dims — a co-tenant chart may have left it at + // its own size. `resize` is a no-op when dims already match. + const pending = this._pendingResize; + this._pendingResize = null; + if (pending) { + this.resize(pending.cssWidth, pending.cssHeight, pending.dpr); + } else { + this.resize(this._cssWidth, this._cssHeight, this._dpr); + } + + const gl = this._ctx.gl; + const canvas = this._ctx.canvas; + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.viewport(0, 0, canvas.width, canvas.height); + gl.disable(gl.SCISSOR_TEST); + gl.disable(gl.DEPTH_TEST); + this._ctx.resetVertexArrayState(); + } + + get cssWidth(): number { + return this._cssWidth; + } + + get cssHeight(): number { + return this._cssHeight; + } + + get dpr(): number { + return this._dpr; + } + + clear(): void { + this._ctx.gl.clearColor(0, 0, 0, 0); + this._ctx.gl.clear( + this._ctx.gl.COLOR_BUFFER_BIT | this._ctx.gl.DEPTH_BUFFER_BIT, + ); + this._uploadedCount = 0; + } + + /** + * Register a per-frame hook invoked at the end of each render. In + * blit-mode rendering the worker installs a callback that transfers + * an `ImageBitmap` from the canvas back to the host. In direct mode + * the callback is null and `endFrame` is a no-op. Pass `null` to + * detach. + */ + setFrameCallback(cb: ((bitmap: ImageBitmap) => void) | null): void { + this._frameCallback = cb; + } + + /** + * Ship the current canvas contents as an `ImageBitmap` to the host + * when a frame callback is registered and the surface is an + * `OffscreenCanvas`. Otherwise no-op (direct mode paints straight to + * the visible drawing buffer). + */ + endFrame(): void { + if (!this._frameCallback) { + return; + } + + // A context lost mid-frame (our own `destroy`, or a browser + // eviction between paint and present) makes + // `transferToImageBitmap` throw. Bail quietly. + if (this.isContextLost()) { + return; + } + + const canvas = this._ctx.canvas as + | OffscreenCanvas + | (HTMLCanvasElement & { transferToImageBitmap?: never }); + if ( + typeof (canvas as OffscreenCanvas).transferToImageBitmap !== + "function" + ) { + return; + } + + const bitmap = (canvas as OffscreenCanvas).transferToImageBitmap(); + this._frameCallback(bitmap); + } + + /** + * Resolve when every GL command submitted up to this call has been + * executed by the GPU. WebGL2 issues a `fenceSync` and polls + * `clientWaitSync` (yielding to the task queue between polls); WebGL1 + * falls back to a blocking `gl.finish()` (acceptable in a worker). + */ + async awaitGpuFence(): Promise { + if (!this._ctx.isWebGL2) { + this._ctx.gl.finish(); + return; + } + + const gl = this._ctx.gl as WebGL2RenderingContext; + const fence = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0); + if (!fence) { + gl.finish(); + return; + } + + try { + let flags: GLbitfield = gl.SYNC_FLUSH_COMMANDS_BIT; + while (true) { + const status = gl.clientWaitSync(fence, flags, 0); + if ( + status === gl.ALREADY_SIGNALED || + status === gl.CONDITION_SATISFIED + ) { + return; + } + + if (status === gl.WAIT_FAILED) { + gl.finish(); + return; + } + + flags = 0; + await this._yieldToTask(); + + if (this._destroyed) { + return; + } + } + } finally { + gl.deleteSync(fence); + } + } + + ensureBufferCapacity(totalRows: number): void { + this._buffers.ensureCapacity(totalRows); + } + + /** + * Yield to the task queue between fence polls. `setTimeout(0)` is + * avoided because Chromium clamps nested worker `setTimeout` to + * ~4ms; a reused per-instance `MessageChannel` resumes in the next + * task with sub-ms latency. `{ once: true }` over `onmessage = ...` + * so concurrent resumes can't clobber each other's resolvers. + */ + private _yieldToTask(): Promise { + if (!this._yieldChannel) { + this._yieldChannel = new MessageChannel(); + this._yieldChannel.port1.start(); + } + + return new Promise((resolve) => { + const ch = this._yieldChannel!; + this._pendingYieldResolve = resolve; + ch.port1.addEventListener( + "message", + () => { + this._pendingYieldResolve = null; + resolve(); + }, + { once: true }, + ); + ch.port2.postMessage(null); + }); + } + + destroy(): void { + this._destroyed = true; + this._frameCallback = null; + this._disposeRestore(); + this._buffers.releaseAll(); + if (this._pendingYieldResolve) { + const resolve = this._pendingYieldResolve; + this._pendingYieldResolve = null; + resolve(); + } + + if (this._yieldChannel) { + this._yieldChannel.port1.close(); + this._yieldChannel.port2.close(); + this._yieldChannel = null; + } + + // Only lose the GL context when we own it. A shared context + // outlives any one tenant — the pool destroys it. + if (this._ownsCtx) { + this._ctx.destroy(); + } + } +} diff --git a/packages/viewer-charts/src/ts/webgl/context-pool.ts b/packages/viewer-charts/src/ts/webgl/context-pool.ts new file mode 100644 index 0000000..2b26798 --- /dev/null +++ b/packages/viewer-charts/src/ts/webgl/context-pool.ts @@ -0,0 +1,78 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { GLContext } from "./gl-context"; + +/** + * Renderer-scope pool of shared {@link GLContext}s for pooled blit mode. + * + * The browser caps live WebGL contexts per agent (~16 in Chromium) and + * force-loses the oldest past that cap. A page with more than ~16 charts + * therefore can't give each its own context. This pool caps the number + * of live contexts at `size`, distributing renderers across them + * round-robin (sticky for a renderer's lifetime — a renderer keeps the + * same context so its resident GPU buffers and any cached textures/FBOs + * stay valid frame to frame). N charts render through K = `size` + * contexts; the scheduler serializes renders that land on the same + * context. + * + * Pooling applies to blit mode only: direct mode renders into the host's + * transferred visible canvas, which is permanently 1:1 with a context. + * + * One pool per renderer scope (the worker, or the main thread in + * in-process mode). Contexts are created lazily on first `acquire` and + * live until renderer-scope teardown — like the contexts they replace, + * they are not refcounted, because a shared context outlives any single + * borrowing renderer. + */ +export class ContextPool { + private _size: number; + private _precompile: boolean; + private _contexts: GLContext[] = []; + private _next = 0; + + constructor(size: number, options: { precompile?: boolean } = {}) { + this._size = Math.max(1, size); + this._precompile = options.precompile ?? false; + } + + /** + * Borrow a context for a new renderer. Fills the pool to `size` + * before reusing, then round-robins. A dead context (browser + * eviction) is replaced in place so a borrower never receives a lost + * context. + */ + acquire(): GLContext { + let ctx: GLContext; + if (this._contexts.length < this._size) { + ctx = this._make(); + this._contexts.push(ctx); + } else { + const idx = this._next % this._contexts.length; + this._next++; + if (this._contexts[idx].isContextLost()) { + this._contexts[idx] = this._make(); + } + + ctx = this._contexts[idx]; + } + + return ctx; + } + + private _make(): GLContext { + // Initial size is arbitrary — every render resizes the shared + // canvas to the borrowing renderer's dimensions in `beginFrame`. + const canvas = new OffscreenCanvas(1, 1); + return new GLContext(canvas, { precompile: this._precompile }); + } +} diff --git a/packages/viewer-charts/src/ts/webgl/gl-context.ts b/packages/viewer-charts/src/ts/webgl/gl-context.ts new file mode 100644 index 0000000..6c09587 --- /dev/null +++ b/packages/viewer-charts/src/ts/webgl/gl-context.ts @@ -0,0 +1,191 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { ShaderRegistry } from "./shader-registry"; +import { SHADER_MANIFEST } from "./shader-manifest"; + +export type WebGLCanvas = HTMLCanvasElement | OffscreenCanvas; + +/** + * The unit the browser actually counts against its per-agent WebGL + * context cap (~16 in Chromium): one canvas + one GL context + the + * shader programs compiled against it. + * + * Factored out of {@link WebGLContextManager} so that *many* managers + * (one per chart) can share *one* GL context. The browser limit is on + * contexts, not on the buffers/programs living inside one — a single + * context can host an arbitrary number of independent + * `WebGLBuffer`s/programs (bounded only by memory). So N charts can + * render through K ≪ N contexts as long as each keeps its own + * `BufferPool` (buffers are keyed by name; two charts would otherwise + * collide) and renders are serialized per context (the shared drawing + * buffer can hold one chart's frame at a time). See + * [context-pool.ts](./context-pool.ts) and the scheduler's per-backend + * grouping in [../render/scheduler.ts](../render/scheduler.ts). + * + * Shaders are shared per context: programs are immutable GLSL once + * compiled (uniforms are set per draw), so co-tenant charts reuse them + * and only pay the compile cost once per context. + */ +let NEXT_CONTEXT_ID = 1; + +export class GLContext { + readonly id: number; + readonly canvas: WebGLCanvas; + readonly gl: WebGL2RenderingContext | WebGLRenderingContext; + readonly isWebGL2: boolean; + shaders: ShaderRegistry; + private _maxVertexAttribs: number; + private _angle: ANGLE_instanced_arrays | null = null; + private _precompile: boolean; + private _destroyed = false; + + /** + * Per-tenant hooks fired after a `webglcontextrestored`. Each + * {@link WebGLContextManager} on this context registers one that + * rebuilds its `BufferPool` (the shaders are rebuilt here, once, for + * all tenants). On an intentional {@link destroy} these are dropped + * without firing. + */ + private _onRestore = new Set<() => void>(); + + constructor(canvas: WebGLCanvas, options: { precompile?: boolean } = {}) { + this.id = NEXT_CONTEXT_ID++; + this.canvas = canvas; + this._precompile = options.precompile ?? false; + const gl2 = canvas.getContext("webgl2", { + antialias: true, + alpha: true, + premultipliedAlpha: false, + }); + + if (gl2) { + this.gl = gl2 as WebGL2RenderingContext; + this.isWebGL2 = true; + } else { + const gl1 = canvas.getContext("webgl", { + antialias: true, + alpha: true, + premultipliedAlpha: false, + }); + + if (!gl1) { + throw new Error("WebGL is not supported"); + } + + this.gl = gl1 as WebGLRenderingContext; + this.isWebGL2 = false; + } + + this.shaders = new ShaderRegistry(this.gl); + if (this._precompile) { + this.shaders.precompile(SHADER_MANIFEST); + } + + this._maxVertexAttribs = this.gl.getParameter( + this.gl.MAX_VERTEX_ATTRIBS, + ) as number; + if (!this.isWebGL2) { + this._angle = this.gl.getExtension("ANGLE_instanced_arrays"); + } + + // Both `HTMLCanvasElement` and `OffscreenCanvas` dispatch + // `webglcontextlost` / `webglcontextrestored` on the canvas. + (canvas as EventTarget).addEventListener( + "webglcontextlost", + (e: Event) => { + // Don't reserve the slot for a restore once we've + // intentionally torn the context down — `destroy()` + // calls `loseContext()`, which fires this event. + if (this._destroyed) { + return; + } + + e.preventDefault(); + }, + ); + + (canvas as EventTarget).addEventListener("webglcontextrestored", () => { + // Rebuild the shared program cache once for the whole + // context, then let each tenant rebuild its own buffers. + this.shaders.releaseAll(); + this.shaders = new ShaderRegistry(this.gl); + if (this._precompile) { + this.shaders.precompile(SHADER_MANIFEST); + } + + for (const cb of this._onRestore) { + cb(); + } + }); + } + + /** + * Return every vertex-attribute slot to its default state: disabled, + * with a zero instance divisor. Vertex-array state (enable flags, + * pointers, divisors) is global to a context's default VAO — there + * are no per-chart VAOs in this codebase — so when many charts share + * one pooled context, a slot a prior chart enabled stays enabled for + * the next chart, now pointing at a buffer the prior chart's + * teardown deleted. Its next `drawArraysInstanced` then fails GL + * validation: "no buffer is bound to enabled attribute", silently + * dropping the draw. + * + * Glyphs already (re)enable + bind + set the divisor for every slot + * they use on each frame, so clearing to the default here is a safe + * baseline — it only removes the *leftover* enables a sibling chart + * left behind. Called from {@link WebGLContextManager.beginFrame} in + * shared (pooled) mode only; the 1:1 default never shares a context + * and so keeps its original, reset-free path. + */ + resetVertexArrayState(): void { + const gl = this.gl; + for (let i = 0; i < this._maxVertexAttribs; i++) { + gl.disableVertexAttribArray(i); + if (this.isWebGL2) { + (gl as WebGL2RenderingContext).vertexAttribDivisor(i, 0); + } else { + this._angle?.vertexAttribDivisorANGLE(i, 0); + } + } + } + + /** + * Register a tenant's post-restore rebuild hook. Returns a disposer. + */ + onRestore(cb: () => void): () => void { + this._onRestore.add(cb); + return () => this._onRestore.delete(cb); + } + + /** + * True once lost — either our own {@link destroy} or a browser + * eviction of the oldest context past the per-agent cap. + */ + isContextLost(): boolean { + return this._destroyed || this.gl.isContextLost(); + } + + get destroyed(): boolean { + return this._destroyed; + } + + destroy(): void { + this._destroyed = true; + this._onRestore.clear(); + this.shaders.releaseAll(); + const ext = this.gl.getExtension("WEBGL_lose_context"); + if (ext) { + ext.loseContext(); + } + } +} diff --git a/packages/viewer-charts/src/ts/webgl/gradient-texture.ts b/packages/viewer-charts/src/ts/webgl/gradient-texture.ts new file mode 100644 index 0000000..e2d181d --- /dev/null +++ b/packages/viewer-charts/src/ts/webgl/gradient-texture.ts @@ -0,0 +1,84 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { buildGradientLUT, type GradientStop } from "../theme/gradient"; +import type { WebGLContextManager } from "./context-manager"; + +const LUT_SIZE = 256; + +export interface GradientTextureCache { + texture: WebGLTexture; + + // The `GradientStop[]` reference last uploaded. `resolveTheme` returns a + // fresh object per render, so comparing the array reference is enough to + // detect a theme change and skip the upload otherwise. + lastStops: GradientStop[] | null; +} + +/** + * Allocate a 256×1 RGBA8 texture (once) and re-upload the LUT only when + * `stops` has changed since the last call. Typical render path: zero + * GPU work beyond binding an already-uploaded texture. + */ +export function ensureGradientTexture( + glManager: WebGLContextManager, + cache: GradientTextureCache | null, + stops: GradientStop[], +): GradientTextureCache { + const gl = glManager.gl; + + let texture: WebGLTexture; + if (cache?.texture) { + texture = cache.texture; + if (cache.lastStops === stops) { + return cache; + } // no-op fast path + } else { + texture = gl.createTexture()!; + } + + const lut = buildGradientLUT(stops, LUT_SIZE); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + LUT_SIZE, + 1, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + lut, + ); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + + return { texture, lastStops: stops }; +} + +/** + * Bind `texture` to a texture unit and set the sampler uniform. Call after + * `useProgram`. + */ +export function bindGradientTexture( + glManager: WebGLContextManager, + texture: WebGLTexture, + samplerLoc: WebGLUniformLocation | null, + unit: number = 0, +): void { + const gl = glManager.gl; + gl.activeTexture(gl.TEXTURE0 + unit); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.uniform1i(samplerLoc, unit); +} diff --git a/packages/viewer-charts/src/ts/webgl/instanced-attrs.ts b/packages/viewer-charts/src/ts/webgl/instanced-attrs.ts new file mode 100644 index 0000000..a139119 --- /dev/null +++ b/packages/viewer-charts/src/ts/webgl/instanced-attrs.ts @@ -0,0 +1,139 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { WebGLContextManager } from "./context-manager"; + +type GL = WebGL2RenderingContext | WebGLRenderingContext; + +export interface Instancing { + setDivisor(location: number, divisor: number): void; + drawArraysInstanced( + mode: number, + first: number, + count: number, + instances: number, + ): void; +} + +/** + * Return an Instancing helper for the given GL context. On WebGL2, native + * `vertexAttribDivisor`/`drawArraysInstanced` are used; on WebGL1 the + * ANGLE_instanced_arrays extension is looked up and cached by caller. + * Negative attribute locations (optimized-out attributes) are tolerated by + * `setDivisor` and ignored. + */ +export function getInstancing(glManager: WebGLContextManager): Instancing { + const gl = glManager.gl; + if (glManager.isWebGL2) { + const gl2 = gl as WebGL2RenderingContext; + return { + setDivisor(location, divisor) { + if (location < 0) { + return; + } + + gl2.vertexAttribDivisor(location, divisor); + }, + drawArraysInstanced(mode, first, count, instances) { + gl2.drawArraysInstanced(mode, first, count, instances); + }, + }; + } + + const ext = gl.getExtension( + "ANGLE_instanced_arrays", + ) as ANGLE_instanced_arrays | null; + return { + setDivisor(location, divisor) { + if (location < 0) { + return; + } + + ext?.vertexAttribDivisorANGLE(location, divisor); + }, + drawArraysInstanced(mode, first, count, instances) { + ext?.drawArraysInstancedANGLE(mode, first, count, instances); + }, + }; +} + +/** + * Allocate the static `[0, 1, 2, 3]` line-strip corner buffer used by + * every instanced line/wick glyph: each corner index is multiplied by + * `LINE_WIDTH_PX` in the shader to expand a 2-vertex segment into a + * `TRIANGLE_STRIP` quad. Identical contents across all callers; one + * helper avoids the four-way `createBuffer` / `bufferData` boilerplate. + */ +export function createLineCornerBuffer(gl: GL): WebGLBuffer { + const buffer = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([0, 1, 2, 3]), + gl.STATIC_DRAW, + ); + return buffer; +} + +/** + * Allocate the static `[(0,0), (1,0), (0,1), (1,1)]` quad-strip corner + * buffer used by instanced rect glyphs (candlestick body). Used as a + * `vec2 a_corner` attribute that the shader scales by per-instance + * width/height to expand into a `TRIANGLE_STRIP` rect. + */ +export function createQuadCornerBuffer(gl: GL): WebGLBuffer { + const buffer = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), + gl.STATIC_DRAW, + ); + return buffer; +} + +/** + * Bind a per-instance float attribute from a named buffer in the buffer + * pool. Returns `true` when the bind happened, `false` when the buffer + * has not yet been allocated (caller should skip the draw rather than + * paint zero data). No-op return `true` when `attr` is negative + * (optimized-out attribute — drawing the rest is still valid). + * + * Render-path uses `peek`, never `getOrCreate`: the latter recreates + * with zero-initialized contents when `_totalCapacity` has grown past + * the current buffer, which would wipe the previous draw's vertex + * data and leave `drawArraysInstanced` to render zeros. See + * {@link BufferPool#peek} for the full rationale. + */ +export function bindInstancedFloatAttr( + glManager: WebGLContextManager, + instancing: Instancing, + attr: number, + name: string, + components: number, +): boolean { + if (attr < 0) { + return true; + } + + const buf = glManager.bufferPool.peek(name); + if (!buf) { + return false; + } + + const gl: GL = glManager.gl; + gl.bindBuffer(gl.ARRAY_BUFFER, buf.buffer); + gl.enableVertexAttribArray(attr); + gl.vertexAttribPointer(attr, components, gl.FLOAT, false, 0, 0); + instancing.setDivisor(attr, 1); + return true; +} diff --git a/packages/viewer-charts/src/ts/webgl/plot-frame.ts b/packages/viewer-charts/src/ts/webgl/plot-frame.ts new file mode 100644 index 0000000..4e8dd61 --- /dev/null +++ b/packages/viewer-charts/src/ts/webgl/plot-frame.ts @@ -0,0 +1,91 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { PlotLayout } from "../layout/plot-layout"; + +type GL = WebGL2RenderingContext | WebGLRenderingContext; + +/** + * Return CSS-pixel dimensions of the GL canvas. + */ +export function cssSize( + gl: GL, + dpr: number, +): { cssWidth: number; cssHeight: number } { + return { + cssWidth: gl.canvas.width / dpr, + cssHeight: gl.canvas.height / dpr, + }; +} + +/** + * Clear the framebuffer + enable alpha blending. Call once per frame, + * before any per-plot-rect {@link withScissor} invocations. Faceted + * renderers call this once and then loop {@link withScissor} per cell + * so the inter-facet clears don't wipe each other's pixels. + */ +export function clearAndSetupFrame(gl: GL): void { + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); +} + +/** + * Scissor-constrain `draw` to `layout.plotRect`. Caller handles + * projection / uniforms / VBO bindings inside `draw`; this helper only + * manages the scissor enable/disable bracket. + * + * Unlike {@link renderInPlotFrame}, this does *not* clear the + * framebuffer — so it's safe to call repeatedly per frame (one per + * facet). Pair with {@link clearAndSetupFrame} at the start of each + * frame. + */ +export function withScissor( + gl: GL, + layout: PlotLayout, + dpr: number, + draw: () => void, +): void { + gl.enable(gl.SCISSOR_TEST); + gl.scissor( + Math.round(layout.margins.left * dpr), + Math.round(layout.margins.bottom * dpr), + Math.round(layout.plotRect.width * dpr), + Math.round(layout.plotRect.height * dpr), + ); + try { + draw(); + } finally { + gl.disable(gl.SCISSOR_TEST); + } +} + +/** + * One-shot convenience: clear + setup blend + scissor + draw. Used by + * single-plot callers (bar / heatmap / candlestick / scatter-without- + * splits) that only draw into one plot rect per frame. + * + * Faceted callers must use {@link clearAndSetupFrame} + + * {@link withScissor} instead; calling this helper in a per-facet loop + * would clear the framebuffer on each invocation and wipe out every + * previously-drawn facet. + */ +export function renderInPlotFrame( + gl: GL, + layout: PlotLayout, + dpr: number, + draw: () => void, +): void { + clearAndSetupFrame(gl); + withScissor(gl, layout, dpr, draw); +} diff --git a/packages/viewer-charts/src/ts/webgl/program-cache.ts b/packages/viewer-charts/src/ts/webgl/program-cache.ts new file mode 100644 index 0000000..a0a4ce7 --- /dev/null +++ b/packages/viewer-charts/src/ts/webgl/program-cache.ts @@ -0,0 +1,46 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { WebGLContextManager } from "./context-manager"; + +/** + * Compile (or fetch from the shader registry) a program and resolve a + * flat record of uniform + attribute locations keyed by name. Uniforms + * resolve to `WebGLUniformLocation | null`; attribs resolve to `number`. + * + * Each glyph drawer used to repeat ~10 lines of `gl.getUniformLocation` + * / `gl.getAttribLocation` calls — replacing those with one + * `compileProgram(...)` call shrinks the worker bundle by ~80 bytes per + * site (uniform/attrib name strings still ship — WebGL needs them + * verbatim — but the per-call wrapper goes away). + */ +export function compileProgram( + glManager: WebGLContextManager, + key: string, + vert: string, + frag: string, + uniforms: readonly string[], + attrs: readonly string[], +): C { + const program = glManager.shaders.getOrCreate(key, vert, frag); + const gl = glManager.gl; + const out: any = { program }; + for (const n of uniforms) { + out[n] = gl.getUniformLocation(program, n); + } + + for (const n of attrs) { + out[n] = gl.getAttribLocation(program, n); + } + + return out as C; +} diff --git a/packages/viewer-charts/src/ts/webgl/shader-manifest.ts b/packages/viewer-charts/src/ts/webgl/shader-manifest.ts new file mode 100644 index 0000000..a057e7d --- /dev/null +++ b/packages/viewer-charts/src/ts/webgl/shader-manifest.ts @@ -0,0 +1,148 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 barVert from "../shaders/bar.vert.glsl"; +import barFrag from "../shaders/bar.frag.glsl"; +import lineVert from "../shaders/line.vert.glsl"; +import lineFrag from "../shaders/line.frag.glsl"; +import scatterVert from "../shaders/scatter.vert.glsl"; +import scatterFrag from "../shaders/scatter.frag.glsl"; +import areaVert from "../shaders/area.vert.glsl"; +import areaFrag from "../shaders/area.frag.glsl"; +import lineUniformVert from "../shaders/line-uniform.vert.glsl"; +import lineUniformFrag from "../shaders/line-uniform.frag.glsl"; +import yScatterVert from "../shaders/y-scatter.vert.glsl"; +import yScatterFrag from "../shaders/y-scatter.frag.glsl"; +import candlestickBodyVert from "../shaders/candlestick-body.vert.glsl"; +import candlestickBodyFrag from "../shaders/candlestick-body.frag.glsl"; +import heatmapVert from "../shaders/heatmap.vert.glsl"; +import heatmapFrag from "../shaders/heatmap.frag.glsl"; +import sunburstArcVert from "../shaders/sunburst-arc.vert.glsl"; +import sunburstArcFrag from "../shaders/sunburst-arc.frag.glsl"; +import treemapVert from "../shaders/treemap.vert.glsl"; +import treemapFrag from "../shaders/treemap.frag.glsl"; +import densitySplatVert from "../shaders/density-splat.vert.glsl"; +import densitySplatFrag from "../shaders/density-splat.frag.glsl"; +import densityExtremeFrag from "../shaders/density-extreme.frag.glsl"; +import densityMrtVert from "../shaders/density-mrt.vert.glsl"; +import densityMrtFrag from "../shaders/density-mrt.frag.glsl"; +import densityResolveVert from "../shaders/density-resolve.vert.glsl"; +import densityResolveFrag from "../shaders/density-resolve.frag.glsl"; +import tileVert from "../shaders/tile.vert.glsl"; +import tileFrag from "../shaders/tile.frag.glsl"; + +/** + * One shader program in the build's static manifest. The `name` is the + * cache key consumed by `ShaderRegistry.getOrCreate(name, ...)` — + * call sites must pass the same name (and the same vert/frag source) + * for the cache to hit. The single source of truth for both fields is + * this file; glyph code re-imports from here so the manifest and the + * call sites can never drift. + */ +export interface ShaderSpec { + name: string; + vert: string; + frag: string; +} + +/** + * Every WebGL program the build ships, keyed by cache name. Used by + * `ShaderRegistry.precompile(SHADER_MANIFEST)` to compile + link all + * programs eagerly during renderer construction so the first-frame + * path doesn't pay the compile cost inline. + * + * Names mirror the existing `getOrCreate` keys at each call site — + * the lazy path stays valid; precompile just primes the cache early. + */ +export const SHADER_MANIFEST: readonly ShaderSpec[] = [ + { name: "bar", vert: barVert, frag: barFrag }, + { name: "line", vert: lineVert, frag: lineFrag }, + { name: "scatter", vert: scatterVert, frag: scatterFrag }, + { name: "bar-area", vert: areaVert, frag: areaFrag }, + { name: "bar-scatter", vert: yScatterVert, frag: yScatterFrag }, + + // Shared by series-line glyph (`bar-line` consolidated here), + // candlestick wicks, and OHLC. One compile per context. + { name: "line-uniform", vert: lineUniformVert, frag: lineUniformFrag }, + { + name: "candlestick-body", + vert: candlestickBodyVert, + frag: candlestickBodyFrag, + }, + { name: "heatmap", vert: heatmapVert, frag: heatmapFrag }, + { name: "sunburst-arc", vert: sunburstArcVert, frag: sunburstArcFrag }, + { name: "treemap", vert: treemapVert, frag: treemapFrag }, + { + name: "density-splat", + vert: densitySplatVert, + frag: densitySplatFrag, + }, + { + name: "density-extreme", + vert: densitySplatVert, + frag: densityExtremeFrag, + }, + { + name: "density-resolve", + vert: densityResolveVert, + frag: densityResolveFrag, + }, + // The MRT variant declares `#extension GL_EXT_draw_buffers : + // require` and only compiles on contexts that advertise it. The + // glyph compiles it lazily after probing `OES_draw_buffers_indexed` + // — adding it here would crash precompile on hardware without + // multi-render-target support. + + // Map tile basemap (textured quad in Mercator space). Compiled + // unconditionally because the program is GLSL 100 and links on + // every WebGL1/2 context; the loader only kicks in when a map + // plugin tag activates. + { name: "map-tile", vert: tileVert, frag: tileFrag }, +]; + +// Re-export each shader source so glyph modules can import their +// source from the manifest rather than from `../shaders/*.glsl` +// directly. This keeps the manifest's vert/frag fields and the +// `getOrCreate` call sites pointing at exactly the same string +// constants — module dedup ensures reference identity, so cache +// hits work even on the lazy path. +export { + barVert, + barFrag, + lineVert, + lineFrag, + scatterVert, + scatterFrag, + areaVert, + areaFrag, + lineUniformVert, + lineUniformFrag, + yScatterVert, + yScatterFrag, + candlestickBodyVert, + candlestickBodyFrag, + heatmapVert, + heatmapFrag, + sunburstArcVert, + sunburstArcFrag, + treemapVert, + treemapFrag, + densitySplatVert, + densitySplatFrag, + densityExtremeFrag, + densityMrtVert, + densityMrtFrag, + densityResolveVert, + densityResolveFrag, + tileVert, + tileFrag, +}; diff --git a/packages/viewer-charts/src/ts/webgl/shader-registry.ts b/packages/viewer-charts/src/ts/webgl/shader-registry.ts new file mode 100644 index 0000000..15c21f7 --- /dev/null +++ b/packages/viewer-charts/src/ts/webgl/shader-registry.ts @@ -0,0 +1,97 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { ShaderSpec } from "./shader-manifest"; + +type GL = WebGL2RenderingContext | WebGLRenderingContext; + +export class ShaderRegistry { + private _gl: GL; + private _programs: Map = new Map(); + + constructor(gl: GL) { + this._gl = gl; + } + + /** + * Compile + link every program in `specs` eagerly. Used by + * `WebGLContextManager` when constructed with `precompile: true` + * so the first-frame path doesn't pay the compile cost inline. + * + * Compilation is synchronous and serial — single-digit ms per + * program on a modern GPU. With `KHR_parallel_shader_compile` + * (browser-supported but not yet wired here) the work could be + * dispatched to driver threads; today we accept the wall-time + * cost in exchange for simpler code and a deterministic init. + */ + precompile(specs: readonly ShaderSpec[]): void { + for (const spec of specs) { + this.getOrCreate(spec.name, spec.vert, spec.frag); + } + } + + getOrCreate(name: string, vertSrc: string, fragSrc: string): WebGLProgram { + let program = this._programs.get(name); + if (program) { + return program; + } + + const gl = this._gl; + + const vert = gl.createShader(gl.VERTEX_SHADER)!; + gl.shaderSource(vert, vertSrc); + gl.compileShader(vert); + if (!gl.getShaderParameter(vert, gl.COMPILE_STATUS)) { + const info = gl.getShaderInfoLog(vert); + gl.deleteShader(vert); + throw new Error(`Vertex shader compile error [${name}]: ${info}`); + } + + const frag = gl.createShader(gl.FRAGMENT_SHADER)!; + gl.shaderSource(frag, fragSrc); + gl.compileShader(frag); + if (!gl.getShaderParameter(frag, gl.COMPILE_STATUS)) { + const info = gl.getShaderInfoLog(frag); + gl.deleteShader(vert); + gl.deleteShader(frag); + throw new Error(`Fragment shader compile error [${name}]: ${info}`); + } + + program = gl.createProgram()!; + gl.attachShader(program, vert); + gl.attachShader(program, frag); + gl.linkProgram(program); + + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const info = gl.getProgramInfoLog(program); + gl.deleteProgram(program); + gl.deleteShader(vert); + gl.deleteShader(frag); + throw new Error(`Shader link error [${name}]: ${info}`); + } + + // Shaders can be deleted after linking + gl.deleteShader(vert); + gl.deleteShader(frag); + + this._programs.set(name, program); + return program; + } + + releaseAll(): void { + for (const program of this._programs.values()) { + this._gl.deleteProgram(program); + } + + this._programs.clear(); + } +} diff --git a/packages/viewer-charts/src/ts/worker/boot.ts b/packages/viewer-charts/src/ts/worker/boot.ts new file mode 100644 index 0000000..fdbb54d --- /dev/null +++ b/packages/viewer-charts/src/ts/worker/boot.ts @@ -0,0 +1,22 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// In Web Worker scope, `window` is undefined but some bundled code +// (`@perspective-dev/viewer` wasm-bindgen JS, plus a few feature +// detects) probes it. Provide a stub *only* in worker scope — when +// this module is dynamic-imported on the main thread for the +// in-process renderer path, `window` is the real `Window` and we must +// not clobber it. +if (typeof window === "undefined") { + // @ts-ignore + globalThis.window = {}; +} diff --git a/packages/viewer-charts/src/ts/worker/dispatch.ts b/packages/viewer-charts/src/ts/worker/dispatch.ts new file mode 100644 index 0000000..c6ea59d --- /dev/null +++ b/packages/viewer-charts/src/ts/worker/dispatch.ts @@ -0,0 +1,98 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { ControlMsg } from "../transport/protocol"; +import type { WorkerRenderer } from "./renderer.worker"; + +/** + * Stateless control-message dispatcher. The renderer instance is + * supplied by the caller so worker mode (one global instance) and + * in-process mode (one instance per host element) share the same + * routing logic. + */ +export function dispatch(r: WorkerRenderer, msg: ControlMsg): void { + switch (msg.kind) { + case "setViewByName": + r.setViewByName(msg.name); + break; + case "setColumnsConfig": + r.chartImpl.setColumnsConfig?.(msg.cfg); + break; + case "setPluginConfig": + r.chartImpl.setPluginConfig?.(msg.cfg); + r.redraw(); + break; + case "setBufferMaxCapacity": + r.glManager.bufferPool.maxCapacity = msg.n; + break; + case "loadAndRender": + r.loadAndRender(msg); + break; + case "redraw": + r.redraw(); + break; + case "resize": + r.resize(msg.cssWidth, msg.cssHeight, msg.dpr); + r.redraw(); + break; + case "clear": + r.clear(); + break; + case "invalidateTheme": + r.chartImpl.setTheme?.(msg.themeVars); + r.chartImpl.invalidateTheme?.(); + r.resize(r.cssWidth, r.cssHeight, r.dpr); + break; + case "restoreZoom": + r.restoreZoom(msg.state); + break; + case "resetAllZooms": + r.resetAllZooms(); + r.redraw(); + r.post({ + kind: "zoomChanged", + isDefault: r.allZoomsDefault(), + }); + break; + case "resetExpandedDomain": + r.resetExpandedDomain(); + break; + case "interaction": + r.onInteraction(msg.event); + break; + case "saveZoom": + r.post({ + kind: "saveZoomReply", + requestId: msg.requestId, + state: r.saveZoom(), + }); + break; + case "destroy": + r.destroy(); + break; + case "init": + // Re-init not supported; ignore to keep the renderer alive + // for the host's `delete()` cleanup path. + break; + case "snapshotPng": { + const requestId = msg.requestId; + r.snapshotPng() + .then((blob) => { + r.post({ kind: "snapshotPngReply", requestId, blob }); + }) + .catch((err) => { + r.post({ kind: "error", message: String(err) }); + }); + break; + } + } +} diff --git a/packages/viewer-charts/src/ts/worker/font-loader.ts b/packages/viewer-charts/src/ts/worker/font-loader.ts new file mode 100644 index 0000000..c52aef9 --- /dev/null +++ b/packages/viewer-charts/src/ts/worker/font-loader.ts @@ -0,0 +1,89 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { FontFaceDescriptor } from "../transport/protocol"; + +/** + * Worker-scope font registration cache, keyed by `family|src|weight| + * style`. Multiple sessions in one shared worker often ship identical + * font snapshots (the host's `snapshotFontFaces()` walks + * `document.styleSheets`, which is page-global). Without this cache, + * each session would `face.load()` + `fonts.add(face)` afresh — + * harmless but wasteful, since `fonts.add` dedupes by family but each + * `face.load()` still pays the await. + * + * The first session to ask for a given font owns the load promise; + * every subsequent session awaits it. + */ +const FONT_LOADS = new Map>(); + +export async function loadFontDeduped(d: FontFaceDescriptor): Promise { + const key = [ + d.family, + d.src, + d.weight ?? "", + d.style ?? "", + // d.stretch ?? "", + // d.unicodeRange ?? "", + // d.variant ?? "", + // d.featureSettings ?? "", + // d.display ?? "", + ].join("|"); + + let p = FONT_LOADS.get(key); + if (p) { + await p; + return; + } + + p = (async () => { + try { + const descriptors: FontFaceDescriptors = {}; + if (d.style) { + descriptors.style = d.style; + } + + if (d.weight) { + descriptors.weight = d.weight; + } + + if (d.stretch) { + descriptors.stretch = d.stretch; + } + + // if (d.unicodeRange) { + // descriptors.unicodeRange = d.unicodeRange; + // } + + // if (d.variant) { + // (descriptors as any).variant = d.variant; + // } + + // if (d.featureSettings) { + // descriptors.featureSettings = d.featureSettings; + // } + + // if (d.display) { + // (descriptors as any).display = d.display; + // } + + const face = new FontFace(d.family, d.src, descriptors); + await face.load(); + (self as any).fonts.add(face); + } catch (err) { + console.warn(`Failed to load font ${d.family}:`, err); + } + })(); + + FONT_LOADS.set(key, p); + await p; +} diff --git a/packages/viewer-charts/src/ts/worker/renderer.worker.ts b/packages/viewer-charts/src/ts/worker/renderer.worker.ts new file mode 100644 index 0000000..f5689c8 --- /dev/null +++ b/packages/viewer-charts/src/ts/worker/renderer.worker.ts @@ -0,0 +1,741 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 "./boot"; + +import type { Client, Table, View } from "@perspective-dev/client"; + +import type * as wasm_module_type from "@perspective-dev/viewer/dist/wasm/perspective-viewer.js"; +import { WebGLContextManager } from "../webgl/context-manager"; +import { ContextPool } from "../webgl/context-pool"; +import { RENDER_CONTEXT_POOL_SIZE } from "../config"; +import { ChartImplementation } from "../charts/chart"; +import { ZoomController } from "../interaction/zoom-controller"; +import { + applyPan, + applyWheel, + type ZoomTarget, +} from "../interaction/zoom-router"; +import { MessageHostSink } from "../interaction/host-sink-message"; +import { CHART_IMPLS } from "../charts/registry"; +import type { PlotLayout } from "../layout/plot-layout"; +import type { + ControlMsg, + InitMsg, + InteractionEvent, + LoadAndRenderMsg, + WorkerMsg, +} from "../transport/protocol"; +import { viewToColumnDataMap } from "../data/view-reader"; +import { loadFontDeduped } from "./font-loader"; +import { dispatch } from "./dispatch"; +import { installSessionHost } from "./session-host"; +import { deferIfDraining, unregister } from "../render/scheduler"; + +/** + * Sentinel thrown inside the `with_typed_arrays` callback when a newer + * `loadAndRender` has bumped the generation counter. Lets the wasm-side + * Arrow buffer release path run (the callback's promise must reject + * cleanly so `with_typed_arrays` unwinds before the next call) without + * polluting the worker's error path — caught and swallowed by + * `loadAndRender`'s try/catch. + */ +class StaleGenerationError extends Error { + constructor() { + super("StaleGenerationError"); + } +} + +/** + * Renderer state. One per host element. In worker mode it lives in + * the worker; in in-process mode (host loads this module via dynamic + * `import(workerURL)`) it lives on the main thread. The class itself + * doesn't care — both modes drive it through a `MessagePort` of + * `ControlMsg`s. + */ +/** + * Resolve a chart tag to its impl class via the lazy registry. Eager + * tags microtask-resolve; map tags trigger a dynamic `import()` that + * the bundler emits as a separately-fetched chunk. + */ +async function resolveChartImpl( + tag: string, +): Promise ChartImplementation> { + const factory = CHART_IMPLS[tag]; + if (!factory) { + throw new Error(`Unknown chart tag: ${tag}`); + } + + return await factory(); +} + +/** + * Renderer-scope shared context pool for pooled blit mode. Lazily + * created on first use so non-blit / non-pooled scopes never allocate + * one. One per renderer scope (the worker, or the main thread in + * in-process mode) — `WorkerRenderer`s in the same scope share its K + * contexts. + */ +let CONTEXT_POOL: ContextPool | null = null; + +function getContextPool(precompile: boolean): ContextPool { + if (!CONTEXT_POOL) { + CONTEXT_POOL = new ContextPool(RENDER_CONTEXT_POOL_SIZE, { + precompile, + }); + } + + return CONTEXT_POOL; +} + +export class WorkerRenderer { + chartImpl: ChartImplementation; + glManager: WebGLContextManager; + zoomController: ZoomController | null = null; + gridlines: OffscreenCanvas; + chrome: OffscreenCanvas; + cssWidth: number; + cssHeight: number; + dpr: number; + client: Client; + view: View; + + /** + * Source `Table` opened once at bootstrap from the host-supplied + * `tableName`. Used by `loadAndRender` to fetch the source schema + * for group-by level types — the worker resolves it itself so the + * host's render path makes zero `Client`/`Table`/`View` awaits. + * Null when the host had no table loaded at init time. + */ + table: Table | null; + controlPort: MessagePort; + + /** + * Monotonic counter bumped by every `loadAndRender` entry. Captured + * locally as `myGen` and re-checked after each await — a stale + * value means a newer call has superseded this one and we must + * bail (throwing inside the `with_typed_arrays` callback so the + * wasm Arrow buffer release runs cleanly). + */ + private _renderGen = 0; + + /** + * Active drag state. `pointerdown` resolves a target via the + * facet grid and stores it; `pointermove` consults this until + * `pointerup` clears it. Pointer capture itself is host-side. + */ + private _dragTarget: ZoomTarget | null = null; + private _lastDragX = 0; + private _lastDragY = 0; + + constructor( + msg: InitMsg, + client: Client, + view: View, + table: Table | null, + controlPort: MessagePort, + ImplClass: new () => ChartImplementation, + ) { + this.client = client; + this.view = view; + this.table = table; + this.controlPort = controlPort; + + this.chartImpl = new ImplClass(); + + // Three surfaces, by mode: + // - direct: the host's transferred `.webgl-canvas` (1:1 with a + // context, permanently). + // - pooled blit: borrow one of K shared contexts so live- + // context count stays bounded regardless of chart count. + // - unpooled blit: a private offscreen surface per chart. + // Blit modes omit `msg.glCanvas`; the renderer ships each frame + // as an `ImageBitmap` via the `endFrame` callback wired below. + const pooled = + msg.renderMode === "blit" && + !msg.glCanvas && + RENDER_CONTEXT_POOL_SIZE > 0; + + if (pooled) { + this.glManager = new WebGLContextManager( + getContextPool(msg.precompileShaders ?? false).acquire(), + ); + } else { + const glCanvas = + msg.glCanvas ?? + new OffscreenCanvas( + Math.max(1, Math.round(msg.cssWidth * msg.dpr)), + Math.max(1, Math.round(msg.cssHeight * msg.dpr)), + ); + + this.glManager = new WebGLContextManager(glCanvas, { + precompile: msg.precompileShaders ?? false, + }); + } + + if (msg.renderMode === "blit") { + this.glManager.setFrameCallback((bitmap) => { + this.post({ kind: "frameBitmap", bitmap }, [bitmap]); + }); + } + + this.gridlines = msg.gridlinesCanvas; + this.chrome = msg.chromeCanvas; + this.cssWidth = msg.cssWidth; + this.cssHeight = msg.cssHeight; + this.dpr = msg.dpr; + + this.chartImpl.setGridlineCanvas?.(msg.gridlinesCanvas); + this.chartImpl.setChromeCanvas?.(msg.chromeCanvas); + this.chartImpl.setTheme?.(msg.themeVars); + + if (msg.defaultChartType) { + this.chartImpl.setDefaultChartType?.(msg.defaultChartType); + } + + this.chartImpl.setFacetConfig?.(msg.facetConfig); + this.chartImpl.setPluginConfig?.(msg.pluginConfig); + + if (this.chartImpl.setZoomController) { + this.zoomController = new ZoomController(); + this.chartImpl.setZoomController(this.zoomController); + } + + this.chartImpl.setView?.(view); + this.glManager.bufferPool.maxCapacity = msg.bufferMaxCapacity; + this.glManager.resize(msg.cssWidth, msg.cssHeight, msg.dpr); + // `MessageHostSink` emits `WorkerMsg`s directly — no translation + // needed here. + this.chartImpl.attachTooltip?.( + new MessageHostSink((msg) => this.post(msg)), + ); + } + + setViewByName(name: string): void { + this.view = this.client.__unsafe_open_view(name); + this.chartImpl.setView?.(this.view); + } + + /** + * Full data-fetch + render pipeline. Owns every `Client`/`Table`/ + * `View` await on the render path: + * + * 1. Resolve metadata (`view.num_rows`, `view.schema`, + * `view.expression_schema`, `table.schema`) in parallel. + * 2. Apply schema + viewer-config to the chart impl (replaces the + * individual `setColumnTypes` / `setGroupByTypes` / + * `setViewPivots` / `setColumnSlots` setters that used to + * stream from the host). + * 3. Compute `totalRows` from `bufferPool.maxCapacity / numCols` + * and grow the buffer pool to fit. + * 4. Run `view.with_typed_arrays`; the inner callback hands the + * resulting `ColumnDataMap` straight to + * `chartImpl.uploadAndRender` — no `postMessage`, no transfer. + * + * Mid-flight cancellation: each entry bumps `_renderGen` and + * captures `myGen`. After the metadata await we re-check; if a + * newer call has superseded this one, ack-and-return so the host + * promise resolves cleanly. Inside the `with_typed_arrays` + * callback the same check throws `StaleGenerationError` so the + * wasm Arrow buffer release path runs (callback's promise must + * reject for `with_typed_arrays` to unwind) before the next call + * proceeds — caught and swallowed here. + * + * Always sends `loadAndRenderAck` (even on stale drop) per the + * "resolve on stale" host contract. + */ + async loadAndRender(msg: LoadAndRenderMsg): Promise { + const myGen = ++this._renderGen; + try { + const [numRows, schema, exprSchema, tableSchema] = + await Promise.all([ + this.view.num_rows(), + this.view.schema() as Promise>, + this.view.expression_schema() as Promise< + Record + >, + (this.table?.schema() ?? Promise.resolve({})) as Promise< + Record + >, + ]); + + if (this._renderGen !== myGen) { + return; + } + + // Order mirrors the pre-refactor host-side message stream + // (pivots → types → groupByTypes → slots) — chart impls + // assume types/groupByTypes are pushed after pivots so + // axis-builder code paths see consistent state. + this.chartImpl.setViewPivots?.( + msg.viewerConfig.group_by, + msg.viewerConfig.split_by, + ); + this.chartImpl.setColumnTypes?.(schema); + this.chartImpl.setGroupByTypes?.({ ...tableSchema, ...exprSchema }); + this.chartImpl.setColumnSlots?.(msg.viewerConfig.columns); + + const numCols = Object.keys(schema).length || 1; + const maxRows = Math.floor( + this.glManager.bufferPool.maxCapacity / numCols, + ); + + const totalRows = Math.min(numRows, maxRows); + this.glManager.ensureBufferCapacity(totalRows); + + try { + await viewToColumnDataMap( + this.view, + async (cols) => { + if (this._renderGen !== myGen) { + throw new StaleGenerationError(); + } + + await this.chartImpl.uploadAndRender( + this.glManager, + cols, + 0, + totalRows, + ); + }, + { end_row: totalRows, float32: msg.options.float32 }, + ); + } catch (e) { + if (!(e instanceof StaleGenerationError)) { + throw e; + } + } + } catch (err) { + if ((err + "").indexOf("View not found") === -1) { + console.error("loadAndRender failed", err); + } + } finally { + this.post({ kind: "loadAndRenderAck", msgId: msg.msgId }); + } + } + + redraw(): void { + this.chartImpl.requestRender(this.glManager); + } + + resize(cssWidth: number, cssHeight: number, dpr: number): void { + // `glManager.resize` would set `canvas.width = N`, which the + // spec mandates clears the drawing buffer immediately. In + // direct / in-process modes the GL canvas IS the host's + // visible canvas, so a clear at message-receipt time + // followed by a paint on the next RAF leaves one full + // compositor cycle between them displaying an empty buffer + // — visible flicker. + // + // `requestResize` only stores the pending dimensions; the + // `canvas.width = N` assignment is deferred to the next + // `drain()` Phase 1, where it runs in the same un-yielded + // synchronous loop as `_fullRender`. Compositor only + // observes the post-paint state. + // + // Because `requestResize` is a pure JS-state operation (no + // GL ops, no canvas mutation), it doesn't need + // `deferIfDraining` — it's safe to call concurrently with + // an in-flight drain. The drain serialization at the + // scheduler level ensures the actual `applyPendingResize` + // happens between drains, never during one. + // + // Multiple `requestResize` calls before the next render + // coalesce: last write wins. Five rapid width changes from + // a window-drag produce one resize+paint, not five. + this.cssWidth = cssWidth; + this.cssHeight = cssHeight; + this.dpr = dpr; + this.glManager.requestResize(cssWidth, cssHeight, dpr); + this.chartImpl.requestRender(this.glManager); + } + + clear(): void { + // Same rationale as `resize`: `gl.clear` would queue after + // Phase 1's draws but could execute before + // `transferToImageBitmap`, wiping the bitmap. Defer. + deferIfDraining(this.glManager, () => { + this.glManager.clear(); + const ctx = this.gridlines.getContext("2d"); + ctx?.clearRect(0, 0, this.gridlines.width, this.gridlines.height); + }); + } + + saveZoom(): any { + return this.zoomController?.serialize(); + } + + restoreZoom(state: any): void { + if (state) { + this.zoomController?.restore(state); + } + } + + allZoomsDefault(): boolean { + if (this.zoomController && !this.zoomController.isDefault()) { + return false; + } + + const facets = (this.chartImpl as any)?._facetZoomControllers; + if (facets) { + for (const zc of facets) { + if (zc && !zc.isDefault()) { + return false; + } + } + } + + return true; + } + + resetAllZooms(): void { + this.zoomController?.reset(); + const facets = (this.chartImpl as any)?._facetZoomControllers; + if (facets) { + for (const zc of facets) { + zc?.reset(); + } + } + + // Also drop any `domain_mode: "expand"` accumulator — the user + // explicitly asked for a clean reset, so the next data load + // should start from the fresh data extent rather than the + // previously-grown one. + this.resetExpandedDomain(); + } + + resetExpandedDomain(): void { + this.chartImpl.resetExpandedDomain?.(); + } + + /** + * Hit-test the cursor against the chart's facet grid (in faceted + * mode) or its current layout (single-plot). The worker owns the + * facet grid and controllers, so the resolution runs here. + */ + private _resolveTarget(mx: number, my: number): ZoomTarget | null { + const chart = this.chartImpl as any; + const facetGrid = chart?._facetGrid as + | { cells: { layout: PlotLayout }[] } + | null + | undefined; + if (facetGrid) { + for (let i = 0; i < facetGrid.cells.length; i++) { + const cell = facetGrid.cells[i]; + const plot = cell.layout.plotRect; + if ( + mx >= plot.x && + mx <= plot.x + plot.width && + my >= plot.y && + my <= plot.y + plot.height + ) { + const zc = + chart.getZoomControllerForFacet?.(i) ?? + this.zoomController; + return zc ? { controller: zc, layout: cell.layout } : null; + } + } + + return null; + } + + if (!this.zoomController) { + return null; + } + + const layout = chart?._lastLayout as PlotLayout | null | undefined; + if (!layout) { + return null; + } + + const plot = layout.plotRect; + if ( + mx < plot.x || + mx > plot.x + plot.width || + my < plot.y || + my > plot.y + plot.height + ) { + return null; + } + + return { controller: this.zoomController, layout }; + } + + onInteraction(event: InteractionEvent): void { + switch (event.type) { + case "wheel": { + const target = this._resolveTarget(event.mx, event.my); + if (!target) { + return; + } + + applyWheel(target, event.mx, event.my, event.deltaY); + this.chartImpl.requestRender(this.glManager); + this.post({ + kind: "zoomChanged", + isDefault: this.allZoomsDefault(), + }); + break; + } + + case "pointerdown": { + const target = this._resolveTarget(event.mx, event.my); + if (!target) { + return; + } + + this._dragTarget = target; + this._lastDragX = event.mx; + this._lastDragY = event.my; + break; + } + + case "pointermove": { + if (this._dragTarget) { + // Mid-drag: pan only; suppress hover dispatch so + // the tooltip doesn't chase the cursor across a + // zoom gesture. + const dx = event.mx - this._lastDragX; + const dy = event.my - this._lastDragY; + this._lastDragX = event.mx; + this._lastDragY = event.my; + applyPan(this._dragTarget, dx, dy); + this.chartImpl.requestRender(this.glManager); + this.post({ + kind: "zoomChanged", + isDefault: this.allZoomsDefault(), + }); + } else { + // Plain hover: route into the chart's + // `TooltipController` (RAF-coalesced). + this._tooltip()?.dispatchHover(event.mx, event.my); + } + + break; + } + + case "pointerup": { + this._dragTarget = null; + break; + } + + case "pointerleave": { + this._tooltip()?.dispatchLeave(); + break; + } + + case "click": { + this._tooltip()?.dispatchClick(event.mx, event.my); + break; + } + + case "dblclick": { + this._tooltip()?.dispatchDblClick(event.mx, event.my); + break; + } + } + } + + /** + * Read the chart impl's `TooltipController`. Charts that don't use + * one (no `attachTooltip` override) yield `null` and the + * mouse-event branches fall through. + */ + private _tooltip(): { + dispatchHover: (mx: number, my: number) => void; + dispatchLeave: () => void; + dispatchClick: (mx: number, my: number) => void; + dispatchDblClick: (mx: number, my: number) => void; + } | null { + const tt = (this.chartImpl as any)?._tooltip; + return tt ?? null; + } + + /** + * Composite the three layers into a single PNG `Blob`. + */ + async snapshotPng(): Promise { + // Snapshot bypasses the scheduler's drain, so it must mirror + // the per-frame prep itself — apply any pending resize, and in + // pooled blit mode size the shared canvas to this chart and + // reset shared GL state — otherwise a snapshot taken after a + // resize message but before the next drain (or after a co-tenant + // chart left the shared canvas at its own size) would render at + // the wrong dimensions. + this.glManager.beginFrame(); + this.chartImpl._fullRender(this.glManager); + const gl = this.glManager.gl; + const glCanvas = gl.canvas as OffscreenCanvas; + const w = glCanvas.width; + const h = glCanvas.height; + const pixels = new Uint8ClampedArray(w * h * 4); + gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + const composite = new OffscreenCanvas(w, h); + const ctx = composite.getContext("2d"); + if (!ctx) { + throw new Error("snapshotPng: 2D context unavailable"); + } + + const theme = (this.chartImpl as any)._resolveTheme?.(); + const bg = theme?.backgroundColor ?? "transparent"; + if (bg !== "transparent") { + ctx.fillStyle = bg; + ctx.fillRect(0, 0, w, h); + } + + ctx.drawImage(this.gridlines, 0, 0); + const glLayer = new OffscreenCanvas(w, h); + const glCtx = glLayer.getContext("2d"); + if (!glCtx) { + throw new Error("snapshotPng: 2D context unavailable for GL blit"); + } + + glCtx.putImageData(new ImageData(pixels, w, h), 0, 0); + ctx.save(); + ctx.scale(1, -1); + + // `readPixels` returns rows bottom-up; flip on the Y axis + ctx.drawImage(glLayer, 0, -h); + ctx.restore(); + ctx.drawImage(this.chrome, 0, 0); + + return await composite.convertToBlob({ type: "image/png" }); + } + + destroy(): void { + // Drop any frame this manager still has queued in the + // module-level scheduler *before* losing its GL context, so a + // next-RAF `drain()` can't paint/present against a dead + // context (the "scheduler: present failed" path). + unregister(this.glManager); + this.chartImpl.destroy(); + this.glManager.destroy(); + } + + post(msg: WorkerMsg, transfer?: Transferable[]): void { + if (transfer && transfer.length > 0) { + this.controlPort.postMessage(msg, transfer); + } else { + this.controlPort.postMessage(msg); + } + } +} + +/** + * Detect whether this module is loaded in a Web Worker scope. + */ +const IS_WORKER_SCOPE = typeof (globalThis as any).importScripts === "function"; + +/** + * Worker-mode bootstrap: receives the host's `InitMsg`, instantiates + * wasm, registers fonts, opens a `Client` against the host's + * `ProxySession`, and constructs a {@link WorkerRenderer} bound to the + * supplied control port (which in worker scope is `self`). + */ +async function bootstrapWorker( + msg: InitMsg, + host: MessagePort, +): Promise { + if (!msg.clientWorkerURL || !msg.clientWasm || !msg.proxyPort) { + throw new Error("Init error"); + } + + const module = (await import( + msg.clientWorkerURL.toString() + )) as typeof wasm_module_type; + + await module.initSync({ module: msg.clientWasm }); + + // Register every `@font-face` the host found in its document so + // Canvas2D `ctx.font` lookups inside this worker resolve correctly. + if (msg.fontFaces?.length) { + await Promise.all(msg.fontFaces.map(loadFontDeduped)); + } + + const proxyPort = msg.proxyPort; + const client = new module.Client( + async (proto: Uint8Array) => { + const buf = proto.slice().buffer; + proxyPort.postMessage(buf, [buf]); + }, + async () => proxyPort.close(), + ); + + proxyPort.addEventListener("message", (e: MessageEvent) => { + client.handle_response(new Uint8Array(e.data)); + }); + + proxyPort.start(); + const view = client.__unsafe_open_view(msg.viewName); + const table = msg.tableName ? await client.open_table(msg.tableName) : null; + const ImplClass = await resolveChartImpl(msg.chartTag); + const renderer = new WorkerRenderer( + msg, + client, + view, + table, + host, + ImplClass, + ); + renderer.post({ kind: "ready" }); + return renderer; +} + +/** + * In-process bootstrap. Used when the host loads this same module via + * `await import(workerURL)` to run the renderer on the main thread — + * skips the wasm / font / proxy-port plumbing because the host already + * owns a live `Client` and the document's `FontFaceSet` is the active + * one. + */ +export async function bootstrapInProcess(opts: { + msg: InitMsg; + client: Client; + controlPort: MessagePort; +}): Promise { + const view = opts.client.__unsafe_open_view(opts.msg.viewName); + const table = opts.msg.tableName + ? await opts.client.open_table(opts.msg.tableName) + : null; + const ImplClass = await resolveChartImpl(opts.msg.chartTag); + const renderer = new WorkerRenderer( + opts.msg, + opts.client, + view, + table, + opts.controlPort, + ImplClass, + ); + + // Listen for control messages on the same port so the host's + // `RendererTransport` shape doesn't need to branch. + opts.controlPort.addEventListener("message", (e: MessageEvent) => { + const ctrl = e.data as ControlMsg; + if (ctrl?.kind === "init") { + return; + } + + dispatch(renderer, ctrl); + }); + + opts.controlPort.start(); + + renderer.post({ kind: "ready" }); + return renderer; +} + +// Worker scope only: install the shared message handler . The same module is +// dynamic-imported on the main thread (in-process mode) where this branch is +// skipped. +if (IS_WORKER_SCOPE) { + installSessionHost(bootstrapWorker); +} diff --git a/packages/viewer-charts/src/ts/worker/session-host.ts b/packages/viewer-charts/src/ts/worker/session-host.ts new file mode 100644 index 0000000..904962e --- /dev/null +++ b/packages/viewer-charts/src/ts/worker/session-host.ts @@ -0,0 +1,147 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { + ControlEnvelope, + InitMsg, + WorkerEnvelope, + WorkerMsg, +} from "../transport/protocol"; +import type { WorkerRenderer } from "./renderer.worker"; +import { dispatch } from "./dispatch"; + +const RENDERERS = new Map(); + +/** + * Sessions whose `destroy` arrived before their async `bootstrap` + * resolved. Bootstrap does multiple awaits (wasm `initSync`, font + * loads, `open_table`, lazy chart-impl import); a host that connects + * then immediately disconnects a viewer can post `init` and `destroy` + * back-to-back, with `destroy` landing while the renderer is still + * being built. The plain `RENDERERS.get(sessionId)` lookup in the + * destroy branch would miss it and the bootstrap would then register a + * renderer (and its WebGL context) that nothing ever tears down — + * leaking one context per raced toggle until the browser evicts the + * oldest. We tombstone the id here and honor it when bootstrap lands. + */ +const PENDING_DESTROY = new Set(); + +function postSession( + sessionId: number, + msg: WorkerMsg, + transfer?: Transferable[], +): void { + const envelope = { sessionId, msg } satisfies WorkerEnvelope; + if (transfer && transfer.length > 0) { + (self as unknown as MessagePort).postMessage(envelope, transfer); + } else { + (self as unknown as MessagePort).postMessage(envelope); + } +} + +/** + * Adapter that satisfies the `MessagePort` shape `WorkerRenderer` + * expects (it only ever calls `.postMessage` on it). Outgoing posts + * get session-tagged on the way out so the host can route them to + * the right `RendererTransport` instance. + * + * The receive direction is handled exclusively by the worker-scope + * `self.addEventListener("message", …)` installed by `installSessionHost`; + * this adapter does not own a real port pair. + */ +export function makeSessionPort(sessionId: number): MessagePort { + return { + postMessage: (msg: WorkerMsg, transfer?: Transferable[]) => + postSession(sessionId, msg, transfer), + addEventListener: () => {}, + removeEventListener: () => {}, + start: () => {}, + close: () => {}, + dispatchEvent: () => false, + onmessage: null, + onmessageerror: null, + } as unknown as MessagePort; +} + +/** + * Install the shared message handler on the worker scope. One worker + * process hosts many `WorkerRenderer` instances, one per `sessionId` + * allocated by the host's `RendererTransport`; this listener + * demultiplexes incoming `ControlEnvelope`s and routes them to the + * matching renderer. + * + * The `bootstrap` callback constructs a `WorkerRenderer` from an + * `InitMsg` and a session-tagged port. It's injected so this module + * doesn't need to import `renderer.worker` for runtime bindings (avoids + * a runtime cycle — `renderer.worker` imports this module). + */ +export function installSessionHost( + bootstrap: (msg: InitMsg, port: MessagePort) => Promise, +): void { + self.addEventListener("message", function (e: MessageEvent) { + const env = e.data as ControlEnvelope; + const { sessionId, msg } = env; + + if (msg.kind === "init") { + if (RENDERERS.has(sessionId)) { + // Should never happen. + postSession(sessionId, { + kind: "error", + message: `sessionId ${sessionId} already initialized`, + }); + return; + } + + bootstrap(msg as InitMsg, makeSessionPort(sessionId)) + .then((r) => { + // A `destroy` raced ahead of bootstrap completing — + // honor it now instead of leaking the freshly-built + // renderer + its WebGL context. + if (PENDING_DESTROY.delete(sessionId)) { + dispatch(r, { kind: "destroy" }); + return; + } + + RENDERERS.set(sessionId, r); + }) + .catch((err) => { + PENDING_DESTROY.delete(sessionId); + postSession(sessionId, { + kind: "error", + message: String(err), + }); + }); + return; + } + + if (msg.kind === "destroy") { + const r = RENDERERS.get(sessionId); + if (r) { + dispatch(r, msg); + RENDERERS.delete(sessionId); + } else { + // Renderer not registered yet — bootstrap is still in + // flight. Tombstone the id so the bootstrap `.then` + // tears it down on arrival instead of registering a + // leaked context. + PENDING_DESTROY.add(sessionId); + } + + return; + } + + const r = RENDERERS.get(sessionId); + if (r) { + dispatch(r, msg); + } + }); +} diff --git a/packages/viewer-charts/test/ts/chart-type-switch.spec.ts b/packages/viewer-charts/test/ts/chart-type-switch.spec.ts new file mode 100644 index 0000000..3001c27 --- /dev/null +++ b/packages/viewer-charts/test/ts/chart-type-switch.spec.ts @@ -0,0 +1,154 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Chart-type-switch vertex-attribute bleed (pooled blit). + * + * In pooled blit mode many chart types share a small set of GL contexts. + * Vertex-array state (attribute enables + divisors) is global to a + * context's default VAO, and glyphs only ever *enable* attribute slots — + * none disables them. So when one chart type renders and is then torn + * down (its buffers deleted) and a *different* chart type lands on the + * same pooled context, the slots the first type left enabled now point + * at deleted buffers. The new type's `drawArraysInstanced` fails GL + * validation — `INVALID_OPERATION: no buffer is bound to enabled + * attribute` — and paints nothing; as more types cycle, more pooled + * contexts get poisoned and rendering collapses. + * + * `WebGLContextManager.beginFrame` resets the shared context's + * vertex-array state before each pooled render, which fixes this. The + * test cycles every chart type (twice, so the K pooled contexts are each + * reused by a different chart program) and asserts every type renders + * after its switch — and that no attribute-bleed GL error fired. + * + * Requires a build with the pooling changes + the fix. `setBlitMode` is + * the process-global static, so flipping it once puts every chart-type + * plugin into pooled blit. + */ + +import type { ConsoleMessage, Page } from "@playwright/test"; +import type { ViewerConfigUpdate } from "@perspective-dev/viewer"; +import { expect, test } from "@perspective-dev/test"; +import { + calibratePlotBaseline, + gotoBasic, + restoreChart, + waitOneFrame, +} from "./helpers"; + +/** + * Glyph-dense configs spanning distinct instanced-attribute layouts + * (points / lines / bars / heatmap), so consecutive switches produce the + * high-attribute-count → low-attribute-count transition that leaves a + * slot enabled across charts. Mirrors `frame-timing.spec.ts`'s set. + */ +const FIXTURES: { name: string; config: ViewerConfigUpdate }[] = [ + { + name: "X/Y Scatter", + config: { plugin: "X/Y Scatter", columns: ["Quantity", "Postal Code"] }, + }, + { name: "Y Line", config: { plugin: "Y Line", columns: ["Profit"] } }, + { name: "Y Area", config: { plugin: "Y Area", columns: ["Profit"] } }, + { + name: "Y Bar", + config: { plugin: "Y Bar", group_by: ["State"], columns: ["Profit"] }, + }, + { + name: "X Bar", + config: { plugin: "X Bar", group_by: ["State"], columns: ["Profit"] }, + }, + { + name: "Heatmap", + config: { + plugin: "Heatmap", + group_by: ["State"], + split_by: ["Region"], + columns: ["Profit"], + }, + }, +]; + +/** + * The precise GL validation failure the bug raises. Best-effort: browser + * GL warnings from the worker may or may not surface via + * `page.on("console")`, so the per-type render assertion below is the + * hard gate; this just adds signal when the message is captured. + */ +const ATTR_BLEED = /no buffer is bound to enabled attribute|INVALID_OPERATION/i; + +function collectAttrErrors(page: Page): string[] { + const hits: string[] = []; + page.on("console", (m: ConsoleMessage) => { + if (ATTR_BLEED.test(m.text())) { + hits.push(m.text()); + } + }); + + page.on("pageerror", (e: Error) => { + if (ATTR_BLEED.test(String(e))) { + hits.push(String(e)); + } + }); + + return hits; +} + +test.describe("Chart-type switch (pooled blit)", () => { + test("cycling every chart type never bleeds vertex-attribute state", async ({ + page, + }) => { + test.setTimeout(180_000); + await gotoBasic(page); + const attrErrors = collectAttrErrors(page); + + // Flip the whole renderer into pooled blit once, via the global + // static setter (reached through the activated plugin's + // constructor), then tear that renderer down so the cycle below + // rebuilds it pooled. + await restoreChart(page, FIXTURES[0].config); + await page.evaluate(() => { + const plugin = ( + document.querySelector("perspective-viewer") as any + ).getPlugin(); + plugin.constructor.setBlitMode("blit"); + plugin.delete(); + }); + + // Two passes over the type list. With a pool of K contexts, + // round-robin sticky assignment means types K+1, K+2, … reuse a + // context a different type already rendered on — the exact + // condition that surfaces leftover-enabled attributes. + const order = [...FIXTURES, ...FIXTURES]; + const results: { name: string; pixels: number }[] = []; + for (const fx of order) { + await restoreChart(page, fx.config); + await waitOneFrame(page); + const pixels = await calibratePlotBaseline(page); + results.push({ name: fx.name, pixels }); + } + + // Every chart type must have rendered after its switch. A type + // that landed on a poisoned pooled context fails + // `drawArraysInstanced` and paints nothing — its plot-pixel + // count collapses to ~0. This is the deterministic, capture- + // independent signal. + for (const r of results) { + expect( + r.pixels, + `${r.name} rendered ${r.pixels} plot pixels after switch — ` + + `likely an attribute-bleed draw failure on a shared context`, + ).toBeGreaterThan(0); + } + + expect(attrErrors).toEqual([]); + }); +}); diff --git a/packages/viewer-charts/test/ts/context-leak.spec.ts b/packages/viewer-charts/test/ts/context-leak.spec.ts new file mode 100644 index 0000000..5c5b3ba --- /dev/null +++ b/packages/viewer-charts/test/ts/context-leak.spec.ts @@ -0,0 +1,142 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { ConsoleMessage, Page } from "@playwright/test"; +import { expect, test } from "@perspective-dev/test"; +import { + assertPlotNeverBlank, + calibratePlotBaseline, + captureFrames, + gotoBasic, + restoreChart, + waitOneFrame, +} from "./helpers"; + +const SCATTER = { + plugin: "X/Y Scatter", + columns: ["Quantity", "Postal Code"], +}; + +const TABLE_NAME = "load-viewer-csv"; +const TOGGLE_COUNT = 12; + +function collectLeakErrors(page: Page): string[] { + const hits: string[] = []; + page.on("console", (m: ConsoleMessage) => { + hits.push(m.text()); + }); + + page.on("pageerror", (e: Error) => { + hits.push(String(e)); + }); + + return hits; +} + +async function toggleSecondViewer(page: Page, cfg: object): Promise { + await page.evaluate( + async ({ cfg, tableName }) => { + const worker = (window as any).__TEST_WORKER__; + const table = await worker.open_table(tableName); + const v = document.createElement("perspective-viewer"); + document.body.appendChild(v); + await v.load(table); + await v.restore(cfg); + }, + { cfg, tableName: TABLE_NAME }, + ); + + await waitOneFrame(page); + await waitOneFrame(page); + await page.evaluate(() => { + document.querySelector("perspective-viewer[data-toggle]")?.remove(); + }); +} + +async function toggleSecondViewerRacingInit( + page: Page, + cfg: object, +): Promise { + await page.evaluate( + async ({ cfg, tableName }) => { + const worker = (window as any).__TEST_WORKER__; + const table = await worker.open_table(tableName); + const v = document.createElement("perspective-viewer"); + document.body.appendChild(v); + await v.load(table); + v.restore(cfg).catch(() => {}); + await new Promise((resolve) => + setTimeout(() => { + v.remove(); + resolve(); + }, 0), + ); + }, + { cfg, tableName: TABLE_NAME }, + ); + + await waitOneFrame(page); +} + +test.describe("WebGL context leak", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + await restoreChart(page, SCATTER); + }); + + test("repeated full toggles never evict the first chart's context", async ({ + page, + }) => { + // test.setTimeout(180_000); + const leakErrors = collectLeakErrors(page); + const baseline = await calibratePlotBaseline(page); + expect(baseline).toBeGreaterThan(0); + const threshold = Math.max(1, Math.floor(baseline * 0.5)); + for (let i = 0; i < TOGGLE_COUNT; i++) { + await toggleSecondViewer(page, SCATTER); + } + + // Force the first viewer to re-render through its own renderer. + // If its context had been evicted by a leak, the worker can no + // longer paint it and the plot region comes back blank. + await restoreChart(page, SCATTER); + const frames = await captureFrames(page, async () => { + await waitOneFrame(page); + await waitOneFrame(page); + }); + + assertPlotNeverBlank(frames, threshold); + expect(leakErrors).toEqual([]); + }); + + test("toggling during renderer init never leaks a context", async ({ + page, + }) => { + // test.setTimeout(180_000); + const leakErrors = collectLeakErrors(page); + const baseline = await calibratePlotBaseline(page); + expect(baseline).toBeGreaterThan(0); + const threshold = Math.max(1, Math.floor(baseline * 0.5)); + for (let i = 0; i < TOGGLE_COUNT; i++) { + await toggleSecondViewerRacingInit(page, SCATTER); + } + + await restoreChart(page, SCATTER); + const frames = await captureFrames(page, async () => { + await waitOneFrame(page); + await waitOneFrame(page); + }); + + assertPlotNeverBlank(frames, threshold); + expect(leakErrors).toEqual([]); + }); +}); diff --git a/packages/viewer-charts/test/ts/context-pool.spec.ts b/packages/viewer-charts/test/ts/context-pool.spec.ts new file mode 100644 index 0000000..b5c797d --- /dev/null +++ b/packages/viewer-charts/test/ts/context-pool.spec.ts @@ -0,0 +1,101 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { ConsoleMessage, Page } from "@playwright/test"; +import { expect, test } from "@perspective-dev/test"; +import { + calibrateAllBaselines, + gotoBasic, + restoreChart, + waitOneFrame, +} from "./helpers"; + +const SCATTER = { + plugin: "X/Y Scatter", + columns: ["Quantity", "Postal Code"], +}; + +const TABLE_NAME = "load-viewer-csv"; + +const VIEWER_COUNT = 18; + +const LEAK_SIGNATURE = + /too many active webgl contexts|context is lost|present failed|transferToImageBitmap/i; + +function collectLeakErrors(page: Page): string[] { + const hits: string[] = []; + page.on("console", (m: ConsoleMessage) => { + if (LEAK_SIGNATURE.test(m.text())) { + hits.push(m.text()); + } + }); + + page.on("pageerror", (e: Error) => { + if (LEAK_SIGNATURE.test(String(e))) { + hits.push(String(e)); + } + }); + + return hits; +} + +test.describe("Pooled blit WebGL contexts", () => { + test("more simultaneous charts than the context cap all render", async ({ + page, + }) => { + test.setTimeout(240_000); + await gotoBasic(page); + const leakErrors = collectLeakErrors(page); + + await restoreChart(page, SCATTER); + await page.evaluate(() => { + const plugin = ( + document.querySelector("perspective-viewer") as any + ).getPlugin(); + plugin.constructor.setBlitMode("blit"); + plugin.delete(); + }); + await restoreChart(page, SCATTER); + + for (let i = 1; i < VIEWER_COUNT; i++) { + await page.evaluate( + async ({ cfg, tableName }) => { + const worker = (window as any).__TEST_WORKER__; + const table = await worker.open_table(tableName); + const v = document.createElement("perspective-viewer"); + document.body.appendChild(v); + await (v as any).load(table); + await (v as any).restore(cfg); + }, + { cfg: SCATTER, tableName: TABLE_NAME }, + ); + } + + await waitOneFrame(page); + await waitOneFrame(page); + + const baselines = await calibrateAllBaselines(page); + expect(baselines.length).toBe(VIEWER_COUNT); + const ref = Math.max(...baselines); + expect(ref).toBeGreaterThan(0); + + for (let i = 0; i < baselines.length; i++) { + expect( + baselines[i], + `viewer ${i} of ${VIEWER_COUNT} rendered ${baselines[i]} ` + + `plot pixels (ref ${ref}) — likely an evicted context`, + ).toBeGreaterThanOrEqual(ref * 0.5); + } + + expect(leakErrors).toEqual([]); + }); +}); diff --git a/packages/viewer-charts/test/ts/events.spec.ts b/packages/viewer-charts/test/ts/events.spec.ts new file mode 100644 index 0000000..6b3ab5a --- /dev/null +++ b/packages/viewer-charts/test/ts/events.spec.ts @@ -0,0 +1,568 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Click-event emission for `viewer-charts`. Asserts the + * `` host element dispatches + * `perspective-click` (`CustomEvent`) and + * `perspective-global-filter` (`CustomEvent`) + * with detail shapes compatible with `viewer-datagrid`'s equivalents. + * + * Coordinate strategy: every chart loaded here is configured so that a + * click at the canvas centerpoint reliably lands on a single glyph + * (one bar across the plot rect, a single treemap leaf filling most + * of the area, etc.). Tests assert detail *shape* rather than exact + * values for the volatile fields (the precise row index / category + * label depends on canvas-size + hit-test geometry); the filter + * structure (column count, `[col, "==", value]` triples, presence of + * `row` keys) is what consumers actually code against. + */ + +import type { Page } from "@playwright/test"; +import { expect, test } from "@perspective-dev/test"; +import type { ViewerConfigUpdate } from "@perspective-dev/viewer"; +import { gotoBasic, restoreChart, waitOneFrame } from "./helpers"; + +/** + * Plain-object snapshot of a captured `CustomEvent.detail`. We + * postMessage these between page and Playwright contexts so class + * instances are flattened — assertions only inspect own fields, not + * the `PerspectiveSelectDetail` prototype getters. + */ +interface CapturedEvent { + name: string; + detail: any; + detailIsSelect: boolean; +} + +/** + * Install listeners on `` for the named events. + * Subsequent `drainCapturedEvents` reads them back as plain objects. + */ +async function installEventCapture(page: Page): Promise { + await page.evaluate(() => { + const v = document.querySelector("perspective-viewer")!; + const events: any[] = []; + const capture = (name: string) => (e: Event) => { + const ce = e as CustomEvent; + // Detect `PerspectiveSelectDetail` via its distinctive + // prototype getters (`insertFilters` / `removeFilters`). + // The class name is mangled by minification so + // `constructor.name` is unreliable, but the getters are + // preserved on the prototype. + const d = ce.detail as any; + const detailIsSelect = !!( + d && + typeof d === "object" && + Array.isArray(d.insertFilters) && + Array.isArray(d.removeFilters) + ); + // Flatten own + getter values into the captured payload + // so it survives the structured-clone boundary. + const detail = + d && typeof d === "object" + ? { + ...d, + ...(detailIsSelect + ? { + insertFilters: d.insertFilters, + removeFilters: d.removeFilters, + } + : {}), + } + : d; + events.push({ name, detail, detailIsSelect }); + }; + + for (const name of ["perspective-click", "perspective-global-filter"]) { + v.addEventListener(name, capture(name)); + } + + (window as any).__capturedEvents = events; + }); +} + +async function drainCapturedEvents(page: Page): Promise { + return page.evaluate(() => { + const events = ((window as any).__capturedEvents ?? []) as any[]; + const copy = events.slice(); + events.length = 0; + return copy; + }); +} + +/** + * Resolve the chart plugin's `.webgl-canvas` and return its center + * coordinates in page pixels. Walks the shadow-DOM chain rooted at + * `` since the canvas lives inside the plugin's + * shadow root. + */ +async function getCanvasCenter( + page: Page, +): Promise<{ x: number; y: number; width: number; height: number }> { + return page.evaluate(() => { + const v = document.querySelector("perspective-viewer")!; + const visit = ( + root: Element | ShadowRoot, + ): HTMLCanvasElement | null => { + if (root instanceof Element) { + const sr = (root as any).shadowRoot as ShadowRoot | null; + if (sr) { + const hit = visit(sr); + if (hit) { + return hit; + } + } + } + + const direct = (root as ParentNode).querySelector?.( + ".webgl-canvas", + ) as HTMLCanvasElement | null; + if (direct) { + return direct; + } + + const els = (root as ParentNode).querySelectorAll?.("*") ?? []; + for (const el of Array.from(els)) { + const sr = (el as any).shadowRoot as ShadowRoot | null; + if (sr) { + const hit = visit(sr); + if (hit) { + return hit; + } + } + } + + return null; + }; + + const canvas = visit(v); + if (!canvas) { + throw new Error("webgl-canvas not found in plugin shadow tree"); + } + + const r = canvas.getBoundingClientRect(); + return { + x: r.left + r.width / 2, + y: r.top + r.height / 2, + width: r.width, + height: r.height, + }; + }); +} + +/** + * Click the GL canvas at a fractional position (0..1) from the + * canvas's top-left corner. + */ +async function clickCanvasLower( + page: Page, + fx = 0.25, + fy = 0.7, +): Promise { + const c = await getCanvasCenter(page); + const x = c.x - c.width / 2 + c.width * fx; + const y = c.y - c.height / 2 + c.height * fy; + const baseline = await page.evaluate( + () => ((window as any).__capturedEvents ?? []).length, + ); + + await page.mouse.click(x, y); + await waitForEvents(page, baseline + 1, 500); +} + +/** + * Poll until `window.__capturedEvents` has at least `min` entries + * (or `timeoutMs` elapses). + */ +async function waitForEvents( + page: Page, + min: number, + timeoutMs: number, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const n = await page.evaluate( + () => ((window as any).__capturedEvents ?? []).length, + ); + if (n >= min) { + return; + } + + await waitOneFrame(page); + } +} + +/** + * Restore + install capture in one step. The chart's first render + * dismisses any prior pin (which can fire a stray + * `perspective-global-filter selected:false`); restore *before* + * installing capture so those startup events don't pollute the + * captured array. + */ +async function setupChart( + page: Page, + config: ViewerConfigUpdate, +): Promise { + await restoreChart(page, config); + await installEventCapture(page); +} + +test.beforeEach(async ({ page }) => { + await gotoBasic(page); +}); + +test.describe("viewer-charts user events", () => { + test("Y Bar: click emits perspective-click with row + filter", async ({ + page, + }) => { + await setupChart(page, { + plugin: "Y Bar", + group_by: ["Category"], + columns: ["Sales"], + }); + + await clickCanvasLower(page); + const events = await drainCapturedEvents(page); + + const click = events.find((e) => e.name === "perspective-click"); + if (!click) { + throw new Error( + `expected perspective-click; got: ${JSON.stringify(events.map((e) => e.name))}`, + ); + } + + expect(click.detail.column_names).toEqual(["Sales"]); + expect(Array.isArray(click.detail.config?.filter)).toBe(true); + // Filter has at least one clause naming the group_by column. + const cats = click.detail.config.filter.filter( + (f: unknown[]) => f[0] === "Category" && f[1] === "==", + ); + expect(cats.length).toBe(1); + expect(typeof cats[0][2]).toBe("string"); + expect(typeof click.detail.row).toBe("object"); + }); + + test("Y Bar: click also emits perspective-global-filter selected:true", async ({ + page, + }) => { + await setupChart(page, { + plugin: "Y Bar", + group_by: ["Category"], + columns: ["Sales"], + }); + + await clickCanvasLower(page); + const events = await drainCapturedEvents(page); + + const select = events.find( + (e) => e.name === "perspective-global-filter", + ); + expect(select).toBeDefined(); + expect(select!.detailIsSelect).toBe(true); + expect(select!.detail.selected).toBe(true); + expect(select!.detail.column_names).toEqual(["Sales"]); + expect(select!.detail.insertConfigs).toHaveLength(1); + expect(select!.detail.removeConfigs).toHaveLength(0); + // Helper getters survived our manual copy. + expect(Array.isArray(select!.detail.insertFilters)).toBe(true); + expect(select!.detail.insertFilters.length).toBeGreaterThan(0); + }); + + test("Y Bar: click + same-target re-click pins then unpins", async ({ + page, + }) => { + await setupChart(page, { + plugin: "Y Bar", + group_by: ["Category"], + columns: ["Sales"], + }); + + await clickCanvasLower(page); + await clickCanvasLower(page); + const events = await drainCapturedEvents(page); + + // Expect the second click to land on the same target and + // dismiss the pin — yielding a `selected:false` after the + // initial `selected:true`. + const selects = events.filter( + (e) => e.name === "perspective-global-filter", + ); + expect(selects.length).toBeGreaterThanOrEqual(2); + expect(selects[0].detail.selected).toBe(true); + const lastUnselect = selects + .reverse() + .find((e) => e.detail.selected === false); + expect(lastUnselect).toBeDefined(); + // The unpin carries the previous insert as `removeConfigs`. + expect(lastUnselect!.detail.removeConfigs.length).toBe(1); + expect(lastUnselect!.detail.insertConfigs).toHaveLength(0); + }); + + test("Y Bar with split_by: filter carries both group_by and split_by clauses", async ({ + page, + }) => { + await setupChart(page, { + plugin: "Y Bar", + group_by: ["Category"], + split_by: ["Region"], + columns: ["Sales"], + }); + + await clickCanvasLower(page); + const events = await drainCapturedEvents(page); + + const click = events.find((e) => e.name === "perspective-click"); + if (!click) { + // Some splits may be hidden; if the centerpoint lands in a + // gap the test asserts nothing — the next assertion would + // fire downstream from a real click. + test.skip(true, "center click missed a glyph"); + return; + } + + const filter = click.detail.config.filter as unknown[][]; + const hasCategory = filter.some( + (f) => f[0] === "Category" && f[1] === "==", + ); + const hasRegion = filter.some( + (f) => f[0] === "Region" && f[1] === "==", + ); + expect(hasCategory).toBe(true); + expect(hasRegion).toBe(true); + }); + + test("Treemap: leaf click emits click + select with full path filter", async ({ + page, + }) => { + await setupChart(page, { + plugin: "Treemap", + group_by: ["Category", "Sub-Category"], + columns: ["Sales"], + }); + + await clickCanvasLower(page, 0.5, 0.5); + const events = await drainCapturedEvents(page); + + const click = events.find((e) => e.name === "perspective-click"); + const select = events.find( + (e) => e.name === "perspective-global-filter", + ); + + expect(click).toBeDefined(); + expect(select).toBeDefined(); + // Path filter carries one clause per resolved group_by level. + // Treemap may drill to a branch (1 level) or a leaf (2 levels); + // assert at least one is present. + const filter = click!.detail.config.filter as unknown[][]; + expect(filter.length).toBeGreaterThanOrEqual(1); + expect(filter[0][0]).toBe("Category"); + expect(select!.detail.selected).toBe(true); + }); + + test("Sunburst: leaf click emits click + select", async ({ page }) => { + await setupChart(page, { + plugin: "Sunburst", + group_by: ["Category", "Sub-Category"], + columns: ["Sales"], + }); + + // Sunburst's center is the drill-up zone, so a centerpoint + // click would either no-op or fire `selected:false`. Click + // off-center to land on an outer ring (leaf) and assert a + // pin-style event fires. + const c = await getCanvasCenter(page); + // Offset 30% of the smaller dimension into the ring area. + const r = Math.min(c.width, c.height) * 0.3; + await page.mouse.click(c.x + r, c.y); + await waitOneFrame(page); + await waitOneFrame(page); + await waitOneFrame(page); + const events = await drainCapturedEvents(page); + + const select = events.find( + (e) => e.name === "perspective-global-filter", + ); + expect(select).toBeDefined(); + }); + + test("Heatmap: cell click emits click + select with both axes filtered", async ({ + page, + }) => { + await setupChart(page, { + plugin: "Heatmap", + group_by: ["Category"], + split_by: ["Region"], + columns: ["Sales"], + }); + + await clickCanvasLower(page, 0.5, 0.5); + const events = await drainCapturedEvents(page); + + const click = events.find((e) => e.name === "perspective-click"); + const select = events.find( + (e) => e.name === "perspective-global-filter", + ); + expect(click).toBeDefined(); + expect(select).toBeDefined(); + expect(click!.detail.column_names).toEqual(["Sales"]); + const filter = click!.detail.config.filter as unknown[][]; + // Either filter clause might be missing if the centerpoint + // lands on a rollup row; require at least one. + expect(filter.length).toBeGreaterThanOrEqual(1); + }); + + test("Candlestick: click on a candle emits click + select", async ({ + page, + }) => { + // Use a coarse group_by so each candle spans many CSS pixels. + await setupChart(page, { + plugin: "Candlestick", + group_by: ["Category"], + columns: ["Sales", "Profit", "Discount", "Quantity"], + }); + + await clickCanvasLower(page); + const events = await drainCapturedEvents(page); + + const click = events.find((e) => e.name === "perspective-click"); + if (!click) { + test.skip(true, "centerpoint missed wick / body"); + return; + } + + const filter = click.detail.config.filter as unknown[][]; + expect(filter.some((f) => f[0] === "Category")).toBe(true); + }); + + test("X/Y Scatter: click on a point emits click + select", async ({ + page, + }) => { + await setupChart(page, { + plugin: "X/Y Scatter", + columns: ["Sales", "Profit"], + }); + + await clickCanvasLower(page, 0.5, 0.5); + const events = await drainCapturedEvents(page); + + const click = events.find((e) => e.name === "perspective-click"); + // Scatter centerpoint may not hit a point in a sparse dataset + // — skip rather than flake. + if (!click) { + test.skip(true, "centerpoint missed a point"); + return; + } + + expect(click.detail.column_names).toEqual(["Profit"]); + // No group_by → filter is empty (or contains only the split + // prefix if `split_by` was set). + expect(Array.isArray(click.detail.config.filter)).toBe(true); + }); + + test("X/Y Scatter with split_by: filter carries the split clause", async ({ + page, + }) => { + await setupChart(page, { + plugin: "X/Y Scatter", + split_by: ["Region"], + columns: ["Sales", "Profit"], + }); + + await clickCanvasLower(page, 0.5, 0.5); + const events = await drainCapturedEvents(page); + + const click = events.find((e) => e.name === "perspective-click"); + if (!click) { + test.skip(true, "centerpoint missed a point"); + return; + } + + const filter = click.detail.config.filter as unknown[][]; + expect(filter.some((f) => f[0] === "Region" && f[1] === "==")).toBe( + true, + ); + }); + + test("Map plugins: no event emitted (scoped out)", async ({ page }) => { + await setupChart(page, { + plugin: "Map Scatter", + columns: ["Postal Code", "Postal Code"], + }); + + await clickCanvasLower(page, 0.5, 0.5); + const events = await drainCapturedEvents(page); + // Map plugins inherit from CartesianChart so they'll wire + // up — the relevant assertion is "no crash", not silence. + // Document the current behavior: map clicks may emit if the + // underlying CartesianChart resolves a hit. Either is OK. + expect(Array.isArray(events)).toBe(true); + }); + + test("Detail prototype: select event detail is a PerspectiveSelectDetail instance", async ({ + page, + }) => { + await setupChart(page, { + plugin: "Y Bar", + group_by: ["Category"], + columns: ["Sales"], + }); + + await clickCanvasLower(page); + const events = await drainCapturedEvents(page); + const select = events.find( + (e) => e.name === "perspective-global-filter", + ); + expect(select).toBeDefined(); + expect(select!.detailIsSelect).toBe(true); + }); + + test("View change: restore() while pinned emits a selected:false", async ({ + page, + }) => { + await setupChart(page, { + plugin: "Y Bar", + group_by: ["Category"], + columns: ["Sales"], + }); + + await clickCanvasLower(page); + const pre = await drainCapturedEvents(page); + if (!pre.find((e) => e.name === "perspective-global-filter")) { + test.skip(true, "initial click missed a glyph"); + return; + } + + // Re-restore with a different filter: the view changes, the + // chart's `setView` runs, the pin is dismissed, and we expect + // a `selected:false` to surface. + await page.evaluate(async () => { + const v = document.querySelector("perspective-viewer")! as any; + await v.restore({ + plugin: "Y Bar", + group_by: ["Sub-Category"], + columns: ["Sales"], + }); + }); + await waitOneFrame(page); + await waitOneFrame(page); + await waitOneFrame(page); + + const post = await drainCapturedEvents(page); + const unselect = post.find( + (e) => + e.name === "perspective-global-filter" && + e.detail.selected === false, + ); + expect(unselect).toBeDefined(); + }); +}); diff --git a/packages/viewer-charts/test/ts/frame-timing.spec.ts b/packages/viewer-charts/test/ts/frame-timing.spec.ts new file mode 100644 index 0000000..d216953 --- /dev/null +++ b/packages/viewer-charts/test/ts/frame-timing.spec.ts @@ -0,0 +1,397 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Frame-timing invariants — the chart's plot region must never go + * blank between gestures. Every test follows the same shape: + * + * 1. Restore a chart with data. + * 2. Calibrate a quiescent-state pixel baseline. + * 3. Drive an interaction (pan / zoom / resize / concurrent draws) + * while `captureFrames` samples the visible canvas every RAF. + * 4. Assert every captured frame's plot region was non-blank. + */ + +import type { Page } from "@playwright/test"; +import { test } from "@perspective-dev/test"; +import type { ViewerConfigUpdate } from "@perspective-dev/viewer"; +import { + assertPlotNeverBlank, + calibratePlotBaseline, + captureFrames, + gotoBasic, + restoreChart, + waitOneFrame, +} from "./helpers"; + +const PLOT_CX = 640; +const PLOT_CY = 360; + +const BLANK_THRESHOLD_FRACTION = 0; + +const RENDER_MODES = ["blit", "direct"] as const; +type RenderMode = (typeof RENDER_MODES)[number]; + +interface ChartFixture { + name: string; + config: ViewerConfigUpdate; +} + +const FIXTURES: ChartFixture[] = [ + { + name: "X/Y Scatter", + config: { + plugin: "X/Y Scatter", + columns: ["Quantity", "Postal Code"], + }, + }, + { + name: "Y Line", + config: { + plugin: "Y Line", + columns: ["Profit"], + }, + }, + { + name: "Y Area", + config: { + plugin: "Y Area", + columns: ["Profit"], + }, + }, + { + name: "Y Bar", + config: { + plugin: "Y Bar", + group_by: ["State"], + columns: ["Profit"], + }, + }, + { + name: "X Bar", + config: { + plugin: "X Bar", + group_by: ["State"], + columns: ["Profit"], + }, + }, + { + name: "Heatmap", + config: { + plugin: "Heatmap", + group_by: ["State"], + split_by: ["Region"], + columns: ["Profit"], + }, + }, +]; + +async function setupChart( + page: Page, + fixture: ChartFixture, + mode: RenderMode, +): Promise<{ baseline: number; threshold: number }> { + await restoreChart(page, fixture.config); + + await page.evaluate( + ({ mode }) => { + const viewer = document.querySelector( + "perspective-viewer", + ) as unknown as { getPlugin(): unknown }; + const plugin = viewer.getPlugin() as { + constructor: { setBlitMode(mode: "blit" | "direct"): void }; + delete(): void; + }; + plugin.constructor.setBlitMode(mode); + plugin.delete(); + }, + { mode }, + ); + + await restoreChart(page, fixture.config); + await waitOneFrame(page); + await waitOneFrame(page); + const baseline = await calibratePlotBaseline(page); + const threshold = Math.max( + 1, + Math.floor(baseline * BLANK_THRESHOLD_FRACTION), + ); + return { baseline, threshold }; +} + +/** + * Drive a drag-pan from `(PLOT_CX, PLOT_CY)` to `(PLOT_CX + dx, + * PLOT_CY + dy)` in `steps` increments. + */ +async function dragPan( + page: Page, + dx: number, + dy: number, + steps = 20, +): Promise { + await page.mouse.move(PLOT_CX, PLOT_CY); + await page.mouse.down(); + for (let i = 1; i <= steps; i++) { + const fx = (i * dx) / steps; + const fy = (i * dy) / steps; + await page.mouse.move(PLOT_CX + fx, PLOT_CY + fy); + } + + await page.mouse.up(); +} + +/** + * Drive a wheel zoom — many small wheel deltas. `deltaY < 0` is + * zoom-in; `> 0` is zoom-out. + */ +async function wheelZoom( + page: Page, + deltaY: number, + steps = 12, +): Promise { + await page.mouse.move(PLOT_CX, PLOT_CY); + const stepDelta = deltaY / steps; + for (let i = 0; i < steps; i++) { + await page.mouse.wheel(0, stepDelta); + } +} + +/** + * Single update row used by the streaming-update tests. Hoisted to + * a helper so A3/A4's `page.evaluate` bodies don't duplicate the + * literal six times. + */ +function makeUpdateRow(rowId: number): Record { + return { + "Product Name": "Fake Prod", + "Ship Date": +new Date(), + City: "Fake Town", + "Row ID": rowId, + "Customer Name": "Fakey Fakerton", + Quantity: 13, + Discount: 0.25, + "Sub-Category": "Chairs", + Segment: "Office Supplies", + Category: "Furniture", + "Order Date": +new Date(), + "Order ID": "ABC123", + Sales: 123.456, + State: "New York", + "Postal Code": 10001, + Country: "US", + "Customer ID": "XYZ321", + "Ship Mode": "First Class", + Region: "Easr", + Profit: 12.34, + "Product ID": "ABC123", + }; +} + +test.describe("Frame timing — blank-plot invariant", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + for (const mode of RENDER_MODES) { + test.describe(`render mode: ${mode}`, () => { + for (const fixture of FIXTURES) { + test.describe(fixture.name, () => { + test("pan does not blank the plot", async ({ page }) => { + const { threshold } = await setupChart( + page, + fixture, + mode, + ); + const frames = await captureFrames(page, async () => { + await dragPan(page, -160, -80); + }); + + assertPlotNeverBlank(frames, threshold); + }); + + test("zoom does not blank the plot", async ({ page }) => { + const { threshold } = await setupChart( + page, + fixture, + mode, + ); + const frames = await captureFrames(page, async () => { + await wheelZoom(page, -600); + }); + + assertPlotNeverBlank(frames, threshold); + }); + + test("resize does not blank", async ({ page }) => { + const { threshold } = await setupChart( + page, + fixture, + mode, + ); + const frames = await captureFrames(page, async () => { + await page.evaluate(async () => { + const viewer = document.querySelector( + "perspective-viewer", + ) as HTMLElement; + const widths = [ + "100%", + "85%", + "100%", + "92%", + "100%", + ]; + for (const w of widths) { + viewer.style.width = w; + await new Promise((resolve) => + requestAnimationFrame(() => resolve()), + ); + } + }); + + await page.evaluate(() => { + const viewer = document.querySelector( + "perspective-viewer", + ) as HTMLElement; + viewer.style.width = ""; + }); + }); + + assertPlotNeverBlank(frames, threshold); + }); + + test("A3 — pan + concurrent draws does not blank", async ({ + page, + }) => { + const { threshold } = await setupChart( + page, + fixture, + mode, + ); + const frames = await captureFrames(page, async () => { + const drawLoop = page.evaluate(async () => { + const viewer = + document.querySelector( + "perspective-viewer", + )!; + const table = await viewer.getTable(); + // @ts-ignore + window["__is_running"] = true; + // @ts-ignore + for (let i = 0; window["__is_running"]; i++) { + await table.update([ + { + "Product Name": "Fake Prod", + "Ship Date": +new Date(), + City: "Fake Town", + "Row ID": 9995 + i, + "Customer Name": "Fakey Fakerton", + Quantity: 13, + Discount: 0.25, + "Sub-Category": "Chairs", + Segment: "Office Supplies", + Category: "Furniture", + "Order Date": +new Date(), + "Order ID": "ABC123", + Sales: 123.456, + State: "New York", + "Postal Code": 10001, + Country: "US", + "Customer ID": "XYZ321", + "Ship Mode": "First Class", + Region: "Easr", + Profit: 12.34, + "Product ID": "ABC123", + }, + ]); + } + }); + + await dragPan(page, -160, -80, 30); + await page.evaluate(async () => { + // @ts-ignore + window["__is_running"] = false; + }); + + await drawLoop; + }); + + assertPlotNeverBlank(frames, threshold); + }); + + test("A4 — zoom + concurrent draws does not blank", async ({ + page, + }) => { + const { threshold } = await setupChart( + page, + fixture, + mode, + ); + const frames = await captureFrames(page, async () => { + const drawLoop = page.evaluate(async () => { + const viewer = + document.querySelector( + "perspective-viewer", + )!; + const table = await viewer.getTable(); + // @ts-ignore + window["__is_running"] = true; + // @ts-ignore + for (let i = 0; window["__is_running"]; i++) { + await table.update([ + { + "Product Name": "Fake Prod", + "Ship Date": +new Date(), + City: "Fake Town", + "Row ID": 9995 + i, + "Customer Name": "Fakey Fakerton", + Quantity: 13, + Discount: 0.25, + "Sub-Category": "Chairs", + Segment: "Office Supplies", + Category: "Furniture", + "Order Date": +new Date(), + "Order ID": "ABC123", + Sales: 123.456, + State: "New York", + "Postal Code": 10001, + Country: "US", + "Customer ID": "XYZ321", + "Ship Mode": "First Class", + Region: "Easr", + Profit: 12.34, + "Product ID": "ABC123", + }, + ]); + } + }); + + await wheelZoom(page, -600, 20); + await page.evaluate(async () => { + // @ts-ignore + window["__is_running"] = false; + }); + + await drawLoop; + }); + + assertPlotNeverBlank(frames, threshold); + }); + }); + } + }); + } +}); + +// Suppress an unused-helper hint when the inline `update` row +// literals haven't been migrated to use it. Kept around so future +// streaming tests can avoid duplicating the row body. +void makeUpdateRow; diff --git a/packages/viewer-charts/test/ts/helpers.ts b/packages/viewer-charts/test/ts/helpers.ts new file mode 100644 index 0000000..e3dab13 --- /dev/null +++ b/packages/viewer-charts/test/ts/helpers.ts @@ -0,0 +1,764 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { Page } from "@playwright/test"; +import { expect, test } from "@perspective-dev/test"; +import type { ViewerConfigUpdate } from "@perspective-dev/viewer"; + +/** + * Default pixel tolerance for chart screenshots. SwiftShader is + * deterministic on a given machine, but a handful of sub-pixel AA + * decisions still wiggle across Chromium versions. + */ +// @ts-ignore +const DEFAULT_MAX_DIFF_PIXEL_RATIO = process.env.CI ? 0.01 : 0; +const DEFAULT_THRESHOLD = 0; + +/** + * Load the shared `basic-test.html` shell and block until the test + * harness signals that perspective is ready. All specs start here. + */ +export async function gotoBasic(page: Page): Promise { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); +} + +export async function restoreChart( + page: Page, + config: ViewerConfigUpdate, +): Promise { + await page.evaluate( + async (c) => { + const viewer = document.querySelector("perspective-viewer")!; + await viewer.restore(c); + }, + config as unknown as Record, + ); + await waitOneFrame(page); +} + +/** Await a single RAF in the page context. */ +export async function waitOneFrame(page: Page): Promise { + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => resolve()), + ), + ); +} + +/** + * Take a screenshot of the viewer element (not the whole page) and + * compare to `name`'s baseline. Cropping to the viewer excludes page + * scrollbars / viewport chrome that would add pixel noise. + */ +export async function expectViewerScreenshot( + page: Page, + options: { maxDiffPixelRatio?: number } = {}, +): Promise { + const viewer = page.locator("perspective-viewer"); + const snapshotName = + test + .info() + .titlePath.slice(1) + .map((s) => + s + .trim() + .replace(/[^a-z0-9]+/gi, "-") + .toLowerCase(), + ) + .join("-") + ".png"; + + await expect(viewer).toHaveScreenshot(snapshotName, { + threshold: DEFAULT_THRESHOLD, + maxDiffPixelRatio: + options.maxDiffPixelRatio ?? DEFAULT_MAX_DIFF_PIXEL_RATIO, + }); +} + +/** + * Full-flow convenience: go to the test page, restore the chart with + * `config`, wait for the render, and screenshot. The snapshot filename + * is derived from the describe path and test title. + */ +export async function renderAndCapture( + page: Page, + config: ViewerConfigUpdate, + options?: { maxDiffPixelRatio?: number }, +): Promise { + await restoreChart(page, config); + await expectViewerScreenshot(page, options); +} + +/** + * One per-frame measurement, captured during the action passed to + * `captureFrames`. `plotPixels` counts non-background pixels inside + * `plotRegionFrac` (default = central 80% of the visible canvas). + */ +export interface FrameSample { + timestampMs: number; + plotPixels: number; + canvasWidth: number; + canvasHeight: number; +} + +/** + * The sub-region of the visible canvas, expressed as fractions of + * canvas width/height, that the capture loop counts pixels in. + */ +export interface PlotRegionFrac { + x: number; + y: number; + w: number; + h: number; +} + +const DEFAULT_PLOT_REGION: PlotRegionFrac = { + x: 0.1, + y: 0.1, + w: 0.8, + h: 0.8, +}; + +/** + * Pixel-color match threshold for "is this pixel part of the chart + * background?" + */ +const DEFAULT_BG_TOLERANCE = 30; + +/** + * Run `action` while a per-frame capture loop reads the visible + * canvas's plot region. Returns one `FrameSample` per browser RAF + * that fired between `start` and `stop`. The capture loop is + * installed and torn down inside this helper — no global state + * leaks between tests. + */ +export async function captureFrames( + page: Page, + action: () => Promise, + options: { plotRegionFrac?: PlotRegionFrac; bgTolerance?: number } = {}, +): Promise { + const region = options.plotRegionFrac ?? DEFAULT_PLOT_REGION; + const tolerance = options.bgTolerance ?? DEFAULT_BG_TOLERANCE; + await page.evaluate( + ({ region, tolerance }) => { + type Sample = { + timestampMs: number; + plotPixels: number; + canvasWidth: number; + canvasHeight: number; + }; + + const w = window as unknown as { + __captureFrames?: Sample[]; + __captureRunning?: boolean; + __captureRAF?: number; + }; + + w.__captureFrames = []; + w.__captureRunning = true; + const findCanvas = (): HTMLCanvasElement | null => { + const visit = ( + root: Document | ShadowRoot, + ): HTMLCanvasElement | null => { + const direct = root.querySelector( + ".webgl-canvas", + ) as HTMLCanvasElement | null; + if (direct) { + return direct; + } + + const all = root.querySelectorAll("*"); + for (const el of Array.from(all)) { + const sr = (el as Element & { shadowRoot?: ShadowRoot }) + .shadowRoot; + if (sr) { + const found = visit(sr); + if (found) { + return found; + } + } + } + + return null; + }; + + return visit(document); + }; + + let cachedCanvas: HTMLCanvasElement | null = null; + + // Sampler canvas: the visible `.webgl-canvas` may have + // any of three context modes: + // - blit mode: 2D context (host blits worker bitmaps + // onto it). `getImageData` works directly. + // - direct mode: `transferControlToOffscreen` — + // placeholder for the worker's WebGL OffscreenCanvas. + // Host has *no* context on this canvas; + // `getImageData` impossible. + // - in-process mode: WebGL context owned by main + // thread. `getContext("2d")` returns null. + const sampler = document.createElement("canvas"); + const samplerCtx = sampler.getContext("2d", { + willReadFrequently: true, + }); + + if (!samplerCtx) { + throw new Error( + "captureFrames: sampler canvas 2D context unavailable", + ); + } + + const tick = () => { + if (!w.__captureRunning) { + return; + } + + if (!cachedCanvas || !cachedCanvas.isConnected) { + cachedCanvas = findCanvas(); + } + + const canvas = cachedCanvas; + if (canvas && canvas.width > 0 && canvas.height > 0) { + const x0 = Math.max(0, Math.round(region.x * canvas.width)); + + const y0 = Math.max( + 0, + Math.round(region.y * canvas.height), + ); + + const rw = Math.max( + 1, + Math.min( + canvas.width - x0, + Math.round(region.w * canvas.width), + ), + ); + + const rh = Math.max( + 1, + Math.min( + canvas.height - y0, + Math.round(region.h * canvas.height), + ), + ); + + if (sampler.width !== rw) { + sampler.width = rw; + } + + if (sampler.height !== rh) { + sampler.height = rh; + } + + samplerCtx.clearRect(0, 0, rw, rh); + try { + samplerCtx.drawImage( + canvas, + x0, + y0, + rw, + rh, + 0, + 0, + rw, + rh, + ); + } catch { + // `drawImage` can throw on transient zero- + // size sources during plugin reconnect; + // skip this frame and try again next RAF. + w.__captureRAF = requestAnimationFrame(tick); + return; + } + + const data = samplerCtx.getImageData(0, 0, rw, rh).data; + let nonBg = 0; + + // The GL canvas is cleared with + // `clearColor(0,0,0,0)` and glyphs paint with + // `a > 0`; in blit mode the host blits with + // `globalCompositeOperation = "copy"`, so post- + // blit pixels are either glyph (`a > 0`) or + // fully transparent (`a == 0`). In direct/in- + // process modes the WebGL canvas itself follows + // the same alpha convention. So a simple alpha + // test is the correct invariant for "glyph + // fragment landed here" in every mode. + void tolerance; + for (let i = 3; i < data.length; i += 4) { + if (data[i] > 0) { + nonBg++; + } + } + + w.__captureFrames!.push({ + timestampMs: performance.now(), + plotPixels: nonBg, + canvasWidth: canvas.width, + canvasHeight: canvas.height, + }); + } + + w.__captureRAF = requestAnimationFrame(tick); + }; + + w.__captureRAF = requestAnimationFrame(tick); + }, + { region, tolerance }, + ); + + try { + await action(); + // One trailing RAF so the final post-action paint is captured. + await waitOneFrame(page); + } finally { + await page.evaluate(() => { + const w = window as unknown as { + __captureRunning?: boolean; + __captureRAF?: number; + }; + w.__captureRunning = false; + if (w.__captureRAF !== undefined) { + cancelAnimationFrame(w.__captureRAF); + w.__captureRAF = undefined; + } + }); + } + + const frames = await page.evaluate(() => { + const w = window as unknown as { __captureFrames?: FrameSample[] }; + const out = w.__captureFrames ?? []; + w.__captureFrames = undefined; + return out; + }); + + return frames; +} + +/** + * Take one quiescent-state pixel sample of the chart's plot region + * to use as the calibration baseline for `assertPlotNeverBlank`. + */ +export async function calibratePlotBaseline( + page: Page, + options: { plotRegionFrac?: PlotRegionFrac; bgTolerance?: number } = {}, +): Promise { + const samples = await captureFrames( + page, + async () => { + await waitOneFrame(page); + await waitOneFrame(page); + }, + options, + ); + + // The first frame can lag the prior render; pick the median of + // the captured frames as a stable baseline. + if (samples.length === 0) { + throw new Error("calibratePlotBaseline: no frames captured"); + } + + const sorted = samples.map((s) => s.plotPixels).sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)]; +} + +/** + * Primary invariant assertion. For every frame in `frames`, + * `plotPixels` must be ≥ `minPixels`. + */ +export function assertPlotNeverBlank( + frames: FrameSample[], + minPixels: number, +): void { + if (frames.length === 0) { + throw new Error("assertPlotNeverBlank: no frames captured"); + } + + for (let i = 0; i < frames.length; i++) { + if (frames[i].plotPixels < minPixels) { + const ctxStart = Math.max(0, i - 3); + const ctxEnd = Math.min(frames.length, i + 4); + const ctxLines = frames + .slice(ctxStart, ctxEnd) + .map((f, j) => { + const idx = ctxStart + j; + const marker = idx === i ? " ← BLANK" : ""; + return ` [${idx}] plotPixels=${f.plotPixels}${marker}`; + }) + .join("\n"); + throw new Error( + `assertPlotNeverBlank: frame ${i} of ${frames.length} ` + + `had ${frames[i].plotPixels} non-background pixels ` + + `(threshold ${minPixels}). Surrounding frames:\n${ctxLines}`, + ); + } + } +} + +// ╭──────────────╮ +// │ │ +// Multi-viewer helpers │ │ +// ╰──────────────╯ +// +// Variants of the single-viewer helpers above scoped to a specific +// `` index, plus a parallel-capture entry point +// that returns one `FrameSample[]` per viewer in DOM order. Used by +// `multi-chart.spec.ts` to verify cross-chart isolation: pan on +// viewer A must not blank viewer B; both panning concurrently must +// leave each chart's invariant intact; streaming-update on the +// shared table must surface as live data on both without blanking. +// +// All multi-viewer helpers walk the document for *all* +// `` elements (not the single one +// `gotoBasic` assumes). The order is `document.querySelectorAll` +// tree order, which matches the order viewers are placed in +// `two-chart-test.html`. + +/** + * Two-viewer page entry. Same shape as `gotoBasic` but loads the + * two-chart fixture and waits for both viewers to be bound to the + * shared table. + */ +export async function gotoTwoChart(page: Page): Promise { + await page.goto("/tools/test/src/html/two-chart-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); +} + +/** + * Restore a specific viewer (by 0-based DOM-order index). Mirrors + * `restoreChart` for the multi-viewer fixture. Awaits one RAF so + * the chart's first paint has fired by the time this returns. + */ +export async function restoreChartAt( + page: Page, + viewerIndex: number, + config: ViewerConfigUpdate, +): Promise { + await page.evaluate( + async ({ viewerIndex, c }) => { + const viewers = document.querySelectorAll("perspective-viewer"); + const viewer = viewers[viewerIndex]; + if (!viewer) { + throw new Error( + `restoreChartAt: no viewer at index ${viewerIndex} (` + + `found ${viewers.length})`, + ); + } + + await (viewer as any).restore(c); + }, + { viewerIndex, c: config as unknown as Record }, + ); + await waitOneFrame(page); +} + +/** + * Run `action` while sampling every viewer's plot region every RAF. + * Returns one `FrameSample[]` per viewer in DOM order. + * + * Implementation parallels `captureFrames` but walks every shadow + * root for *all* `.webgl-canvas` elements, samples each one's plot + * region per tick, and tracks per-canvas logs in arrays indexed by + * the canvas's discovery order. Cached canvas list is invalidated + * if any cached canvas becomes detached (plugin reconnect during + * capture); the rebuild is O(n) over reachable elements but only + * runs when the cache is stale. + */ +export async function captureFramesAllViewers( + page: Page, + action: () => Promise, + options: { plotRegionFrac?: PlotRegionFrac } = {}, +): Promise { + const region = options.plotRegionFrac ?? DEFAULT_PLOT_REGION; + + await page.evaluate( + ({ region }) => { + type Sample = { + timestampMs: number; + plotPixels: number; + canvasWidth: number; + canvasHeight: number; + }; + + const w = window as unknown as { + __captureMultiFrames?: Sample[][]; + __captureRunning?: boolean; + __captureRAF?: number; + }; + + w.__captureMultiFrames = []; + w.__captureRunning = true; + + const findAllCanvases = (): HTMLCanvasElement[] => { + const out: HTMLCanvasElement[] = []; + const visit = (root: Document | ShadowRoot): void => { + const direct = root.querySelectorAll(".webgl-canvas"); + for (const c of Array.from(direct)) { + out.push(c as HTMLCanvasElement); + } + + const all = root.querySelectorAll("*"); + for (const el of Array.from(all)) { + const sr = (el as Element & { shadowRoot?: ShadowRoot }) + .shadowRoot; + if (sr) { + visit(sr); + } + } + }; + + visit(document); + return out; + }; + + let cachedCanvases: HTMLCanvasElement[] = []; + + const cacheStale = (): boolean => { + if (cachedCanvases.length === 0) { + return true; + } + + for (const c of cachedCanvases) { + if (!c.isConnected) { + return true; + } + } + + return false; + }; + + // Single shared sampler canvas — resized per-viewer + // inside the loop. Same rationale as in `captureFrames`: + // routing the read through a 2D sampler via `drawImage` + // works whether the source canvas is in blit mode (2D + // context), direct mode (`transferControlToOffscreen` + // placeholder), or in-process mode (WebGL on main + // thread). `getImageData` directly on the source canvas + // would fail in two of those three modes. + const sampler = document.createElement("canvas"); + const samplerCtx = sampler.getContext("2d"); + if (!samplerCtx) { + throw new Error( + "captureFramesAllViewers: sampler canvas 2D context unavailable", + ); + } + + const tick = () => { + if (!w.__captureRunning) { + return; + } + + if (cacheStale()) { + cachedCanvases = findAllCanvases(); + // Make sure the per-viewer logs array has slots + // for every discovered canvas; preserve any + // existing prefix for viewers that survived the + // rebuild. + while ( + w.__captureMultiFrames!.length < cachedCanvases.length + ) { + w.__captureMultiFrames!.push([]); + } + } + + const tMs = performance.now(); + for (let vi = 0; vi < cachedCanvases.length; vi++) { + const canvas = cachedCanvases[vi]; + if (!canvas || canvas.width === 0 || canvas.height === 0) { + continue; + } + + const x0 = Math.max(0, Math.round(region.x * canvas.width)); + const y0 = Math.max( + 0, + Math.round(region.y * canvas.height), + ); + const rw = Math.max( + 1, + Math.min( + canvas.width - x0, + Math.round(region.w * canvas.width), + ), + ); + const rh = Math.max( + 1, + Math.min( + canvas.height - y0, + Math.round(region.h * canvas.height), + ), + ); + + if (sampler.width !== rw) { + sampler.width = rw; + } + + if (sampler.height !== rh) { + sampler.height = rh; + } + + samplerCtx.clearRect(0, 0, rw, rh); + try { + samplerCtx.drawImage( + canvas, + x0, + y0, + rw, + rh, + 0, + 0, + rw, + rh, + ); + } catch { + continue; + } + + const data = samplerCtx.getImageData(0, 0, rw, rh).data; + let nonBg = 0; + for (let i = 3; i < data.length; i += 4) { + if (data[i] > 0) { + nonBg++; + } + } + + w.__captureMultiFrames![vi].push({ + timestampMs: tMs, + plotPixels: nonBg, + canvasWidth: canvas.width, + canvasHeight: canvas.height, + }); + } + + w.__captureRAF = requestAnimationFrame(tick); + }; + + w.__captureRAF = requestAnimationFrame(tick); + }, + { region }, + ); + + try { + await action(); + await waitOneFrame(page); + } finally { + await page.evaluate(() => { + const w = window as unknown as { + __captureRunning?: boolean; + __captureRAF?: number; + }; + w.__captureRunning = false; + if (w.__captureRAF !== undefined) { + cancelAnimationFrame(w.__captureRAF); + w.__captureRAF = undefined; + } + }); + } + + const frames = await page.evaluate(() => { + const w = window as unknown as { + __captureMultiFrames?: FrameSample[][]; + }; + const out = w.__captureMultiFrames ?? []; + w.__captureMultiFrames = undefined; + return out; + }); + + return frames; +} + +/** + * Quiescent baselines for every viewer. One median value per + * viewer, in DOM order. Used by `multi-chart.spec.ts` to set + * per-viewer blank thresholds before driving the action. + */ +export async function calibrateAllBaselines( + page: Page, + options: { plotRegionFrac?: PlotRegionFrac } = {}, +): Promise { + const samples = await captureFramesAllViewers( + page, + async () => { + await waitOneFrame(page); + await waitOneFrame(page); + }, + options, + ); + + if (samples.length === 0) { + throw new Error("calibrateAllBaselines: no viewers found"); + } + + return samples.map((perViewer, idx) => { + if (perViewer.length === 0) { + throw new Error( + `calibrateAllBaselines: viewer ${idx} captured no frames`, + ); + } + + const sorted = perViewer.map((s) => s.plotPixels).sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)]; + }); +} + +/** + * Cross-chart isolation invariant: viewer at `viewerIndex` should + * have stayed quiescent across `frames` (no rendered changes from + * actions targeting other viewers). Allows ±`tolerance` pixels + * around the median to absorb anti-aliasing noise from the host's + * compositor; rejects on a single frame whose `plotPixels` deviates + * by more than that. + * + * Use when the test drives an interaction on a different viewer and + * needs to verify the un-interacted viewer was not collateral + * damage. + */ +export function assertViewerQuiescent( + frames: FrameSample[], + tolerance: number, +): void { + if (frames.length === 0) { + throw new Error("assertViewerQuiescent: no frames captured"); + } + + const counts = frames.map((f) => f.plotPixels); + const sorted = [...counts].sort((a, b) => a - b); + const median = sorted[Math.floor(sorted.length / 2)]; + + for (let i = 0; i < counts.length; i++) { + if (Math.abs(counts[i] - median) > tolerance) { + const ctxStart = Math.max(0, i - 3); + const ctxEnd = Math.min(counts.length, i + 4); + const ctxLines = counts + .slice(ctxStart, ctxEnd) + .map((c, j) => { + const idx = ctxStart + j; + const marker = idx === i ? " ← DEVIANT" : ""; + return ` [${idx}] plotPixels=${c}${marker}`; + }) + .join("\n"); + throw new Error( + `assertViewerQuiescent: frame ${i} of ${counts.length} ` + + `had ${counts[i]} non-background pixels, median=${median}, ` + + `tolerance=±${tolerance}. Surrounding frames:\n${ctxLines}`, + ); + } + } +} diff --git a/packages/viewer-charts/test/ts/multi-chart.spec.ts b/packages/viewer-charts/test/ts/multi-chart.spec.ts new file mode 100644 index 0000000..3ee7aad --- /dev/null +++ b/packages/viewer-charts/test/ts/multi-chart.spec.ts @@ -0,0 +1,218 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Multi-chart shared-renderer invariants. + * + * Two ``s loaded from a single shared table + * (`load-viewer-two-csv.js`). Each tests a different cross-chart + * isolation property: + */ + +import type { Page } from "@playwright/test"; +import { test } from "@perspective-dev/test"; +import { + assertPlotNeverBlank, + assertViewerQuiescent, + calibrateAllBaselines, + captureFramesAllViewers, + gotoTwoChart, + restoreChartAt, + waitOneFrame, +} from "./helpers"; + +const LEFT_VIEWER_CX = 320; +const LEFT_VIEWER_CY = 360; +const RIGHT_VIEWER_OFFSET_X = 640; + +const BLANK_THRESHOLD_FRACTION = 0; + +/** + * Tolerance (in pixels) for the "viewer B was unaffected" check. + */ +const QUIESCENT_TOLERANCE_PIXELS = 50; + +/** + * Drag-pan a specific viewer. Same shape as the single-viewer + * helper in `frame-timing.spec.ts`, parameterized by a center + * point so we can target either viewer. + */ +async function dragPanAt( + page: Page, + cx: number, + cy: number, + dx: number, + dy: number, + steps = 20, +): Promise { + await page.mouse.move(cx, cy); + await page.mouse.down(); + for (let i = 1; i <= steps; i++) { + const fx = (i * dx) / steps; + const fy = (i * dy) / steps; + await page.mouse.move(cx + fx, cy + fy); + } + + await page.mouse.up(); +} + +/** + * Set up both viewers with a chart that has enough glyph density + * for blank-detection. Returns per-viewer blank-frame thresholds + * derived from each viewer's quiescent baseline. + */ +async function setupBoth(page: Page): Promise<{ thresholds: number[] }> { + await restoreChartAt(page, 0, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Postal Code"], + }); + await restoreChartAt(page, 1, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Postal Code"], + }); + + // Two extra RAFs so paint settles before baseline. + await waitOneFrame(page); + await waitOneFrame(page); + + const baselines = await calibrateAllBaselines(page); + const thresholds = baselines.map((b) => + Math.max(1, Math.floor(b * BLANK_THRESHOLD_FRACTION)), + ); + return { thresholds }; +} + +test.describe("Multi-chart shared renderer", () => { + test.beforeEach(async ({ page }) => { + await gotoTwoChart(page); + }); + + test("pan on viewer A leaves viewer B quiescent", async ({ page }) => { + const { thresholds } = await setupBoth(page); + const allFrames = await captureFramesAllViewers(page, async () => { + await dragPanAt(page, LEFT_VIEWER_CX, LEFT_VIEWER_CY, -120, -60); + }); + + // Viewer A (the panned one) must satisfy the standard + // blank-frame invariant. + assertPlotNeverBlank(allFrames[0], thresholds[0]); + + // Viewer B (the un-interacted one) must have stayed + // quiescent — its plot pixel count should not deviate from + // its own baseline by more than the AA tolerance. + assertViewerQuiescent(allFrames[1], QUIESCENT_TOLERANCE_PIXELS); + }); + + test("both viewers panning concurrently stay non-blank", async ({ + page, + }) => { + const { thresholds } = await setupBoth(page); + const allFrames = await captureFramesAllViewers(page, async () => { + // Run viewer A's pan and viewer B's pan in parallel by + // interleaving sub-step mouse events through both + // viewers' centers. + const leftSteps = 12; + const rightSteps = 12; + for (let i = 0; i < Math.max(leftSteps, rightSteps); i++) { + if (i < leftSteps) { + await page.mouse.move(LEFT_VIEWER_CX, LEFT_VIEWER_CY); + await page.mouse.down(); + await page.mouse.move( + LEFT_VIEWER_CX - (i + 1) * 6, + LEFT_VIEWER_CY - (i + 1) * 3, + ); + await page.mouse.up(); + } + + if (i < rightSteps) { + const rcx = LEFT_VIEWER_CX + RIGHT_VIEWER_OFFSET_X; + await page.mouse.move(rcx, LEFT_VIEWER_CY); + await page.mouse.down(); + await page.mouse.move( + rcx - (i + 1) * 6, + LEFT_VIEWER_CY - (i + 1) * 3, + ); + await page.mouse.up(); + } + } + }); + + assertPlotNeverBlank(allFrames[0], thresholds[0]); + assertPlotNeverBlank(allFrames[1], thresholds[1]); + }); + + test("pan on viewer A + streaming updates does not blank either chart", async ({ + page, + }) => { + const { thresholds } = await setupBoth(page); + const allFrames = await captureFramesAllViewers(page, async () => { + // Streaming `table.update` runs as a background loop + // gated by `__is_running`. + const drawLoop = page.evaluate(async () => { + const viewer = document.querySelector( + "perspective-viewer", + ) as any; + + const table = await viewer.getTable(); + + // @ts-ignore + window["__is_running"] = true; + // @ts-ignore + for (let i = 0; window["__is_running"]; i++) { + await table.update([ + { + "Product Name": "Fake Prod", + "Ship Date": +new Date(), + City: "Fake Town", + "Row ID": 9995 + i, + "Customer Name": "Fakey Fakerton", + Quantity: 13, + Discount: 0.25, + "Sub-Category": "Chairs", + Segment: "Office Supplies", + Category: "Furniture", + "Order Date": +new Date(), + "Order ID": "ABC123", + Sales: 123.456, + State: "New York", + "Postal Code": 10001, + Country: "US", + "Customer ID": "XYZ321", + "Ship Mode": "First Class", + Region: "Easr", + Profit: 12.34, + "Product ID": "ABC123", + }, + ]); + } + }); + + await dragPanAt( + page, + LEFT_VIEWER_CX, + LEFT_VIEWER_CY, + -160, + -80, + 30, + ); + await page.evaluate(() => { + // @ts-ignore + window["__is_running"] = false; + }); + + await drawLoop; + }); + + assertPlotNeverBlank(allFrames[0], thresholds[0]); + assertPlotNeverBlank(allFrames[1], thresholds[1]); + }); +}); diff --git a/packages/viewer-charts/test/ts/plugin-config-toggle.spec.ts b/packages/viewer-charts/test/ts/plugin-config-toggle.spec.ts new file mode 100644 index 0000000..aff33cd --- /dev/null +++ b/packages/viewer-charts/test/ts/plugin-config-toggle.spec.ts @@ -0,0 +1,100 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Regression test for the blank-canvas bug on `plugin_config` toggle. + * + * Bug chain (all three required to reproduce): + * 1. `RendererTransport.saveZoom` was `async` but never returned the + * pending-reply promise — every caller got `Promise`. + * 2. `plugin.save()` called it without `await` and stored the bare + * Promise as `state.zoom`. `JSON.stringify` collapses any Promise + * to `{}`. + * 3. A later `plugin.restore(token)` with `token.zoom === {}` reached + * `ZoomController.restore({})`, which blindly assigned `undefined` + * to every internal field. `isDefault()` then returned false + * (`1 === undefined`) and `getVisibleDomain()` produced NaN on + * every axis, the projection matrix went NaN, every glyph + * projected off-screen, the canvas painted blank. + * + * Fix lives in three places — `ZoomController.restore` validates, + * `saveZoom` returns the promise, `plugin.save` awaits it — and we want + * the suite to fail if *any* of them regresses. + * + * Pixel-count assertion (not a screenshot snapshot): the bug produces + * `plotPixels: 0` which is what `assertPlotNeverBlank` is built for, and + * pixel counts don't drift across Chromium versions the way snapshots + * do. + */ + +import { test } from "@perspective-dev/test"; +import { + assertPlotNeverBlank, + calibratePlotBaseline, + captureFrames, + gotoBasic, + restoreChart, + waitOneFrame, +} from "./helpers"; + +/** + * Half the calibrated quiescent pixel count — far above 0 (the blank- + * canvas signature) and well below the steady-state baseline, so a + * single skipped intermediate frame from RAF scheduling doesn't trip + * the assertion. Frame-to-frame jitter on a healthy chart is ~1% + * (see `frame-timing.spec.ts`). + */ +const BLANK_HEADROOM = 0.5; + +test.describe("plugin_config toggle (regression)", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + /** + * Reproduces the user-visible symptom: toggling `series_zoom_mode` + * via the wholesale-restore path that the dropdown UI ultimately + * routes through. Fails pre-fix when the active plugin's bucket has + * accumulated a malformed `zoom: {}` from a prior save round-trip, + * because `plugin.restore` then calls `restoreZoom({})` and + * corrupts the controller. + */ + test("Y Line + datetime axis stays rendered across series_zoom_mode toggle", async ({ + page, + }) => { + await restoreChart(page, { + plugin: "Y Line", + columns: ["Profit"], + group_by: ["Order Date"], + }); + + const baseline = await calibratePlotBaseline(page); + + const frames = await captureFrames(page, async () => { + await page.evaluate(async () => { + const viewer = document.querySelector( + "perspective-viewer", + )! as unknown as { restore: (c: unknown) => Promise }; + await viewer.restore({ + plugin_config: { series_zoom_mode: "fixed" }, + }); + await viewer.restore({ + plugin_config: { series_zoom_mode: "dynamic" }, + }); + }); + await waitOneFrame(page); + await waitOneFrame(page); + }); + + assertPlotNeverBlank(frames, Math.floor(baseline * BLANK_HEADROOM)); + }); +}); diff --git a/packages/viewer-charts/test/ts/snapshot/candlestick.spec.ts b/packages/viewer-charts/test/ts/snapshot/candlestick.spec.ts new file mode 100644 index 0000000..8a8cf73 --- /dev/null +++ b/packages/viewer-charts/test/ts/snapshot/candlestick.spec.ts @@ -0,0 +1,62 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test } from "@perspective-dev/test"; +import { gotoBasic, renderAndCapture } from "../helpers"; + +test.describe("Y Candlestick", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("open-only falls back to next-row open for close", async ({ + page, + }) => { + // Exercises the d3fc-inherited fallback path: close = next row's + // open; high = max(open, close); low = min(open, close). + await renderAndCapture(page, { + plugin: "Candlestick", + columns: ["Sales"], + group_by: ["Order Date"], + }); + }); + + test("full OHLC four-column layout", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Candlestick", + columns: ["Sales", "Profit", "Quantity", "Discount"], + group_by: ["Order Date"], + }); + }); + + test("up/down colors sampled from gradient extremes", async ({ page }) => { + // With Profit as Close and Sales as Open, positive Profit rows + // (Close > Open) render at the gradient top; negative rows at + // the bottom. Pins the bichromatic rendering. + await renderAndCapture(page, { + plugin: "Candlestick", + columns: ["Sales", "Profit"], + group_by: ["Category"], + }); + }); + + test("with split_by — side-by-side candles per category", async ({ + page, + }) => { + await renderAndCapture(page, { + plugin: "Candlestick", + columns: ["Sales"], + group_by: ["Category"], + split_by: ["Region"], + }); + }); +}); diff --git a/packages/viewer-charts/test/ts/snapshot/categorical-value-axis.spec.ts b/packages/viewer-charts/test/ts/snapshot/categorical-value-axis.spec.ts new file mode 100644 index 0000000..8d5ad30 --- /dev/null +++ b/packages/viewer-charts/test/ts/snapshot/categorical-value-axis.spec.ts @@ -0,0 +1,75 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test } from "@perspective-dev/test"; +import { gotoBasic, renderAndCapture } from "../helpers"; + +test.describe("Categorical value axis", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + // X/Y Scatter with a `string` X column — the X column type triggers + // categorical-X dispatch in `cartesian-build` / `cartesian-render`. + // Y stays numeric. + test("cartesian categorical X", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X/Y Scatter", + columns: ["Category", "Profit"], + }); + }); + + // Mirror of the above with the `string` column on Y instead. + test("cartesian categorical Y", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X/Y Scatter", + columns: ["Profit", "Category"], + }); + }); + + // Both X and Y are `string`-typed: build pipeline writes slot + // indices into both axes, render pass dispatches the categorical + // painter on both sides. Per the locked decision, points stack at + // each (catX, catY) cell center — no jitter / no aggregation. + test("cartesian categorical X and Y", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X/Y Scatter", + columns: ["Category", "Region"], + }); + }); + + // Y Bar with a string-typed value aggregate (`last(Category)`): + // `_leftValueAxisMode` switches to `"category"`, bar `y0`/`y1` + // carry dictionary slot indices, and the value-axis chrome paints + // the categorical Y axis via the broadened `renderBarAxesChrome`. + test("y-bar categorical value axis", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Category"], + group_by: ["State"], + aggregates: { Category: "last" }, + }); + }); + + // X Bar mirror: categorical value axis lands on the bottom (X) + // side and the chart uses the horizontal projection. Verifies the + // `_isHorizontal` branch of `renderBarAxesChrome` dispatches the + // value side correctly. + test("x-bar categorical value axis", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X Bar", + columns: ["Category"], + group_by: ["State"], + aggregates: { Category: "last" }, + }); + }); +}); diff --git a/packages/viewer-charts/test/ts/snapshot/datetime-subsecond.spec.ts b/packages/viewer-charts/test/ts/snapshot/datetime-subsecond.spec.ts new file mode 100644 index 0000000..194f45f --- /dev/null +++ b/packages/viewer-charts/test/ts/snapshot/datetime-subsecond.spec.ts @@ -0,0 +1,76 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// Sub-second datetime regression coverage. The numeric category axis +// path narrows positions to f32 for GPU upload; with absolute epoch +// timestamps (~1.7e12) and a sub-second window, naive narrowing +// collapses every distinct point onto one of ~5 representable values +// (~262144 ms apart at this magnitude) and the chart renders a single +// stripe — or nothing at all when the projection's `tx` term blows up +// the shader's clip-space cancellation. The fix is twofold: +// +// 1. WASM emits datetime columns as Float64 regardless of the +// `float32` flag, preserving millisecond precision into JS. +// 2. Each chart subtracts an origin (data min) before f32 narrowing +// and threads the same origin into `buildProjectionMatrix`, so +// `tx ≈ 0` and the shader sees rebased values that fit cleanly +// in f32. +// +// The expression below pins timestamps near 2025-01-01 UTC, scaling +// `Quantity` (1–14) by 200ms to produce 14 distinct buckets across a +// ~2.8s window — well inside the f32 collapse zone for absolute +// epoch-ms. + +import { test } from "@perspective-dev/test"; +import { gotoBasic, renderAndCapture } from "../helpers"; + +const SUBSECOND_DATETIME_EXPR = { + "Order DT Subsecond": 'datetime(1735689600000 + "Quantity" * 200)', +}; + +test.describe("Sub-second datetime numeric axis", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("Y Bar renders distinct bars across a 2.8s window", async ({ + page, + }) => { + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Order DT Subsecond"], + expressions: SUBSECOND_DATETIME_EXPR, + }); + }); + + test("Y Line traces all sub-second points", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Line", + columns: ["Sales"], + group_by: ["Order DT Subsecond"], + expressions: SUBSECOND_DATETIME_EXPR, + }); + }); + + test("Heatmap fills cells across a sub-second X domain", async ({ + page, + }) => { + await renderAndCapture(page, { + plugin: "Heatmap", + columns: ["Sales"], + group_by: ["Order DT Subsecond"], + split_by: ["Category"], + expressions: SUBSECOND_DATETIME_EXPR, + }); + }); +}); diff --git a/packages/viewer-charts/test/ts/snapshot/gradient-heatmap.spec.ts b/packages/viewer-charts/test/ts/snapshot/gradient-heatmap.spec.ts new file mode 100644 index 0000000..29291ca --- /dev/null +++ b/packages/viewer-charts/test/ts/snapshot/gradient-heatmap.spec.ts @@ -0,0 +1,86 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test } from "@perspective-dev/test"; +import { gotoBasic, renderAndCapture } from "../helpers"; + +test.describe("Density", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("basic x/y", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Density", + columns: ["Quantity", "Profit"], + }); + }); + + test("with numeric color", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Density", + columns: ["Quantity", "Profit", "Sales"], + }); + }); + + test("split_by produces faceted heatmaps", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Density", + columns: ["Quantity", "Profit"], + split_by: ["Category"], + }); + }); + + test("date X axis", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Density", + columns: ["Order Date", "Profit"], + }); + }); + + // color_mode variants + // Snapshots compare side-by-side on the same fixture so any + // regression to one mode's resolve / splat branch is obvious. + test.describe("color_mode", () => { + test("mean (default, density-weighted average)", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Density", + columns: ["Quantity", "Profit", "Sales"], + plugin_config: { gradient_color_mode: "mean" }, + }); + }); + + test("density (ignores color column)", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Density", + columns: ["Quantity", "Profit", "Sales"], + plugin_config: { gradient_color_mode: "density" }, + }); + }); + + test("extreme (signed max deviation)", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Density", + columns: ["Quantity", "Profit", "Profit"], + plugin_config: { gradient_color_mode: "extreme" }, + }); + }); + + test("signed (net positive vs negative)", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Density", + columns: ["Quantity", "Profit", "Profit"], + plugin_config: { gradient_color_mode: "signed" }, + }); + }); + }); +}); diff --git a/packages/viewer-charts/test/ts/snapshot/heatmap.spec.ts b/packages/viewer-charts/test/ts/snapshot/heatmap.spec.ts new file mode 100644 index 0000000..c5efe65 --- /dev/null +++ b/packages/viewer-charts/test/ts/snapshot/heatmap.spec.ts @@ -0,0 +1,159 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test } from "@perspective-dev/test"; +import { gotoBasic, renderAndCapture } from "../helpers"; + +// `Order Date` is parsed as `date` in the superstore CSV. To exercise +// the `datetime` axis path (distinct schema type, same numeric code +// path) we materialize a synthetic datetime column via expression. +const DATETIME_EXPR = { + "Order DT": 'datetime("Quantity" * 86400000)', +}; + +test.describe("Heatmap", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("basic", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Heatmap", + columns: ["Sales"], + group_by: ["Region"], + split_by: ["Category"], + }); + + await page.pause(); + }); + + test("nested group_by rows", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Heatmap", + columns: ["Sales"], + group_by: ["Region", "Category"], + split_by: ["Ship Mode"], + }); + }); + + test("diverging data with Profit", async ({ page }) => { + // Profit crosses zero — the sign-aware gradient should center + // value=0 on the gradient midpoint. + await renderAndCapture(page, { + plugin: "Heatmap", + columns: ["Profit"], + group_by: ["Region"], + split_by: ["Category"], + }); + }); + + test("multi-faceted", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Heatmap", + columns: ["Sales", "Profit"], + group_by: ["Region"], + split_by: ["Category"], + }); + + await page.pause(); + }); + + test("hierarchial X axis", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Heatmap", + columns: ["Sales"], + group_by: ["Region", "State"], + split_by: ["Category"], + }); + + await page.pause(); + }); + + test("hierarchial Y axis", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Heatmap", + columns: ["Sales"], + group_by: ["Region"], + split_by: ["Category", "Sub-Category"], + }); + + await page.pause(); + }); +}); + +// Numeric-axis coverage for the heatmap. The X axis sources from +// `__ROW_PATH_0__` (group_by). +test.describe("Heatmap numeric axes", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("integer X axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Heatmap", + columns: ["Sales"], + group_by: ["Quantity"], + split_by: ["Category"], + }); + }); + + test("datetime X axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Heatmap", + columns: ["Sales"], + group_by: ["Order DT"], + split_by: ["Category"], + expressions: DATETIME_EXPR, + }); + }); + + test("integer Y axis as sole split_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Heatmap", + columns: ["Sales"], + group_by: ["Region"], + split_by: ["Quantity"], + }); + }); + + test("datetime Y axis as sole split_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Heatmap", + columns: ["Sales"], + group_by: ["Region"], + split_by: ["Order DT"], + expressions: DATETIME_EXPR, + }); + }); + + test("integer X falls back to categorical with extra group_by level", async ({ + page, + }) => { + await renderAndCapture(page, { + plugin: "Heatmap", + columns: ["Sales"], + group_by: ["Region", "Quantity"], + split_by: ["Category"], + }); + }); + + test("integer Y falls back to categorical with extra split_by level", async ({ + page, + }) => { + await renderAndCapture(page, { + plugin: "Heatmap", + columns: ["Sales"], + group_by: ["Region"], + split_by: ["Category", "Quantity"], + }); + }); +}); diff --git a/packages/viewer-charts/test/ts/snapshot/line.spec.ts b/packages/viewer-charts/test/ts/snapshot/line.spec.ts new file mode 100644 index 0000000..81e5208 --- /dev/null +++ b/packages/viewer-charts/test/ts/snapshot/line.spec.ts @@ -0,0 +1,46 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test } from "@perspective-dev/test"; +import { gotoBasic, renderAndCapture } from "../helpers"; + +test.describe("X/Y Line", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("basic x/y", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X/Y Line", + columns: ["Order Date", "Profit"], + group_by: ["Order Date"], + }); + }); + + test("split_by produces distinct series colors", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X/Y Line", + columns: ["Order Date", "Profit"], + group_by: ["Order Date"], + split_by: ["Category"], + }); + }); + + test("date X axis with two splits", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X/Y Line", + columns: ["Order Date", "Sales"], + group_by: ["Order Date"], + split_by: ["Region"], + }); + }); +}); diff --git a/packages/viewer-charts/test/ts/snapshot/numeric-axis.spec.ts b/packages/viewer-charts/test/ts/snapshot/numeric-axis.spec.ts new file mode 100644 index 0000000..bb1a068 --- /dev/null +++ b/packages/viewer-charts/test/ts/snapshot/numeric-axis.spec.ts @@ -0,0 +1,230 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// Coverage for the numeric-axis path on the bar family. A non-string +// single `group_by` level (date / datetime / integer / float) should +// render as a numeric axis on the categorical side; multi-level +// group_bys with a non-string leaf fall back to a stringified +// hierarchical category axis. + +import { test } from "@perspective-dev/test"; +import { gotoBasic, renderAndCapture } from "../helpers"; + +// `Order Date` is parsed as `date` in the superstore CSV. To exercise +// the `datetime` axis path (distinct schema type, same numeric code +// path) we materialize a synthetic datetime column via expression: +// `Quantity` ranges 1–14, scaled to ms-into-1970 gives a small datetime +// spread that fits cleanly on a single axis. +const DATETIME_EXPR = { + "Order DT": 'datetime("Quantity" * 86400000)', +}; + +test.describe("Numeric category axis (Y Bar)", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("date axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Order Date"], + }); + }); + + test("datetime axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Order DT"], + expressions: DATETIME_EXPR, + }); + }); + + test("integer axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Quantity"], + }); + }); + + test("float axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Discount"], + }); + }); + + test("integer last but not only group_by stays categorical", async ({ + page, + }) => { + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Region", "Quantity"], + }); + }); + + test("float last but not only group_by stays categorical", async ({ + page, + }) => { + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Region", "Discount"], + }); + }); + + test("datetime last but not only group_by stays categorical", async ({ + page, + }) => { + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Region", "Order DT"], + expressions: DATETIME_EXPR, + }); + }); +}); + +test.describe("Numeric category axis (X Bar)", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("date axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X Bar", + columns: ["Sales"], + group_by: ["Order Date"], + }); + }); + + test("datetime axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X Bar", + columns: ["Sales"], + group_by: ["Order DT"], + expressions: DATETIME_EXPR, + }); + }); + + test("integer axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X Bar", + columns: ["Sales"], + group_by: ["Quantity"], + }); + }); + + test("float axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X Bar", + columns: ["Sales"], + group_by: ["Discount"], + }); + }); + + test("integer last but not only group_by stays categorical", async ({ + page, + }) => { + await renderAndCapture(page, { + plugin: "X Bar", + columns: ["Sales"], + group_by: ["Region", "Quantity"], + }); + }); +}); + +test.describe("Numeric category axis (Y Line)", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("date axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Line", + columns: ["Sales"], + group_by: ["Order Date"], + }); + }); + + test("integer axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Line", + columns: ["Sales"], + group_by: ["Quantity"], + }); + }); + + test("float axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Line", + columns: ["Sales"], + group_by: ["Discount"], + }); + }); +}); + +test.describe("Numeric category axis (Y Scatter)", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("date axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Scatter", + columns: ["Sales"], + group_by: ["Order Date"], + }); + }); + + test("integer axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Scatter", + columns: ["Sales"], + group_by: ["Quantity"], + }); + }); + + test("float axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Scatter", + columns: ["Sales"], + group_by: ["Discount"], + }); + }); +}); + +test.describe("Numeric category axis (Y Area)", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("date axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Area", + columns: ["Sales"], + group_by: ["Order Date"], + }); + }); + + test("integer axis as sole group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Area", + columns: ["Sales"], + group_by: ["Quantity"], + }); + }); +}); diff --git a/packages/viewer-charts/test/ts/snapshot/pan.spec.ts b/packages/viewer-charts/test/ts/snapshot/pan.spec.ts new file mode 100644 index 0000000..127f4be --- /dev/null +++ b/packages/viewer-charts/test/ts/snapshot/pan.spec.ts @@ -0,0 +1,46 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test } from "@perspective-dev/test"; +import { + expectViewerScreenshot, + gotoBasic, + restoreChart, + waitOneFrame, +} from "../helpers"; + +const PLOT_CX = 640; +const PLOT_CY = 360; + +test.describe("Pan", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("drag pans after wheel zoom", async ({ page }) => { + await restoreChart(page, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Profit"], + }); + + // Zoom in first so the pan offset is visible in the frame. + await page.mouse.move(PLOT_CX, PLOT_CY); + await page.mouse.wheel(0, -500); + await waitOneFrame(page); + + await page.mouse.down(); + await page.mouse.move(PLOT_CX - 150, PLOT_CY - 80); + await page.mouse.up(); + await waitOneFrame(page); + await expectViewerScreenshot(page); + }); +}); diff --git a/packages/viewer-charts/test/ts/snapshot/regressions.spec.ts b/packages/viewer-charts/test/ts/snapshot/regressions.spec.ts new file mode 100644 index 0000000..a88c1b0 --- /dev/null +++ b/packages/viewer-charts/test/ts/snapshot/regressions.spec.ts @@ -0,0 +1,104 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Pinned screenshots for every rendering regression we've shipped a fix + * for. These exist so the bug can't silently come back without + * snapshot diff lighting up in CI. + */ + +import { test } from "@perspective-dev/test"; +import { gotoBasic, renderAndCapture } from "../helpers"; + +test.describe("Regressions", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + // split_by series correctness + // Bug: the point/line glyph's draw count used + // `numSeries * maxSeriesUploaded` which is correct only for a packed + // buffer layout. With the slotted layout series 1..N live at + // `s * seriesCapacity` — far beyond the draw range — so only series 0 + // rendered. Fix: draw count is `numSeries * seriesCapacity` with + // sentinel discard in the shader. + test("scatter split_by renders every series", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Profit"], + split_by: ["Region"], + }); + }); + + test("line split_by renders every series", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X/Y Line", + columns: ["Order Date", "Profit"], + group_by: ["Order Date"], + split_by: ["Region"], + }); + }); + + // scatter categorical colors match legend + // Bug: the scatter vertex shader used a sign-aware color-t mapping + // that folded non-negative domains into the top half of the + // gradient, so split indices `0..N-1` mapped to `[0.5, 1]` while the + // legend sampled evenly across `[0, 1]`. Fix: shader uses linear + // mapping for single-sign domains; sign-aware only when crossing 0. + test("scatter string color matches legend swatches", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Profit", "Category"], + }); + }); + + // Y Line shows series colors (not all-black) + // Bug: after refactoring the line shader to read color from a + // gradient LUT + varying, bar/glyphs/draw-lines.ts was still wiring + // up the old `u_color` uniform and never bound the LUT — every + // fragment fetched `(0, 0, 0, 1)`. Fix: dedicated uniform-color + // shader pair (line-uniform.vert/frag.glsl) for the bar line glyph. + test("Y Line shows series palette colors", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Line", + columns: ["Sales"], + group_by: ["Category"], + split_by: ["Region"], + }); + }); + + // Treemap transparent background + // Bug: treemap cleared WebGL to a dimmed gridline color instead of + // transparent, so themed hosts got an opaque backdrop under the + // chart. + test("Treemap background is transparent", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Treemap", + columns: ["Sales"], + group_by: ["Region"], + }); + }); + + // Treemap color-mode: date/datetime → numeric gradient + // Bug: `_colorMode` detection read `ColumnData.type` (runtime storage + // type "float32"/"int32"/"string") instead of the view-typed schema + // type. Date and datetime columns were incorrectly classified as + // "series" instead of "numeric". Fix: use `_columnTypes` (view types + // `float`/`integer`/`date`/`datetime`) with explicit numeric list. + test("Treemap with date Color uses gradient mode", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Treemap", + columns: ["Sales", "Order Date"], + group_by: ["Region", "Category"], + }); + }); +}); diff --git a/packages/viewer-charts/test/ts/snapshot/scatter.spec.ts b/packages/viewer-charts/test/ts/snapshot/scatter.spec.ts new file mode 100644 index 0000000..fa61c26 --- /dev/null +++ b/packages/viewer-charts/test/ts/snapshot/scatter.spec.ts @@ -0,0 +1,79 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test } from "@perspective-dev/test"; +import { gotoBasic, renderAndCapture } from "../helpers"; + +test.describe("X/Y Scatter", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("basic x/y", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Profit"], + }); + }); + + test("with numeric color", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Profit", "Sales"], + }); + }); + + test("with string color", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Profit", "Category"], + }); + }); + + test("with size column", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Profit", null, "Sales"], + }); + }); + + test("split_by produces distinct series", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Profit"], + split_by: ["Category"], + }); + }); + + test("split_by respects color column", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Profit", null, "Sales"], + split_by: ["Category"], + }); + }); + + test("group_by aggregates points", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Profit"], + group_by: ["State"], + }); + }); + + test("date X axis", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X/Y Scatter", + columns: ["Order Date", "Profit"], + }); + }); +}); diff --git a/packages/viewer-charts/test/ts/snapshot/sunburst.spec.ts b/packages/viewer-charts/test/ts/snapshot/sunburst.spec.ts new file mode 100644 index 0000000..692d424 --- /dev/null +++ b/packages/viewer-charts/test/ts/snapshot/sunburst.spec.ts @@ -0,0 +1,110 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test } from "@perspective-dev/test"; +import { gotoBasic, renderAndCapture } from "../helpers"; + +test.describe("Sunburst", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("basic hierarchy", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Sunburst", + columns: ["Sales"], + group_by: ["Region", "Category"], + }); + }); + + test("no color slot → single-palette series mode", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Sunburst", + columns: ["Sales"], + group_by: ["Region"], + }); + }); + + test("numeric color → gradient", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Sunburst", + columns: ["Sales", "Profit"], + group_by: ["Region", "Category"], + }); + }); + + test("string color → series palette", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Sunburst", + columns: ["Sales", "Ship Mode"], + group_by: ["Region", "Category"], + }); + }); + + test("three-level group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Sunburst", + columns: ["Sales"], + group_by: ["Region", "Category", "Sub-Category"], + }); + }); + + // `split_by` activates the facet grid: one sunburst per split value, + // each with its own center / radius / drill root. + test("faceted by split_by — labels per facet", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Sunburst", + columns: ["Sales"], + group_by: ["Region", "Category"], + split_by: ["Ship Mode"], + }); + }); + + test("faceted with numeric color gradient", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Sunburst", + columns: ["Sales", "Profit"], + group_by: ["Region", "Category"], + split_by: ["Ship Mode"], + }); + }); + + // Regression: with `split_by` populated but `group_by` empty, + // `processTreeChunk` synthesizes a `[prefix, "Row N"]` path per + // row. Before the fix at `tree-data.ts`, `effectiveGroupLen` + // resolved to `1` against that depth-2 path, so the leaf size + // store was silently skipped and every node ended up with size 0 + // — sunburst computed zero-width arcs and rendered an empty plot. + test("split_by only, no group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Sunburst", + columns: ["Sales"], + split_by: ["Ship Mode"], + }); + }); + + test("split_by + string color, no group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Sunburst", + columns: ["Sales", "Region"], + split_by: ["Ship Mode"], + }); + }); + + test("split_by + numeric color, no group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Sunburst", + columns: ["Sales", "Profit"], + split_by: ["Ship Mode"], + }); + }); +}); diff --git a/packages/viewer-charts/test/ts/snapshot/tooltip.spec.ts b/packages/viewer-charts/test/ts/snapshot/tooltip.spec.ts new file mode 100644 index 0000000..9562989 --- /dev/null +++ b/packages/viewer-charts/test/ts/snapshot/tooltip.spec.ts @@ -0,0 +1,51 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test } from "@perspective-dev/test"; +import { expectViewerScreenshot, gotoBasic, restoreChart } from "../helpers"; + +const PLOT_CX = 640; +const PLOT_CY = 360; + +// Hover dispatches `onHover` from a RAF, and the tooltip text is built +// via a `buildTooltipLines` Promise that repaints the chrome overlay +// once the row fetch resolves. 200ms covers both hops reliably under +// swiftshader without flaking on a slow CI machine. +const TOOLTIP_SETTLE_MS = 200; + +test.describe("Tooltip", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("hover paints tooltip chrome", async ({ page }) => { + await restoreChart(page, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Profit"], + }); + await page.mouse.move(PLOT_CX, PLOT_CY); + await page.waitForTimeout(TOOLTIP_SETTLE_MS); + await expectViewerScreenshot(page, "scatter-hover.png"); + }); + + test("click pins tooltip", async ({ page }) => { + await restoreChart(page, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Profit"], + }); + await page.mouse.move(PLOT_CX, PLOT_CY); + await page.waitForTimeout(TOOLTIP_SETTLE_MS); + await page.mouse.click(PLOT_CX, PLOT_CY); + await page.waitForTimeout(TOOLTIP_SETTLE_MS); + await expectViewerScreenshot(page, "scatter-pinned.png"); + }); +}); diff --git a/packages/viewer-charts/test/ts/snapshot/treemap.spec.ts b/packages/viewer-charts/test/ts/snapshot/treemap.spec.ts new file mode 100644 index 0000000..7697fbe --- /dev/null +++ b/packages/viewer-charts/test/ts/snapshot/treemap.spec.ts @@ -0,0 +1,138 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test } from "@perspective-dev/test"; +import { gotoBasic, renderAndCapture } from "../helpers"; + +test.describe("Treemap", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("basic hierarchy", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Treemap", + columns: ["Sales"], + group_by: ["Region", "Category"], + }); + }); + + test("no color slot → single-palette series mode", async ({ page }) => { + // Regression: when Color is empty, _colorMode is "empty", + // every leaf gets palette[0], and the legend is suppressed. + await renderAndCapture(page, { + plugin: "Treemap", + columns: ["Sales"], + group_by: ["Region"], + }); + }); + + test("numeric color → gradient + gradient legend", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Treemap", + columns: ["Sales", "Profit"], + group_by: ["Region", "Category"], + }); + }); + + test("string color → series palette + categorical legend", async ({ + page, + }) => { + await renderAndCapture(page, { + plugin: "Treemap", + columns: ["Sales", "Ship Mode"], + group_by: ["Region", "Category"], + }); + }); + + test("three-level group_by", async ({ page }) => { + await renderAndCapture( + page, + { + plugin: "Treemap", + columns: ["Sales"], + group_by: ["Region", "Category", "Sub-Category"], + }, + + // Treemaps have a lot of text that gets shredded on CI + { maxDiffPixelRatio: 0.02 }, + ); + }); + + // `split_by` activates the facet grid: one treemap per split value + // sharing a color scale + legend. + test("faceted by split_by", async ({ page }) => { + await renderAndCapture( + page, + { + plugin: "Treemap", + columns: ["Sales"], + group_by: ["Region", "Category"], + split_by: ["Ship Mode"], + }, + { maxDiffPixelRatio: 0.02 }, + ); + }); + + test("faceted with numeric color gradient", async ({ page }) => { + await renderAndCapture( + page, + { + plugin: "Treemap", + columns: ["Sales", "Profit"], + group_by: ["Region", "Category"], + split_by: ["Ship Mode"], + }, + { maxDiffPixelRatio: 0.02 }, + ); + }); + + // Regression: see the matching sunburst test for the full path — + // `split_by` with no `group_by` produced depth-2 paths whose + // leaves were never sized, leaving treemap with zero-area rects + // and an empty canvas. + test("split_by only, no group_by", async ({ page }) => { + await renderAndCapture( + page, + { + plugin: "Treemap", + columns: ["Sales"], + split_by: ["Ship Mode"], + }, + { maxDiffPixelRatio: 0.02 }, + ); + }); + + test("split_by + string color, no group_by", async ({ page }) => { + await renderAndCapture( + page, + { + plugin: "Treemap", + columns: ["Sales", "Region"], + split_by: ["Ship Mode"], + }, + { maxDiffPixelRatio: 0.02 }, + ); + }); + + test("split_by + numeric color, no group_by", async ({ page }) => { + await renderAndCapture( + page, + { + plugin: "Treemap", + columns: ["Sales", "Profit"], + split_by: ["Ship Mode"], + }, + { maxDiffPixelRatio: 0.02 }, + ); + }); +}); diff --git a/packages/viewer-charts/test/ts/snapshot/x-bar.spec.ts b/packages/viewer-charts/test/ts/snapshot/x-bar.spec.ts new file mode 100644 index 0000000..5b44616 --- /dev/null +++ b/packages/viewer-charts/test/ts/snapshot/x-bar.spec.ts @@ -0,0 +1,53 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test } from "@perspective-dev/test"; +import { gotoBasic, renderAndCapture } from "../helpers"; + +test.describe("X Bar", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("basic single series", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X Bar", + columns: ["Sales"], + group_by: ["Category"], + }); + }); + + test("split_by series colors", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X Bar", + columns: ["Sales"], + group_by: ["Category"], + split_by: ["Region"], + }); + }); + + test("multiple X columns", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X Bar", + columns: ["Sales", "Profit"], + group_by: ["Category"], + }); + }); + + test("nested group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "X Bar", + columns: ["Sales"], + group_by: ["Region", "Category"], + }); + }); +}); diff --git a/packages/viewer-charts/test/ts/snapshot/y-area.spec.ts b/packages/viewer-charts/test/ts/snapshot/y-area.spec.ts new file mode 100644 index 0000000..09d95a3 --- /dev/null +++ b/packages/viewer-charts/test/ts/snapshot/y-area.spec.ts @@ -0,0 +1,37 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test } from "@perspective-dev/test"; +import { gotoBasic, renderAndCapture } from "../helpers"; + +test.describe("Y Area", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("basic", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Area", + columns: ["Sales"], + group_by: ["Category"], + }); + }); + + test("split_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Area", + columns: ["Sales"], + group_by: ["Category"], + split_by: ["Region"], + }); + }); +}); diff --git a/packages/viewer-charts/test/ts/snapshot/y-bar.spec.ts b/packages/viewer-charts/test/ts/snapshot/y-bar.spec.ts new file mode 100644 index 0000000..4fbba7d --- /dev/null +++ b/packages/viewer-charts/test/ts/snapshot/y-bar.spec.ts @@ -0,0 +1,53 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test } from "@perspective-dev/test"; +import { gotoBasic, renderAndCapture } from "../helpers"; + +test.describe("Y Bar", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("basic single series", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Category"], + }); + }); + + test("split_by series colors", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Category"], + split_by: ["Region"], + }); + }); + + test("multiple Y columns", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Sales", "Profit"], + group_by: ["Category"], + }); + }); + + test("nested group_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Region", "Category"], + }); + }); +}); diff --git a/packages/viewer-charts/test/ts/snapshot/y-line.spec.ts b/packages/viewer-charts/test/ts/snapshot/y-line.spec.ts new file mode 100644 index 0000000..4ee988d --- /dev/null +++ b/packages/viewer-charts/test/ts/snapshot/y-line.spec.ts @@ -0,0 +1,42 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test } from "@perspective-dev/test"; +import { gotoBasic, renderAndCapture } from "../helpers"; + +test.describe("Y Line", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("basic", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Line", + columns: ["Sales"], + group_by: ["Category"], + }); + }); + + test("split_by shows distinct per-series colors (regression)", async ({ + page, + }) => { + // This spec pins the fix for the Y-Line-renders-all-black bug: + // bar/glyphs/draw-lines.ts must use the uniform-color shader + // variant and set u_color per series. + await renderAndCapture(page, { + plugin: "Y Line", + columns: ["Sales"], + group_by: ["Category"], + split_by: ["Region"], + }); + }); +}); diff --git a/packages/viewer-charts/test/ts/snapshot/y-ohlc.spec.ts b/packages/viewer-charts/test/ts/snapshot/y-ohlc.spec.ts new file mode 100644 index 0000000..1fd24bc --- /dev/null +++ b/packages/viewer-charts/test/ts/snapshot/y-ohlc.spec.ts @@ -0,0 +1,45 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test } from "@perspective-dev/test"; +import { gotoBasic, renderAndCapture } from "../helpers"; + +test.describe("Y OHLC", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("basic four-column OHLC", async ({ page }) => { + await renderAndCapture(page, { + plugin: "OHLC", + columns: ["Sales", "Profit", "Quantity", "Discount"], + group_by: ["Order Date"], + }); + }); + + test("open-only falls back to next-row open", async ({ page }) => { + await renderAndCapture(page, { + plugin: "OHLC", + columns: ["Sales"], + group_by: ["Order Date"], + }); + }); + + test("with split_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "OHLC", + columns: ["Sales"], + group_by: ["Category"], + split_by: ["Region"], + }); + }); +}); diff --git a/packages/viewer-charts/test/ts/snapshot/y-scatter.spec.ts b/packages/viewer-charts/test/ts/snapshot/y-scatter.spec.ts new file mode 100644 index 0000000..67019aa --- /dev/null +++ b/packages/viewer-charts/test/ts/snapshot/y-scatter.spec.ts @@ -0,0 +1,37 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test } from "@perspective-dev/test"; +import { gotoBasic, renderAndCapture } from "../helpers"; + +test.describe("Y Scatter", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("basic", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Scatter", + columns: ["Sales"], + group_by: ["Category"], + }); + }); + + test("split_by", async ({ page }) => { + await renderAndCapture(page, { + plugin: "Y Scatter", + columns: ["Sales"], + group_by: ["Category"], + split_by: ["Region"], + }); + }); +}); diff --git a/packages/viewer-charts/test/ts/snapshot/zoom.spec.ts b/packages/viewer-charts/test/ts/snapshot/zoom.spec.ts new file mode 100644 index 0000000..7fe1b59 --- /dev/null +++ b/packages/viewer-charts/test/ts/snapshot/zoom.spec.ts @@ -0,0 +1,138 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test } from "@perspective-dev/test"; +import { + expectViewerScreenshot, + gotoBasic, + restoreChart, + waitOneFrame, +} from "../helpers"; + +// Plot center is well inside the layout's plotRect for the default +// 1280×720 viewport — the layout leaves ~80px of gutter on every side. +const PLOT_CX = 640; +const PLOT_CY = 360; + +test.describe("Zoom", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("wheel zooms in on scatter", async ({ page }) => { + await restoreChart(page, { + plugin: "X/Y Scatter", + columns: ["Quantity", "Profit"], + }); + + await page.mouse.move(PLOT_CX, PLOT_CY); + await page.mouse.wheel(0, -500); + await waitOneFrame(page); + await expectViewerScreenshot(page); + }); + + test("wheel zooms in on line with date axis", async ({ page }) => { + await restoreChart(page, { + plugin: "X/Y Line", + columns: ["Order Date", "Profit"], + group_by: ["Order Date"], + }); + + await page.mouse.move(PLOT_CX, PLOT_CY); + await page.mouse.wheel(0, -500); + await waitOneFrame(page); + await expectViewerScreenshot(page); + }); + + test("wheel zooms in on Y Bar", async ({ page }) => { + await restoreChart(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["State"], + }); + + await page.mouse.move(PLOT_CX, PLOT_CY); + await page.mouse.wheel(0, -500); + await waitOneFrame(page); + await expectViewerScreenshot(page); + }); + + test("wheel zooms in on Candlestick", async ({ page }) => { + await restoreChart(page, { + plugin: "Candlestick", + columns: ["Sales"], + group_by: ["Order Date"], + }); + + await page.mouse.move(PLOT_CX, PLOT_CY); + await page.mouse.wheel(0, -500); + await waitOneFrame(page); + await expectViewerScreenshot(page); + }); + + // Datetime-axis dynamic-zoom refit. With a numeric category axis + // (single `date` / `datetime` / `integer` / `float` group_by), the + // zoom controller's visible domain is in *absolute data units* + // (e.g. ~1.7e12 for a datetime). The auto-fit lookup needs to map + // that back to catIdx via `_categoryPositions` — without that map, + // the value-axis refit silently no-ops and `"dynamic"` looks + // identical to `"fixed"` on every datetime-axis chart. + test.describe("datetime x axis", () => { + test("Y Line wheel zoom refits value axis (dynamic)", async ({ + page, + }) => { + await restoreChart(page, { + plugin: "Y Line", + columns: ["Profit"], + group_by: ["Order Date"], + plugin_config: { series_zoom_mode: "dynamic" }, + }); + + await page.mouse.move(PLOT_CX, PLOT_CY); + await page.mouse.wheel(0, -500); + await waitOneFrame(page); + await expectViewerScreenshot(page); + }); + + test("Y Line wheel zoom keeps value axis pinned (fixed)", async ({ + page, + }) => { + await restoreChart(page, { + plugin: "Y Line", + columns: ["Profit"], + group_by: ["Order Date"], + plugin_config: { series_zoom_mode: "fixed" }, + }); + + await page.mouse.move(PLOT_CX, PLOT_CY); + await page.mouse.wheel(0, -500); + await waitOneFrame(page); + await expectViewerScreenshot(page); + }); + + test("Y Bar wheel zoom refits value axis (dynamic)", async ({ + page, + }) => { + await restoreChart(page, { + plugin: "Y Bar", + columns: ["Sales"], + group_by: ["Order Date"], + plugin_config: { series_zoom_mode: "dynamic" }, + }); + + await page.mouse.move(PLOT_CX, PLOT_CY); + await page.mouse.wheel(0, -500); + await waitOneFrame(page); + await expectViewerScreenshot(page); + }); + }); +}); diff --git a/packages/viewer-charts/test/ts/tsconfig.json b/packages/viewer-charts/test/ts/tsconfig.json new file mode 100644 index 0000000..9ec391d --- /dev/null +++ b/packages/viewer-charts/test/ts/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + }, + "include": ["./**/*.ts"] +} diff --git a/packages/viewer-charts/test/ts/xy_symbols.spec.ts b/packages/viewer-charts/test/ts/xy_symbols.spec.ts new file mode 100644 index 0000000..a3f9ab2 --- /dev/null +++ b/packages/viewer-charts/test/ts/xy_symbols.spec.ts @@ -0,0 +1,126 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { PageView as PspViewer, expect, test } from "@perspective-dev/test"; +import { Page } from "@playwright/test"; + +const symbols = [ + "circle", + "cross", + "diamond", + "square", + "star", + "triangle", + "wye", +]; + +async function checkSymbolsSection( + page: Page, + columnName: string, + searchVal: [string, string, string], + editVal: string, +) { + // setup viewer + const viewer = new PspViewer(page); + const settingsPanel = await viewer.openSettingsPanel(); + const symbolsEditor = viewer.columnSettingsSidebar.styleTab.symbolsEditor; + const symbolColumn = settingsPanel.container.locator( + "div[data-label=Symbol]", + ); + await settingsPanel.selectPlugin("X/Y Scatter"); + await symbolColumn.waitFor(); + const colInput = symbolColumn.locator("input"); + await colInput.type(columnName); + await page + .locator("perspective-dropdown .selected") + .first() // NOTE: There probably shouldn't actually be more than one. + .waitFor(); + await colInput.press("Enter"); + + const editBtn = symbolColumn.locator(".expression-edit-button"); + await editBtn.waitFor(); + await editBtn.click(); + + // setup symbols + await symbolsEditor.container.waitFor(); + const key = symbolsEditor.container.locator(".column_name"); + const keyInput = symbolsEditor.container.locator("input"); + const valSelect = symbolsEditor.container.locator("select"); + const value = symbolsEditor.container.locator("option[selected]"); + + const setKey = async (inputIdx, keyIdx, val) => { + await keyInput.nth(inputIdx).clear(); + await keyInput.nth(inputIdx).type(val); + await page.locator("perspective-dropdown .selected").waitFor(); + await keyInput.nth(inputIdx).press("Enter"); + expect((await key.nth(keyIdx).textContent())?.toLowerCase()).toContain( + val.toLowerCase(), + ); + }; + + const setValue = async (i, val) => { + await valSelect.nth(i).selectOption(val); + expect(await value.nth(i).textContent()).toBe(val); + }; + + // add + expect(await symbolsEditor.getSymbolPairs()).toStrictEqual([]); + for (let i = 0; i < 3; i++) { + await setKey(0, i, searchVal[i]); + await setValue(i, symbols[i + 1]); + expect(await symbolsEditor.getPairsLength()).toBe(i + 2); + } + + // edit + await symbolsEditor.container.locator(".column_name").first().dblclick(); + await setKey(0, 0, editVal); + expect( + await symbolsEditor.container + .locator(".column_name") + .first() + .textContent(), + ).toContain(editVal); + + // remove + for (let i = 0; i < 3; i++) { + await symbolsEditor.container + .locator(".is_column_active") + .nth(2 - i) + .dispatchEvent("click"); // pointer is way off + expect(await symbolsEditor.getPairsLength()).toBe(3 - i); + } +} + +test.describe("X/Y Scatter", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + }); + + test.skip("Symbols column - Datetime values", async ({ page }) => { + await checkSymbolsSection(page, "Order Date", ["0", "1", "2"], "9"); + }); + + test.skip("Symbols column - Numeric values", async ({ page }) => { + await checkSymbolsSection(page, "Discount", ["0", "1", "2"], "7"); + }); + + test.skip("Symbols column - String values", async ({ page }) => { + await checkSymbolsSection(page, "State", ["A", "B", "C"], "D"); + }); + + test.fixme("Symbols column - Expression values", async ({ page }) => {}); +}); diff --git a/packages/viewer-charts/tsconfig.json b/packages/viewer-charts/tsconfig.json new file mode 100644 index 0000000..4c28052 --- /dev/null +++ b/packages/viewer-charts/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "ESNext", + "target": "ESNext", + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./dist/esm", + "rootDir": "./src/ts", + "moduleResolution": "bundler", + "skipLibCheck": false, + "resolveJsonModule": true, + "strict": true, + "noImplicitAny": true, + "esModuleInterop": true + }, + "include": ["./src/ts/**/*", "./types.d.ts"] +} diff --git a/packages/viewer-charts/types.d.ts b/packages/viewer-charts/types.d.ts new file mode 100644 index 0000000..208ab49 --- /dev/null +++ b/packages/viewer-charts/types.d.ts @@ -0,0 +1,21 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +declare module "*.css" { + const value: string; + export default value; +} + +declare module "*.glsl" { + const value: string; + export default value; +} diff --git a/packages/viewer-datagrid/build.mjs b/packages/viewer-datagrid/build.mjs new file mode 100644 index 0000000..7583c57 --- /dev/null +++ b/packages/viewer-datagrid/build.mjs @@ -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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { NodeModulesExternal } from "@perspective-dev/esbuild-plugin/external.js"; +import { build } from "@perspective-dev/esbuild-plugin/build.js"; +import { bundle as bundleCss } from "lightningcss"; +import * as fs from "node:fs"; +import { inlineUrlVisitor } from "@perspective-dev/viewer/tools.mjs"; + +import "zx/globals"; + +const BUILD = [ + { + define: { + global: "window", + }, + entryPoints: ["src/ts/index.ts"], + plugins: [NodeModulesExternal()], + format: "esm", + loader: { + ".css": "text", + ".html": "text", + }, + outfile: "dist/esm/perspective-viewer-datagrid.js", + }, + { + define: { + global: "window", + }, + entryPoints: ["src/ts/index.ts"], + plugins: [], + format: "esm", + loader: { + ".css": "text", + ".html": "text", + }, + outfile: "dist/cdn/perspective-viewer-datagrid.js", + }, +]; + +async function compile_css() { + fs.mkdirSync("dist/css", { recursive: true }); + const { code: datagridCode } = bundleCss({ + filename: "./src/css/perspective-viewer-datagrid.css", + minify: true, + visitor: inlineUrlVisitor("./src/css/perspective-viewer-datagrid.css"), + }); + fs.writeFileSync("dist/css/perspective-viewer-datagrid.css", datagridCode); + + const { code: toolbarCode } = bundleCss({ + filename: "./src/css/toolbar.css", + minify: true, + visitor: inlineUrlVisitor("./src/css/toolbar.css"), + }); + fs.writeFileSync( + "dist/css/perspective-viewer-datagrid-toolbar.css", + toolbarCode, + ); +} + +async function build_all() { + await compile_css(); + await Promise.all(BUILD.map(build)).catch(() => process.exit(1)); + try { + await $`tsc --project ./tsconfig.json`.stdio( + "inherit", + "inherit", + "inherit", + ); + } catch (e) { + console.error(e.stdout); + process.exit(1); + } +} + +build_all(); diff --git a/packages/viewer-datagrid/clean.mjs b/packages/viewer-datagrid/clean.mjs new file mode 100644 index 0000000..49a5253 --- /dev/null +++ b/packages/viewer-datagrid/clean.mjs @@ -0,0 +1,15 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 "node:fs"; + +fs.rmSync("dist", { recursive: true, force: true }); diff --git a/packages/viewer-datagrid/index.d.ts b/packages/viewer-datagrid/index.d.ts new file mode 100644 index 0000000..193cf50 --- /dev/null +++ b/packages/viewer-datagrid/index.d.ts @@ -0,0 +1,37 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { IPerspectiveViewerPlugin } from "@perspective-dev/viewer"; + +declare global { + interface CustomElementRegistry { + get( + tagName: "perspective-viewer-datagrid", + ): typeof HTMLPerspectiveViewerDatagridPluginElement; + + // TODO is this needed? + whenDefined(tagName: "perspective-viewer-datagrid"): Promise; + } +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +interface HTMLPerspectiveViewerDatagridPluginElement + extends IPerspectiveViewerPlugin {} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export declare class HTMLPerspectiveViewerDatagridPluginElement + extends HTMLElement + implements IPerspectiveViewerPlugin +{ + // Controls whether datagrid renders its children into a shadow DOM tree + public static renderTarget: "shadow" | "light"; +} diff --git a/packages/viewer-datagrid/package.json b/packages/viewer-datagrid/package.json new file mode 100644 index 0000000..6875818 --- /dev/null +++ b/packages/viewer-datagrid/package.json @@ -0,0 +1,48 @@ +{ + "name": "@perspective-dev/viewer-datagrid", + "version": "4.5.2", + "description": "Perspective datagrid plugin based on `regular-table`", + "unpkg": "dist/cdn/perspective-viewer-datagrid.js", + "jsdelivr": "dist/cdn/perspective-viewer-datagrid.js", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/perspective-viewer-datagrid.js" + }, + "./dist/*": "./dist/*", + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "type": "module", + "types": "dist/esm/index.d.ts", + "files": [ + "dist/**/*", + "src/**/*" + ], + "scripts": { + "build": "node ./build.mjs", + "clean": "node ./clean.mjs" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/perspective-dev/perspective" + }, + "author": "", + "license": "Apache-2.0", + "dependencies": { + "@perspective-dev/client": "workspace:", + "@perspective-dev/viewer": "workspace:", + "regular-table": "catalog:" + }, + "devDependencies": { + "lightningcss": "catalog:", + "@perspective-dev/esbuild-plugin": "workspace:", + "@perspective-dev/test": "workspace:", + "prettier": "catalog:", + "typescript": "catalog:", + "zx": "catalog:" + } +} diff --git a/packages/viewer-datagrid/src/css/mitered-headers.css b/packages/viewer-datagrid/src/css/mitered-headers.css new file mode 100644 index 0000000..0c8a1f6 --- /dev/null +++ b/packages/viewer-datagrid/src/css/mitered-headers.css @@ -0,0 +1,64 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ 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). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +.psp-header-border:not(.psp-is-top):not(.psp-header-leaf) { + /* right */ + box-shadow: 1px 0px var(--psp-inactive--border-color, #8b868045); +} + +.psp-header-group { + /* bottom */ + box-shadow: 0px 10px 0 -9px var(--psp-inactive--border-color, #8b868045); +} + +.psp-is-top { + /* top-miter-right */ + box-shadow: 5px 4px 0px -4px var(--psp-inactive--border-color, #8b868045); +} + +.psp-is-top.psp-header-group:not(.psp-header-group-corner) { + /* top-miter-right and bottom */ + box-shadow: + 5px 4px 0px -4px var(--psp-inactive--border-color, #8b868045), + 0px 10px 0 -9px var(--psp-inactive--border-color, #8b868045); +} + +.psp-header-border.psp-header-group { + &:not(.psp-is-top):not(.psp-header-group-corner) { + /* right and bottom */ + box-shadow: + 1px 0px var(--psp-inactive--border-color, #8b868045), + 0px 10px 0 -9px var(--psp-inactive--border-color, #8b868045); + } +} + +perspective-viewer[settings] + tr.rt-autosize + .psp-header-leaf.psp-header-border:not(.psp-menu-enabled) { + box-shadow: 1px 0px var(--psp-inactive--border-color, #8b868045); +} + +:host-context(perspective-viewer[settings]) + tr.rt-autosize + .psp-header-leaf.psp-header-border:not(.psp-menu-enabled) { + box-shadow: 1px 0px var(--psp-inactive--border-color, #8b868045); +} + +.psp-header-leaf.psp-header-border { + /* bottom-miter-right */ + box-shadow: 5px -4px 0px -4px var(--psp-inactive--border-color, #8b868045); +} + +tr:only-child th { + box-shadow: none !important; +} diff --git a/packages/viewer-datagrid/src/css/perspective-viewer-datagrid.css b/packages/viewer-datagrid/src/css/perspective-viewer-datagrid.css new file mode 100644 index 0000000..9dde8b4 --- /dev/null +++ b/packages/viewer-datagrid/src/css/perspective-viewer-datagrid.css @@ -0,0 +1,6 @@ +@import "./pro.css"; +@import "./mitered-headers.css"; +@import "./row-hover.css"; +@import "./sub-cell-scroll.css"; +@import "./scrollbar.css"; +@import "./regular_table.css"; diff --git a/packages/viewer-datagrid/src/css/pro.css b/packages/viewer-datagrid/src/css/pro.css new file mode 100644 index 0000000..3f88644 --- /dev/null +++ b/packages/viewer-datagrid/src/css/pro.css @@ -0,0 +1,197 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ 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). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +regular-table { + padding: 0; + margin: 12px 0 0 12px; + font-family: inherit; + --psp-datagrid--hover--border-color: var(--psp-inactive--color, #c5c9d080); + + div[tabindex] { + outline: none; + } + + & > div { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; + } + + th { + text-align: center; + } + + /* Header groups should overflow and not contribute to auto-sizing. */ + thead tr:not(.rt-autosize) th { + overflow: hidden; + max-width: 0px; + } + + thead tr:last-child .rt-float, + tbody .rt-float { + text-align: right; + } + + thead .rt-integer, + tbody .rt-integer { + text-align: right; + } + + tbody th { + text-align: left; + } + + span.rt-tree-container { + display: flex; + align-items: center; + height: 100%; + } + + thead .rt-string, + tbody .rt-string, + thead .rt-date, + tbody .rt-date, + thead .rt-datetime, + tbody .rt-datetime { + text-align: left; + } + + /* frozen rows */ + + thead tr:last-child th { + border-bottom: 1px solid #8b868045; + } + + tbody tr:first-child td, + tbody tr:first-child th { + border-top: 1px solid transparent !important; + } + + th { + position: relative; + } + + tr th span.rt-tree-group { + margin-left: 5px; + margin-right: 15px; + border-left: 1px solid #eee; + height: 100%; + } + + td, + th { + white-space: nowrap; + padding-right: 5px; + padding-left: 5px; + padding-top: 0px; + padding-bottom: 0px; + height: var(--psp-datagrid--row--height, 19px); + } + + table * { + box-sizing: border-box; + } + + table { + position: absolute; + color: #666; + outline: none; + } + + span.rt-row-header-icon { + color: #aaa; + padding-right: 4px; + font-family: var(--psp-button--font-family); + } + + span.rt-column-header-icon { + font-size: 10px; + padding-left: 3px; + display: inline-block; + width: 10px; + font-family: var(--psp-button--font-family); + } + + span.rt-row-header-icon:hover { + color: #1a7da1; + text-shadow: 0px 0px 3px #1a7da1; + } + + .rt-selected td { + background-color: #eee; + } + + .rt-cell-clip { + overflow: hidden; + text-overflow: ellipsis; + } + + /* OPTIONAL zebra striping */ + + /* @zebra-stripe-color: rgb(238,238,238); */ + + /* tbody tr:nth-child(even) td:not(.rt-group-header) { */ + /* background: @zebra-stripe-color; */ + /* } */ + + /* tbody tr:nth-child(even) span.rt-group-name { */ + /* background: @zebra-stripe-color; */ + /* } */ + + td span.rt-group-name, + th span.rt-group-name { + margin-right: -5px; + padding-right: 5px; + padding-left: 8px; + flex: 1; + height: 100%; + } + + th span.rt-group-name { + text-align: left; + } + + td th span.rt-group-leaf, + th span.rt-group-leaf { + margin-left: 16px; + height: 100%; + } + + .rt-column-resize { + height: 100%; + width: 10px; + position: absolute; + top: 0; + right: 0; + cursor: col-resize; + } + + a { + color: var(--psp-datagrid--pos-cell--color); + } + + a:visited { + color: var(--psp-active--color); + } + + td.psp-null:after, + th.psp-null:after { + content: var(--psp-label--null--content, "-"); + color: var(--psp-label--null--color, inherit); + font-size: var(--psp-label--null--font-size, inherit); + vertical-align: middle; + } +} diff --git a/packages/viewer-datagrid/src/css/regular_table.css b/packages/viewer-datagrid/src/css/regular_table.css new file mode 100644 index 0000000..9434927 --- /dev/null +++ b/packages/viewer-datagrid/src/css/regular_table.css @@ -0,0 +1,625 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ 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). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ +/* + +/* Row Selection */ + +/* // 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; */ +/* } */ +/* } */ +/* } */ + +perspective-viewer, +:host { + .psp-menu-enabled { + padding: 0 6px; + font-size: 8px; + border-radius: 3px 3px 0 0; + } + + .psp-menu-enabled:hover, + tr:not(.rt-autosize) th.psp-menu-open { + color: var(--psp--background-color); + background-color: var(--psp--color); + border-bottom-color: var(--psp--color); + cursor: pointer; + } + + tr:not(.rt-autosize) th.psp-menu-open { + &:before { + background-repeat: no-repeat; + background-color: var(--psp--color); + content: ""; + display: inline-block; + -webkit-mask-size: cover; + mask-size: cover; + height: 8px; + width: 10px; + -webkit-mask-image: var(--psp-icon--column-settings--mask-image); + mask-image: var(--psp-icon--column-settings--mask-image); + margin-right: 4px; + background-color: var(--psp--background-color); + } + } + + td.psp-menu-open { + box-shadow: + inset -2px 0px 0px var(--psp--color), + inset 2px 0px 0px var(--psp--color); + } + + tr:first-child td.psp-menu-open { + border-top-color: var(--psp--color) !important; + } + + tr:last-child td.psp-menu-open { + box-shadow: + inset -2px 0px 0px var(--psp--color), + inset 2px 0px 0px var(--psp--color), + inset 0px -2px 0px var(--psp--color); + } +} + +perspective-viewer, +:host-context(perspective-viewer) { + regular-table table thead tr:last-child th { + border-bottom-width: 1px; + border-bottom-color: var(--psp-inactive--border-color, #8b868045); + } +} + +.psp-sort-enabled:hover { + cursor: pointer; +} + +regular-table.flat-group-rollup-mode.vertical-row-headers + th.psp-tree-label:not(:last-of-type) { + writing-mode: vertical-lr; +} + +.psp-select-region-inactive, +:hover .psp-select-region-inactive, +:hover th.psp-tree-leaf.psp-select-region-inactive, +:hover th.psp-tree-label.psp-select-region-inactive { + background-color: var(--psp-inactive--color) !important; + color: var(--psp--background-color) !important; + border-color: var(--psp--background-color) !important; +} + +.psp-error { + color: red; +} + +td:focus { + outline: #666; + outline-style: dotted; + outline-width: 1px; +} + +perspective-viewer.dragging, +:host-context(perspective-viewer.dragging) { + regular-table { + pointer-events: none; + } +} + +.psp-header-border:last-child { + border-right-width: 0px; +} + +.psp-header-sort-desc:after { + background-repeat: no-repeat; + background-color: var(--psp--color); + content: ""; + display: inline-block; + -webkit-mask-size: cover; + mask-size: cover; + width: 14px; + height: 11px; + margin-left: 4px; + -webkit-mask-image: var(--psp-icon--sort-desc--mask-image); + mask-image: var(--psp-icon--sort-desc--mask-image); +} + +.psp-header-sort-asc:after { + background-repeat: no-repeat; + background-color: var(--psp--color); + content: ""; + display: inline-block; + -webkit-mask-size: cover; + mask-size: cover; + width: 14px; + height: 11px; + margin-left: 4px; + -webkit-mask-image: var(--psp-icon--sort-asc--mask-image); + mask-image: var(--psp-icon--sort-asc--mask-image); +} + +.psp-header-sort-col-desc:after { + background-repeat: no-repeat; + background-color: var(--psp--color); + content: ""; + display: inline-block; + -webkit-mask-size: cover; + mask-size: cover; + width: 14px; + height: 11px; + margin-left: 4px; + -webkit-mask-image: var(--psp-icon--sort-col-desc--mask-image); + mask-image: var(--psp-icon--sort-col-desc--mask-image); +} + +.psp-header-sort-col-asc:after { + background-repeat: no-repeat; + background-color: var(--psp--color); + content: ""; + display: inline-block; + -webkit-mask-size: cover; + mask-size: cover; + width: 14px; + height: 11px; + margin-left: 4px; + -webkit-mask-image: var(--psp-icon--sort-col-asc--mask-image); + mask-image: var(--psp-icon--sort-col-asc--mask-image); +} + +.psp-header-sort-abs-desc:after { + background-repeat: no-repeat; + background-color: var(--psp--color); + content: ""; + display: inline-block; + -webkit-mask-size: cover; + mask-size: cover; + width: 14px; + height: 11px; + margin-left: 4px; + -webkit-mask-image: var(--psp-icon--sort-abs-desc--mask-image); + mask-image: var(--psp-icon--sort-abs-desc--mask-image); +} + +.psp-header-sort-abs-asc:after { + background-repeat: no-repeat; + background-color: var(--psp--color); + content: ""; + display: inline-block; + -webkit-mask-size: cover; + mask-size: cover; + width: 14px; + height: 11px; + margin-left: 4px; + -webkit-mask-image: var(--psp-icon--sort-abs-asc--mask-image); + mask-image: var(--psp-icon--sort-abs-asc--mask-image); +} + +.psp-header-sort-abs-col-desc:after { + background-repeat: no-repeat; + background-color: var(--psp--color); + content: ""; + display: inline-block; + -webkit-mask-size: cover; + mask-size: cover; + width: 14px; + height: 11px; + margin-left: 4px; + -webkit-mask-image: var(--psp-icon--sort-abs-col-desc--mask-image); + mask-image: var(--psp-icon--sort-abs-col-desc--mask-image); +} + +.psp-header-sort-abs-col-asc:after { + background-repeat: no-repeat; + background-color: var(--psp--color); + content: ""; + display: inline-block; + -webkit-mask-size: cover; + mask-size: cover; + width: 14px; + height: 11px; + margin-left: 4px; + -webkit-mask-image: var(--psp-icon--sort-abs-col-asc--mask-image); + mask-image: var(--psp-icon--sort-abs-col-asc--mask-image); +} + +/* Shift-key affordance: while the user holds Shift on the host + * ``, sort-arrow mask-image icons and tree + * expand/collapse carets recolor to advertise their Shift-modified actions + * (abs-sort and depth-level expand/collapse). Both `:host-context` (shadow + * render target) and the bare selector (light render target) are emitted to + * cover the runtime branch in datagrid.ts. */ +:host-context(perspective-viewer.shift-active) + :is( + .psp-header-sort-asc, + .psp-header-sort-desc, + .psp-header-sort-col-asc, + .psp-header-sort-col-desc + ):after, +perspective-viewer.shift-active + :is( + .psp-header-sort-asc, + .psp-header-sort-desc, + .psp-header-sort-col-asc, + .psp-header-sort-col-desc + ):after { + background-color: var( + --shift-active--color, + var(--psp-datagrid--pos-cell--color, #1078d1) + ); +} + +:host-context(perspective-viewer.shift-active) + :is(.psp-tree-label-expand, .psp-tree-label-collapse):before, +perspective-viewer.shift-active + :is(.psp-tree-label-expand, .psp-tree-label-collapse):before { + color: var( + --shift-active--color, + var(--psp-datagrid--pos-cell--color, #1078d1) + ); +} + +tbody th:last-of-type { + border-right: 1px solid var(--psp-inactive--border-color, #8b868045); + overflow: hidden; + text-overflow: ellipsis; +} + +tbody th:empty { + background-image: linear-gradient( + to right, + transparent 9px, + var(--psp-inactive--border-color, #eee) 10px, + transparent 11px + ); + background-repeat: no-repeat; + min-width: 20px; + max-width: 20px; + pointer-events: none; +} + +regular-table:not(.flat-group-rollup-mode) { + .psp-tree-label { + max-width: 0px; + min-width: 0px; + } + + .psp-tree-label:before { + color: var(--psp--color); + font-family: var(--psp-button--font-family, inherit); + padding-right: 11px; + } + + .psp-tree-label-expand:before { + content: var(--psp-label--tree-expand--content, "+"); + } + + .psp-tree-label-collapse:before { + content: var(--psp-label--tree-collapse--content, "-"); + } + + .psp-tree-label-expand, + .psp-tree-label-collapse { + cursor: pointer; + } + + .psp-tree-label:hover:before { + color: var(--psp-active--color); + text-shadow: 0px 0px 5px var(--psp-active--color); + } +} + +.psp-tree-leaf { + padding-left: 24px; +} + +.psp-align-right { + text-align: right; +} + +.psp-color-mode-bar { + padding: 0 2px; +} + +.psp-color-mode-label-bar { + position: relative; + padding: 0 2px; + + .psp-bar { + isolation: isolate; + position: unset; + } + + .psp-bar:before { + color: transparent; + pointer-events: none; + content: var(--label); + display: inline-flex; + justify-content: flex-end; + align-items: center; + padding: 0 3px; + } + + .psp-bar:after { + color: var(--psp-label-bar-bg); + content: var(--label); + mix-blend-mode: difference; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: inline-flex; + justify-content: flex-end; + align-items: center; + padding: 0 5px; + } +} + +.psp-label-bar { + inset: 0; + pointer-events: none; + display: flex; + align-items: center; + justify-content: flex-end; + padding: 0px; +} + +.psp-label-bar-fill { + position: absolute; + top: 10%; + height: 80%; + background: var(--psp-label-bar-color); + pointer-events: none; +} + +.psp-label-bar-text { + position: relative; + color: var(--psp-label-bar-bg); + mix-blend-mode: difference; + pointer-events: none; +} + +.psp-align-left { + text-align: left; +} + +.psp-positive:not(:focus) { + color: var(--psp-datagrid--pos-cell--color); +} + +.psp-negative:not(:focus) { + color: var(--psp-datagrid--neg-cell--color); +} + +regular-table table tbody td { + min-width: 52px !important; +} + +.psp-is-width-override .rt-column-resize, +.rt-column-resize:hover { + border: 1px dashed #999; + border-bottom-width: 0px; + border-left-width: 0px; +} + +.boolean-editable { + cursor: pointer; +} + +regular-table table { + font-size: 1em; + user-select: none; + color: inherit; + border-collapse: separate; + + th { + font-weight: 400; + } + + td, + th { + border-color: var(--psp-inactive--border-color, #8b868045); + height: var(--psp-datagrid--row--height, 23px); + } + + .psp-header-group { + text-overflow: ellipsis; + } + + th.psp-header-leaf { + border-bottom-width: 0px; + + span { + height: var(--psp-datagrid--row--height, 23px); + min-height: var(--psp-datagrid--row--height, 23px); + } + } + + td, + th.psp-tree-label, + th.psp-tree-label, + th.psp-tree-leaf, + tbody tr:first-child th { + border-style: solid; + border-width: 0px; + border-top-width: 1px; + } + + tbody th:empty { + background-position: 0px -10px; + } + + td.psp-select-region, + th.psp-select-region { + /* transition: background-color 0.5s, color 0.5s, border-color 0.5s; */ + background-color: var(--psp--color) !important; + color: var(--psp--background-color) !important; + border-color: var(--psp--background-color) !important; + } +} + +regular-table + tbody + tr:hover + td.psp-select-region:not(.psp-select-region-inactive), +regular-table + tbody + tr:hover + + tr + td.psp-select-region:not(.psp-select-region-inactive) { + border-color: var(--psp--background-color) !important; +} + +regular-table tbody tr:hover { + td.psp-select-region.psp-menu-open { + &:not(.psp-select-region-inactive) { + box-shadow: + inset -2px 0px 0px var(--psp--background-color), + inset 2px 0px 0px var(--psp--background-color); + } + } +} + +:host-context(perspective-viewer[settings]) td.psp-select-region.psp-menu-open { + box-shadow: + inset -2px 0px 0px var(--psp--background-color), + inset 2px 0px 0px var(--psp--background-color); +} + +/* This is a design/architecture bug. When the datagrid draws during a sidepanel */ +/* collapse/uncollapse, the `settings` attribute is set to the _previous_ state */ +/* as this collapse takes a while and we dont want the new state CSS to apply */ +/* until the drawing is done. However, this causes the datagrid to draw its */ +/* _first_ frame as if its in the wrong state, as it detects sidepanel state */ +/* via HTML attribute checking. */ +regular-table + #psp-column-edit-buttons + th:not(.rt-group-corner) + span:not(.rt-column-resize):before { + content: var(--psp-datagrid--column-edit-button--content, "Edit"); +} + +perspective-viewer:not([settings]), +:host-context(perspective-viewer:not([settings])) { + regular-table #psp-column-edit-buttons:after { + content: none; + } +} + +/* Style the last row of the `` so that is has a bottom border. */ +regular-table table thead tr:last-child:after { + width: 10000px; + box-sizing: border-box; + display: block; + height: var(--psp-datagrid--row--height, 23px); + content: " "; + border-bottom: 1px solid var(--psp-inactive--border-color); +} + +/* Extend every row to the right edge of the page with a fake column. */ +regular-table table tbody tr:after { + width: 10000px; + box-sizing: border-box; + display: block; + height: var(--psp-datagrid--row--height, 23px); + content: " "; + border-top: 1px solid transparent; +} + +regular-table table tbody tr:not(:first-child):after { + border-top: 1px solid var(--psp-inactive--border-color); +} + +regular-table table tbody tr:hover:not(:first-child):after { + border-top: 1px solid var(--psp-datagrid--hover--border-color); +} + +regular-table table tbody tr:hover + tr:after { + border-top: 1px solid var(--psp-datagrid--hover--border-color); +} + +@keyframes pulse_pos { + 0% { + background-color: var( + --pulse--background-color-start, + rgba(0, 128, 255, 0.5) + ); + } + + 100% { + background-color: var( + --pulse--background-color-end, + rgba(0, 128, 255, 0) + ); + } +} + +@keyframes pulse_pos2 { + 0% { + background-color: var( + --pulse--background-color-start, + rgba(0, 128, 255, 0.5) + ); + } + + 100% { + background-color: var( + --pulse--background-color-end, + rgba(0, 128, 255, 0) + ); + } +} + +@keyframes pulse_neg { + 0% { + background-color: var( + --pulse--background-color-start, + rgba(255, 25, 0, 0.5) + ); + } + + 100% { + background-color: var( + --pulse--background-color-end, + rgba(255, 25, 0, 0) + ); + } +} + +@keyframes pulse_neg2 { + 0% { + background-color: var( + --pulse--background-color-start, + rgba(255, 25, 0, 0.5) + ); + } + + 100% { + background-color: var( + --pulse--background-color-end, + rgba(255, 25, 0, 0) + ); + } +} diff --git a/packages/viewer-datagrid/src/css/row-hover.css b/packages/viewer-datagrid/src/css/row-hover.css new file mode 100644 index 0000000..430fc65 --- /dev/null +++ b/packages/viewer-datagrid/src/css/row-hover.css @@ -0,0 +1,107 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ 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). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +regular-table { + tbody { + tr:hover + th.psp-tree-leaf:not(.psp-select-region):not( + .psp-select-region-inactive + ), + tr:hover + th.psp-tree-label:not(.psp-select-region):not( + .psp-select-region-inactive + ), + tr:hover td:not(.psp-select-region):not(.psp-select-region-inactive), + tr:hover:after { + border-color: var( + --psp-datagrid--hover--border-color, + #c5c9d080 + ) !important; + box-shadow: 0px 1px 0px + var(--psp-datagrid--hover--border-color, #c5c9d080); + + &.psp-menu-open { + box-shadow: + inset -2px 0px 0px var(--psp--color), + inset 2px 0px 0px var(--psp--color), + 0px 1px 0px + var(--psp-datagrid--hover--border-color, #c5c9d080); + } + } + + tr:last-child:hover + td:not(.psp-select-region):not( + .psp-select-region-inactive + ).psp-menu-open { + box-shadow: + inset -2px 0px 0px var(--psp--color), + inset 2px 0px 0px var(--psp--color), + inset 0px -2px 0px var(--psp--color), + 0px 1px 0px var(--psp-datagrid--hover--border-color, #c5c9d080); + } + + tr:hover + + tr + th.psp-tree-leaf:not(.psp-select-region):not( + .psp-select-region-inactive + ), + tr:hover + + tr + th.psp-tree-label:not(.psp-select-region):not( + .psp-select-region-inactive + ), + tr:hover + + tr + td:not(.psp-select-region):not(.psp-select-region-inactive) { + border-top-color: transparent; + } + + tr { + th:first-child:not(:empty), + th:first-child:empty + th:not(:empty), + th:first-child:empty ~ th:empty + th:not(:empty), + td:first-child { + border-left-width: 1px; + border-left-color: transparent; + } + + th:last-child, + td:last-child { + border-right-width: 0px; + border-right-color: transparent; + } + } + + tr:hover { + color: inherit; + + th:first-child:not(:empty), + th:first-child:empty + th:not(:empty), + th:first-child:empty ~ th:empty + th:not(:empty), + td:first-child { + border-left-color: var( + --psp-datagrid--hover--border-color, + #c5c9d080 + ) !important; + } + + th:last-child, + td:last-child { + border-right-color: var( + --psp-datagrid--hover--border-color, + #c5c9d080 + ) !important; + } + } + } +} diff --git a/packages/viewer-datagrid/src/css/scrollbar.css b/packages/viewer-datagrid/src/css/scrollbar.css new file mode 100644 index 0000000..70d6a3c --- /dev/null +++ b/packages/viewer-datagrid/src/css/scrollbar.css @@ -0,0 +1,35 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ 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). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +/* Scrollbar styling */ +regular-table { + outline: none; + + /* webkit (chrome, safari, etc) scrollbar styling */ + &::-webkit-scrollbar, + &::-webkit-scrollbar-corner { + background-color: transparent; + height: 12px; + width: 12px; + } + + &::-webkit-scrollbar-thumb { + background-clip: content-box; + background: var(--psp--color); + border: 5.5px solid var(--psp--background-color); + max-height: 50%; + max-width: 50%; + min-width: 10%; + min-height: 10%; + } +} diff --git a/packages/viewer-datagrid/src/css/sub-cell-scroll.css b/packages/viewer-datagrid/src/css/sub-cell-scroll.css new file mode 100644 index 0000000..c85944f --- /dev/null +++ b/packages/viewer-datagrid/src/css/sub-cell-scroll.css @@ -0,0 +1,83 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ 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). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +/* Handles sub-cell scrolling via CSS transform offset by variables set during */ +/* rendering. */ +perspective-viewer-datagrid:not(.sub-cell-scroll-disabled) regular-table table, +:host(:not(.sub-cell-scroll-disabled)) regular-table table { + tbody td, + thead th:not(.rt-group-corner) { + transform: translate(var(--regular-table--transform-x, 0px)); + } + + tbody { + transform: translate(0, var(--regular-table--transform-y, 0px)); + } + + tbody tr:first-child { + td, + th, + &:after { + clip-path: polygon( + 0 var(--regular-table--clip-y, 0), + 0 200%, + 200% 200%, + 200% var(--regular-table--clip-y, 0) + ); + } + } + + tbody tr td:first-of-type { + clip-path: polygon( + var(--regular-table--clip-x, 0) 0, + var(--regular-table--clip-x, 0) 200%, + 200% 200%, + 200% 0 + ); + } + + tbody tr:first-child td:first-of-type { + clip-path: polygon( + var(--regular-table--clip-x, 0) var(--regular-table--clip-y, 0), + var(--regular-table--clip-x, 0) 200%, + 200% 200%, + 200% var(--regular-table--clip-y, 0) + ); + } + + thead tr:last-child:after { + transform: translate(var(--regular-table--transform-x, 0px)); + } + + tbody tr:not(:first-child):after { + transform: translate(var(--regular-table--transform-x, 0px)); + } + + tbody tr:hover:first-child:after { + clip-path: polygon( + 0 var(--regular-table--clip-y, 0), + 0 200%, + 200% 200%, + 200% var(--regular-table--clip-y, 0) + ); + } + + thead tr th.rt-group-corner + th:not(.rt-group-corner) { + clip-path: polygon( + var(--regular-table--clip-x, 0) 0, + var(--regular-table--clip-x, 0) 200%, + 200% 200%, + 200% 0 + ); + } +} diff --git a/packages/viewer-datagrid/src/css/toolbar.css b/packages/viewer-datagrid/src/css/toolbar.css new file mode 100644 index 0000000..c8ccdbf --- /dev/null +++ b/packages/viewer-datagrid/src/css/toolbar.css @@ -0,0 +1,219 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ 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). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +:host { + position: relative; + display: block; + height: 100%; +} + +:host #container { + position: absolute; + display: flex; + flex-direction: column; + justify-content: stretch; + align-items: stretch; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +:host #toolbar { + display: flex; + align-items: stretch; + height: 100%; +} + +:host #toolbar .hover-target { + margin: 0; + display: inline-flex; + align-items: center; + cursor: pointer; + + &:hover { + box-shadow: + -4px 0 0 var(--psp--color), + 4px 0 0 var(--psp--color); + background-color: var(--psp--color); + } +} + +:host #slot-container { + flex: 1; + position: relative; +} + +#scroll_lock.lock-scroll:before { + -webkit-mask-image: var(--psp-toolbar-scroll-lock-active--content); +} + +#scroll_lock:before { + -webkit-mask-image: var(--psp-toolbar-scroll-lock--content); +} + +#select_mode:before { + content: "highlight_alt"; +} + +#edit_mode:before { + -webkit-mask-image: ""; +} + +#edit_mode[data-edit-mode="READ_ONLY"]:before { + -webkit-mask-image: var(--psp-toolbar-edit-mode-read-only--content); +} + +#edit_mode[data-edit-mode="EDIT"]:before { + -webkit-mask-image: var(--psp-toolbar-edit-mode-edit--content); +} + +:host(.aggregated) #toolbar #edit_mode[data-edit-mode="EDIT"]:before { + -webkit-mask-image: var(--psp-toolbar-edit-mode-read-only--content); +} + +#edit_mode[data-edit-mode="SELECT_ROW"]:before { + -webkit-mask-image: var(--psp-toolbar-edit-mode-select-row--content); +} + +#edit_mode[data-edit-mode="SELECT_COLUMN"]:before { + -webkit-mask-image: var(--psp-toolbar-edit-mode-select-column--content); +} + +#edit_mode[data-edit-mode="SELECT_REGION"]:before { + -webkit-mask-image: var(--psp-toolbar-edit-mode-select-region--content); +} + +#edit_mode[data-edit-mode="SELECT_ROW_TREE"]:before { + -webkit-mask-image: var(--psp-toolbar-edit-mode-select-row-tree--content); +} + +/* #edit_mode span:before { */ +/* content: var(--edit-mode-toggle--content, "N/A"); */ +/* } */ + +#edit_mode[data-edit-mode="READ_ONLY"] span:before { + content: var(--psp-label--edit-mode-read-only--content, "Read Only"); +} + +#edit_mode[data-edit-mode="EDIT"] span:before { + content: var(--psp-label--edit-mode-edit--content, "Editable"); +} + +#edit_mode[data-edit-mode="SELECT_ROW"] span:before { + content: var(--psp-label--edit-mode-select-row--content, "Row Select"); +} + +#edit_mode[data-edit-mode="SELECT_COLUMN"] span:before { + content: var( + --psp-label--edit-mode-select-column--content, + "Column Select" + ); +} + +#edit_mode[data-edit-mode="SELECT_REGION"] span:before { + content: var( + --psp-label--edit-mode-select-region--content, + "Region Select" + ); +} + +#edit_mode[data-edit-mode="SELECT_ROW_TREE"] span:before { + content: var( + --psp-label--edit-mode-select-row-tree--content, + "Tree Select" + ); +} + +#scroll_lock span:before { + content: var(--psp-label--scroll-lock-toggle--content, "Free Scroll"); +} + +#scroll_lock.lock-scroll span:before { + content: var(--psp-label--scroll-lock-alt-toggle--content, "Align Scroll"); +} + +/* The icon. */ +.button:before { + width: 21px; + height: 21px; + content: ""; + -webkit-mask-size: cover; + mask-size: cover; + background-color: var(--psp--color); +} + +.button.editable:before, +.button.lock-scroll:before { + color: inherit; +} + +.button { + display: inline-flex; + justify-content: center; + align-items: center; + user-select: none; + padding: 0 5px; + border: 1px solid transparent; + border-radius: 3px; + border: 1px solid transparent; + box-sizing: border-box; + display: inline-flex; + font-size: var(--label--font-size, 0.75em); + height: 22px; + user-select: none; + white-space: nowrap; + width: 37px; +} + +.button > span { + display: none; + margin: 0; + padding: 0; +} + +.hover-target:focus-within .button, +.hover-target:hover .button { + position: relative; + background-color: var(--psp--color); + color: var(--psp--background-color); + opacity: 1; + display: flex; + align-items: center; + cursor: pointer; +} + +.hover-target:focus-within .button:before, +.hover-target:hover .button:before { + background-color: var(--psp--background-color); +} + +/* The label. */ +.hover-target:focus-within .button > span, +.hover-target:hover .button > span { + display: block; + position: absolute; + top: calc(100% + 3px); + left: 50%; + translate: -50%; + margin: 0; + padding: 5px; + height: auto; + white-space: pre-wrap; + line-height: 1; + font-size: 9px; + background-color: var(--psp--color); + width: 35px; + text-align: center; + border-radius: 0 0 3px 3px; +} diff --git a/packages/viewer-datagrid/src/ts/color_utils.ts b/packages/viewer-datagrid/src/ts/color_utils.ts new file mode 100644 index 0000000..5db4f3a --- /dev/null +++ b/packages/viewer-datagrid/src/ts/color_utils.ts @@ -0,0 +1,234 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { ColorRecord } from "./types.js"; + +/** 8-bit sRGB color as `[r, g, b]` with each channel in `[0, 255]`. */ +export type RGB = [number, number, number]; + +/** HSL color as `[h, s, l]` with `h` in degrees `[0, 360)` and `s`, `l` in `[0, 1]`. */ +export type HSL = [number, number, number]; + +const parse_cache = new Map(); +let parse_ctx: CanvasRenderingContext2D | null = null; + +/** Parse a CSS hex color (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`). Returns `null` if `input` is not a hex literal. Alpha is ignored. */ +function parse_hex(input: string): RGB | null { + const s = input.startsWith("#") ? input.slice(1) : input; + if (s.length === 3 || s.length === 4) { + const r = parseInt(s[0] + s[0], 16); + const g = parseInt(s[1] + s[1], 16); + const b = parseInt(s[2] + s[2], 16); + if (!isNaN(r) && !isNaN(g) && !isNaN(b)) { + return [r, g, b]; + } + } else if (s.length === 6 || s.length === 8) { + const r = parseInt(s.slice(0, 2), 16); + const g = parseInt(s.slice(2, 4), 16); + const b = parseInt(s.slice(4, 6), 16); + if (!isNaN(r) && !isNaN(g) && !isNaN(b)) { + return [r, g, b]; + } + } + + return null; +} + +/** Parse a CSS `rgb()` or `rgba()` functional color. Returns `null` if `input` does not match. Alpha is ignored. */ +function parse_rgb_fn(input: string): RGB | null { + const m = input.match( + /^rgba?\(\s*([\d.]+)\s*[, ]\s*([\d.]+)\s*[, ]\s*([\d.]+)/i, + ); + if (!m) { + return null; + } + + return [ + Math.round(parseFloat(m[1])), + Math.round(parseFloat(m[2])), + Math.round(parseFloat(m[3])), + ]; +} + +/** Fallback parser that defers to the browser by assigning `input` to a 2D canvas `fillStyle` and re-reading the normalized value. Handles named colors, `hsl()`, etc. Returns `[0, 0, 0]` if the value is invalid or no canvas context is available. */ +function parse_via_canvas(input: string): RGB { + if (!parse_ctx) { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 1; + parse_ctx = canvas.getContext("2d"); + } + + if (!parse_ctx) { + return [0, 0, 0]; + } + + parse_ctx.fillStyle = "#000"; + parse_ctx.fillStyle = input; + const normalized = parse_ctx.fillStyle as string; + return parse_hex(normalized) ?? parse_rgb_fn(normalized) ?? [0, 0, 0]; +} + +/** Parse any CSS color string into an `RGB` triple. Tries hex and `rgb()` fast paths, then falls back to a canvas-based parser for named colors, `hsl()`, etc. Results are memoized per input. */ +export function parseColor(input: string): RGB { + const key = input.trim(); + const cached = parse_cache.get(key); + if (cached) { + return cached; + } + + const rgb = parse_hex(key) ?? parse_rgb_fn(key) ?? parse_via_canvas(key); + parse_cache.set(key, rgb); + return rgb; +} + +/** Format a single channel as a clamped, zero-padded two-digit hex byte. */ +function toHex(c: number): string { + const v = Math.max(0, Math.min(255, Math.round(c))); + return v.toString(16).padStart(2, "0"); +} + +/** Format an `RGB` triple as a `#rrggbb` hex string. Channels are clamped to `[0, 255]`. */ +export function rgbToHex([r, g, b]: RGB): string { + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; +} + +/** Convert sRGB to HSL. Output `h` is in degrees `[0, 360)`; `s` and `l` are in `[0, 1]`. */ +export function rgbToHsl([r, g, b]: RGB): HSL { + const rn = r / 255, + gn = g / 255, + bn = b / 255; + const max = Math.max(rn, gn, bn); + const min = Math.min(rn, gn, bn); + const l = (max + min) / 2; + const d = max - min; + let h = 0; + let s = 0; + if (d !== 0) { + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + if (max === rn) { + h = ((gn - bn) / d + (gn < bn ? 6 : 0)) * 60; + } else if (max === gn) { + h = ((bn - rn) / d + 2) * 60; + } else { + h = ((rn - gn) / d + 4) * 60; + } + } + + return [h, s, l]; +} + +/** Convert HSL to sRGB. `h` is wrapped into `[0, 360)`; `s` and `l` are expected in `[0, 1]`. Output channels are rounded to integers in `[0, 255]`. */ +export function hslToRgb([h, s, l]: HSL): RGB { + const hn = (((h % 360) + 360) % 360) / 360; + if (s === 0) { + const v = Math.round(l * 255); + return [v, v, v]; + } + + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + const f = (t: number): number => { + if (t < 0) { + t += 1; + } + + if (t > 1) { + t -= 1; + } + + if (t < 1 / 6) { + return p + (q - p) * 6 * t; + } + + if (t < 1 / 2) { + return q; + } + + if (t < 2 / 3) { + return p + (q - p) * (2 / 3 - t) * 6; + } + + return p; + }; + + return [ + Math.round(f(hn + 1 / 3) * 255), + Math.round(f(hn) * 255), + Math.round(f(hn - 1 / 3) * 255), + ]; +} + +/** + * Blend two `RGB` colors using LRGB (gamma-naive linear) interpolation, + * matching chroma-js's default `mix` mode. `f` is the weight of `b` in `[0, 1]` + * (0 returns `a`, 1 returns `b`). + */ +export function mixRgb(a: RGB, b: RGB, f = 0.5): RGB { + return [ + Math.round(Math.sqrt(a[0] * a[0] * (1 - f) + b[0] * b[0] * f)), + Math.round(Math.sqrt(a[1] * a[1] * (1 - f) + b[1] * b[1] * f)), + Math.round(Math.sqrt(a[2] * a[2] * (1 - f) + b[2] * b[2] * f)), + ]; +} + +/** 50/50 LRGB blend of CSS color `a` with `RGB`-ish triple `b`, returned as `#rrggbb`. */ +export function blend(a: string, b: number[]): string { + return rgbToHex(mixRgb(parseColor(a), [b[0], b[1], b[2]], 0.5)); +} + +/** Composite a premultiplied-style `RGBA` cell color over `source` (default white) and return the resulting opaque `RGB`. Used to flatten heatmap cells against the background. */ +export function rgbaToRgb( + [r, g, b, a]: [number, number, number, number], + source: [number, number, number] = [255, 255, 255], +): [number, number, number] { + function f(i: number, c: number): number { + return ((1 - a) * (source[i] / 255) + a * (c / 255)) * 255; + } + + return [f(0, r), f(1, g), f(2, b)]; +} + +/** Pick a readable foreground (`#161616` or `#ffffff`) for the given background using a perceptual luminance threshold. */ +export function infer_foreground_from_background([r, g, b]: [ + number, + number, + number, +]): string { + // TODO Implement dark/light themes. + return Math.sqrt(r * r * 0.299 + g * g * 0.587 + b * b * 0.114) > 130 + ? "#161616" + : "#ffffff"; +} + +/** Build a CSS `linear-gradient` that fans `rgb` ±15° in hue, used as the negative-value swatch in column color pickers. */ +function make_gradient(rgb: RGB): string { + const [h, s, l] = rgbToHsl(rgb); + const [r, g, b] = rgb; + const [r1, g1, b1] = hslToRgb([h - 15, s, l]); + const [r2, g2, b2] = hslToRgb([h + 15, s, l]); + return `linear-gradient(to right top,rgb(${r1},${g1},${b1}),rgb(${r},${g},${b}) 50%,rgb(${r2},${g2},${b2}))`; +} + +/** Precompute the tuple of derived color strings (RGB channels, gradient, opaque/transparent rgba) cached on the model for a configured plugin color. */ +export function make_color_record(color: string): ColorRecord { + const rgb = parseColor(color); + const _neg_grad = make_gradient(rgb); + return [ + color, + rgb[0], + rgb[1], + rgb[2], + _neg_grad, + `rgba(${rgb[0]},${rgb[1]},${rgb[2]},1)`, + `rgba(${rgb[0]},${rgb[1]},${rgb[2]},0)`, + ]; +} diff --git a/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts b/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts new file mode 100644 index 0000000..183abf2 --- /dev/null +++ b/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts @@ -0,0 +1,269 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { PRIVATE_PLUGIN_SYMBOL } from "../model/index.js"; +import { activate } from "../plugin/activate.js"; +import { restore } from "../plugin/restore.js"; +import { save } from "../plugin/save.js"; +import { draw } from "../plugin/draw.js"; +import column_config_schema, { + ColumnConfigSchema, +} from "../plugin/column_config_schema.js"; +import datagridStyles from "../../../dist/css/perspective-viewer-datagrid.css"; +import { format_raw } from "../data_listener/format_cell.js"; +import { sourceColumn } from "@perspective-dev/viewer/src/ts/column-format.js"; + +import type { View, ViewWindow } from "@perspective-dev/client"; +import type { + IPerspectiveViewerPlugin, + PluginStaticConfig, +} from "@perspective-dev/viewer"; +import type { + DatagridModel, + DatagridToolbarElement, + EditMode, + DatagridPluginConfig, + ColumnsConfig, +} from "../types.js"; +import { RegularTableElement } from "regular-table"; + +type RenderTarget = "shadow" | "light"; + +/** + * The custom element class for this plugin. The interface methods for this + */ +export class HTMLPerspectiveViewerDatagridPluginElement + extends HTMLElement + implements IPerspectiveViewerPlugin +{ + private static _global_stylesheet_installed: boolean = false; + private static _sheet: CSSStyleSheet | undefined; + + // Determines whether this datagrid renders in the light DOM. This will + // break style encapsulation and may cause inconsistent behavior. + static renderTarget: RenderTarget = + window.CSS?.supports && + window.CSS?.supports("selector(:host-context(foo))") + ? "shadow" + : "light"; + + regular_table: RegularTableElement; + model?: DatagridModel; + _toolbar?: DatagridToolbarElement; + _edit_button?: HTMLElement; + _scroll_lock?: HTMLElement; + _is_scroll_lock: boolean; + _edit_mode: EditMode; + _initialized?: boolean; + _reset_scroll_top?: boolean; + _reset_scroll_left?: boolean; + _reset_select?: boolean; + _reset_column_size?: boolean; + + constructor() { + super(); + this.regular_table = document.createElement( + "regular-table", + ) as RegularTableElement; + this.regular_table.part = "regular-table"; + this._is_scroll_lock = false; + this._edit_mode = "READ_ONLY"; + const Elem = HTMLPerspectiveViewerDatagridPluginElement; + if (!Elem._sheet) { + Elem._sheet = new CSSStyleSheet(); + Elem._sheet.replaceSync(datagridStyles); + } + + if (Elem.renderTarget === "shadow") { + const shadow = this.attachShadow({ mode: "open" }); + shadow.adoptedStyleSheets.push(Elem._sheet); + } else if ( + Elem.renderTarget === "light" && + !Elem._global_stylesheet_installed + ) { + Elem._global_stylesheet_installed = true; + document.adoptedStyleSheets.push(Elem._sheet); + } + } + + connectedCallback(): void { + if (!this._toolbar) { + this._toolbar = document.createElement( + "perspective-viewer-datagrid-toolbar", + ) as DatagridToolbarElement; + } + + const parent = this.parentElement; + if (parent) { + parent.appendChild(this._toolbar); + } + } + + disconnectedCallback(): void { + this._toolbar?.parentElement?.removeChild?.(this._toolbar); + } + + async activate(view: View): Promise { + return await activate.call(this, view); + } + + get_static_config(): PluginStaticConfig { + return { + name: "Datagrid", + category: "Basic", + select_mode: "toggle", + config_column_names: ["Columns"], + group_rollup_modes: ["rollup", "flat", "total"], + // Higher priority than the chart plugins so the Datagrid is + // loaded by default. + priority: 1, + can_render_column_styles: true, + }; + } + + plugin_config_schema(): ColumnConfigSchema { + const fields = []; + fields.push({ + kind: "Enum", + key: "edit_mode", + default: "READ_ONLY", + variants: [ + { value: "EDIT", label: "Edit" }, + { value: "READ_ONLY", label: "Read-only" }, + { value: "SELECT_ROW", label: "Row Select" }, + { value: "SELECT_COLUMN", label: "Column Select" }, + { value: "SELECT_REGION", label: "Region Select" }, + { value: "SELECT_ROW_TREE", label: "Tree Select" }, + ], + }); + + fields.push({ + kind: "Bool", + key: "scroll_lock", + default: false, + }); + + return { + fields, + }; + } + + column_config_schema( + type: string, + group: string | undefined, + column_name: string, + current_value: Record | null, + viewer_config?: { group_by?: string[]; group_rollup_mode?: string }, + column_stats?: { abs_max: number }, + ): ColumnConfigSchema { + return column_config_schema.call( + this, + type as any, + group, + column_name, + current_value, + viewer_config, + column_stats, + ); + } + + async draw(view: View): Promise { + return await draw.call(this, view); + } + + async update(view: View): Promise { + if (this.model === undefined) { + await this.draw(view); + } else if (this.model._config.split_by?.length > 0) { + const dimensions = await view.dimensions(); + this.model._num_rows = dimensions.num_view_rows; + await this.regular_table.draw(); + } else { + this.model._num_rows = await view.num_rows(); + await this.regular_table.draw(); + } + } + + async render(view: View, viewport?: ViewWindow): Promise { + const json = await view.to_columns(viewport as any); + const cols = await view.column_paths(viewport as any); + + const nrows = + viewport?.end_row !== undefined && + viewport?.end_row !== null && + viewport?.start_row !== undefined && + viewport?.start_row !== null + ? viewport.end_row - viewport.start_row + : await view.num_rows(); + + let out = ""; + for (let ridx = 0; ridx < nrows; ridx++) { + for (const col_name of cols) { + const col = (json as Record)[col_name]; + const type = this.model!._schema[col_name]; + const pluginConfig = (this.regular_table as any)[ + PRIVATE_PLUGIN_SYMBOL + ] as ColumnsConfig | undefined; + const columnName = sourceColumn(col_name); + const formatter = format_raw( + type, + pluginConfig?.[columnName] || {}, + ); + + if (formatter) { + out += formatter.format(col[ridx]) + "\t"; + } else { + out += col[ridx] + "\t"; + } + } + + out += "\n"; + } + + return out.trim(); + } + + async resize(_view: View): Promise { + if (!this.isConnected || this.offsetParent == null) { + return; + } + + if (this._initialized) { + await this.regular_table.draw(); + } + } + + async clear(): Promise { + this.regular_table.resetAutoSize(); + this.regular_table.clear(); + } + + save(): any { + return save.call(this); + } + + restore(token: DatagridPluginConfig, columns_config?: ColumnsConfig): void { + return restore.call(this, token, columns_config ?? {}); + } + + restyle() {} + + delete(): void { + this.disconnectedCallback(); + this._toolbar = undefined; + if ((this.regular_table as any).table_model) { + this.regular_table.resetAutoSize(); + } + + this.regular_table.clear(); + } +} diff --git a/packages/viewer-datagrid/src/ts/custom_elements/toolbar.ts b/packages/viewer-datagrid/src/ts/custom_elements/toolbar.ts new file mode 100644 index 0000000..ec7adeb --- /dev/null +++ b/packages/viewer-datagrid/src/ts/custom_elements/toolbar.ts @@ -0,0 +1,74 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { HTMLPerspectiveViewerElement } from "@perspective-dev/viewer"; +import TOOLBAR_STYLE from "../../../dist/css/perspective-viewer-datagrid-toolbar.css"; +import { toggle_edit_mode, toggle_scroll_lock } from "../model/toolbar.js"; +import type { DatagridPluginElement } from "../types.js"; + +const stylesheet = new CSSStyleSheet(); +stylesheet.replaceSync(TOOLBAR_STYLE); + +/** + * The custom element for this plugin's toolbar, a component which displays in + * the host ``'s status bar when this plugin is active. + * In the case of Datagrid, this comprises "Editable" and "Scroll Lock" toggle + * buttons. + */ +export class HTMLPerspectiveViewerDatagridToolbarElement extends HTMLElement { + private _initialized: boolean = false; + + connectedCallback(): void { + if (this._initialized) { + return; + } + + this._initialized = true; + this.setAttribute("slot", "statusbar-extra"); + this.attachShadow({ mode: "open" }); + this.shadowRoot!.adoptedStyleSheets.push(stylesheet); + this.shadowRoot!.innerHTML = ` +
    + + + + + + + + + + +
    + `; + + const viewer = this.parentElement as HTMLPerspectiveViewerElement; + const plugin = this.previousElementSibling as DatagridPluginElement; + + plugin._scroll_lock = this.shadowRoot!.querySelector( + "#scroll_lock", + ) as HTMLElement; + plugin._scroll_lock.addEventListener("click", () => + toggle_scroll_lock.call(plugin), + ); + + plugin._edit_button = this.shadowRoot!.querySelector( + "#edit_mode", + ) as HTMLElement; + + plugin._edit_button.addEventListener("click", () => { + toggle_edit_mode.call(plugin); + plugin.regular_table.draw(); + viewer.dispatchEvent(new Event("perspective-config-update")); + }); + } +} diff --git a/packages/viewer-datagrid/src/ts/data_listener/format_cell.ts b/packages/viewer-datagrid/src/ts/data_listener/format_cell.ts new file mode 100644 index 0000000..5828a4d --- /dev/null +++ b/packages/viewer-datagrid/src/ts/data_listener/format_cell.ts @@ -0,0 +1,103 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { FormatterCache, Formatter } from "./formatter_cache.js"; +import type { DatagridModel, ColumnsConfig, ColumnConfig } from "../types.js"; +import type { ColumnType } from "@perspective-dev/client"; + +const FORMAT_CACHE = new FormatterCache(); +const MAX_BAR_WIDTH_PCT = 1; + +export function format_raw( + type: ColumnType, + value: ColumnConfig, +): Formatter | false | undefined { + return FORMAT_CACHE.get(type, value); +} + +/** + * Format a single cell's text content as the content of a `` or ``. + */ +export function format_cell( + this: DatagridModel, + title: string, + val: unknown, + plugins: ColumnsConfig = {}, + use_table_schema = false, +): string | HTMLElement | null { + if (val === null) { + return null; + } + + const type: ColumnType = ((use_table_schema && this._table_schema[title]) || + this._schema[title] || + "string") as ColumnType; + const plugin: ColumnConfig = plugins[title] || {}; + const is_numeric = type === "integer" || type === "float"; + + if ( + is_numeric && + (plugin?.number_fg_mode === "bar" || + plugin?.number_fg_mode === "label-bar") + ) { + const a = Math.max( + 0, + Math.min( + MAX_BAR_WIDTH_PCT, + Math.abs((val as number) / plugin.fg_gradient!) * + MAX_BAR_WIDTH_PCT, + ), + ); + + const anchor = (val as number) >= 0 ? "" : "justify-self:flex-end;"; + const pct = (a * 100).toFixed(2); + + if (plugin.number_fg_mode === "bar") { + const div = this._div_factory.get(); + div.className = "psp-bar"; + div.setAttribute( + "style", + `${anchor}width:${pct}%;height:80%;top:10%;pointer-events:none;background:var(--psp-label-bar-color)`, + ); + + return div; + } else { + const formatter = FORMAT_CACHE.get(type, plugin); + const label = formatter ? formatter.format(val) : (val as string); + + const div = this._div_factory.get(); + div.className = "psp-bar"; + div.setAttribute( + "style", + `--label:"${label}";${anchor}width:${pct}%;height:80%;top:10%;pointer-events:none;background:var(--psp-label-bar-color)`, + ); + return div; + } + } else if (plugin?.format === "link" && type === "string") { + const anchor = document.createElement("a"); + anchor.setAttribute("href", val as string); + anchor.setAttribute("target", "_blank"); + anchor.textContent = val as string; + return anchor; + } else if (plugin?.format === "bold" && type === "string") { + const bold = document.createElement("b"); + bold.textContent = val as string; + return bold; + } else if (plugin?.format === "italics" && type === "string") { + const italic = document.createElement("i"); + italic.textContent = val as string; + return italic; + } else { + const formatter = FORMAT_CACHE.get(type, plugin); + return formatter ? formatter.format(val) : (val as string); + } +} diff --git a/packages/viewer-datagrid/src/ts/data_listener/format_tree_header.ts b/packages/viewer-datagrid/src/ts/data_listener/format_tree_header.ts new file mode 100644 index 0000000..78e166f --- /dev/null +++ b/packages/viewer-datagrid/src/ts/data_listener/format_tree_header.ts @@ -0,0 +1,98 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { PRIVATE_PLUGIN_SYMBOL } from "../types.js"; +import { format_cell } from "./format_cell.js"; +import type { DatagridModel, RegularTable, ColumnsConfig } from "../types.js"; + +type RowHeaderCell = string | HTMLElement | { toString(): string }; + +/** + * Format a single cell of the `group_by` tree header for __ROW_PATH__ data. + */ +export function* format_tree_header_row_path( + this: DatagridModel, + paths: unknown[][] = [], + row_headers: string[], + regularTable: RegularTable, +): Generator { + const plugins: ColumnsConfig = + (regularTable as any)[PRIVATE_PLUGIN_SYMBOL] || {}; + for (const path of paths) { + const fullPath: unknown[] = ["TOTAL", ...path]; + const last = fullPath[fullPath.length - 1]; + let newPath: RowHeaderCell[] = fullPath + .slice(0, fullPath.length - 1) + .fill("") as string[]; + const formatted = format_cell.call( + this, + row_headers[newPath.length - 1], + last, + plugins, + true, + ); + + if (formatted instanceof HTMLElement) { + newPath = newPath.concat(formatted); + } else { + newPath = newPath.concat({ toString: () => formatted as string }); + } + + newPath.length = row_headers.length + 1; + yield newPath; + } +} + +export function* format_flat_header_row_path( + this: DatagridModel, + paths: unknown[][] = [], + row_headers: string[], + regularTable: RegularTable, +): Generator { + const plugins: ColumnsConfig = + (regularTable as any)[PRIVATE_PLUGIN_SYMBOL] || {}; + + for (const path of paths) { + yield path.map((part, i) => + format_cell.call(this, row_headers[i], part, plugins, true), + ) as RowHeaderCell[]; + } +} + +/** + * Format a single cell of the `group_by` tree header. + */ +export function* format_tree_header( + this: DatagridModel, + paths: unknown[][] = [], + row_headers: string[], + regularTable: RegularTable, +): Generator { + const plugins: ColumnsConfig = + (regularTable as any)[PRIVATE_PLUGIN_SYMBOL] || {}; + for (const path of paths) { + const new_path: unknown[] = [""]; + for (const idx in path) { + new_path.push( + format_cell.call( + this, + row_headers[idx], + path[idx], + plugins, + true, + ), + ); + } + + yield path; + } +} diff --git a/packages/viewer-datagrid/src/ts/data_listener/formatter_cache.ts b/packages/viewer-datagrid/src/ts/data_listener/formatter_cache.ts new file mode 100644 index 0000000..7983a44 --- /dev/null +++ b/packages/viewer-datagrid/src/ts/data_listener/formatter_cache.ts @@ -0,0 +1,104 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { ColumnType } from "@perspective-dev/client"; +import { + createDateFormatter, + createDatetimeFormatter, + createNumberFormatter, +} from "@perspective-dev/viewer/src/ts/column-format.js"; +import type { ColumnConfig } from "../types.js"; + +export interface Formatter { + format(val: unknown): string; +} + +class BooleanFormatter implements Formatter { + format(val: unknown): string { + return val ? "true" : "false"; + } +} + +// PluginConfig is a subset of ColumnConfig with the formatting properties +type PluginConfig = Pick; + +export class FormatterCache { + private _formatters: Map; + + constructor() { + this._formatters = new Map(); + } + + private create_datetime_formatter( + _type: ColumnType, + plugin: PluginConfig, + ): Intl.DateTimeFormat { + return createDatetimeFormatter(plugin.date_format); + } + + private create_date_formatter( + _type: ColumnType, + plugin: PluginConfig, + ): Intl.DateTimeFormat { + return createDateFormatter(plugin.date_format); + } + + private create_number_formatter( + type: ColumnType, + plugin: PluginConfig, + ): Intl.NumberFormat { + return createNumberFormatter(type, plugin.number_format); + } + + private create_boolean_formatter( + _type: ColumnType, + _plugin: PluginConfig, + ): Formatter { + return new BooleanFormatter(); + } + + get(type: ColumnType, plugin: PluginConfig): Formatter | false | undefined { + const formatter_key = [ + type, + ...Object.values(plugin.date_format ?? {}), + ...Object.values(plugin.number_format ?? {}), + ].join("-"); + + if (!this._formatters.has(formatter_key)) { + if (type === "date") { + this._formatters.set( + formatter_key, + this.create_date_formatter(type, plugin), + ); + } else if (type === "datetime") { + this._formatters.set( + formatter_key, + this.create_datetime_formatter(type, plugin), + ); + } else if (type === "integer" || type === "float") { + this._formatters.set( + formatter_key, + this.create_number_formatter(type, plugin), + ); + } else if (type === "boolean") { + this._formatters.set( + formatter_key, + this.create_boolean_formatter(type, plugin), + ); + } else { + this._formatters.set(formatter_key, false); + } + } + + return this._formatters.get(formatter_key); + } +} diff --git a/packages/viewer-datagrid/src/ts/data_listener/index.ts b/packages/viewer-datagrid/src/ts/data_listener/index.ts new file mode 100644 index 0000000..3f55df1 --- /dev/null +++ b/packages/viewer-datagrid/src/ts/data_listener/index.ts @@ -0,0 +1,264 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { PRIVATE_PLUGIN_SYMBOL } from "../types.js"; +import { isMetaColumn } from "../model/meta_columns.js"; +import { format_cell } from "./format_cell.js"; +import { + format_flat_header_row_path, + format_tree_header, + format_tree_header_row_path, +} from "./format_tree_header.js"; +import type { DatagridModel, RegularTable, Schema } from "../types.js"; +import type { CellScalar, DataResponse } from "regular-table/dist/esm/types.js"; +import type { ViewConfig, ViewWindow } from "@perspective-dev/client"; +import type { HTMLPerspectiveViewerElement } from "@perspective-dev/viewer"; + +interface ColumnData { + __ROW_PATH__?: unknown[][]; + __ID__?: unknown[][]; + [key: string]: unknown[] | undefined; +} + +/** + * Creates a new DataListener, suitable for passing to `regular-table`'s + * `.setDataListener()` method. This should be called as a method on the + * plugin. + * + * @returns A data listener for the plugin. + */ +export function createDataListener( + viewer: HTMLPerspectiveViewerElement, +): ( + regularTable: RegularTable, + x0: number, + y0: number, + x1: number, + y1: number, +) => Promise { + let last_meta: unknown[][] | undefined; + let last_column_paths: string[] | undefined; + let last_ids: unknown[][] | undefined; + let last_reverse_ids: Map | undefined; + let last_reverse_columns: Map | undefined; + + return async function dataListener( + this: DatagridModel, + regularTable: RegularTable, + x0: number, + y0: number, + x1: number, + y1: number, + ): Promise { + let columns: ColumnData = {}; + const new_window: ViewWindow = { + start_row: y0, + start_col: x0, + end_row: y1, + end_col: x1, + id: true, + }; + + let num_columns: number | null = null; + + if (x1 - x0 > 0 && y1 - y0 > 0) { + this._is_old_viewport = + this._last_window?.start_row === y0 && + this._last_window?.end_row === y1 && + this._last_window?.start_col === x0 && + this._last_window?.end_col === x1; + + const [x, z] = await Promise.all([ + this._view.to_columns_string(new_window as any), + this._view.num_columns(), + ]); + + num_columns = z; + columns = JSON.parse(x as string) as ColumnData; + const y = Object.keys(columns); + // `isMetaColumn` covers `__ROW_PATH__`, `__ID__`, + // `__GROUPING_ID__`, and the per-level `__ROW_PATH___` + // columns the DuckDB virtual server emits inline alongside + // the JSON sidecar. Exact-match against `__ROW_PATH__` / + // `__ID__` (the previous filter) misses the per-level + // form and the virtual server's columns leak into the + // visible grid. + const new_col_paths = y.filter((x) => !isMetaColumn(x)); + + let changed_cols = false; + for (let i = 0; i < new_col_paths.length; i++) { + if ( + this._column_paths[new_window.start_col! + i] !== + new_col_paths[i] + ) { + changed_cols = true; + this._column_paths[new_window.start_col! + i] = + new_col_paths[i]; + } + } + + if (changed_cols) { + const [a, b] = await Promise.all([ + this._view.schema(), + this._view.expression_schema(), + ]); + + this._schema = { ...(a as Schema), ...(b as Schema) }; + for (let i = 0; i < new_col_paths.length; i++) { + const column_path_parts = new_col_paths[i].split("|"); + const column = + column_path_parts[this._config.split_by.length]; + + this._is_editable[i + new_window.start_col!] = + !!this._table_schema[column]; + + this._column_types[i + new_window.start_col!] = + this._schema[column]; + } + } + + this._last_window = new_window; + this._ids = + columns.__ID__ || + Array(y1 - y0) + .fill(null) + .map((_, index) => [index + y0]); + + this._reverse_columns = this._column_paths + .slice(x0, x1) + .reduce((acc, x, i) => { + acc.set(x, i); + return acc; + }, new Map()); + + this._reverse_ids = this._ids?.reduce((acc, x, i) => { + acc.set(x?.join("|"), i); + return acc; + }, new Map()); + } else { + this._div_factory.clear(); + num_columns = await this._view.num_columns(); + } + + const data: (string | HTMLElement)[][] = []; + const metadata: unknown[][] = []; + const column_headers: string[][] = []; + const column_paths: string[] = []; + + const is_settings_open = viewer.hasAttribute("settings"); + for ( + let ipath = x0; + ipath < Math.min(x1, this._column_paths.length); + ++ipath + ) { + const path = this._column_paths[ipath]; + const path_parts = path.split("|"); + const column = columns[path] || new Array(y1 - y0).fill(null); + const agg_depth = Math.min( + (regularTable as any)[PRIVATE_PLUGIN_SYMBOL]?.[ + path_parts[this._config.split_by.length] + ]?.aggregate_depth || 0, + this._config.group_by.length, + ); + + data.push( + column.map((x, i) => { + if ((columns?.__ROW_PATH__?.[i]?.length ?? 0) < agg_depth) { + return ""; + } else { + return format_cell.call( + this, + path_parts[this._config.split_by.length], + x, + (regularTable as any)[PRIVATE_PLUGIN_SYMBOL] || {}, + ) as string | HTMLElement; + } + }), + ); + + metadata.push(column as unknown[]); + if (is_settings_open) { + path_parts.push(""); + } + + column_headers.push(path_parts); + column_paths.push(path); + } + + const is_dim_call = x1 - x0 > 0 && y1 - y0 > 0; + + // Only update the last state if this is not a "phantom" call. + if (is_dim_call) { + this.last_column_paths = last_column_paths; + this.last_meta = last_meta; + this.last_ids = last_ids; + this.last_reverse_ids = last_reverse_ids; + this.last_reverse_columns = last_reverse_columns; + + last_column_paths = column_paths; + last_meta = metadata; + last_ids = this._ids; + last_reverse_ids = this._reverse_ids; + last_reverse_columns = this._reverse_columns; + } + + const is_row_path = columns.__ROW_PATH__ !== undefined; + const is_flat = this._config.group_rollup_mode === "flat"; + const row_headers = Array.from( + (is_row_path + ? is_flat + ? format_flat_header_row_path + : format_tree_header_row_path + : format_tree_header + ).call( + this, + columns.__ROW_PATH__, + this._config.group_by, + regularTable, + ), + ) as (string | HTMLElement)[][]; + + const num_row_headers = !is_dim_call + ? row_header_depth(this._config) + : row_headers[0]?.length; + + const result: DataResponse = { + num_column_headers: + this._config.split_by.length + (is_settings_open ? 2 : 1), + num_row_headers, + num_rows: this._num_rows, + num_columns, + row_headers: row_headers as CellScalar[][], // Add `HTMLElement` + column_headers, + data: data as CellScalar[][], + metadata, + column_header_merge_depth: Math.max( + 0, + this._config.split_by.length, + ), + }; + + return result; + }; +} + +function row_header_depth(config: ViewConfig) { + if (config.group_rollup_mode === "flat") { + return config.group_by.length; + } else if (config.group_rollup_mode === "total") { + return 0; + } else if (config.group_by.length === 0) { + return 0; + } else { + return config.group_by.length + 1; + } +} diff --git a/packages/viewer-datagrid/src/ts/event_handlers/click.ts b/packages/viewer-datagrid/src/ts/event_handlers/click.ts new file mode 100644 index 0000000..421fdf8 --- /dev/null +++ b/packages/viewer-datagrid/src/ts/event_handlers/click.ts @@ -0,0 +1,63 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 edit_click from "./click/edit_click.js"; +import * as edit_keydown from "./keydown/edit_keydown.js"; +import type { DatagridModel, SelectedPositionMap } from "../types.js"; +import { isEditableMode } from "../types.js"; +import { RegularTableElement } from "regular-table"; +import type { HTMLPerspectiveViewerElement } from "@perspective-dev/viewer"; + +export function createKeydownListener( + model: DatagridModel, + table: RegularTableElement, + viewer: HTMLPerspectiveViewerElement, + selected_position_map: SelectedPositionMap, +): EventListener { + return (event: Event): void => { + const keyEvent = event as KeyboardEvent; + if (model._edit_mode === "EDIT") { + if (!isEditableMode(model, viewer)) { + return; + } + + edit_keydown.keydownListener( + model, + table, + viewer, + selected_position_map, + keyEvent, + ); + } else { + console.debug( + `Mode ${model._edit_mode} for "keydown" event not yet implemented`, + ); + } + }; +} + +export function createEditClickListener( + model: DatagridModel, + table: RegularTableElement, + viewer: HTMLPerspectiveViewerElement, +): EventListener { + return (event: Event): void => { + const mouseEvent = event as MouseEvent; + if (model._edit_mode === "EDIT") { + if (!isEditableMode(model, viewer)) { + return; + } + + edit_click.clickListener(model, table, viewer, mouseEvent); + } + }; +} diff --git a/packages/viewer-datagrid/src/ts/event_handlers/click/edit_click.ts b/packages/viewer-datagrid/src/ts/event_handlers/click/edit_click.ts new file mode 100644 index 0000000..b20b008 --- /dev/null +++ b/packages/viewer-datagrid/src/ts/event_handlers/click/edit_click.ts @@ -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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { CellMetadataBody } from "regular-table/dist/esm/types.js"; +import { + type RegularTable, + type DatagridModel, + get_psp_type, +} from "../../types.js"; + +import type { HTMLPerspectiveViewerElement } from "@perspective-dev/viewer"; + +export function write_cell( + table: RegularTable, + model: DatagridModel, + active_cell: HTMLElement, +): boolean { + const meta = table.getMeta(active_cell) as CellMetadataBody; + if (!meta) { + return false; + } + + const type = model._schema[model._column_paths[meta.x!]]; + let text: string | number | boolean | null = active_cell.textContent || ""; + const id = model._ids[meta.y! - meta.y0][0]; + if (type === "float" || type === "integer") { + const parsed = parseFloat(text.replace(/,/g, "")); + if (isNaN(parsed)) { + return false; + } + + text = parsed; + } else if (type === "date" || type === "datetime") { + const parsed = Date.parse(text); + if (isNaN(parsed)) { + return false; + } + + text = parsed; + } else if (type === "boolean") { + text = text === "true" ? false : text === "false" ? true : null; + } + + const msg = { + __INDEX__: id, + [model._column_paths[meta.x]]: text, + }; + + model._table.update([msg], { port_id: model._edit_port, format: null }); + return true; +} + +export function clickListener( + model: DatagridModel, + table: RegularTable, + _viewer: HTMLPerspectiveViewerElement, + event: MouseEvent, +): void { + const meta = table.getMeta(event.target as HTMLElement); + if (meta?.type === "body" || meta?.type === "column_header") { + const is_editable2 = model._is_editable[meta.x]; + const is_bool = get_psp_type(model, meta) === "boolean"; + const is_null = (event.target as Element).classList.contains( + "psp-null", + ); + + if (is_editable2 && is_bool && !is_null) { + write_cell(table, model, event.target as HTMLElement); + } + } +} diff --git a/packages/viewer-datagrid/src/ts/event_handlers/dispatch_click.ts b/packages/viewer-datagrid/src/ts/event_handlers/dispatch_click.ts new file mode 100644 index 0000000..5578c98 --- /dev/null +++ b/packages/viewer-datagrid/src/ts/event_handlers/dispatch_click.ts @@ -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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { RegularTableElement } from "regular-table"; +import getCellConfig from "../get_cell_config.js"; +import type { DatagridModel, PerspectiveClickDetail } from "../types.js"; +import type { HTMLPerspectiveViewerElement } from "@perspective-dev/viewer"; + +export function createDispatchClickListener( + model: DatagridModel, + table: RegularTableElement, + viewer: HTMLPerspectiveViewerElement, +): EventListener { + return async (event: Event): Promise => { + const mouseEvent = event as MouseEvent; + const meta = table.getMeta(mouseEvent.target as HTMLElement); + if (!meta || meta.type !== "body") { + return; + } + + const { x, y } = meta; + const { row, column_names, config } = await getCellConfig(model, y, x); + viewer.dispatchEvent( + new CustomEvent("perspective-click", { + bubbles: true, + composed: true, + detail: { + row, + column_names, + config, + }, + }), + ); + }; +} diff --git a/packages/viewer-datagrid/src/ts/event_handlers/expand_collapse.ts b/packages/viewer-datagrid/src/ts/event_handlers/expand_collapse.ts new file mode 100644 index 0000000..009ed1b --- /dev/null +++ b/packages/viewer-datagrid/src/ts/event_handlers/expand_collapse.ts @@ -0,0 +1,48 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { RegularTable, DatagridModel } from "../types.js"; + +export async function expandCollapseHandler( + model: DatagridModel, + regularTable: RegularTable, + event: MouseEvent, +): Promise { + const meta = regularTable.getMeta(event.target as HTMLElement); + if (!meta || meta.type !== "row_header") { + return; + } + + const is_collapse = (event.target as Element).classList.contains( + "psp-tree-label-collapse", + ); + + if (event.shiftKey && is_collapse) { + model._view.set_depth( + (meta.row_header as unknown[]).filter((x) => x !== undefined) + .length - 2, + ); + } else if (event.shiftKey) { + model._view.set_depth( + (meta.row_header as unknown[]).filter((x) => x !== undefined) + .length - 1, + ); + } else if (is_collapse) { + model._view.collapse(meta.y); + } else { + model._view.expand(meta.y); + } + + model._num_rows = await model._view.num_rows(); + model._num_columns = await model._view.num_columns(); + regularTable.draw(); +} diff --git a/packages/viewer-datagrid/src/ts/event_handlers/focus.ts b/packages/viewer-datagrid/src/ts/event_handlers/focus.ts new file mode 100644 index 0000000..821ac81 --- /dev/null +++ b/packages/viewer-datagrid/src/ts/event_handlers/focus.ts @@ -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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { write_cell } from "./click/edit_click.js"; +import type { + RegularTable, + DatagridModel, + SelectedPosition, + SelectedPositionMap, +} from "../types.js"; +import { isEditableMode } from "../types.js"; +import type { HTMLPerspectiveViewerElement } from "@perspective-dev/viewer"; + +export function createFocusoutListener( + model: DatagridModel, + table: RegularTable, + viewer: HTMLPerspectiveViewerElement, + selected_position_map: SelectedPositionMap, +): EventListener { + return (event: Event): void => { + const focusEvent = event as FocusEvent; + if (isEditableMode(model, viewer) && selected_position_map.has(table)) { + const target = focusEvent.target as HTMLElement; + target.classList.remove("psp-error"); + const selectedPosition = selected_position_map.get(table)!; + selected_position_map.delete(table); + if (selectedPosition.content !== target.textContent) { + if (!write_cell(table, model, target)) { + target.textContent = selectedPosition.content || ""; + target.classList.add("psp-error"); + target.focus(); + } + } + } + }; +} + +export function createFocusinListener( + _model: DatagridModel, + table: RegularTable, + _viewer: HTMLPerspectiveViewerElement, + selected_position_map: SelectedPositionMap, +): EventListener { + return (event: Event): void => { + const focusEvent = event as FocusEvent; + const target = focusEvent.target as HTMLElement; + const meta = table.getMeta(target); + if (meta?.type === "body") { + const new_state: SelectedPosition = { + x: meta.x, + y: meta.y, + content: target.textContent || undefined, + }; + selected_position_map.set(table, new_state); + } + }; +} diff --git a/packages/viewer-datagrid/src/ts/event_handlers/header_click.ts b/packages/viewer-datagrid/src/ts/event_handlers/header_click.ts new file mode 100644 index 0000000..9f545f1 --- /dev/null +++ b/packages/viewer-datagrid/src/ts/event_handlers/header_click.ts @@ -0,0 +1,130 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { sortHandler } from "./sort.js"; +import { expandCollapseHandler } from "./expand_collapse.js"; +import type { RegularTable, DatagridModel } from "../types.js"; +import type { HTMLPerspectiveViewerElement } from "@perspective-dev/viewer"; + +export function createMousedownListener( + model: DatagridModel, + regularTable: RegularTable, + viewer: HTMLPerspectiveViewerElement, +): EventListener { + return async (event: Event): Promise => { + const mouseEvent = event as MouseEvent; + if (mouseEvent.which !== 1) { + return; + } + + let target = mouseEvent.target as HTMLElement | null; + if (target?.tagName === "A") { + return; + } + + while (target && target.tagName !== "TD" && target.tagName !== "TH") { + target = target.parentElement; + if (!target || !regularTable.contains(target)) { + return; + } + } + + if (!target) { + return; + } + + if (target.classList.contains("psp-tree-label")) { + if (model._edit_mode !== "SELECT_ROW_TREE") { + expandCollapseHandler(model, regularTable, mouseEvent); + } + + return; + } + + if (target.classList.contains("psp-menu-enabled")) { + const meta = regularTable.getMeta(target); + const column_name = + meta?.column_header?.[model._config.split_by.length]; + await viewer.toggleColumnSettings(`${column_name}`); + } else if (target.classList.contains("psp-sort-enabled")) { + sortHandler(model, regularTable, viewer, mouseEvent, target); + } + }; +} + +export function createDblclickListener( + model: DatagridModel, + regularTable: RegularTable, + viewer: HTMLPerspectiveViewerElement, +): EventListener { + return async (event: Event): Promise => { + const mouseEvent = event as MouseEvent; + if (mouseEvent.which !== 1) { + return; + } + + let target = mouseEvent.target as HTMLElement | null; + if (target?.tagName === "A") { + return; + } + + while (target && target.tagName !== "TD" && target.tagName !== "TH") { + target = target.parentElement; + if (!target || !regularTable.contains(target)) { + return; + } + } + + if (!target) { + return; + } + + if (target.classList.contains("psp-tree-label")) { + if (model._edit_mode === "SELECT_ROW_TREE") { + expandCollapseHandler(model, regularTable, mouseEvent); + } + } + }; +} + +export function createClickListener(regularTable: RegularTable): EventListener { + return (event: Event): void => { + const mouseEvent = event as MouseEvent; + if (mouseEvent.which !== 1) { + return; + } + + let target = mouseEvent.target as HTMLElement | null; + while (target && target.tagName !== "TD" && target.tagName !== "TH") { + target = target.parentElement; + if (!target || !regularTable.contains(target)) { + return; + } + } + + if (!target) { + return; + } + + if ( + target.classList.contains("psp-tree-label") && + mouseEvent.offsetX < 26 + ) { + mouseEvent.stopImmediatePropagation(); + } else if ( + target.classList.contains("psp-header-leaf") && + !target.classList.contains("psp-header-corner") + ) { + mouseEvent.stopImmediatePropagation(); + } + }; +} diff --git a/packages/viewer-datagrid/src/ts/event_handlers/keydown/edit_keydown.ts b/packages/viewer-datagrid/src/ts/event_handlers/keydown/edit_keydown.ts new file mode 100644 index 0000000..506f93f --- /dev/null +++ b/packages/viewer-datagrid/src/ts/event_handlers/keydown/edit_keydown.ts @@ -0,0 +1,219 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { focusSelectedCell } from "../../style_handlers/focus.js"; +import type { + RegularTable, + DatagridModel, + SelectedPositionMap, +} from "../../types.js"; +import type { HTMLPerspectiveViewerElement } from "@perspective-dev/viewer"; + +type AsyncMoveFunction = ( + model: DatagridModel, + table: RegularTable, + selected_position_map: SelectedPositionMap, + active_cell: HTMLElement, + dx: number, + dy: number, +) => Promise; + +function lock(body: AsyncMoveFunction): AsyncMoveFunction { + let lockPromise: Promise | undefined; + return async function ( + model: DatagridModel, + table: RegularTable, + selected_position_map: SelectedPositionMap, + active_cell: HTMLElement, + dx: number, + dy: number, + ): Promise { + if (lockPromise) { + await lockPromise; + return; + } + + let resolve: () => void; + lockPromise = new Promise((x) => (resolve = x)); + const result = await body( + model, + table, + selected_position_map, + active_cell, + dx, + dy, + ); + lockPromise = undefined; + resolve!(); + return result; + }; +} + +interface ContentEditableElement extends HTMLElement { + isContentEditable: boolean; + selectionStart?: number; +} + +function getPos(elem: ContentEditableElement): number { + if (elem.isContentEditable) { + const _range = (elem.getRootNode() as Document) + .getSelection() + ?.getRangeAt(0); + if (!_range) { + return 0; + } + + const range = _range.cloneRange(); + range.selectNodeContents(elem); + range.setEnd(_range.endContainer, _range.endOffset); + return range.toString().length; + } else { + return elem.selectionStart || 0; + } +} + +const moveSelection = lock(async function ( + model: DatagridModel, + table: RegularTable, + selected_position_map: SelectedPositionMap, + active_cell: HTMLElement, + dx: number, + dy: number, +): Promise { + const meta = table.getMeta(active_cell); + if (!meta || meta.type !== "body") { + return; + } + + const num_columns = model._column_paths.length; + const num_rows = model._num_rows; + const selected_position = selected_position_map.get(table); + if (!selected_position) { + return; + } + + if (meta.x + dx < num_columns && 0 <= meta.x + dx) { + selected_position.x = meta.x + dx; + } + + if (meta.y + dy < num_rows && 0 <= meta.y + dy) { + selected_position.y = meta.y + dy; + } + + const xmin = Math.max(meta.x0 - 10, 0); + const xmax = Math.min(meta.x0 + 10, num_columns); + const ymin = Math.max(meta.y0 - 5, 0); + const ymax = Math.min(meta.y0 + 10, num_rows); + let x = meta.x0 + dx, + y = meta.y0 + dy; + while ( + !focusSelectedCell(table, selected_position_map) && + x >= xmin && + x < xmax && + y >= ymin && + y < ymax + ) { + await table.scrollToCell(x, y); + selected_position_map.set(table, selected_position); + x += dx; + y += dy; + } +}); + +function isLastCell( + model: DatagridModel, + table: RegularTable, + target: HTMLElement, +): boolean { + const meta = table.getMeta(target); + return meta?.type === "body" && meta.y === model._num_rows - 1; +} + +export function keydownListener( + model: DatagridModel, + table: RegularTable, + _viewer: HTMLPerspectiveViewerElement, + selected_position_map: SelectedPositionMap, + event: KeyboardEvent, +): void { + const target = (table.getRootNode() as Document) + .activeElement as HTMLElement; + (event.target as HTMLElement).classList.remove("psp-error"); + switch (event.key) { + case "Enter": + event.preventDefault(); + if (isLastCell(model, table, target)) { + target.blur(); + selected_position_map.delete(table); + } else if (event.shiftKey) { + moveSelection( + model, + table, + selected_position_map, + target, + 0, + -1, + ); + } else { + moveSelection( + model, + table, + selected_position_map, + target, + 0, + 1, + ); + } + + break; + case "ArrowLeft": + if (getPos(target as ContentEditableElement) === 0) { + event.preventDefault(); + moveSelection( + model, + table, + selected_position_map, + target, + -1, + 0, + ); + } + + break; + case "ArrowUp": + event.preventDefault(); + moveSelection(model, table, selected_position_map, target, 0, -1); + break; + case "ArrowRight": + if ( + getPos(target as ContentEditableElement) === + (target.textContent?.length || 0) + ) { + event.preventDefault(); + moveSelection( + model, + table, + selected_position_map, + target, + 1, + 0, + ); + } + + break; + case "ArrowDown": + event.preventDefault(); + moveSelection(model, table, selected_position_map, target, 0, 1); + break; + default: + } +} diff --git a/packages/viewer-datagrid/src/ts/event_handlers/select_region.ts b/packages/viewer-datagrid/src/ts/event_handlers/select_region.ts new file mode 100644 index 0000000..a6f6abb --- /dev/null +++ b/packages/viewer-datagrid/src/ts/event_handlers/select_region.ts @@ -0,0 +1,461 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { RegularTableElement } from "regular-table"; +import type { + DatagridPluginElement, + EditMode, + SelectionArea, +} from "../types.js"; +import type { HTMLPerspectiveViewerElement } from "@perspective-dev/viewer"; +import type { ViewWindow } from "@perspective-dev/client"; +import type { CellMetadataBody } from "regular-table/dist/esm/types.js"; + +const MOUSE_SELECTED_AREA_CLASS = "mouse-selected-area"; + +export type OnSelectCallback = ( + area: SelectionArea, + isDeselect: boolean, +) => void; + +interface AddAreaMouseSelectionOptions { + className?: string; + selected?: SelectionArea[]; + onSelect?: OnSelectCallback; +} + +export const addAreaMouseSelection = ( + datagrid: DatagridPluginElement, + table: RegularTableElement, + { + className = MOUSE_SELECTED_AREA_CLASS, + selected = [], + onSelect, + }: AddAreaMouseSelectionOptions = {}, +): RegularTableElement => { + datagrid.model!._selection_state = { + selected_areas: selected, + dirty: true, + }; + + table.addEventListener( + "mousedown", + getMousedownListener(datagrid, table, className), + ); + + table.addEventListener( + "mouseover", + getMouseoverListener(datagrid, table, className), + ); + + table.addEventListener( + "mouseup", + getMouseupListener(datagrid, table, className, onSelect), + ); + + table.addStyleListener(() => + applyMouseAreaSelections(datagrid, table, className), + ); + + return table; +}; + +function isSingleClickMode(mode: EditMode): boolean { + return mode === "SELECT_ROW_TREE"; +} + +const getMousedownListener = + ( + datagrid: DatagridPluginElement, + table: RegularTableElement, + className: string, + ) => + (event: Event): void => { + const mouseEvent = event as MouseEvent; + if ( + mouseEvent.button === 0 && + isSelectionMode(datagrid.model!._edit_mode) + ) { + if (isSingleClickMode(datagrid.model!._edit_mode)) { + return; + } + + datagrid.model!._selection_state.CURRENT_MOUSEDOWN_COORDINATES = {}; + const meta = table.getMeta(mouseEvent.target as HTMLElement); + if ( + meta?.type === "body" && + meta.x !== undefined && + meta.y !== undefined + ) { + datagrid.model!._selection_state.CURRENT_MOUSEDOWN_COORDINATES = + { + x: meta.x, + y: meta.y, + }; + + datagrid.model!._selection_state.old_selected_areas = + datagrid.model!._selection_state.selected_areas; + datagrid.model!._selection_state.selected_areas = []; + + const start: SelectionArea = { + x0: meta.x, + x1: meta.x, + y0: meta.y, + y1: meta.y, + }; + datagrid.model!._selection_state.potential_selection = start; + applyMouseAreaSelections( + datagrid, + table, + className, + datagrid.model!._selection_state.selected_areas.concat([ + start, + ]), + ); + + return; + } + } + + datagrid.model!._selection_state.selected_areas = []; + }; + +const getMouseoverListener = + ( + datagrid: DatagridPluginElement, + table: RegularTableElement, + className: string, + ) => + (event: Event): void => { + const mouseEvent = event as MouseEvent; + const mode = datagrid.model!._edit_mode; + if (isSelectionMode(mode) && !isSingleClickMode(mode)) { + if ( + datagrid.model!._selection_state + .CURRENT_MOUSEDOWN_COORDINATES && + datagrid.model!._selection_state.CURRENT_MOUSEDOWN_COORDINATES + .x !== undefined + ) { + const meta = table.getMeta(mouseEvent.target as HTMLElement); + if ( + meta?.type === "body" && + meta.x !== undefined && + meta.y !== undefined + ) { + const potentialSelection: SelectionArea = { + x0: Math.min( + meta.x, + datagrid.model!._selection_state + .CURRENT_MOUSEDOWN_COORDINATES.x!, + ), + x1: Math.max( + meta.x, + datagrid.model!._selection_state + .CURRENT_MOUSEDOWN_COORDINATES.x!, + ), + y0: Math.min( + meta.y, + datagrid.model!._selection_state + .CURRENT_MOUSEDOWN_COORDINATES.y!, + ), + y1: Math.max( + meta.y, + datagrid.model!._selection_state + .CURRENT_MOUSEDOWN_COORDINATES.y!, + ), + }; + + datagrid.model!._selection_state.potential_selection = + potentialSelection; + + applyMouseAreaSelections( + datagrid, + table, + className, + datagrid.model!._selection_state.selected_areas.concat([ + potentialSelection, + ]), + ); + } + } + } + }; + +const getMouseupListener = + ( + datagrid: DatagridPluginElement, + table: RegularTableElement, + className: string, + onSelect?: OnSelectCallback, + ) => + (event: Event): void => { + const mouseEvent = event as MouseEvent; + const mode = datagrid.model!._edit_mode; + if (isSelectionMode(mode)) { + const meta = table.getMeta(mouseEvent.target as HTMLElement); + if (!meta) { + return; + } + + // For single-click modes (SELECT_ROW_TREE), handle toggle + if (isSingleClickMode(mode)) { + if ( + (meta.type === "body" || meta.type === "row_header") && + meta.y !== undefined && + meta.y >= 0 + ) { + const existing = + datagrid.model!._selection_state.selected_areas; + const isSameRow = + existing.length > 0 && existing[0].y0 === meta.y; + + if (isSameRow) { + // Deselect + datagrid.model!._selection_state.selected_areas = []; + datagrid.model!._selection_state.dirty = true; + applyMouseAreaSelections( + datagrid, + table, + className, + [], + ); + onSelect?.(existing[0], true); + } else { + // Select new row + const area: SelectionArea = { + x0: 0, + x1: 0, + y0: meta.y, + y1: meta.y, + }; + datagrid.model!._selection_state.selected_areas = [ + area, + ]; + datagrid.model!._selection_state.dirty = true; + applyMouseAreaSelections(datagrid, table, className); + onSelect?.(area, false); + } + } + + datagrid.model!._selection_state.CURRENT_MOUSEDOWN_COORDINATES = + {}; + datagrid.model!._selection_state.potential_selection = + undefined; + return; + } + + // Drag-based modes (SELECT_ROW, SELECT_COLUMN, SELECT_REGION) + if ( + (datagrid.model!._selection_state.old_selected_areas?.length ?? + 0) > 0 + ) { + const selected = + datagrid.model!._selection_state.old_selected_areas![0]; + if ( + selected.x0 === selected.x1 && + selected.y0 === selected.y1 && + meta?.type === "body" && + selected.x0 === meta.x && + selected.y0 === meta.y + ) { + datagrid.model!._selection_state.selected_areas = []; + datagrid.model!._selection_state.old_selected_areas = []; + datagrid.model!._selection_state.CURRENT_MOUSEDOWN_COORDINATES = + {}; + datagrid.model!._selection_state.potential_selection = + undefined; + applyMouseAreaSelections(datagrid, table, className, []); + return; + } + } + + datagrid.model!._selection_state.old_selected_areas = []; + + if ( + datagrid.model!._selection_state + .CURRENT_MOUSEDOWN_COORDINATES && + datagrid.model!._selection_state.CURRENT_MOUSEDOWN_COORDINATES + .x !== undefined && + meta?.type === "body" && + meta.x !== undefined && + meta.y !== undefined + ) { + const selection: SelectionArea = { + x0: Math.min( + meta.x, + datagrid.model!._selection_state + .CURRENT_MOUSEDOWN_COORDINATES.x!, + ), + x1: Math.max( + meta.x, + datagrid.model!._selection_state + .CURRENT_MOUSEDOWN_COORDINATES.x!, + ), + y0: Math.min( + meta.y, + datagrid.model!._selection_state + .CURRENT_MOUSEDOWN_COORDINATES.y!, + ), + y1: Math.max( + meta.y, + datagrid.model!._selection_state + .CURRENT_MOUSEDOWN_COORDINATES.y!, + ), + }; + datagrid.model!._selection_state.selected_areas.push(selection); + applyMouseAreaSelections(datagrid, table, className); + } + + datagrid.model!._selection_state.CURRENT_MOUSEDOWN_COORDINATES = {}; + datagrid.model!._selection_state.potential_selection = undefined; + } + }; + +function modeIncludesColumns(mode: EditMode): boolean { + return mode === "SELECT_COLUMN" || mode === "SELECT_REGION"; +} + +function modeIncludesRows(mode: EditMode): boolean { + return ( + mode === "SELECT_ROW" || + mode === "SELECT_REGION" || + mode === "SELECT_ROW_TREE" + ); +} + +function set_psp_selection( + viewer: HTMLPerspectiveViewerElement, + datagrid: DatagridPluginElement, + { x0, x1, y0, y1 }: SelectionArea, +): void { + const viewport: ViewWindow = {}; + const mode = datagrid.model!._edit_mode; + if (x0 !== undefined && modeIncludesColumns(mode)) { + viewport.start_col = x0; + } + + if (x1 !== undefined && modeIncludesColumns(mode)) { + viewport.end_col = x1 + 1; + } + + if (y0 !== undefined && modeIncludesRows(mode)) { + viewport.start_row = y0; + } + + if (y1 !== undefined && modeIncludesRows(mode)) { + viewport.end_row = y1 + 1; + } + + viewer.setSelection(viewport); +} + +type CellPredicate = (meta: CellMetadataBody, area: SelectionArea) => boolean; + +const SELECTION_PREDICATES: Record = { + SELECT_REGION: (m, a) => + a.x0 <= m.x && m.x <= a.x1 && a.y0 <= m.y && m.y <= a.y1, + SELECT_ROW: (m, a) => a.y0 <= m.y && m.y <= a.y1, + SELECT_ROW_TREE: (m, a) => a.y0 <= m.y && m.y <= a.y1, + SELECT_COLUMN: (m, a) => a.x0 <= m.x && m.x <= a.x1, +}; + +function isSelectionMode(mode: EditMode): boolean { + return ( + mode === "SELECT_REGION" || + mode === "SELECT_ROW" || + mode === "SELECT_COLUMN" || + mode === "SELECT_ROW_TREE" + ); +} + +export const applyMouseAreaSelections = ( + datagrid: DatagridPluginElement, + table: RegularTableElement, + className: string, + selected?: SelectionArea[], +): void => { + const mode = datagrid.model!._edit_mode; + if (isSelectionMode(mode)) { + selected = datagrid.model!._selection_state.selected_areas.slice(0); + if (datagrid.model!._selection_state.potential_selection) { + selected.push(datagrid.model!._selection_state.potential_selection); + } + + if (selected.length > 0) { + set_psp_selection( + datagrid.parentElement as HTMLPerspectiveViewerElement, + datagrid, + selected[0], + ); + + // SELECT_ROW_TREE styling is handled entirely by the + // identity-based system in body.ts, which styles both td + // and th uniformly in a single draw pass. + if (!isSingleClickMode(mode)) { + applyMouseAreaSelection(datagrid, table, selected, className); + } + } else { + ( + datagrid.parentElement as HTMLPerspectiveViewerElement + ).setSelection(); + const tds = table.querySelectorAll("tbody td"); + for (const td of tds) { + td.classList.remove(className); + } + } + } else if (datagrid.model!._selection_state.dirty) { + datagrid.model!._selection_state.dirty = false; + const cells = table.querySelectorAll("tbody td, tbody th"); + for (const cell of cells) { + cell.classList.remove(className); + } + } +}; + +const applyMouseAreaSelection = ( + datagrid: DatagridPluginElement, + table: RegularTableElement, + selected: SelectionArea[], + className: string, +): void => { + const predicate = SELECTION_PREDICATES[datagrid.model!._edit_mode]; + if (!predicate || selected.length === 0) { + return; + } + + const tds = table.querySelectorAll("tbody td"); + for (const td of tds) { + const meta = table.getMeta(td as HTMLElement); + if (!meta || meta.type !== "body") { + continue; + } + + let rendered = false; + for (const area of selected) { + if ( + area.x0 !== undefined && + area.y0 !== undefined && + area.x1 !== undefined && + area.y1 !== undefined && + predicate(meta, area) + ) { + rendered = true; + datagrid.model!._selection_state.dirty = true; + td.classList.add(className); + } + } + + if (!rendered) { + td.classList.remove(className); + } + } +}; diff --git a/packages/viewer-datagrid/src/ts/event_handlers/sort.ts b/packages/viewer-datagrid/src/ts/event_handlers/sort.ts new file mode 100644 index 0000000..bec21ee --- /dev/null +++ b/packages/viewer-datagrid/src/ts/event_handlers/sort.ts @@ -0,0 +1,116 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { RegularTableElement } from "regular-table"; +import type { DatagridModel, SortRotationOrder, SortTerm } from "../types.js"; +import type { SortDir } from "@perspective-dev/client"; +import type { HTMLPerspectiveViewerElement } from "@perspective-dev/viewer"; + +const ROW_SORT_ORDER: SortRotationOrder = { + desc: "asc", + asc: undefined, + "desc abs": "asc abs", + "asc abs": undefined, +}; + +const ROW_COL_SORT_ORDER: SortRotationOrder = { + desc: "asc", + asc: undefined, + "desc abs": "asc abs", + "asc abs": undefined, +}; + +export async function sortHandler( + model: DatagridModel, + regularTable: RegularTableElement, + viewer: HTMLPerspectiveViewerElement, + event: MouseEvent, + target: HTMLElement, +): Promise { + const meta = regularTable.getMeta(target); + if (!meta?.column_header) { + return; + } + + const column_name = meta.column_header[model._config.split_by.length]; + const sort_method = + event.ctrlKey || + (event as MouseEvent & { metaKey?: boolean }).metaKey || + event.altKey + ? append_sort + : override_sort; + + const abs = event.shiftKey; + const sort = sort_method(model, `${column_name}`, abs); + await viewer.restore({ sort }); +} + +export function append_sort( + model: DatagridModel, + column_name: string, + abs: boolean, +): SortTerm[] { + const sort: SortTerm[] = []; + let found = false; + for (const sort_term of model._config.sort) { + const [_column_name, _sort_dir] = sort_term; + if (_column_name === column_name) { + found = true; + const term = create_sort(model, column_name, _sort_dir, abs); + if (term) { + sort.push(term); + } + } else { + sort.push(sort_term); + } + } + + if (!found) { + sort.push([column_name, abs ? "desc abs" : "desc"]); + } + + return sort; +} + +export function override_sort( + model: DatagridModel, + column_name: string, + abs: boolean, +): SortTerm[] { + for (const [_column_name, _sort_dir] of model._config.sort) { + if (_column_name === column_name) { + const sort = create_sort(model, column_name, _sort_dir, abs); + return sort ? [sort] : []; + } + } + + return [[column_name, abs ? "desc abs" : "desc"]]; +} + +export function create_sort( + model: DatagridModel, + column_name: string, + sort_dir: SortDir | undefined, + _abs: boolean, +): SortTerm | undefined { + const is_col_sortable = model._config.split_by.length > 0; + const order = is_col_sortable ? ROW_COL_SORT_ORDER : ROW_SORT_ORDER; + const inc_sort_dir: SortDir | undefined = sort_dir + ? order[sort_dir] + : "desc"; + + if (inc_sort_dir) { + return [column_name, inc_sort_dir]; + } + + return undefined; +} diff --git a/packages/viewer-datagrid/src/ts/get_cell_config.ts b/packages/viewer-datagrid/src/ts/get_cell_config.ts new file mode 100644 index 0000000..d7089a1 --- /dev/null +++ b/packages/viewer-datagrid/src/ts/get_cell_config.ts @@ -0,0 +1,76 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { View, ViewConfig, Filter, Scalar } from "@perspective-dev/client"; +import type { CellConfigResult } from "./types.js"; +import { isMetaColumn } from "./model/meta_columns.js"; + +interface ModelWithViewAndConfig { + _view: View; + _config: ViewConfig; +} + +interface RowData { + __ROW_PATH__?: unknown[]; + [key: string]: unknown; +} + +export default async function getCellConfig( + { _view, _config }: ModelWithViewAndConfig, + row_idx: number, + col_idx: number, +): Promise { + const group_by = _config.group_by; + const split_by = _config.split_by; + const start_row = row_idx >= 0 ? row_idx : 0; + const end_row = start_row + 1; + const r = (await _view.to_json({ start_row, end_row })) as RowData[]; + const row_paths = r.map((x) => x.__ROW_PATH__); + const group_by_values = (row_paths[0] || []) as Scalar[]; + const row_filters = group_by + .map((pivot, index): Filter | undefined => { + const pivot_value = group_by_values[index]; + return pivot_value ? [pivot, "==", pivot_value] : undefined; + }) + .filter((x): x is Filter => x !== undefined); + + // Filter out *all* meta columns before indexing into the row's + // keys — the DuckDB virtual server's JSON output now includes + // per-level `__ROW_PATH___` columns alongside the + // `__ROW_PATH__` sidecar, so the previous `+1` skip (which + // assumed exactly one leading meta column) lands on a meta key + // when group_by has multiple levels. + const user_keys = Object.keys(r[0]).filter((k) => !isMetaColumn(k)); + const column_paths = user_keys[col_idx]; + const result: CellConfigResult = { + row: r[0] as Record, + column_names: [], + config: { filter: [] }, + }; + + let column_filters: Filter[] = []; + if (column_paths) { + const split_by_values = column_paths.split("|"); + result.column_names = [split_by_values[split_by.length]]; + column_filters = split_by + .map((pivot, index): Filter | undefined => { + const pivot_value = split_by_values[index]; + return pivot_value ? [pivot, "==", pivot_value] : undefined; + }) + .filter((x): x is Filter => x !== undefined) + .filter(([, , value]) => !isMetaColumn(value as string)); + } + + const filter = _config.filter.concat(row_filters).concat(column_filters); + result.config = { filter }; + return result; +} diff --git a/packages/viewer-datagrid/src/ts/index.ts b/packages/viewer-datagrid/src/ts/index.ts new file mode 100644 index 0000000..e13416f --- /dev/null +++ b/packages/viewer-datagrid/src/ts/index.ts @@ -0,0 +1,49 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 "regular-table"; +import { HTMLPerspectiveViewerDatagridPluginElement } from "./custom_elements/datagrid.js"; +import { HTMLPerspectiveViewerDatagridToolbarElement } from "./custom_elements/toolbar.js"; + +/****************************************************************************** + * + * Main + * + */ + +interface PerspectiveViewerConstructor { + registerPlugin(tagName: string): void; +} + +async function _register_element(): Promise { + customElements.define( + "perspective-viewer-datagrid-toolbar", + HTMLPerspectiveViewerDatagridToolbarElement, + ); + + customElements.define( + "perspective-viewer-datagrid", + HTMLPerspectiveViewerDatagridPluginElement, + ); + + await customElements.whenDefined("perspective-viewer"); + const PerspectiveViewer = customElements.get( + "perspective-viewer", + ) as unknown as PerspectiveViewerConstructor; + PerspectiveViewer.registerPlugin("perspective-viewer-datagrid"); +} + +_register_element(); + +export { PRIVATE_PLUGIN_SYMBOL } from "./types.js"; +export { HTMLPerspectiveViewerDatagridPluginElement }; +export { HTMLPerspectiveViewerDatagridToolbarElement }; diff --git a/packages/viewer-datagrid/src/ts/model/column_overrides.ts b/packages/viewer-datagrid/src/ts/model/column_overrides.ts new file mode 100644 index 0000000..69eb90d --- /dev/null +++ b/packages/viewer-datagrid/src/ts/model/column_overrides.ts @@ -0,0 +1,119 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { ColumnOverrides, DatagridPluginElement } from "../types.js"; + +interface RegularTableWithOverrides { + restoreColumnSizes(overrides: Record): void; + saveColumnSizes(): Record; +} + +interface DatagridPluginWithCache extends DatagridPluginElement { + _cached_column_sizes?: ColumnOverrides; +} + +/** + * Restore a saved column width override token. + * + * @param old_sizes An object previously returned by a call to + * `save_column_size_overrides()` + * @param cache A flag indicating whether this value should + * be cached so a future `resetAutoSize()` call does not clear it. + */ +export function restore_column_size_overrides( + this: DatagridPluginWithCache, + old_sizes: ColumnOverrides, + cache = false, +): void { + if (!this._initialized) { + return; + } + + if (cache) { + this._cached_column_sizes = old_sizes; + } + + const regular_table = this.regular_table as RegularTableWithOverrides; + const overrides: Record = { + ...regular_table.saveColumnSizes(), + }; + const { group_by } = this.model!._config; + const tree_header_offset = group_by?.length > 0 ? group_by.length + 1 : 0; + + for (const key of Object.keys(old_sizes)) { + if (key === "__ROW_PATH__") { + overrides[tree_header_offset - 1] = old_sizes[key] as + | number + | undefined; + } else { + const index = this.model!._column_paths.indexOf(key); + + // Skip keys that don't resolve to a known column — e.g. on the + // first draw after `activate`, `_column_paths` has not yet been + // populated by the data listener, so we leave any existing + // `regular-table` widths untouched rather than clobbering them + // with garbage indices. + if (index === -1) { + continue; + } + + overrides[index + tree_header_offset] = old_sizes[key] as + | number + | undefined; + } + } + + regular_table.restoreColumnSizes(overrides); +} + +/** + * Extract the current user-overriden column widths from + * `regular-table`. This function depends on the internal + * implementation of `regular-table` and may break! + * + * @returns An Object-as-dictionary keyed by column_path string, and + * valued by the column's user-overridden pixel width. + */ +export function save_column_size_overrides( + this: DatagridPluginWithCache, +): ColumnOverrides { + if (!this._initialized) { + return {}; + } + + if (this._cached_column_sizes) { + const x = this._cached_column_sizes; + this._cached_column_sizes = undefined; + return x; + } + + const overrides = ( + this.regular_table as RegularTableWithOverrides + ).saveColumnSizes(); + const { group_by } = this.model!._config; + const tree_header_offset = group_by?.length > 0 ? group_by.length + 1 : 0; + + const old_sizes: ColumnOverrides = {}; + for (const key of Object.keys(overrides)) { + const numKey = Number(key); + if (overrides[numKey] !== undefined) { + const index = numKey - tree_header_offset; + if (index > -1) { + old_sizes[this.model!._column_paths[index]] = overrides[numKey]; + } else if (index === -1) { + old_sizes["__ROW_PATH__"] = overrides[numKey]; + } + } + } + + return old_sizes; +} diff --git a/packages/viewer-datagrid/src/ts/model/create.ts b/packages/viewer-datagrid/src/ts/model/create.ts new file mode 100644 index 0000000..df0aa06 --- /dev/null +++ b/packages/viewer-datagrid/src/ts/model/create.ts @@ -0,0 +1,257 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { createDataListener } from "../data_listener/index.js"; +import { blend, make_color_record, parseColor } from "../color_utils.js"; +import type { + ColumnType, + Table, + View, + ViewConfig, +} from "@perspective-dev/client"; +import { + type DatagridModel, + type DatagridPluginElement, + type RegularTable, + type Schema, + type ElemFactory, + type EditMode, +} from "../types.js"; +import type { HTMLPerspectiveViewerElement } from "@perspective-dev/viewer"; + +function arraysChanged(a: T[], b: T[]): boolean { + if (a.length !== b.length) { + return true; + } + + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return true; + } + } + + return false; +} + +function nestedArraysChanged(a: T[][], b: T[][]): boolean { + if (a.length !== b.length) { + return true; + } + + for (let i = 0; i < a.length; i++) { + if (a[i].length !== b[i].length) { + return true; + } + + for (let j = 0; j < a[i].length; j++) { + if (a[i][j] !== b[i][j]) { + return true; + } + } + } + + return false; +} + +function get_rule(regular: HTMLElement, tag: string, def: string): string { + const color = window.getComputedStyle(regular).getPropertyValue(tag).trim(); + if (color.length > 0) { + return color; + } else { + return def; + } +} + +class ElemFactoryImpl implements ElemFactory { + private _name: string; + private _elements: HTMLElement[]; + private _index: number; + + constructor(name: string) { + this._name = name; + this._elements = []; + this._index = 0; + } + + clear(): void { + this._index = 0; + } + + get(): HTMLElement { + if (!this._elements[this._index]) { + this._elements[this._index] = document.createElement(this._name); + } + + const elem = this._elements[this._index]; + this._index += 1; + return elem; + } +} + +export async function createModel( + this: DatagridPluginElement, + regular: RegularTable, + table: Table, + view: View, + theme: string, + extend: Partial = {}, +): Promise { + const config = (await view.get_config()) as ViewConfig; + if (this?.model?._config) { + const old = this.model._config; + const group_by_changed = arraysChanged(old.group_by, config.group_by); + const type_changed = + (old.group_by.length === 0 || config.group_by.length === 0) && + group_by_changed; + + const split_by_changed = arraysChanged(old.split_by, config.split_by); + const columns_changed = arraysChanged(old.columns, config.columns); + const filter_changed = nestedArraysChanged( + old.filter as unknown[][], + config.filter as unknown[][], + ); + + const sort_changed = nestedArraysChanged( + old.sort as unknown[][], + config.sort as unknown[][], + ); + + const group_rollup_mode_changed = + old.group_rollup_mode !== config.group_rollup_mode; + + const theme_changed = this.model._theme !== theme; + this._reset_scroll_top = group_by_changed; + this._reset_scroll_left = split_by_changed; + this._reset_select = + group_by_changed || + split_by_changed || + filter_changed || + sort_changed || + columns_changed; + + this._reset_column_size = + group_rollup_mode_changed || + split_by_changed || + group_by_changed || + columns_changed || + theme_changed || + type_changed; + } + + const [table_schema, num_rows, schema, expression_schema, _edit_port] = + await Promise.all([ + table.schema(), + view.num_rows(), + view.schema(), + view.expression_schema(), + (this.parentElement as HTMLPerspectiveViewerElement).getEditPort(), + ]); + + const _plugin_background = parseColor( + get_rule(regular, "--psp--background-color", "#FFFFFF"), + ); + + const _pos_fg_color = make_color_record( + get_rule(regular, "--psp-datagrid--pos-cell--color", "#338DCD"), + ); + + const _neg_fg_color = make_color_record( + get_rule(regular, "--psp-datagrid--neg-cell--color", "#FF5942"), + ); + + const _pos_bg_color = make_color_record( + blend(_pos_fg_color[0], _plugin_background), + ); + + const _neg_bg_color = make_color_record( + blend(_neg_fg_color[0], _plugin_background), + ); + + const _color = make_color_record( + get_rule(regular, "--psp-active--color", "#ff0000"), + ); + + const _schema: Schema = { + ...(schema as Schema), + ...(expression_schema as Schema), + }; + const _table_schema: Schema = { + ...(table_schema as Schema), + ...(expression_schema as Schema), + }; + + const _column_paths: string[] = []; + const _is_editable: boolean[] = []; + const _column_types: ColumnType[] = []; + let _edit_mode: EditMode = this._edit_mode || "READ_ONLY"; + + if ( + _edit_mode === "SELECT_ROW_TREE" && + (config.group_by.length === 0 || config.group_rollup_mode === "flat") + ) { + _edit_mode = "READ_ONLY"; + this._edit_mode = _edit_mode; + } + + this._edit_button!.dataset.editMode = _edit_mode; + const model: DatagridModel = Object.assign(extend, { + _edit_port, + _view: view, + _table: table, + _table_schema, + _config: config, + _num_rows: num_rows, + _schema, + _ids: [], + _plugin_background, + _color, + _pos_fg_color, + _neg_fg_color, + _pos_bg_color, + _neg_bg_color, + _column_paths, + _column_types, + _theme: theme, + _is_editable, + _edit_mode, + _selection_state: { + selected_areas: [], + dirty: false, + }, + _row_header_types: config.group_by.map((column_path) => { + return _table_schema[column_path]; + }), + _series_color_map: new Map(), + _series_color_seed: new Map(), + + // get_psp_type, + _div_factory: extend._div_factory || new ElemFactoryImpl("div"), + }) as DatagridModel; + + regular.setDataListener( + createDataListener( + this.parentElement as HTMLPerspectiveViewerElement, + ).bind(model, regular) as any, + { + virtual_mode: (window + .getComputedStyle(regular) + .getPropertyValue("--datagrid-virtual-mode") + ?.trim() || "both") as + | "both" + | "horizontal" + | "vertical" + | "none", + }, + ); + + return model; +} diff --git a/packages/viewer-datagrid/src/ts/model/index.ts b/packages/viewer-datagrid/src/ts/model/index.ts new file mode 100644 index 0000000..5c570ca --- /dev/null +++ b/packages/viewer-datagrid/src/ts/model/index.ts @@ -0,0 +1,19 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +export { PRIVATE_PLUGIN_SYMBOL } from "../types.js"; +export { createModel } from "./create.js"; +export { toggle_edit_mode, toggle_scroll_lock } from "./toolbar.js"; +export { + save_column_size_overrides, + restore_column_size_overrides, +} from "./column_overrides.js"; diff --git a/packages/viewer-datagrid/src/ts/model/meta_columns.ts b/packages/viewer-datagrid/src/ts/model/meta_columns.ts new file mode 100644 index 0000000..2ce964c --- /dev/null +++ b/packages/viewer-datagrid/src/ts/model/meta_columns.ts @@ -0,0 +1,47 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Single-source-of-truth predicate for "this column is metadata, hide + * it from the user-visible grid." Five distinct names show up across + * the perspective protocol; older datagrid code filtered them by + * exact-match in three places, which only worked when the wire shape + * was the JSON-sidecar form (`__ROW_PATH__` array-of-arrays). + * + * The DuckDB virtual-server backend additionally surfaces per-level + * `__ROW_PATH___` columns directly in `to_columns_string` / + * `to_json` output (a side effect of keeping them in the frozen Arrow + * batch so that `to_arrow` consumers — viewer-charts via + * `with_typed_arrays` — see them inline, matching native + * `perspective-server`'s `to_arrow` behavior). Without this helper, + * those per-level columns slip through the legacy exact-match filters + * and render as user columns to the right of the grid. + * + * Match list: + * - `__ROW_PATH__` — JSON sidecar; used by the tree header. + * - `__ROW_PATH___` — per-level columns from the virtual server's + * inline-arrow shape. + * - `__ID__` — per-row identity column. + * - `__GROUPING_ID__` — internal SQL-rollup discriminator. The + * virtual server strips it server-side, but + * we cover it defensively in case a future + * backend leaks it. + * + * User columns named with leading/trailing double-underscores (e.g. + * `__user_col__`) are *not* matched — the regex requires the exact + * stems above. + */ +const META_COLUMN_RE = /^__(?:ROW_PATH(?:_\d+)?|ID|GROUPING_ID)__$/; + +export function isMetaColumn(name: string): boolean { + return META_COLUMN_RE.test(name); +} diff --git a/packages/viewer-datagrid/src/ts/model/toolbar.ts b/packages/viewer-datagrid/src/ts/model/toolbar.ts new file mode 100644 index 0000000..bca372a --- /dev/null +++ b/packages/viewer-datagrid/src/ts/model/toolbar.ts @@ -0,0 +1,89 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { HTMLPerspectiveViewerElement } from "@perspective-dev/viewer"; +import type { + DatagridModel, + DatagridPluginElement, + EditMode, +} from "../types.js"; + +export const EDIT_MODES: readonly EditMode[] = [ + "READ_ONLY", + "EDIT", + "SELECT_ROW", + "SELECT_COLUMN", + "SELECT_REGION", + "SELECT_ROW_TREE", +] as const; + +function isSelectRowTreeAvailable(model?: DatagridModel): boolean { + if (!model) { + return false; + } + + return ( + model._config.group_by.length > 0 && + model._config.group_rollup_mode !== "flat" + ); +} + +export function toggle_edit_mode( + this: DatagridPluginElement, + mode?: EditMode, +): void { + if (typeof mode === "undefined") { + let idx = EDIT_MODES.indexOf(this._edit_mode); + do { + idx = (idx + 1) % EDIT_MODES.length; + } while ( + EDIT_MODES[idx] === "SELECT_ROW_TREE" && + !isSelectRowTreeAvailable(this.model) + ); + + mode = EDIT_MODES[idx]; + } + + (this.parentElement as HTMLPerspectiveViewerElement)?.setSelection?.(); + this._edit_mode = mode; + if (this.model) { + this.model._edit_mode = mode; + this.model._tree_selection_id = undefined; + this.model._selection_state = { + selected_areas: [], + dirty: true, + }; + } + + (this.parentElement as HTMLPerspectiveViewerElement)?.restore?.({ + plugin_config: { edit_mode: mode }, + }); + + if (this._edit_button !== undefined) { + this._edit_button.dataset.editMode = mode; + } +} + +export function toggle_scroll_lock( + this: DatagridPluginElement, + force?: boolean, +): void { + if (typeof force === "undefined") { + force = !this._is_scroll_lock; + } + + this._is_scroll_lock = force; + this.classList.toggle("sub-cell-scroll-disabled", force); + if (this._scroll_lock !== undefined) { + this._scroll_lock.classList.toggle("lock-scroll", force); + } +} diff --git a/packages/viewer-datagrid/src/ts/plugin/activate.ts b/packages/viewer-datagrid/src/ts/plugin/activate.ts new file mode 100644 index 0000000..5f1921b --- /dev/null +++ b/packages/viewer-datagrid/src/ts/plugin/activate.ts @@ -0,0 +1,265 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { style_selected_column } from "../style_handlers/column_header.js"; +import { + createMousedownListener, + createClickListener, + createDblclickListener, +} from "../event_handlers/header_click.js"; + +import { + createFocusinListener, + createFocusoutListener, +} from "../event_handlers/focus.js"; +import { + createKeydownListener, + createEditClickListener, +} from "../event_handlers/click.js"; + +import { createModel } from "../model/create.js"; +import { createDispatchClickListener } from "../event_handlers/dispatch_click.js"; + +import { + addAreaMouseSelection, + type OnSelectCallback, +} from "../event_handlers/select_region.js"; + +import { createConsolidatedStyleListener } from "../style_handlers/consolidated.js"; + +import getCellConfig from "../get_cell_config.js"; + +import type { View } from "@perspective-dev/client"; +import { + type DatagridPluginElement, + type SelectedPositionMap, + type SelectionArea, + PerspectiveSelectDetail, +} from "../types.js"; + +import type { HTMLPerspectiveViewerElement } from "@perspective-dev/viewer"; + +interface ToggleColumnSettingsEvent extends CustomEvent { + detail: { + column_name: string | null; + open: boolean; + }; +} + +/** + * Lazy initialize this plugin with various listeners. + */ +export async function activate( + this: DatagridPluginElement, + view: View, +): Promise { + const viewer = this.parentElement as HTMLPerspectiveViewerElement; + const table = await viewer.getTable(); + + if (!this._initialized) { + this.innerHTML = ""; + if (this.shadowRoot) { + this.shadowRoot.appendChild(this.regular_table); + } else { + this.appendChild(this.regular_table); + } + + this.model = await createModel.call( + this, + this.regular_table, + table, + view, + viewer.getAttribute("theme")!, + ); + + if (!this.model) { + return; + } + + const model = this.model; + const regularTable = this.regular_table; + const onSelect: OnSelectCallback = async ( + area: SelectionArea, + isDeselect: boolean, + ) => { + if (model._edit_mode !== "SELECT_ROW_TREE") { + return; + } + + // Store the selected row identity on the model so it persists + // even when the selected row scrolls out of the viewport. + if (isDeselect) { + model._tree_selection_id = undefined; + } else { + const idx = area.y0 - (model._last_window?.start_row ?? 0); + if (idx >= 0 && idx < model._ids.length) { + model._tree_selection_id = model._ids[idx]; + } + } + + const { row, column_names, config } = await getCellConfig( + model, + area.y0, + 0, + ); + + let detail: PerspectiveSelectDetail; + if (isDeselect) { + if ((model._last_insert_configs?.length || 0) > 0) { + detail = new PerspectiveSelectDetail( + false, + row, + [], + model._last_insert_configs ?? [], + [], + ); + } else { + throw new Error("Suprious deselect"); + } + + model._last_insert_configs = undefined; + } else { + detail = new PerspectiveSelectDetail( + true, + row, + column_names, + model._last_insert_configs ?? [], + [config], + ); + model._last_insert_configs = [config]; + } + + await regularTable.draw({ preserve_width: true }); + viewer.dispatchEvent( + new CustomEvent( + "perspective-global-filter", + { + bubbles: true, + composed: true, + detail, + }, + ), + ); + }; + + addAreaMouseSelection(this, this.regular_table, { + className: "psp-select-region", + onSelect, + }); + + // Create shared state map for focus tracking + const selected_position_map: SelectedPositionMap = new WeakMap(); + + this.regular_table.addStyleListener( + createConsolidatedStyleListener( + this, + this.model, + this.regular_table, + viewer, + selected_position_map, + ), + ); + + this.regular_table.addEventListener( + "click", + createClickListener(this.regular_table), + ); + + // User event click + this.regular_table.addEventListener( + "click", + createDispatchClickListener(this.model, this.regular_table, viewer), + ); + + // tree collapse, expand, edit button headers + this.regular_table.addEventListener( + "mousedown", + createMousedownListener(this.model, this.regular_table, viewer), + ); + + this.regular_table.addEventListener( + "dblclick", + createDblclickListener(this.model, this.regular_table, viewer), + ); + + // Editing event handlers + this.regular_table.addEventListener( + "click", + createEditClickListener(this.model, this.regular_table, viewer), + ); + + this.regular_table.addEventListener( + "focusin", + createFocusinListener( + this.model, + this.regular_table, + viewer, + selected_position_map, + ), + ); + + this.regular_table.addEventListener( + "focusout", + createFocusoutListener( + this.model, + this.regular_table, + viewer, + selected_position_map, + ), + ); + + this.regular_table.addEventListener( + "keydown", + createKeydownListener( + this.model, + this.regular_table, + viewer, + selected_position_map, + ), + ); + + // viewer event listeners + viewer.addEventListener( + "perspective-toggle-column-settings", + (event: Event) => { + const toggleEvent = event as ToggleColumnSettingsEvent; + if (this.isConnected) { + style_selected_column( + this.model!, + this.regular_table, + viewer, + toggleEvent.detail.column_name ?? undefined, + ); + if (!toggleEvent.detail.open) { + this.model!._column_settings_selected_column = + undefined; + return; + } + + this.model!._column_settings_selected_column = + toggleEvent.detail.column_name ?? undefined; + } + }, + ); + + this._initialized = true; + } else { + await createModel.call( + this, + this.regular_table, + table, + view, + viewer.getAttribute("theme")!, + this.model, + ); + } +} diff --git a/packages/viewer-datagrid/src/ts/plugin/column_config_schema.ts b/packages/viewer-datagrid/src/ts/plugin/column_config_schema.ts new file mode 100644 index 0000000..5bd6125 --- /dev/null +++ b/packages/viewer-datagrid/src/ts/plugin/column_config_schema.ts @@ -0,0 +1,187 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { ColumnType } from "@perspective-dev/client"; +import type { DatagridPluginElement } from "../types.js"; + +interface ViewerConfigLike { + group_by?: string[]; + group_rollup_mode?: string; +} + +type ControlSpec = Record & { kind: string }; + +export interface ColumnConfigSchema { + fields: ControlSpec[]; +} + +/** + * Plugin schema for the Datagrid column-settings sidebar. Returns the + * controls the viewer should render in the Style tab for a given column. + * + * Each entry in `fields` is a `ControlSpec` discriminated by `kind`. + * Composite kinds (`NumberStyle`, `DatetimeFormat`, `StringFormat`, + * `NumberFormat`, `AggregateDepth`) own a fixed key namespace and + * carry only their `default`. Primitive kinds (`Enum`, `Bool`, `Color`, + * etc.) carry their own `key` (storage) and `label` (UI) inline. + * + * Aggregate Depth is plugin-owned — surfaced only inside the Datagrid + * because rollup-mode pivots are a Datagrid concern. Emitted only when + * the active view has a non-empty `group_by` and rollup mode is `Rollup`. + */ +interface ColumnStats { + abs_max?: number; +} + +export default function column_config_schema( + this: DatagridPluginElement, + type: ColumnType, + _group: string | undefined, + _column_name: string, + current_value: Record | null, + viewer_config?: ViewerConfigLike, + column_stats?: ColumnStats, +): ColumnConfigSchema { + const fields: ControlSpec[] = []; + + if (type === "integer" || type === "float") { + const pos_fg = this.model!._pos_fg_color[0]; + const neg_fg = this.model!._neg_fg_color[0]; + const pos_bg = this.model!._pos_bg_color[0]; + const neg_bg = this.model!._neg_bg_color[0]; + + fields.push({ + kind: "Enum", + key: "number_fg_mode", + default: "color", + variants: [ + { value: "disabled", label: "Disabled" }, + { value: "color", label: "Color" }, + { value: "bar", label: "Bar" }, + { value: "label-bar", label: "Gradient" }, + ], + }); + + const fg_mode = (current_value?.number_fg_mode as string) ?? "color"; + if (fg_mode !== "disabled") { + fields.push({ + kind: "ColorRange", + key_pos: "pos_fg_color", + key_neg: "neg_fg_color", + default_pos: pos_fg, + default_neg: neg_fg, + is_gradient: false, + }); + } + + if (fg_mode === "bar" || fg_mode === "label-bar") { + fields.push({ + kind: "Number", + key: "fg_gradient", + default: column_stats?.abs_max ?? 0, + include: true, + }); + } + + fields.push({ + kind: "Enum", + key: "number_bg_mode", + default: "disabled", + variants: [ + { value: "disabled", label: "Disabled" }, + { value: "color", label: "Color" }, + { value: "gradient", label: "Gradient" }, + { value: "pulse", label: "Pulse" }, + ], + }); + + const bg_mode = (current_value?.number_bg_mode as string) ?? "disabled"; + if (bg_mode !== "disabled") { + fields.push({ + kind: "ColorRange", + key_pos: "pos_bg_color", + key_neg: "neg_bg_color", + default_pos: pos_bg, + default_neg: neg_bg, + is_gradient: bg_mode === "gradient" || bg_mode === "pulse", + }); + } + + if (bg_mode === "gradient") { + fields.push({ + kind: "Number", + key: "bg_gradient", + include: true, + default: column_stats?.abs_max ?? 0, + }); + } + + fields.push({ kind: "NumberFormat" }); + } else if (type === "date" || type === "datetime") { + fields.push({ kind: "DatetimeFormat" }); + + fields.push({ + kind: "Enum", + key: "datetime_color_mode", + default: "none", + variants: [ + { value: "none", label: "None" }, + { value: "foreground", label: "Foreground" }, + { value: "background", label: "Background" }, + ], + }); + + const dt_mode = + (current_value?.datetime_color_mode as string) ?? "none"; + + if (dt_mode !== "none") { + fields.push({ + kind: "Color", + key: "color", + default: this.model!._color[0], + }); + } + } else if (type === "string") { + fields.push({ kind: "StringFormat" }); + + fields.push({ + kind: "Enum", + key: "string_color_mode", + default: "none", + variants: [ + { value: "none", label: "None" }, + { value: "foreground", label: "Foreground" }, + { value: "background", label: "Background" }, + { value: "series", label: "Series" }, + ], + }); + + const str_mode = (current_value?.string_color_mode as string) ?? "none"; + if (str_mode !== "none") { + fields.push({ + kind: "Color", + key: "color", + default: this.model!._color[0], + }); + } + } + + const group_by = viewer_config?.group_by ?? []; + const is_rollup = + (viewer_config?.group_rollup_mode ?? "rollup") === "rollup"; + + if (group_by.length > 0 && is_rollup) { + fields.push({ kind: "AggregateDepth" }); + } + + return { fields }; +} diff --git a/packages/viewer-datagrid/src/ts/plugin/draw.ts b/packages/viewer-datagrid/src/ts/plugin/draw.ts new file mode 100644 index 0000000..db486b3 --- /dev/null +++ b/packages/viewer-datagrid/src/ts/plugin/draw.ts @@ -0,0 +1,71 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { + restore_column_size_overrides, + save_column_size_overrides, +} from "../model/column_overrides.js"; +import type { View } from "@perspective-dev/client"; +import type { DatagridPluginElement } from "../types.js"; + +interface DatagridPluginWithActivate extends DatagridPluginElement { + activate(view: View): Promise; +} + +export async function draw( + this: DatagridPluginWithActivate, + view: View, +): Promise { + if (this.parentElement) { + await this.activate(view); + } + + if (!this.isConnected || this.offsetParent == null || !this.model) { + return; + } + + const old_sizes = save_column_size_overrides.call(this); + const drawPromise = this.regular_table.draw({ + invalid_columns: true, + } as any); + + if (this._reset_scroll_top) { + this.regular_table.scrollTop = 0; + this._reset_scroll_top = false; + } + + if (this._reset_scroll_left) { + this.regular_table.scrollLeft = 0; + this._reset_scroll_left = false; + } + + if (this._reset_select) { + this.regular_table.dispatchEvent( + new CustomEvent("psp-deselect-all", { bubbles: false }), + ); + this._reset_select = false; + } + + if (this._reset_column_size) { + this.regular_table.resetAutoSize(); + this._reset_column_size = false; + } + + restore_column_size_overrides.call(this, old_sizes); + await drawPromise; + + this._toolbar?.classList.toggle( + "aggregated", + this.model._config.group_by.length > 0 || + this.model._config.split_by.length > 0, + ); +} diff --git a/packages/viewer-datagrid/src/ts/plugin/restore.ts b/packages/viewer-datagrid/src/ts/plugin/restore.ts new file mode 100644 index 0000000..64cdf6d --- /dev/null +++ b/packages/viewer-datagrid/src/ts/plugin/restore.ts @@ -0,0 +1,114 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { restore_column_size_overrides } from "../model/column_overrides.js"; +import { + EDIT_MODES, + toggle_edit_mode, + toggle_scroll_lock, +} from "../model/toolbar.js"; +import { PRIVATE_PLUGIN_SYMBOL } from "../model/index.js"; +import { make_color_record } from "../color_utils.js"; +import type { + DatagridPluginElement, + ColumnOverrides, + EditMode, + ColumnsConfig, + ColorRecord, +} from "../types.js"; + +interface RestoreToken { + columns?: Record; + edit_mode?: EditMode; + scroll_lock?: boolean; +} + +// interface ColumnConfigWithColors { +// pos_fg_color?: string; +// neg_fg_color?: string; +// pos_bg_color?: string; +// neg_bg_color?: string; +// color?: string; +// [key: string]: unknown; +// } + +interface StylesConfig { + pos_fg_color?: ColorRecord; + neg_fg_color?: ColorRecord; + pos_bg_color?: ColorRecord; + neg_bg_color?: ColorRecord; + color?: ColorRecord; + [key: string]: unknown; +} + +export function restore( + this: DatagridPluginElement, + token: RestoreToken, + columns: ColumnsConfig, +): void { + token = JSON.parse(JSON.stringify(token)); + columns = JSON.parse(JSON.stringify(columns)); + const overrides: ColumnOverrides = {}; + + if (token.columns) { + for (const [col, value] of Object.entries(token.columns)) { + if (value.column_size_override !== undefined) { + overrides[col] = value.column_size_override; + delete value["column_size_override"]; + } + } + } + + const styles: Record = {}; + if (columns) { + for (const [col_name, controls] of Object.entries(columns)) { + styles[col_name] = { + ...controls, + pos_fg_color: controls.pos_fg_color + ? make_color_record(controls.pos_fg_color) + : undefined, + neg_fg_color: controls.neg_fg_color + ? make_color_record(controls.neg_fg_color) + : undefined, + pos_bg_color: controls.pos_bg_color + ? make_color_record(controls.pos_bg_color) + : undefined, + neg_bg_color: controls.neg_bg_color + ? make_color_record(controls.neg_bg_color) + : undefined, + color: controls.color + ? make_color_record(controls.color) + : undefined, + }; + } + } + + if ("edit_mode" in token) { + if (EDIT_MODES.indexOf(token.edit_mode!) !== -1) { + toggle_edit_mode.call(this, token.edit_mode); + } else { + console.error("Unknown edit mode " + token.edit_mode); + } + } else { + toggle_edit_mode.call(this, "READ_ONLY"); + } + + if ("scroll_lock" in token) { + toggle_scroll_lock.call(this, token.scroll_lock); + } else { + toggle_scroll_lock.call(this, false); + } + + const datagrid = this.regular_table; + restore_column_size_overrides.call(this, overrides, true); + (datagrid as any)[PRIVATE_PLUGIN_SYMBOL] = styles as ColumnsConfig; +} diff --git a/packages/viewer-datagrid/src/ts/plugin/save.ts b/packages/viewer-datagrid/src/ts/plugin/save.ts new file mode 100644 index 0000000..da3cbca --- /dev/null +++ b/packages/viewer-datagrid/src/ts/plugin/save.ts @@ -0,0 +1,42 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { save_column_size_overrides } from "../model/column_overrides.js"; +import type { DatagridPluginElement, DatagridPluginConfig } from "../types.js"; + +export function save( + this: DatagridPluginElement, +): DatagridPluginConfig | Record { + if (this.regular_table) { + const token: DatagridPluginConfig = { + columns: {}, + scroll_lock: !!this._is_scroll_lock, + edit_mode: this._edit_mode, + }; + + const column_size_overrides = save_column_size_overrides.call(this); + + for (const col of Object.keys(column_size_overrides || {})) { + if (!token.columns?.[col]) { + token.columns = token.columns || {}; + token.columns[col] = {}; + } + + token.columns[col].column_size_override = + column_size_overrides[col]; + } + + return JSON.parse(JSON.stringify(token)); + } + + return {}; +} diff --git a/packages/viewer-datagrid/src/ts/style_handlers/body.ts b/packages/viewer-datagrid/src/ts/style_handlers/body.ts new file mode 100644 index 0000000..ab305e8 --- /dev/null +++ b/packages/viewer-datagrid/src/ts/style_handlers/body.ts @@ -0,0 +1,227 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { RegularTableElement } from "regular-table"; + +import { + type DatagridModel, + type ColumnsConfig, + get_psp_type, +} 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 { CollectedCell } from "./types.js"; + +/** + * Apply styles to all body cells in a single pass. + */ +export function applyBodyCellStyles( + model: DatagridModel, + cells: CollectedCell[], + plugins: ColumnsConfig, + isSettingsOpen: boolean, + isSelectable: boolean, + isEditable: boolean, + regularTable: RegularTableElement, +): void { + const selectedId = isSelectable ? model._tree_selection_id : undefined; + + regularTable.classList.toggle( + "flat-group-rollup-mode", + model._config.group_rollup_mode === "flat", + ); + + for (const { element: td, metadata, isHeader } of cells) { + const column_name = + metadata.column_header?.[model._config.split_by.length]; + const type = get_psp_type(model, metadata); + const plugin = column_name + ? plugins[column_name.toString()] + : undefined; + const is_numeric = type === "integer" || type === "float"; + + // Calculate aggregate depth visibility + // @ts-ignore + metadata._is_hidden_by_aggregate_depth = + model._config.group_rollup_mode === "rollup" && + ((x?: number) => + x === 0 || x === undefined + ? false + : x - 1 < + Math.min( + model._config.group_by.length, + plugin?.aggregate_depth || 0, + ))( + (metadata.row_header as unknown[] | undefined)?.filter( + (x) => x !== undefined, + )?.length, + ); + + // Apply type-specific cell styling + if (is_numeric) { + cell_style_numeric( + model, + plugin as any, + td, + metadata as any, + isSettingsOpen, + ); + } else if (type === "boolean") { + cell_style_boolean(model, plugin, td, metadata as any); + } else if (type === "string") { + cell_style_string(model, plugin as any, td, metadata as any); + } else if (type === "date" || type === "datetime") { + cell_style_datetime(model, plugin as any, td, metadata); + } else { + td.style.backgroundColor = ""; + td.style.color = ""; + } + + // Apply common cell classes + td.classList.toggle( + "psp-bool-type", + type === "boolean" && metadata.user !== null, + ); + + td.classList.toggle("psp-null", metadata.value === null); + td.classList.toggle("psp-align-right", !isHeader && is_numeric); + td.classList.toggle("psp-align-left", isHeader || !is_numeric); + if (model._column_settings_selected_column) { + td.classList.toggle( + "psp-menu-open", + column_name === model._column_settings_selected_column, + ); + } else { + td.classList.toggle("psp-menu-open", false); + } + + td.classList.toggle( + "psp-color-mode-bar", + plugin?.number_fg_mode === "bar" && is_numeric, + ); + + td.classList.toggle( + "psp-color-mode-label-bar", + plugin?.number_fg_mode === "label-bar" && is_numeric, + ); + + // Apply row header styling + if (isHeader) { + cell_style_row_header(model, regularTable, td, metadata as any); + } + + // Set data attributes + const tr = td.parentElement as HTMLElement; + if (tr) { + tr.dataset.y = String(metadata.y); + } + + if ( + metadata.type !== "row_header" || + metadata.row_header_x === + (metadata.row_header as unknown[]).length - 1 || + (metadata.row_header as unknown[])[metadata.row_header_x + 1] === + undefined + ) { + td.dataset.y = String(metadata.y); + if (metadata.type !== "row_header") { + td.dataset.x = String(metadata.x); + } else { + delete td.dataset.x; + } + } else { + delete td.dataset.y; + delete td.dataset.x; + } + + // Apply tree selection styling (SELECT_ROW_TREE). + // psp-select-region-inactive is exclusively a tree-selection class, + // so always clean it up. psp-select-region is shared with the + // coordinate-based selection modes, so only touch it when in + // SELECT_ROW_TREE mode (isSelectable). + td.classList.toggle("psp-select-region-inactive", false); + if (isSelectable) { + if (!selectedId) { + td.classList.toggle("psp-select-region", false); + } else { + const id = model._ids[(metadata.y ?? 0) - (metadata.y0 ?? 0)]; + const key_match = selectedId.reduce( + (agg, x, i) => agg && x === id[i], + true, + ); + + const isExact = id.length === selectedId.length && key_match; + const isSub = id.length !== selectedId.length && key_match; + + if (isHeader) { + if ( + metadata.type === "row_header" && + metadata.row_header_x !== undefined && + !!id[metadata.row_header_x] + ) { + td.classList.toggle("psp-select-region", false); + } else { + td.classList.toggle("psp-select-region", isExact); + td.classList.toggle( + "psp-select-region-inactive", + isSub, + ); + } + } else { + td.classList.toggle("psp-select-region", isExact); + td.classList.toggle("psp-select-region-inactive", isSub); + } + } + // } else if ( + // model._edit_mode === "READ_ONLY" || + // model._edit_mode === "EDIT" + // ) { + // td.classList.toggle("psp-select-region", false); + } + + // Apply editable styling (if editable) + if (!isHeader && metadata.type === "body") { + if (isEditable && model._is_editable[metadata.x]) { + const col_name = + metadata.column_header?.[model._config.split_by.length]; + const col_name_str = col_name?.toString(); + if ( + col_name_str && + type === "string" && + plugins[col_name_str]?.format === "link" + ) { + td.toggleAttribute("contenteditable", false); + td.classList.toggle("boolean-editable", false); + } else if (type === "boolean") { + td.toggleAttribute("contenteditable", false); + td.classList.toggle( + "boolean-editable", + (metadata as { user?: unknown }).user !== null, + ); + } else { + if (isEditable !== td.hasAttribute("contenteditable")) { + td.toggleAttribute("contenteditable", isEditable); + } + + td.classList.toggle("boolean-editable", false); + } + } else { + td.toggleAttribute("contenteditable", false); + td.classList.toggle("boolean-editable", false); + } + } + } +} diff --git a/packages/viewer-datagrid/src/ts/style_handlers/column_header.ts b/packages/viewer-datagrid/src/ts/style_handlers/column_header.ts new file mode 100644 index 0000000..345b79a --- /dev/null +++ b/packages/viewer-datagrid/src/ts/style_handlers/column_header.ts @@ -0,0 +1,196 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { RegularTableElement } from "regular-table"; +import { get_psp_type, type DatagridModel } from "../types.js"; +import { CollectedHeaderRow } from "./types.js"; +import type { HTMLPerspectiveViewerElement } from "@perspective-dev/viewer"; + +/** + * Apply selected column styling in response to column settings toggle events. + * This is called directly (not as a style listener) when the user opens/closes + * the column settings panel. + */ +export function style_selected_column( + model: DatagridModel, + regularTable: RegularTableElement, + viewer: HTMLPerspectiveViewerElement, + selectedColumn: string | undefined, +): void { + const group_header_trs = Array.from( + regularTable.children[0].children[0].children, + ) as HTMLTableRowElement[]; + + const len = group_header_trs.length; + const settings_open = viewer.hasAttribute("settings"); + if (len <= 1) { + group_header_trs[0]?.removeAttribute("id"); + } else { + group_header_trs.forEach((tr, i) => { + const offset = settings_open ? 1 : 0; + const id = + i === len - (offset + 1) + ? "psp-column-titles" + : i === len - offset + ? "psp-column-edit-buttons" + : null; + id ? tr.setAttribute("id", id) : tr.removeAttribute("id"); + }); + } + + viewer.classList.toggle("psp-menu-open", !!selectedColumn); + if (settings_open && len >= 2) { + const titles = Array.from( + group_header_trs[len - 2].children, + ) as HTMLElement[]; + const editBtns = Array.from( + group_header_trs[len - 1].children, + ) as HTMLElement[]; + if (titles && editBtns) { + group_header_trs.slice(0, len - 2).forEach((tr) => { + Array.from(tr.children).forEach((th) => { + th.classList.toggle("psp-menu-open", false); + }); + }); + + for (let i = 0; i < titles.length; i++) { + const title = titles[i]; + const editBtn = editBtns[i]; + + const open = title.textContent === selectedColumn; + title.classList.toggle("psp-menu-open", open); + editBtn.classList.toggle("psp-menu-open", open); + if (model._config.columns.length > 1) { + for (const r of regularTable.querySelectorAll("td")) { + const meta = regularTable.getMeta(r); + if (!meta?.column_header) { + continue; + } + + const isOpen = + meta.column_header[ + meta.column_header.length - 2 + ] === selectedColumn; + r.classList.toggle("psp-menu-open", isOpen); + } + } + } + } + } +} + +/** + * Style a single column header row. + */ +export function styleColumnHeaderRow( + model: DatagridModel, + headerRow: CollectedHeaderRow, + regularTable: RegularTableElement, + is_menu_row: boolean, +): void { + const header_depth = + model._config.group_by.length - + (model._config.group_rollup_mode === "flat" ? 1 : 0); + + const selectedColumn = model._column_settings_selected_column; + for (const { element: td, metadata } of headerRow.cells) { + if ( + !metadata || + metadata.type === "body" || + metadata.type === "row_header" + ) { + continue; + } + + const column_name = + metadata.column_header?.[model._config.split_by.length]; + + const sort = model._config.sort.find((x) => x[0] === column_name); + const is_corner = typeof metadata.x === "undefined"; + const needs_border = + (metadata.type === "corner" && + metadata.row_header_x === header_depth) || + (!is_corner && + (metadata.x + 1) % model._config.columns.length === 0); + + td.classList.toggle("psp-header-border", needs_border); + td.classList.toggle("psp-header-group", false); + td.classList.toggle("psp-header-leaf", true); + td.classList.toggle("psp-is-top", false); + td.classList.toggle("psp-header-corner", is_corner); + td.classList.toggle( + "psp-header-sort-asc", + !is_menu_row && !!sort && sort[1] === "asc", + ); + td.classList.toggle( + "psp-header-sort-desc", + !is_menu_row && !!sort && sort[1] === "desc", + ); + td.classList.toggle( + "psp-header-sort-col-asc", + !is_menu_row && !!sort && sort[1] === "col asc", + ); + td.classList.toggle( + "psp-header-sort-col-desc", + !is_menu_row && !!sort && sort[1] === "col desc", + ); + td.classList.toggle( + "psp-header-sort-abs-asc", + !is_menu_row && !!sort && sort[1] === "asc abs", + ); + td.classList.toggle( + "psp-header-sort-abs-desc", + !is_menu_row && !!sort && sort[1] === "desc abs", + ); + td.classList.toggle( + "psp-header-sort-abs-col-asc", + !is_menu_row && !!sort && sort[1] === "col asc abs", + ); + td.classList.toggle( + "psp-header-sort-abs-col-desc", + !is_menu_row && !!sort && sort[1] === "col desc abs", + ); + + const type = get_psp_type(model, metadata); + const is_numeric = type === "integer" || type === "float"; + const is_string = type === "string"; + const is_date = type === "date"; + const is_datetime = type === "datetime"; + + td.classList.toggle("psp-align-right", is_numeric); + td.classList.toggle("psp-align-left", !is_numeric); + td.classList.toggle( + "psp-menu-enabled", + (is_string || is_numeric || is_date || is_datetime) && + !is_corner && + metadata.column_header_y === model._config.split_by.length + 1, + ); + td.classList.toggle( + "psp-sort-enabled", + (is_string || is_numeric || is_date || is_datetime) && + !is_corner && + metadata.column_header_y === model._config.split_by.length, + ); + td.classList.toggle( + "psp-is-width-override", + regularTable.saveColumnSizes()[metadata.size_key!] !== undefined, + ); + + // Apply menu-open for selected column + if (model._config.columns.length > 1 && selectedColumn) { + const isOpen = + metadata.column_header?.[metadata.column_header.length - 2] === + selectedColumn; + td.classList.toggle("psp-menu-open", isOpen); + } + } +} diff --git a/packages/viewer-datagrid/src/ts/style_handlers/consolidated.ts b/packages/viewer-datagrid/src/ts/style_handlers/consolidated.ts new file mode 100644 index 0000000..66263a9 --- /dev/null +++ b/packages/viewer-datagrid/src/ts/style_handlers/consolidated.ts @@ -0,0 +1,118 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { RegularTableElement } from "regular-table"; +import { PRIVATE_PLUGIN_SYMBOL } from "../model/index.js"; +import type { + DatagridModel, + ColumnsConfig, + DatagridPluginElement, + SelectedPositionMap, +} from "../types.js"; +import { isEditableMode } from "../types.js"; +import type { HTMLPerspectiveViewerElement } from "@perspective-dev/viewer"; + +import { applyFocusStyle } from "./focus.js"; +import { applyColumnHeaderStyles } from "./editable.js"; +import { applyGroupHeaderStyles } from "./group_header.js"; +import { applyBodyCellStyles } from "./body.js"; +import { CellMetadata } from "regular-table/dist/esm/types.js"; +import { CollectedCell, CollectedHeaderRow } from "./types.js"; + +/** + * Consolidated style listener that handles all cell styling in a single pass. + * This eliminates redundant DOM traversals and reduces layout thrashing by: + * 1. Collecting all cell metadata in a read phase + * 2. Applying all styles in a write phase + */ +export function createConsolidatedStyleListener( + datagrid: DatagridPluginElement, + model: DatagridModel, + regularTable: RegularTableElement, + viewer: HTMLPerspectiveViewerElement, + selectedPositionMap: SelectedPositionMap, +): () => void { + return function consolidatedStyleListener(): void { + const plugins: ColumnsConfig = + (regularTable as any)[PRIVATE_PLUGIN_SYMBOL] || {}; + const isSettingsOpen = viewer.hasAttribute("settings"); + const isSelectable = model._edit_mode === "SELECT_ROW_TREE"; + const isEditable = isEditableMode(model, viewer); + const isEditableAllowed = isEditableMode(model, viewer, true); + + // Toggle edit mode class on datagrid + datagrid.classList.toggle("edit-mode-allowed", isEditableAllowed); + const bodyCells: CollectedCell[] = []; + const groupHeaderRows: CollectedHeaderRow[] = []; + const tbody = regularTable.children[0]?.children[1]; + if (tbody) { + for (const tr of tbody.children) { + for (const cell of tr.children) { + const metadata = regularTable.getMeta( + cell as HTMLElement, + ) as CellMetadata | undefined; + + if ( + metadata && + (metadata.type === "body" || + metadata.type === "row_header") + ) { + const isHeader = cell.tagName === "TH"; + bodyCells.push({ + element: cell as HTMLElement, + metadata, + isHeader, + }); + } + } + } + } + + // Collect header rows (thead) + const thead = regularTable.children[0]?.children[0]; + if (thead) { + for (const tr of thead.children) { + const rowData: CollectedHeaderRow = { + row: tr as HTMLTableRowElement, + cells: [], + }; + + for (const cell of tr.children) { + const metadata = regularTable.getMeta( + cell as HTMLElement, + ) as CellMetadata | undefined; + + rowData.cells.push({ + element: cell as HTMLTableCellElement, + metadata, + }); + } + + groupHeaderRows.push(rowData); + } + } + + applyBodyCellStyles( + model, + bodyCells, + plugins, + isSettingsOpen, + isSelectable, + isEditable, + regularTable, + ); + + applyGroupHeaderStyles(model, groupHeaderRows, regularTable); + applyColumnHeaderStyles(model, groupHeaderRows, regularTable, viewer); + applyFocusStyle(model, bodyCells, regularTable, selectedPositionMap); + }; +} diff --git a/packages/viewer-datagrid/src/ts/style_handlers/editable.ts b/packages/viewer-datagrid/src/ts/style_handlers/editable.ts new file mode 100644 index 0000000..4d40fdd --- /dev/null +++ b/packages/viewer-datagrid/src/ts/style_handlers/editable.ts @@ -0,0 +1,100 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { RegularTableElement } from "regular-table"; + +import type { DatagridModel } from "../types.js"; +import type { HTMLPerspectiveViewerElement } from "@perspective-dev/viewer"; +import { styleColumnHeaderRow } from "./column_header.js"; + +import { CollectedHeaderRow } from "./types.js"; + +/** + * Apply styles to column header rows. + */ +export function applyColumnHeaderStyles( + model: DatagridModel, + headerRows: CollectedHeaderRow[], + regularTable: RegularTableElement, + viewer: HTMLPerspectiveViewerElement, +): void { + if (headerRows.length === 0) { + return; + } + + // Style selected column for settings panel + const selectedColumn = model._column_settings_selected_column; + const len = headerRows.length; + const settings_open = viewer.hasAttribute("settings"); + + // Set row IDs + if (len <= 1) { + headerRows[0]?.row.removeAttribute("id"); + } else { + headerRows.forEach(({ row }, i) => { + const offset = settings_open ? 1 : 0; + const id = + i === len - (offset + 1) + ? "psp-column-titles" + : i === len - offset + ? "psp-column-edit-buttons" + : null; + id ? row.setAttribute("id", id) : row.removeAttribute("id"); + }); + } + + viewer.classList.toggle("psp-menu-open", !!selectedColumn); + + // Style column titles and edit buttons when settings open + if (settings_open && len >= 2) { + const titlesRow = headerRows[len - 2]; + const editBtnsRow = headerRows[len - 1]; + + if (titlesRow && editBtnsRow) { + // Clear menu-open from other rows + headerRows.slice(0, len - 2).forEach(({ cells }) => { + cells.forEach(({ element }) => { + element.classList.toggle("psp-menu-open", false); + }); + }); + + for (let i = 0; i < titlesRow.cells.length; i++) { + const title = titlesRow.cells[i]?.element; + const editBtn = editBtnsRow.cells[i]?.element; + if (!title || !editBtn) { + continue; + } + + const open = title.textContent === selectedColumn; + title.classList.toggle("psp-menu-open", open); + editBtn.classList.toggle("psp-menu-open", open); + } + } + } + + // Style the actual column header rows + const colHeadersIndex = model._config.split_by.length; + if (colHeadersIndex < headerRows.length) { + const colHeaders = headerRows[colHeadersIndex]; + if (colHeaders) { + styleColumnHeaderRow(model, colHeaders, regularTable, false); + } + } + + const menuHeadersIndex = model._config.split_by.length + 1; + if (menuHeadersIndex < headerRows.length) { + const menuHeaders = headerRows[menuHeadersIndex]; + if (menuHeaders) { + styleColumnHeaderRow(model, menuHeaders, regularTable, true); + } + } +} diff --git a/packages/viewer-datagrid/src/ts/style_handlers/focus.ts b/packages/viewer-datagrid/src/ts/style_handlers/focus.ts new file mode 100644 index 0000000..1770bc3 --- /dev/null +++ b/packages/viewer-datagrid/src/ts/style_handlers/focus.ts @@ -0,0 +1,102 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { RegularTableElement } from "regular-table"; +import type { DatagridModel, SelectedPositionMap } from "../types.js"; +import { CollectedCell } from "./types.js"; + +/** + * Apply focus style to the selected cell. + * Optimized to use collected cells instead of querySelectorAll. + */ +export function applyFocusStyle( + _model: DatagridModel, + cells: CollectedCell[], + regularTable: RegularTableElement, + selectedPositionMap: SelectedPositionMap, +): void { + const selected_position = selectedPositionMap.get(regularTable); + const host = regularTable.getRootNode() as Document; + + if (selected_position) { + for (const { element: td, metadata } of cells) { + if ( + metadata.type === "body" && + metadata.x === selected_position.x && + metadata.y === selected_position.y + ) { + if (host.activeElement !== td) { + td.focus({ preventScroll: true }); + } + + return; + } + } + + // If we didn't find the cell to focus, blur current + if ( + document.activeElement !== document.body && + regularTable.contains(host.activeElement) + ) { + (host.activeElement as HTMLElement).blur(); + } + } +} + +/** + * Standalone function to focus the selected cell. + * This collects cells from the table and tries to focus the selected position. + * Returns true if focus was successful, false otherwise. + * + * Used by edit_keydown.ts for keyboard navigation. + */ +export function focusSelectedCell( + regularTable: RegularTableElement, + selectedPositionMap: SelectedPositionMap, +): boolean { + const selected_position = selectedPositionMap.get(regularTable); + if (!selected_position) { + return false; + } + + const host = regularTable.getRootNode() as Document; + const tbody = regularTable.children[0]?.children[1]; + + if (tbody) { + for (const tr of tbody.children) { + for (const cell of tr.children) { + const metadata = regularTable.getMeta(cell as HTMLElement); + if ( + metadata?.type === "body" && + metadata.x === selected_position.x && + metadata.y === selected_position.y + ) { + if (host.activeElement !== cell) { + (cell as HTMLElement).focus({ preventScroll: true }); + } + + return true; + } + } + } + } + + // If we didn't find the cell to focus, blur current + if ( + document.activeElement !== document.body && + regularTable.contains(host.activeElement) + ) { + (host.activeElement as HTMLElement).blur(); + } + + return false; +} diff --git a/packages/viewer-datagrid/src/ts/style_handlers/group_header.ts b/packages/viewer-datagrid/src/ts/style_handlers/group_header.ts new file mode 100644 index 0000000..53e42e2 --- /dev/null +++ b/packages/viewer-datagrid/src/ts/style_handlers/group_header.ts @@ -0,0 +1,88 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { RegularTableElement } from "regular-table"; +import type { DatagridModel } from "../types.js"; + +import { CollectedHeaderRow } from "./types.js"; + +/** + * Apply styles to group header rows. + */ +export function applyGroupHeaderStyles( + model: DatagridModel, + headerRows: CollectedHeaderRow[], + regularTable: RegularTableElement, +): void { + const header_depth = model._config.group_by.length; + const m: boolean[][] = []; + let marked = new Set(); + + for (let y = 0; y < headerRows.length; y++) { + const { cells } = headerRows[y]; + const tops = new Set(); + + for (let x = 0; x < cells.length; x++) { + const { element: td, metadata } = cells[x]; + if (!metadata) { + continue; + } + + td.style.backgroundColor = ""; + + const needs_border = + (metadata.type === "corner" && + header_depth > 0 && + metadata.row_header_x === header_depth) || + (metadata.type === "column_header" && metadata.x >= 0); + + td.classList.toggle("psp-align-right", false); + td.classList.toggle("psp-align-left", false); + td.classList.toggle("psp-header-group", true); + td.classList.toggle("psp-header-leaf", false); + td.classList.toggle("psp-header-border", needs_border); + td.classList.toggle( + "psp-header-group-corner", + metadata.type === "corner", + ); + + td.classList.toggle("psp-color-mode-bar", false); + td.classList.toggle("psp-color-mode-label-bar", false); + td.classList.toggle("psp-header-sort-asc", false); + td.classList.toggle("psp-header-sort-desc", false); + td.classList.toggle("psp-header-sort-col-asc", false); + td.classList.toggle("psp-header-sort-col-desc", false); + td.classList.toggle("psp-sort-enabled", false); + + // Calculate spanning for psp-is-top + let xx = x; + for (; m[y] && m[y][xx]; ++xx) {} + + tops.add(xx); + + const cell = td; + for (let tx = xx; tx < xx + cell.colSpan; ++tx) { + for (let ty = y; ty < y + cell.rowSpan; ++ty) { + if (!m[ty]) { + m[ty] = []; + } + + m[ty][tx] = true; + } + } + + cell.classList.toggle("psp-is-top", y === 0 || !marked.has(xx)); + } + + marked = tops; + } +} diff --git a/packages/viewer-datagrid/src/ts/style_handlers/table_cell/boolean.ts b/packages/viewer-datagrid/src/ts/style_handlers/table_cell/boolean.ts new file mode 100644 index 0000000..caa0d4f --- /dev/null +++ b/packages/viewer-datagrid/src/ts/style_handlers/table_cell/boolean.ts @@ -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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { CellMetadata } from "regular-table/dist/esm/types.js"; +import type { DatagridModel, ColumnConfig, ColorRecord } from "../../types.js"; + +export function cell_style_boolean( + model: DatagridModel, + _plugin: ColumnConfig | undefined, + td: HTMLElement, + metadata: CellMetadata, +): void { + // @ts-ignore + if (metadata._is_hidden_by_aggregate_depth) { + td.style.backgroundColor = ""; + td.style.color = ""; + } else { + const [hex]: ColorRecord | [string, number, number, number, string] = + metadata.user === true + ? model._pos_fg_color + : metadata.user === false + ? model._neg_fg_color + : ["", 0, 0, 0, ""]; + td.style.backgroundColor = ""; + td.style.color = hex; + } +} diff --git a/packages/viewer-datagrid/src/ts/style_handlers/table_cell/cell_flash.ts b/packages/viewer-datagrid/src/ts/style_handlers/table_cell/cell_flash.ts new file mode 100644 index 0000000..3b1095a --- /dev/null +++ b/packages/viewer-datagrid/src/ts/style_handlers/table_cell/cell_flash.ts @@ -0,0 +1,75 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { CellMetadataBody } from "regular-table/dist/esm/types.js"; +import type { DatagridModel, ColorRecord } from "../../types.js"; + +export function style_cell_flash( + model: DatagridModel, + metadata: CellMetadataBody, + td: HTMLElement, + [, , , , , pos_s, pos_e]: ColorRecord, + [, , , , , neg_s, neg_e]: ColorRecord, + is_settings_open: boolean, +): void { + const id = model._ids?.[metadata.dy ?? 0]?.join("|"); + const metadata_path = ( + is_settings_open + ? (metadata.column_header ?? []).slice(0, -1) + : (metadata.column_header ?? []) + ).join("|"); + + if ( + model.last_reverse_columns?.has(metadata_path) && + model.last_reverse_ids?.has(id) + ) { + const row_idx = model.last_reverse_ids?.get(id); + const col_idx = model.last_reverse_columns.get(metadata_path); + if (!model._is_old_viewport) { + td.style.animation = ""; + } else if ( + col_idx !== undefined && + row_idx !== undefined && + (model.last_meta?.[col_idx]?.[row_idx] as number | undefined) !== + undefined && + (model.last_meta![col_idx]![row_idx] as number) > + ((metadata.user ?? 0) as number) + ) { + td.style.setProperty("--pulse--background-color-start", neg_s); + td.style.setProperty("--pulse--background-color-end", neg_e); + if (td.style.animationName === "pulse_neg") { + td.style.animation = "pulse_neg2 0.5s linear"; + } else { + td.style.animation = "pulse_neg 0.5s linear"; + } + } else if ( + col_idx !== undefined && + row_idx !== undefined && + (model.last_meta?.[col_idx]?.[row_idx] as number | undefined) !== + undefined && + (model.last_meta![col_idx]![row_idx] as number) < + ((metadata.user ?? 0) as number) + ) { + td.style.setProperty("--pulse--background-color-start", pos_s); + td.style.setProperty("--pulse--background-color-end", pos_e); + if (td.style.animationName === "pulse_pos") { + td.style.animation = "pulse_pos2 0.5s linear"; + } else { + td.style.animation = "pulse_pos 0.5s linear"; + } + } else if (row_idx !== metadata.dy) { + td.style.animation = ""; + } + } else { + td.style.animation = ""; + } +} diff --git a/packages/viewer-datagrid/src/ts/style_handlers/table_cell/datetime.ts b/packages/viewer-datagrid/src/ts/style_handlers/table_cell/datetime.ts new file mode 100644 index 0000000..0d8a6ce --- /dev/null +++ b/packages/viewer-datagrid/src/ts/style_handlers/table_cell/datetime.ts @@ -0,0 +1,64 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { CellMetadata } from "regular-table/dist/esm/types.js"; +import { + rgbaToRgb, + infer_foreground_from_background, +} from "../../color_utils.js"; +import type { DatagridModel, ColumnConfig, ColorRecord } from "../../types.js"; + +interface PluginWithColor extends Omit { + color?: ColorRecord; +} + +export function cell_style_datetime( + model: DatagridModel, + plugin: PluginWithColor, + td: HTMLElement, + metadata: CellMetadata, +): void { + const colorRecord: ColorRecord = (() => { + if (plugin?.color !== undefined) { + return plugin.color; + } else { + return model._color; + } + })(); + + const [hex, r, g, b] = colorRecord; + + // @ts-ignore + if (metadata._is_hidden_by_aggregate_depth) { + td.style.backgroundColor = ""; + td.style.color = ""; + } else if ( + plugin?.datetime_color_mode === "foreground" && + metadata.user !== null + ) { + td.style.color = hex; + td.style.backgroundColor = ""; + } else if ( + plugin?.datetime_color_mode === "background" && + metadata.user !== null + ) { + const source = model._plugin_background as [number, number, number]; + const foreground = infer_foreground_from_background( + rgbaToRgb([r, g, b, 1], source), + ); + td.style.color = foreground; + td.style.backgroundColor = hex; + } else { + td.style.backgroundColor = ""; + td.style.color = ""; + } +} diff --git a/packages/viewer-datagrid/src/ts/style_handlers/table_cell/numeric.ts b/packages/viewer-datagrid/src/ts/style_handlers/table_cell/numeric.ts new file mode 100644 index 0000000..f61c6b0 --- /dev/null +++ b/packages/viewer-datagrid/src/ts/style_handlers/table_cell/numeric.ts @@ -0,0 +1,185 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { style_cell_flash } from "./cell_flash.js"; +import { + rgbaToRgb, + infer_foreground_from_background, +} from "../../color_utils.js"; +import type { DatagridModel, ColumnConfig, ColorRecord } from "../../types.js"; + +interface CellMetaWithExtras { + _is_hidden_by_aggregate_depth?: boolean; + user?: number; + dy: number; + column_header?: string[]; +} + +interface PluginWithColors + extends Omit< + ColumnConfig, + "pos_fg_color" | "neg_fg_color" | "pos_bg_color" | "neg_bg_color" + > { + pos_bg_color?: ColorRecord; + neg_bg_color?: ColorRecord; + pos_fg_color?: ColorRecord; + neg_fg_color?: ColorRecord; +} + +export function cell_style_numeric( + model: DatagridModel, + plugin: PluginWithColors | undefined, + td: HTMLElement, + metadata: CellMetaWithExtras, + is_settings_open: boolean, +): void { + const is_positive = (metadata.user ?? 0) > 0; + const is_negative = (metadata.user ?? 0) < 0; + + let pos_bg_color: ColorRecord; + if (plugin?.pos_bg_color !== undefined) { + pos_bg_color = plugin.pos_bg_color; + } else { + pos_bg_color = model._pos_bg_color; + } + + let neg_bg_color: ColorRecord; + if (plugin?.neg_bg_color !== undefined) { + neg_bg_color = plugin.neg_bg_color; + } else { + neg_bg_color = model._neg_bg_color; + } + + const bg_tuple: ColorRecord = is_positive + ? pos_bg_color + : is_negative + ? neg_bg_color + : [ + "", + model._plugin_background[0], + model._plugin_background[1], + model._plugin_background[2], + "", + "", + "", + ]; + + { + const [hex, r, g, b] = bg_tuple; + + td.style.position = ""; + if (metadata._is_hidden_by_aggregate_depth) { + td.style.animation = ""; + td.style.backgroundColor = ""; + } else if (plugin?.number_bg_mode === "color") { + td.style.animation = ""; + td.style.backgroundColor = hex; + } else if (plugin?.number_bg_mode === "gradient") { + const a = Math.max( + 0, + Math.min( + 1, + Math.abs((metadata.user ?? 0) / (plugin.bg_gradient ?? 1)), + ), + ); + const source = model._plugin_background as [number, number, number]; + const foreground = infer_foreground_from_background( + rgbaToRgb([r, g, b, a], source), + ); + + td.style.animation = ""; + td.style.color = foreground; + td.style.backgroundColor = `rgba(${r},${g},${b},${a})`; + } else if (plugin?.number_bg_mode === "pulse") { + style_cell_flash( + model, + metadata as any, + td, + pos_bg_color, + neg_bg_color, + is_settings_open, + ); + td.style.backgroundColor = ""; + } else if ( + plugin?.number_bg_mode === "disabled" || + !plugin?.number_bg_mode + ) { + td.style.animation = ""; + td.style.backgroundColor = ""; + } else { + td.style.animation = ""; + td.style.backgroundColor = ""; + } + } + + const fg_tuple: ColorRecord = (() => { + if (plugin?.pos_fg_color !== undefined) { + return is_positive + ? plugin.pos_fg_color + : is_negative + ? plugin.neg_fg_color! + : [ + "", + model._plugin_background[0], + model._plugin_background[1], + model._plugin_background[2], + "", + "", + "", + ]; + } else { + return is_positive + ? model._pos_fg_color + : is_negative + ? model._neg_fg_color + : [ + "", + model._plugin_background[0], + model._plugin_background[1], + model._plugin_background[2], + "", + "", + "", + ]; + } + })(); + + const [hex, , , , gradhex] = fg_tuple; + + if (metadata._is_hidden_by_aggregate_depth) { + td.style.backgroundColor = ""; + td.style.color = ""; + } else if (plugin?.number_fg_mode === "disabled") { + if (plugin?.number_bg_mode === "color") { + const source = model._plugin_background as [number, number, number]; + const foreground = infer_foreground_from_background( + rgbaToRgb([bg_tuple[1], bg_tuple[2], bg_tuple[3], 1], source), + ); + td.style.color = foreground; + } else if (plugin?.number_bg_mode === "gradient") { + // Color already set above + } else { + td.style.color = ""; + } + } else if (plugin?.number_fg_mode === "bar") { + td.style.color = ""; + td.style.position = "relative"; + td.style.setProperty("--psp-label-bar-color", gradhex); + td.style.setProperty("--psp-label-bar-bg", hex); + } else if (plugin?.number_fg_mode === "label-bar") { + td.style.color = ""; + td.style.setProperty("--psp-label-bar-color", gradhex); + td.style.setProperty("--psp-label-bar-bg", hex); + } else if (plugin?.number_fg_mode === "color" || !plugin?.number_fg_mode) { + td.style.color = hex; + } +} diff --git a/packages/viewer-datagrid/src/ts/style_handlers/table_cell/row_header.ts b/packages/viewer-datagrid/src/ts/style_handlers/table_cell/row_header.ts new file mode 100644 index 0000000..5d15b40 --- /dev/null +++ b/packages/viewer-datagrid/src/ts/style_handlers/table_cell/row_header.ts @@ -0,0 +1,54 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { + CellMetadata, + CellMetadataRowHeader, +} from "regular-table/dist/esm/types.js"; +import type { DatagridModel } from "../../types.js"; +import { RegularTableElement } from "regular-table"; + +export function cell_style_row_header( + model: DatagridModel, + regularTable: RegularTableElement, + td: HTMLElement, + metadata: CellMetadataRowHeader, +): void { + const is_not_empty = + metadata.value !== undefined && + metadata.value !== null && + metadata.value?.toString()?.trim().length > 0; + const is_leaf = + (metadata.row_header_x ?? 0) >= model._config.group_by.length; + const next = regularTable.getMeta({ + dx: 0, + dy: (metadata.y ?? 0) - (metadata.y0 ?? 0) + 1, + } as CellMetadata); + + const is_collapse = + next && + next.row_header && + typeof next.row_header[(metadata.row_header_x ?? 0) + 1] !== + "undefined"; + + td.classList.toggle("psp-tree-label", is_not_empty && !is_leaf); + td.classList.toggle( + "psp-tree-label-expand", + is_not_empty && !is_leaf && !is_collapse, + ); + + td.classList.toggle( + "psp-tree-label-collapse", + is_not_empty && !is_leaf && is_collapse, + ); + td.classList.toggle("psp-tree-leaf", is_not_empty && is_leaf); +} diff --git a/packages/viewer-datagrid/src/ts/style_handlers/table_cell/string.ts b/packages/viewer-datagrid/src/ts/style_handlers/table_cell/string.ts new file mode 100644 index 0000000..55d500c --- /dev/null +++ b/packages/viewer-datagrid/src/ts/style_handlers/table_cell/string.ts @@ -0,0 +1,104 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { + hslToRgb, + infer_foreground_from_background, + parseColor, + rgbaToRgb, + rgbToHex, + rgbToHsl, +} from "../../color_utils.js"; +import type { DatagridModel, ColumnConfig, ColorRecord } from "../../types.js"; + +interface CellMetaWithExtras { + _is_hidden_by_aggregate_depth?: boolean; + user?: string | null; + column_header?: string[]; +} + +interface PluginWithColor extends Omit { + color?: ColorRecord; +} + +export function cell_style_string( + model: DatagridModel, + plugin: PluginWithColor | undefined, + td: HTMLElement, + metadata: CellMetaWithExtras, +): void { + const column_name = metadata.column_header?.[model._config.split_by.length]; + const colorRecord: ColorRecord = (() => { + if (plugin?.color !== undefined) { + return plugin.color; + } else { + return model._color; + } + })(); + + const [hex, r, g, b] = colorRecord; + + if (metadata._is_hidden_by_aggregate_depth) { + td.style.backgroundColor = ""; + td.style.color = ""; + } else if ( + plugin?.string_color_mode === "foreground" && + metadata.user !== null + ) { + td.style.color = hex; + td.style.backgroundColor = ""; + if (plugin?.format === "link" && td.children[0]) { + (td.children[0] as HTMLElement).style.color = hex; + } + } else if ( + plugin?.string_color_mode === "background" && + metadata.user !== null + ) { + const source = model._plugin_background as [number, number, number]; + const foreground = infer_foreground_from_background( + rgbaToRgb([r, g, b, 1], source), + ); + td.style.color = foreground; + td.style.backgroundColor = hex; + } else if ( + plugin?.string_color_mode === "series" && + metadata.user !== null && + column_name + ) { + if (!model._series_color_map.has(column_name)) { + model._series_color_map.set(column_name, new Map()); + model._series_color_seed.set(column_name, 0); + } + + const series_map = model._series_color_map.get(column_name)!; + if (metadata.user && !series_map.has(metadata.user)) { + const seed = model._series_color_seed.get(column_name) ?? 0; + series_map.set(metadata.user, seed); + model._series_color_seed.set(column_name, seed + 1); + } + + const color_seed = series_map.get(metadata.user!) ?? 0; + const [h, s, l] = rgbToHsl(parseColor(hex)); + const rotated = hslToRgb([h + ((color_seed * 150) % 360), s, l]); + const [r2, g2, b2] = rotated; + const hex2 = rgbToHex(rotated); + const source = model._plugin_background as [number, number, number]; + const foreground = infer_foreground_from_background( + rgbaToRgb([r2, g2, b2, 1], source), + ); + td.style.color = foreground; + td.style.backgroundColor = hex2; + } else { + td.style.backgroundColor = ""; + td.style.color = ""; + } +} diff --git a/packages/viewer-datagrid/src/ts/style_handlers/types.ts b/packages/viewer-datagrid/src/ts/style_handlers/types.ts new file mode 100644 index 0000000..b9ac4c1 --- /dev/null +++ b/packages/viewer-datagrid/src/ts/style_handlers/types.ts @@ -0,0 +1,31 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { + CellMetadata, + CellMetadataBody, + CellMetadataRowHeader, +} from "regular-table/dist/esm/types.js"; + +export interface CollectedCell { + element: HTMLElement; + metadata: CellMetadataRowHeader | CellMetadataBody; + isHeader: boolean; +} + +export interface CollectedHeaderRow { + row: HTMLTableRowElement; + cells: Array<{ + element: HTMLTableCellElement; + metadata: CellMetadata | undefined; + }>; +} diff --git a/packages/viewer-datagrid/src/ts/types.ts b/packages/viewer-datagrid/src/ts/types.ts new file mode 100644 index 0000000..2084f0c --- /dev/null +++ b/packages/viewer-datagrid/src/ts/types.ts @@ -0,0 +1,297 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { + View, + Table, + ViewConfig, + ColumnType, + SortDir, + ViewWindow, + ViewConfigUpdate, +} from "@perspective-dev/client"; +import type { + HTMLPerspectiveViewerElement, + ViewerConfig, +} from "@perspective-dev/viewer"; +import type { RegularTableElement } from "regular-table"; +import type { CellMetadata, DataResponse } from "regular-table/dist/esm/types"; + +// Re-export types from regular-table for use throughout the codebase +export type { RegularTableElement as RegularTable }; + +export function get_psp_type( + model: DatagridModel, + metadata: CellMetadata, +): ColumnType { + if ( + metadata.type === "body" || + metadata.type === "column_header" || + metadata.type === "corner" + ) { + return model._column_types[metadata.x]; + } else { + return model._row_header_types[(metadata.row_header_x ?? 0) - 1]; + } +} + +// Edit mode for the datagrid +export type EditMode = + | "READ_ONLY" + | "EDIT" + | "SELECT_COLUMN" + | "SELECT_ROW" + | "SELECT_REGION" + | "SELECT_ROW_TREE"; + +// Color record for styling - tuple returned by make_color_record +export type ColorRecord = [ + string, // hex color + number, // red + number, // green + number, // blue + string, // gradient + string, // rgba solid + string, // rgba transparent +]; + +export type SortTerm = [string, SortDir]; + +// Selection state for mouse-based region selection +export interface SelectionArea { + x0: number; + x1: number; + y0: number; + y1: number; +} + +export interface SelectionState { + selected_areas: SelectionArea[]; + dirty: boolean; + CURRENT_MOUSEDOWN_COORDINATES?: { x?: number; y?: number }; + old_selected_areas?: SelectionArea[]; + potential_selection?: SelectionArea; +} + +// Position tracking for cell focus +export interface SelectedPosition { + x: number; + y: number; + content?: string; +} + +// Column configuration values from viewer +export interface ColumnConfig { + color?: string; + pos_fg_color?: string; + neg_fg_color?: string; + pos_bg_color?: string; + neg_bg_color?: string; + fg_gradient?: number; + bg_gradient?: number; + number_fg_mode?: string; + number_bg_mode?: string; + string_color_mode?: string; + datetime_color_mode?: string; + fixed?: number; + aggregate_depth?: number; + column_size_override?: number; + format?: string; + date_format?: DateFormatConfig; + number_format?: NumberFormatConfig; +} + +// Date format configuration for column styling +export interface DateFormatConfig { + format?: "custom" | string; + timeZone?: string; + dateStyle?: "short" | "medium" | "long" | "full" | "disabled"; + timeStyle?: "short" | "medium" | "long" | "full" | "disabled"; + second?: "numeric" | "2-digit" | "disabled"; + minute?: "numeric" | "2-digit" | "disabled"; + hour?: "numeric" | "2-digit" | "disabled"; + day?: "numeric" | "2-digit" | "disabled"; + weekday?: "narrow" | "short" | "long" | "disabled"; + month?: "numeric" | "2-digit" | "narrow" | "short" | "long" | "disabled"; + year?: "numeric" | "2-digit" | "disabled"; + hour12?: boolean; + fractionalSecondDigits?: 1 | 2 | 3; +} + +// Number format configuration for column styling +export interface NumberFormatConfig { + style?: "decimal" | "currency" | "percent" | "unit"; + minimumFractionDigits?: number; + maximumFractionDigits?: number; + minimumIntegerDigits?: number; + minimumSignificantDigits?: number; + maximumSignificantDigits?: number; + currency?: string; + currencyDisplay?: "code" | "symbol" | "narrowSymbol" | "name"; + notation?: "standard" | "scientific" | "engineering" | "compact"; + compactDisplay?: "short" | "long"; + useGrouping?: boolean; +} + +export type ColumnsConfig = Record; + +// Plugin save state +export interface DatagridPluginConfig { + columns?: ColumnsConfig; + editable?: boolean; + scroll_lock?: boolean; + edit_mode?: EditMode; + column_size_override?: Record; +} + +// Element factory for reusing DOM elements +export interface ElemFactory { + clear(): void; + get(): HTMLElement; +} + +export type Schema = Record; + +// Model object stored on regular-table +export interface DatagridModel { + _edit_port: number; + _view: View; + _table: Table; + _table_schema: Schema; + _config: ViewerConfig; + _num_rows: number; + _num_columns?: number; + _schema: Schema; + _theme: string; + _ids: unknown[][]; + _plugin_background: number[]; + _color: ColorRecord; + _pos_fg_color: ColorRecord; + _neg_fg_color: ColorRecord; + _pos_bg_color: ColorRecord; + _neg_bg_color: ColorRecord; + _column_paths: string[]; + _column_types: ColumnType[]; + _is_editable: boolean[]; + _edit_mode: EditMode; + _tree_selection_id?: unknown[]; + _last_insert_configs?: ViewConfigUpdate[]; + _selection_state: SelectionState; + _row_header_types: ColumnType[]; + _series_color_map: Map>; + _series_color_seed: Map; + _div_factory: ElemFactory; + _last_window?: ViewWindow; + _is_old_viewport?: boolean; + _reverse_columns?: Map; + _reverse_ids?: Map; + last_column_paths?: string[]; + last_meta?: unknown[][]; + last_ids?: unknown[][]; + last_reverse_ids?: Map; + last_reverse_columns?: Map; + get_psp_type(metadata: CellMetadata): ColumnType; + _column_settings_selected_column?: string; +} + +// Symbol for private plugin data on regular-table +export const PRIVATE_PLUGIN_SYMBOL: unique symbol = Symbol( + "Perspective Column Config", +); + +// Data listener function type +export type DataListener = ( + regularTable: RegularTableElement, + x0: number, + y0: number, + x1: number, + y1: number, +) => Promise; + +// Style listener function type +export type StyleListener = () => void; + +// Toolbar element interface +export interface DatagridToolbarElement extends HTMLElement { + setEditButton(button: HTMLElement): void; + setScrollLockButton(button: HTMLElement): void; +} + +// Column override for persisting column sizes +export type ColumnOverrides = Record; + +// Formatter cache types +export interface FormatterCacheEntry { + format(value: unknown): string; +} + +export type FormatterCache = Map; + +// Cell config result from getCellConfig +export interface CellConfigResult { + row: Record; + column_names: string[]; + config: ViewConfigUpdate; +} + +// Custom event detail types +export interface PerspectiveClickDetail { + row: Record; + column_names: string[]; + config: Partial; +} + +export { PerspectiveSelectDetail } from "@perspective-dev/viewer/src/ts/extensions.js"; + +// Mouse event with handled flag +export interface HandledMouseEvent extends MouseEvent { + handled?: boolean; +} + +// Sort order mappings +export type SortRotationOrder = Record; + +// Datagrid plugin element interface for toolbar +export interface DatagridPluginElement extends HTMLElement { + regular_table: RegularTableElement; + model?: DatagridModel; + _toolbar?: DatagridToolbarElement; + _edit_button?: HTMLElement; + _scroll_lock?: HTMLElement; + _is_scroll_lock: boolean; + _edit_mode: EditMode; + _initialized?: boolean; + _reset_scroll_top?: boolean; + _reset_scroll_left?: boolean; + _reset_select?: boolean; + _reset_column_size?: boolean; +} + +// Map types for selected positions +export type SelectedPositionMap = WeakMap< + RegularTableElement, + SelectedPosition +>; + +// Centralized editable mode check - used by style handlers and event handlers +export function isEditableMode( + model: DatagridModel, + viewer: HTMLPerspectiveViewerElement, + allowed: boolean = false, +): boolean { + const has_pivots = + model._config.group_by.length === 0 && + model._config.split_by.length === 0; + const plugin = viewer.children[0] as DatagridPluginElement | undefined; + const editable = allowed || plugin?._edit_mode === "EDIT"; + return has_pivots && editable; +} diff --git a/packages/viewer-datagrid/test/js/column_settings.spec.ts b/packages/viewer-datagrid/test/js/column_settings.spec.ts new file mode 100644 index 0000000..771cc6f --- /dev/null +++ b/packages/viewer-datagrid/test/js/column_settings.spec.ts @@ -0,0 +1,318 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { + PageView as PspViewer, + compareNodes, + expect, + test, +} from "@perspective-dev/test"; + +test.describe("Datagrid Column Styles", function () { + test.beforeEach(async ({ page }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + }); + + test.skip("Interacting with column settings does not override column width", async function ({ + page, + }) { + const view = new PspViewer(page); + await view.openSettingsPanel(); + const resize_handle = view.dataGrid.regularTable.columnTitleRow + .locator("th .rt-column-resize") + .first(); + + const pos = await resize_handle.boundingBox(); + await page.mouse.move(pos!.x + 2, pos!.y + 5); + await page.mouse.down(); + await page.mouse.move(pos!.x + 100, pos!.y + 5); + await page.mouse.up(); + + const editBtn = view.dataGrid.regularTable.editBtnRow + .locator("th.psp-menu-enabled span") + .first(); + + await editBtn.click(); + await view.columnSettingsSidebar.container.waitFor(); + await page + .locator('div[data-value="Decimal"] select') + .selectOption("Percent"); + const token = await view.save(); + test.expect(token.columns_config).toEqual({ + "Row ID": { + number_string_format: { + style: "percent", + }, + }, + }); + test.expect(token.plugin_config.columns).toEqual({ + "Row ID": { + column_size_override: 150, + }, + }); + }); +}); + +const runTests = (title: string, beforeEachAndLocalTests: () => void) => { + return test.describe(title, () => { + beforeEachAndLocalTests.call(this); + + test("Clicking edit button toggles sidebar", async ({ page }) => { + const view = new PspViewer(page); + await view.openSettingsPanel(); + const editBtn = view.dataGrid.regularTable.editBtnRow + .locator("th.psp-menu-enabled span") + .first(); + await editBtn.click(); + await view.columnSettingsSidebar.container.waitFor(); + await editBtn.click(); + await view.columnSettingsSidebar.container.waitFor({ + state: "hidden", + }); + }); + + test("Toggling a column in the sidebar highlights in the plugin", async ({ + page, + }) => { + const view = new PspViewer(page); + const table = view.dataGrid.regularTable; + const activeColumns = view.settingsPanel.activeColumns; + + await view.openSettingsPanel(); + const col = await activeColumns.getFirstVisibleColumn(); + const name = await col.name.innerText(); + expect(name).toBeDefined(); + + const n = await table.getTitleIdx(name); + expect(n).toBeGreaterThan(-1); + + const nthEditBtn = table.realEditBtns.nth(n); + const selectedEditBtn = table.editBtnRow + .locator(".psp-menu-open") + .first(); + + await col.editBtn.click(); + await selectedEditBtn.waitFor(); + + expect(await compareNodes(nthEditBtn, selectedEditBtn, page)).toBe( + true, + ); + + await col.editBtn.click(); + await selectedEditBtn.waitFor({ state: "hidden" }); + }); + + test("Scrolling the table horizontally keeps the correct column highlighted", async ({ + page, + }) => { + const view = new PspViewer(page); + const table = view.dataGrid.regularTable; + + const thirdTitle = table.columnTitleRow.locator("th").nth(3); + const thirdEditBtn = table.editBtnRow.locator("th").nth(3); + const selectedTitle = table.columnTitleRow + .locator(".psp-menu-open") + .first(); + const selectedEditBtn = table.editBtnRow + .locator(".psp-menu-open") + .first(); + + await view.openSettingsPanel(); + await table.element.evaluate((node) => (node.scrollLeft = 0)); + await thirdEditBtn.click(); + await selectedEditBtn.waitFor(); + await selectedTitle.waitFor(); + expect( + await compareNodes(thirdEditBtn, selectedEditBtn, page), + ).toBe(true); + expect(await compareNodes(thirdTitle, selectedTitle, page)).toBe( + true, + ); + + await table.element.evaluate((node) => (node.scrollLeft = 1000)); + await table.element.evaluate((node) => (node.scrollLeft = 0)); + await page.evaluate( + async () => await new Promise((x) => requestAnimationFrame(x)), + ); + await selectedEditBtn.waitFor(); + await selectedTitle.waitFor(); + expect( + await compareNodes(thirdEditBtn, selectedEditBtn, page), + ).toBe(true); + expect(await compareNodes(thirdTitle, selectedTitle, page)).toBe( + true, + ); + }); + }); +}; + +runTests("Datagrid Column Styles", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + }); + + // These tests only check that a connection is made between the column settings sidebar + // and the plugin itself. They do not need to check the exact contents of the plugin. + test.skip("Numeric styling", async ({ page }) => { + const view = new PspViewer(page); + const table = view.dataGrid.regularTable; + + const col = await view.getOrCreateColumnByType("numeric"); + await col.editBtn.click(); + const name = await col.name.innerText(); + expect(name).toBeTruthy(); + const td = await table.getFirstCellByColumnName(name); + await td.waitFor(); + + // bg style + await view.columnSettingsSidebar.openTab("Style"); + + const oldContents = await td.evaluate((node) => node.innerHTML); + const listener = await view.getEventListener( + "perspective-column-style-change", + ); + await page + .locator('div[data-value="Decimal"] select') + .selectOption("Percent"); + expect(await listener()).toBe(true); + const newContents = await td.evaluate((node) => node.innerHTML); + expect(oldContents).not.toBe(newContents); + }); + + test.skip("Calendar styling", async ({ page }) => { + const view = new PspViewer(page); + const table = view.dataGrid.regularTable; + const col = await view.getOrCreateColumnByType("calendar"); + const name = await col.name.innerText(); + expect(name).toBeTruthy(); + const td = await table.getFirstCellByColumnName(name); + await td.waitFor(); + + // text style + view.assureColumnSettingsOpen(col); + await view.columnSettingsSidebar.openTab("Style"); + const checkbox = view.columnSettingsSidebar.container + .getByRole("checkbox", { disabled: false }) + .first(); + + const tdStyle = await td.evaluate((node) => { + return node.style.cssText; + }); + const listener = await view.getEventListener( + "perspective-column-style-change", + ); + await checkbox.click(); + expect(await listener()).toBe(true); + const newStyle = await td.evaluate((node) => { + return node.style.cssText; + }); + expect(tdStyle).not.toBe(newStyle); + }); + + test.skip("Boolean styling", async ({ page }) => { + // Boolean styling is not implemented. + }); + + test.skip("String styling", async ({ page }) => { + const view = new PspViewer(page); + const table = view.dataGrid.regularTable; + + const col = await view.getOrCreateColumnByType("string"); + const name = await col.name.innerText(); + expect(name).toBeTruthy(); + const td = await table.getFirstCellByColumnName(name); + await td.waitFor(); + + // bg color + await view.assureColumnSettingsOpen(col); + await view.columnSettingsSidebar.openTab("Style"); + const container = view.columnSettingsSidebar.container; + const checkbox = container.getByRole("checkbox").last(); + await checkbox.waitFor(); + + const tdStyle = await td.evaluate((node) => node.style.cssText); + const listener = await view.getEventListener( + "perspective-column-style-change", + ); + await checkbox.check(); + expect(await listener()).toBe(true); + const newStyle = await td.evaluate((node) => node.style.cssText); + expect(tdStyle).not.toBe(newStyle); + }); +}); + +// Keeping the column sidebar open makes this unncessary. +test.skip("Edit highlights go away when view re-draws", async ({ page }) => { + const viewer = new PspViewer(page); + await viewer.openSettingsPanel(); + const btn = await viewer.dataGrid.regularTable.getEditBtnByName("Row ID"); + await btn.click(); + await viewer.settingsPanel.groupby("Ship Mode"); + await viewer.columnSettingsSidebar.container.waitFor({ + state: "detached", + }); + + await viewer.dataGrid.regularTable.openColumnEditBtn + .first() + .waitFor({ state: "detached" }); +}); + +// Data grid table header rows look different when a split-by is present. +runTests("Datagrid Column Styles - Split-by", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/tools/test/src/html/superstore-test.html"); + + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + }); + + test("Datagrid Column Styles - Only edit buttons get styled", async ({ + page, + }) => { + await page.goto("/tools/test/src/html/superstore-test.html"); + + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + const viewer = new PspViewer(page); + const headers = viewer.dataGrid.regularTable.table + .locator("thead") + .filter({ + hasNot: page + .locator("#psp-column-titles") + .or(page.locator("#psp-column-edit-buttons")), + has: page.locator("th.psp-menu-open"), + }); + + await viewer.openSettingsPanel(); + const btn = + await viewer.dataGrid.regularTable.getEditBtnByName("Sales"); + await expect(btn).toBeVisible(); + await btn.click(); + await expect(headers).not.toBeAttached(); + }); +}); diff --git a/packages/viewer-datagrid/test/js/column_style.spec.ts b/packages/viewer-datagrid/test/js/column_style.spec.ts new file mode 100644 index 0000000..505cd76 --- /dev/null +++ b/packages/viewer-datagrid/test/js/column_style.spec.ts @@ -0,0 +1,717 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import { compareContentsToSnapshot } from "@perspective-dev/test"; +import type { Page } from "@playwright/test"; + +async function test_column( + page: Page, + selector: string, + container_class: string, +): Promise { + const { x, y } = await page.evaluate(async (selector) => { + const viewer = document.querySelector("perspective-viewer")!; + await viewer.getTable(); + await viewer.toggleConfig(); + (window as any).__events__ = []; + viewer.addEventListener("perspective-config-update", (evt) => { + (window as any).__events__.push(evt); + }); + + const header_button = ( + viewer.querySelector("perspective-viewer-datagrid") as any + ).shadowRoot.querySelector( + "regular-table thead tr:last-child th" + selector, + ); + + const rect = header_button.getBoundingClientRect(); + return { + x: Math.floor(rect.left + rect.width / 2), + y: Math.floor(rect.top + (3 * rect.height) / 4), + }; + }, selector); + + await page.mouse.click(x, y); + const column_style_selector = `#column-style-container.${container_class}`; + await page.waitForSelector(column_style_selector); + + await new Promise((x) => setTimeout(x, 3000)); + + return await page + .locator(`perspective-viewer ${column_style_selector}`) + .innerHTML(); +} + +test.describe("Column Style Tests", () => { + test.skip("perspective-config-update event is fired when column style is changed", async ({ + page, + }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.restore({ + plugin: "Datagrid", + }); + }); + + const { x, y } = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + // Await the table load + await viewer.getTable(); + + // Open the config panel + await viewer.toggleConfig(); + + // Register a listener for `perspective-config-update` event + (window as any).__events__ = []; + viewer.addEventListener("perspective-config-update", (evt) => { + console.log(evt.type, evt.detail); + (window as any).__events__.push(evt); + }); + viewer.addEventListener( + "perspective-column-style-change", + (evt) => { + // console.log(evt.type, evt.detail); + (window as any).__events__.push(evt); + }, + ); + + // Find the column config menu button + const header_button = ( + viewer.querySelector("perspective-viewer-datagrid") as any + ).shadowRoot.querySelector("regular-table thead tr:last-child th"); + + // Get the button coords (slightly lower than center + // because of the location of the menu button within + // this element) + const rect = header_button.getBoundingClientRect(); + return { + x: Math.floor(rect.left + rect.width / 2), + y: Math.floor(rect.top + (3 * rect.height) / 4), + }; + }); + + // Click the menu button + await page.mouse.click(x, y); + + // Await the style menu existing on the page + const style_menu = await page.waitForSelector( + "#column-style-container", + ); + + const { x: xx, y: yy } = await page.evaluate(async (style_menu) => { + // Find the 'bar' button + const bar_button = style_menu.querySelector("select")!; + + // Get its coords + const rect = bar_button.getBoundingClientRect(); + return { + x: Math.floor(rect.left + rect.width / 2), + y: Math.floor(rect.top + rect.height / 2), + }; + }, style_menu); + + // Click the button + await page.mouse.click(xx, yy); + + const count = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + // Await the plugin rendering + await viewer.flush(); + + // Count the events; + return (window as any).__events__.length; + }); + + // Expect 1 event + expect(count).toEqual(2); + }); + + test.skip("Pulse styling works", async ({ page }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + await viewer.restore({ + plugin: "Datagrid", + columns: ["Row ID", "Sales"], + columns_config: { + Sales: { + datagrid_number_style: { number_bg_mode: "pulse" }, + }, + }, + }); + + const table = await viewer.getTable(); + await table.update([{ "Row ID": 1, Sales: 2 }]); + await viewer.resize(); + await table.update([{ "Row ID": 1, Sales: 3 }]); + }); + + const contents = await page + .locator(`perspective-viewer-datagrid regular-table`) + .innerHTML(); + + await compareContentsToSnapshot(contents); + }); + + test.skip("Pulse styling works when settings panel is open", async ({ + page, + }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + await viewer.restore({ + plugin: "Datagrid", + columns: ["Row ID", "Sales"], + settings: true, + columns_config: { + Sales: { + datagrid_number_style: { number_bg_mode: "pulse" }, + }, + }, + }); + + const table = await viewer.getTable(); + await table.update([{ "Row ID": 1, Sales: 2 }]); + await viewer.resize(); + await table.update([{ "Row ID": 1, Sales: 3 }]); + }); + + const contents = await page + .locator(`perspective-viewer-datagrid regular-table`) + .innerHTML(); + + await compareContentsToSnapshot(contents); + }); + + test("Column style menu opens for numeric columns", async ({ page }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.restore({ + plugin: "Datagrid", + }); + }); + + const contents = await test_column(page, "", "tab-section"); + await compareContentsToSnapshot(contents); + }); + + test("Column style label-bar", async ({ page }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.restore({ + columns_config: { + "Row ID": { + number_fg_mode: "label-bar", + }, + }, + plugin: "Datagrid", + settings: true, + sort: [["Order ID", "desc"]], + columns: ["Row ID", "Order ID"], + }); + }); + + const contents = await page + .locator(`perspective-viewer-datagrid regular-table`) + .innerHTML(); + + await compareContentsToSnapshot(contents); + }); + + test("Column style label-bar on an expression column", async ({ page }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.restore({ + columns_config: { + test: { + number_fg_mode: "label-bar", + }, + }, + plugin: "Datagrid", + settings: true, + expressions: { test: `"Row ID" + 100` }, + sort: [["Order ID", "desc"]], + columns: ["test", "Row ID", "Order ID"], + }); + }); + + await page.pause(); + const contents = await page + .locator(`perspective-viewer-datagrid regular-table`) + .innerHTML(); + + await compareContentsToSnapshot(contents); + }); + + test("Column style menu opens for string columns", async ({ page }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.restore({ + plugin: "Datagrid", + }); + }); + + const contents = await test_column( + page, + ":nth-child(2)", + "string-column-style-container", + ); + + await compareContentsToSnapshot(contents); + }); + + // ────────────────────────────────────────────────────────────────── + // Foreground rendering modes against a float column that contains + // negatives ("Profit"), so the pos/neg color split has signal in + // both halves of the range. + // ────────────────────────────────────────────────────────────────── + test("Bar foreground on float column with negatives", async ({ page }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + await viewer.restore({ + plugin: "Datagrid", + columns: ["Row ID", "Profit"], + columns_config: { + Profit: { number_fg_mode: "bar" }, + }, + }); + }); + + const contents = await page + .locator(`perspective-viewer-datagrid regular-table`) + .innerHTML(); + + await compareContentsToSnapshot(contents); + }); + + test("Label-bar foreground on float column with negatives", async ({ + page, + }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + await viewer.restore({ + plugin: "Datagrid", + columns: ["Row ID", "Profit"], + columns_config: { + Profit: { number_fg_mode: "label-bar" }, + }, + }); + }); + + const contents = await page + .locator(`perspective-viewer-datagrid regular-table`) + .innerHTML(); + + await compareContentsToSnapshot(contents); + }); + + test("Label-bar foreground + gradient background on float column", async ({ + page, + }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + await viewer.restore({ + plugin: "Datagrid", + columns: ["Row ID", "Profit"], + columns_config: { + Profit: { + number_fg_mode: "label-bar", + number_bg_mode: "gradient", + }, + }, + }); + }); + + const contents = await page + .locator(`perspective-viewer-datagrid regular-table`) + .innerHTML(); + + await compareContentsToSnapshot(contents); + }); + + // ────────────────────────────────────────────────────────────────── + // Sidebar should re-query schema and surface extra controls (the + // background `ColorRange` and gradient `Number` max) when + // `number_bg_mode` is set to `gradient`. + // ────────────────────────────────────────────────────────────────── + test("Sidebar surfaces gradient controls when bg_mode = gradient", async ({ + page, + }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + await viewer.restore({ + plugin: "Datagrid", + columns: ["Row ID", "Profit"], + settings: true, + columns_config: { + Profit: { number_bg_mode: "gradient" }, + }, + }); + }); + + const { x, y } = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + const editBtn = ( + viewer.querySelector("perspective-viewer-datagrid") as any + ).shadowRoot.querySelector( + "#psp-column-edit-buttons th.psp-menu-enabled:nth-child(2) span", + ); + + const rect = editBtn.getBoundingClientRect(); + return { + x: Math.floor(rect.left + rect.width / 2), + y: Math.floor(rect.top + rect.height / 2), + }; + }); + + await page.mouse.click(x, y); + + // The schema for `Profit` with bg_mode=gradient should emit a + // `ColorRange` (background-pos/neg) and a `Number` field for + // `bg_gradient`. Both are tab-section children in the StyleTab. + await page + .locator("perspective-viewer #column_settings_sidebar") + .waitFor(); + + const sidebar_locator = page.locator( + "perspective-viewer #column_settings_sidebar #style-tab", + ); + + // Background ColorRange ids derive from the `label` + // ("background") in the Datagrid schema. + await sidebar_locator.locator(".pos_bg_color").waitFor(); + await sidebar_locator.locator(".neg_bg_color").waitFor(); + + // Snapshot the sidebar's style-tab DOM as a holistic check. + const contents = await sidebar_locator.innerHTML(); + await compareContentsToSnapshot(contents); + }); + + // ────────────────────────────────────────────────────────────────── + // At least one columns_config setting from each column type renders + // a visible change in the grid when applied. + // ────────────────────────────────────────────────────────────────── + test("float number_format use_grouping renders in grid", async ({ + page, + }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + await viewer.restore({ + plugin: "Datagrid", + columns: ["Row ID", "Sales"], + columns_config: { + Sales: { + number_format: { use_grouping: "always" }, + }, + }, + }); + }); + + const contents = await page + .locator(`perspective-viewer-datagrid regular-table`) + .innerHTML(); + + await compareContentsToSnapshot(contents); + }); + + test("integer number_format notation renders in grid", async ({ page }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + await viewer.restore({ + plugin: "Datagrid", + columns: ["Row ID"], + columns_config: { + "Row ID": { + number_format: { notation: "compact" }, + }, + }, + }); + }); + + const contents = await page + .locator(`perspective-viewer-datagrid regular-table`) + .innerHTML(); + + await compareContentsToSnapshot(contents); + }); + + test("string format renders in grid", async ({ page }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + await viewer.restore({ + plugin: "Datagrid", + columns: ["Row ID", "State"], + columns_config: { + State: { format: "bold" }, + }, + }); + }); + + const contents = await page + .locator(`perspective-viewer-datagrid regular-table`) + .innerHTML(); + + await compareContentsToSnapshot(contents); + }); + + test("date date_format renders in grid", async ({ page }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + await viewer.restore({ + plugin: "Datagrid", + columns: ["Row ID", "Order Date"], + columns_config: { + "Order Date": { + date_format: { + date_style: "full", + time_style: "medium", + }, + }, + }, + }); + }); + + const contents = await page + .locator(`perspective-viewer-datagrid regular-table`) + .innerHTML(); + + await compareContentsToSnapshot(contents); + }); + + test("datetime date_format renders in grid", async ({ page }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + await viewer.restore({ + plugin: "Datagrid", + // Order Date is a datetime in basic-test fixture. + columns: ["Row ID", "Order Date"], + columns_config: { + "Order Date": { + date_format: { + date_style: "long", + time_style: "long", + }, + }, + }, + }); + }); + + const contents = await page + .locator(`perspective-viewer-datagrid regular-table`) + .innerHTML(); + + await compareContentsToSnapshot(contents); + }); + + // Regression: a single `restore()` carrying both `plugin_config` and + // `columns_config` used to drop `columns_config` because + // `restore_and_render` combined them with a short-circuiting `||`. + // Asserting via `save()` keeps the test independent of any plugin's + // rendering choices. + test("first restore applies columns_config when plugin_config is also set", async ({ + page, + }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + const saved = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + await viewer.restore({ + plugin: "Datagrid", + columns: ["Row ID", "Sales"], + plugin_config: { edit_mode: "EDIT" }, + columns_config: { + Sales: { number_bg_mode: "pulse" }, + }, + }); + return await viewer.save(); + }); + + expect(saved.plugin_config).toEqual({ edit_mode: "EDIT" }); + expect(saved.columns_config).toEqual({ + Sales: { number_bg_mode: "pulse" }, + }); + }); + + test("first restore applies columns_config + plugin_config without an explicit plugin", async ({ + page, + }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + const saved = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + await viewer.restore({ + columns: ["Row ID", "Sales"], + plugin_config: { edit_mode: "EDIT" }, + columns_config: { + Sales: { number_bg_mode: "pulse" }, + }, + }); + return await viewer.save(); + }); + + expect(saved.plugin_config).toEqual({ edit_mode: "EDIT" }); + expect(saved.columns_config).toEqual({ + Sales: { number_bg_mode: "pulse" }, + }); + }); + + test("repeated restore with plugin_config + columns_config is stable", async ({ + page, + }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + const [first, second] = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + const payload = { + plugin: "Datagrid", + columns: ["Row ID", "Sales"], + plugin_config: { edit_mode: "EDIT" }, + columns_config: { + Sales: { number_bg_mode: "pulse" }, + }, + }; + await viewer.restore(payload); + const first = await viewer.save(); + await viewer.restore(payload); + const second = await viewer.save(); + return [first, second]; + }); + + expect(first.plugin_config).toEqual({ edit_mode: "EDIT" }); + expect(first.columns_config).toEqual({ + Sales: { number_bg_mode: "pulse" }, + }); + expect(second.plugin_config).toEqual(first.plugin_config); + expect(second.columns_config).toEqual(first.columns_config); + }); +}); diff --git a/packages/viewer-datagrid/test/js/number_string_format.spec.ts b/packages/viewer-datagrid/test/js/number_string_format.spec.ts new file mode 100644 index 0000000..e24ee58 --- /dev/null +++ b/packages/viewer-datagrid/test/js/number_string_format.spec.ts @@ -0,0 +1,204 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { + PageView, + compareContentsToSnapshot, + test, + expect, +} from "@perspective-dev/test"; + +import { DataGrid } from "@perspective-dev/test/src/js/models/plugins/datagrid.ts"; + +test.beforeEach(async ({ page }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); +}); + +test.describe("Number & String Format", () => { + test("Integer/float styles", async ({ page }) => { + const view = new PageView(page); + await view.restore({ + settings: true, + plugin: "Datagrid", + columns: ["Profit", "Row ID"], + }); + const profit = + await view.settingsPanel.activeColumns.getColumnByName("Profit"); + await profit.editBtn.click(); + const styleContainer = view.columnSettingsSidebar.styleTab.container; + await styleContainer.locator("#fractional-digits-label").waitFor(); + const rowId = + await view.settingsPanel.activeColumns.getColumnByName("Row ID"); + await rowId.editBtn.click(); + await styleContainer + .getByText("Fractional Digits") + .waitFor({ state: "detached" }); + }); + + for (const name of ["Significant Digits", "Fractional Digits"]) { + test.skip(`Rounding Increment doesn't send when ${name} is open`, async ({ + page, + }) => { + const view = new PageView(page); + await view.restore({ + settings: true, + plugin: "Datagrid", + columns: ["Profit"], + }); + const profit = + await view.settingsPanel.activeColumns.getColumnByName( + "Profit", + ); + await profit.editBtn.click(); + const styleContainer = + view.columnSettingsSidebar.styleTab.container; + await styleContainer + .locator('div[data-value="Auto"] select') + .first() + .selectOption("20"); + + await styleContainer.getByText(name).click(); + let config = await view.save(); + expect(config).toMatchObject({ + columns_config: {}, + }); + + await styleContainer.getByText(name).click(); + config = await view.save(); + expect(config).toMatchObject({ + columns_config: { + Profit: { + number_format: { + maximumFractionDigits: 0, + roundingIncrement: 20, + }, + }, + }, + }); + }); + } + + test.skip("Rounding Priority doesn't send unless Fractional and Significant Digits are open", async ({ + page, + }) => { + const view = new PageView(page); + await view.restore({ + settings: true, + plugin: "Datagrid", + columns: ["Profit"], + }); + const col = + await view.settingsPanel.activeColumns.getColumnByName("Profit"); + await col.editBtn.click(); + const styleContainer = view.columnSettingsSidebar.styleTab.container; + await expect( + styleContainer.locator("#Rounding-Priority-checkbox"), + ).toBeEnabled(); + + const select = styleContainer + .locator('div[data-value="Auto"] select') + .nth(1); + await select.scrollIntoViewIfNeeded(); + await select.selectOption("MorePrecision"); + const config = await view.save(); + expect(config).toMatchObject({ + columns_config: { + Profit: { + number_format: { + roundingPriority: "morePrecision", + }, + }, + }, + }); + await styleContainer.getByText("Fractional Digits").click(); + const config2 = await view.save(); + expect(config2).toMatchObject({ + columns_config: {}, + }); + }); + + test("Datagrid integration", async ({ page }) => { + const view = new PageView(page); + const datagrid = new DataGrid(page); + await view.restore({ + plugin: "Datagrid", + columns: ["Profit"], + columns_config: { + Profit: { + number_format: { + minimumIntegerDigits: 3, + maximumFractionDigits: 0, + roundingIncrement: 50, + roundingMode: "ceil", + notation: "compact", + compactDisplay: "short", + signDisplay: "always", + }, + }, + }, + }); + + const decimal = await datagrid.regularTable.table.innerHTML(); + await page.pause(); + await compareContentsToSnapshot(decimal); + await view.restore({ + plugin: "Datagrid", + columns: ["Profit"], + columns_config: { + Profit: { + number_format: { + style: "currency", + currency: "USD", + currencySign: "accounting", + }, + }, + }, + }); + + const currency = await datagrid.regularTable.table.innerHTML(); + await compareContentsToSnapshot(currency); + await view.restore({ + plugin: "Datagrid", + columns: ["Profit"], + columns_config: { + Profit: { + number_format: { + style: "unit", + unit: "byte", + }, + }, + }, + }); + + const unit = await datagrid.regularTable.table.innerHTML(); + await compareContentsToSnapshot(unit); + await view.restore({ + plugin: "Datagrid", + columns: ["Profit"], + columns_config: { + Profit: { + number_format: { + style: "percent", + }, + }, + }, + }); + + const data = await datagrid.regularTable.table.innerHTML(); + await compareContentsToSnapshot(data); + }); +}); diff --git a/packages/viewer-datagrid/test/js/scroll.spec.ts b/packages/viewer-datagrid/test/js/scroll.spec.ts new file mode 100644 index 0000000..bb81f12 --- /dev/null +++ b/packages/viewer-datagrid/test/js/scroll.spec.ts @@ -0,0 +1,443 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test } from "@perspective-dev/test"; +import { compareContentsToSnapshot } from "@perspective-dev/test"; +import type { Page } from "@playwright/test"; +import * as prettier from "prettier"; + +async function getDatagridContents(page: Page): Promise { + const raw = await page.evaluate(async () => { + const datagrid = document.querySelector( + "perspective-viewer perspective-viewer-datagrid", + ) as any; + if (!datagrid) { + return "MISSING DATAGRID"; + } + + const regularTable = datagrid.shadowRoot.querySelector("regular-table"); + return regularTable?.innerHTML || ""; + }); + + return await prettier.format(raw, { + parser: "html", + }); +} + +async function getScrollState(page: Page) { + return await page.evaluate(async () => { + const datagrid = document.querySelector( + "perspective-viewer perspective-viewer-datagrid", + ) as any; + const regularTable = datagrid.shadowRoot.querySelector("regular-table"); + return { + scrollTop: regularTable.scrollTop, + scrollLeft: regularTable.scrollLeft, + scrollHeight: regularTable.scrollHeight, + scrollWidth: regularTable.scrollWidth, + clientHeight: regularTable.clientHeight, + clientWidth: regularTable.clientWidth, + }; + }); +} + +async function getVisibleCellData(page: Page) { + return await page.evaluate(async () => { + const datagrid = document.querySelector( + "perspective-viewer perspective-viewer-datagrid", + ) as any; + const regularTable = datagrid.shadowRoot.querySelector("regular-table"); + const tbody = regularTable.querySelector("table tbody"); + const thead = regularTable.querySelector("table thead"); + + const headerCells = Array.from( + thead.querySelectorAll("tr:last-child th"), + ).map((th: any) => th.textContent.trim()); + + const firstRow = tbody.querySelector("tr"); + const firstRowCells = firstRow + ? Array.from(firstRow.querySelectorAll("td")).map((td: any) => + td.textContent.trim(), + ) + : []; + + const lastRow = tbody.querySelector("tr:last-child"); + const lastRowCells = lastRow + ? Array.from(lastRow.querySelectorAll("td")).map((td: any) => + td.textContent.trim(), + ) + : []; + + return { + headerCells, + firstRowCells, + lastRowCells, + rowCount: tbody.querySelectorAll("tr").length, + }; + }); +} + +test.describe("Datagrid scroll tests with superstore data", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.restore({ + plugin: "Datagrid", + }); + }); + }); + + test.describe("Vertical scroll", () => { + test("Scrolling down updates visible rows", async ({ page }) => { + const initialData = await getVisibleCellData(page); + + await page.evaluate(async () => { + const datagrid = document.querySelector( + "perspective-viewer-datagrid", + ) as any; + + const regularTable = + datagrid.shadowRoot.querySelector("regular-table"); + + regularTable.scrollTop = 1000; + await regularTable.draw(); + }); + + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.flush(); + }); + + const scrolledData = await getVisibleCellData(page); + const scrollState = await getScrollState(page); + + test.expect(scrollState.scrollTop).toBe(1000); + test.expect(scrolledData.firstRowCells).not.toEqual( + initialData.firstRowCells, + ); + }); + + test("Scrolling to bottom shows last rows", async ({ page }) => { + await page.evaluate(async () => { + const datagrid = document.querySelector( + "perspective-viewer-datagrid", + ) as any; + const regularTable = + datagrid.shadowRoot.querySelector("regular-table"); + regularTable.scrollTop = + regularTable.scrollHeight - regularTable.clientHeight; + await regularTable.draw(); + }); + + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.flush(); + }); + + const scrollState = await getScrollState(page); + + test.expect(scrollState.scrollTop).toBeGreaterThan(0); + test.expect( + scrollState.scrollTop + scrollState.clientHeight, + ).toBeGreaterThanOrEqual(scrollState.scrollHeight - 1); + + await compareContentsToSnapshot(await getDatagridContents(page)); + }); + + test("Vertical scroll position is preserved after flush", async ({ + page, + }) => { + await page.evaluate(async () => { + const datagrid = document.querySelector( + "perspective-viewer-datagrid", + ) as any; + const regularTable = + datagrid.shadowRoot.querySelector("regular-table"); + regularTable.scrollTop = 500; + await regularTable.draw(); + }); + + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.flush(); + }); + + const scrollStateAfterFlush = await getScrollState(page); + test.expect(scrollStateAfterFlush.scrollTop).toBe(500); + }); + }); + + test.describe("Horizontal scroll", () => { + test("Scrolling right updates visible columns", async ({ page }) => { + const initialData = await getVisibleCellData(page); + + await page.evaluate(async () => { + const datagrid = document.querySelector( + "perspective-viewer-datagrid", + ) as any; + const regularTable = + datagrid.shadowRoot.querySelector("regular-table"); + regularTable.scrollLeft = 450; + await regularTable.draw(); + }); + + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.flush(); + }); + + const scrolledData = await getVisibleCellData(page); + const scrollState = await getScrollState(page); + + test.expect(scrollState.scrollLeft).toBe(450); + test.expect(scrolledData.headerCells).not.toEqual( + initialData.headerCells, + ); + }); + + test("Scrolling to rightmost edge shows last columns", async ({ + page, + }) => { + await page.evaluate(async () => { + const datagrid = document.querySelector( + "perspective-viewer-datagrid", + ) as any; + const regularTable = + datagrid.shadowRoot.querySelector("regular-table"); + + // Dimensions change as new columns are rendered + for (let i = 0; i < 3; i++) { + regularTable.scrollLeft = + regularTable.scrollWidth - regularTable.clientWidth; + await regularTable.draw(); + } + }); + + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.flush(); + }); + + const scrollState = await getScrollState(page); + + test.expect(scrollState.scrollLeft).toBeGreaterThan(0); + test.expect( + scrollState.scrollLeft + scrollState.clientWidth, + ).toBeGreaterThanOrEqual(scrollState.scrollWidth - 1); + + await compareContentsToSnapshot(await getDatagridContents(page)); + }); + + test("Horizontal scroll position is preserved after flush", async ({ + page, + }) => { + await page.evaluate(async () => { + const datagrid = document.querySelector( + "perspective-viewer-datagrid", + ) as any; + const regularTable = + datagrid.shadowRoot.querySelector("regular-table"); + regularTable.scrollLeft = 300; + await regularTable.draw(); + }); + + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.flush(); + }); + + const scrollStateAfterFlush = await getScrollState(page); + test.expect(scrollStateAfterFlush.scrollLeft).toBe(300); + }); + }); + + test.describe("Combined scroll", () => { + test("Scrolling both vertically and horizontally updates viewport", async ({ + page, + }) => { + const initialData = await getVisibleCellData(page); + + await page.evaluate(async () => { + const datagrid = document.querySelector( + "perspective-viewer-datagrid", + ) as any; + const regularTable = + datagrid.shadowRoot.querySelector("regular-table"); + regularTable.scrollTop = 800; + regularTable.scrollLeft = 400; + await regularTable.draw(); + }); + + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.flush(); + }); + + const scrolledData = await getVisibleCellData(page); + const scrollState = await getScrollState(page); + + test.expect(scrollState.scrollTop).toBe(800); + test.expect(scrollState.scrollLeft).toBe(400); + test.expect(scrolledData.firstRowCells).not.toEqual( + initialData.firstRowCells, + ); + test.expect(scrolledData.headerCells).not.toEqual( + initialData.headerCells, + ); + + await compareContentsToSnapshot(await getDatagridContents(page)); + }); + + test("Scroll to bottom-right corner", async ({ page }) => { + await page.evaluate(async () => { + const datagrid = document.querySelector( + "perspective-viewer-datagrid", + ) as any; + const regularTable = + datagrid.shadowRoot.querySelector("regular-table"); + regularTable.scrollTop = + regularTable.scrollHeight - regularTable.clientHeight; + regularTable.scrollLeft = + regularTable.scrollWidth - regularTable.clientWidth; + await regularTable.draw(); + }); + + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.flush(); + }); + + const scrollState = await getScrollState(page); + + test.expect(scrollState.scrollTop).toBeGreaterThan(0); + test.expect(scrollState.scrollLeft).toBeGreaterThan(0); + + await compareContentsToSnapshot(await getDatagridContents(page)); + }); + }); + + test.describe("Scroll with grouped data", () => { + test("Vertical scroll with group_by preserves tree structure", async ({ + page, + }) => { + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.restore({ + plugin: "Datagrid", + group_by: ["State", "City"], + columns: ["Sales", "Quantity", "Profit"], + }); + }); + + await page.evaluate(async () => { + const datagrid = document.querySelector( + "perspective-viewer-datagrid", + ) as any; + const regularTable = + datagrid.shadowRoot.querySelector("regular-table"); + regularTable.scrollTop = 500; + await regularTable.draw(); + }); + + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.flush(); + }); + + await compareContentsToSnapshot(await getDatagridContents(page)); + }); + + test("Horizontal scroll with split_by shows correct column groups", async ({ + page, + }) => { + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.restore({ + plugin: "Datagrid", + split_by: ["Ship Mode"], + columns: ["Sales", "Quantity", "Profit"], + }); + }); + + await page.evaluate(async () => { + const datagrid = document.querySelector( + "perspective-viewer-datagrid", + ) as any; + const regularTable = + datagrid.shadowRoot.querySelector("regular-table"); + regularTable.scrollLeft = 300; + await regularTable.draw(); + }); + + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.flush(); + }); + + await compareContentsToSnapshot(await getDatagridContents(page)); + }); + }); + + test.describe("Scroll state after operations", () => { + test("Scroll position resets only horizontally after changing columns", async ({ + page, + }) => { + await page.evaluate(async () => { + const datagrid = document.querySelector( + "perspective-viewer-datagrid", + ) as any; + const regularTable = + datagrid.shadowRoot.querySelector("regular-table"); + regularTable.scrollTop = 500; + regularTable.scrollLeft = 300; + await regularTable.draw(); + }); + + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.restore({ + columns: ["State", "City"], + }); + }); + + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.flush(); + }); + + const scrollState = await getScrollState(page); + + test.expect(scrollState.scrollTop).toBe(500); + test.expect(scrollState.scrollLeft).toBe(0); + }); + + test("Scroll position resets after applying filter", async ({ + page, + }) => { + await page.evaluate(async () => { + const datagrid = document.querySelector( + "perspective-viewer-datagrid", + ) as any; + const regularTable = + datagrid.shadowRoot.querySelector("regular-table"); + regularTable.scrollTop = 500; + await regularTable.draw(); + }); + + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.restore({ + filter: [["State", "==", "California"]], + }); + }); + + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.flush(); + }); + + const scrollState = await getScrollState(page); + + test.expect(scrollState.scrollTop).toBe(0); + }); + }); +}); diff --git a/packages/viewer-datagrid/test/js/superstore.spec.ts b/packages/viewer-datagrid/test/js/superstore.spec.ts new file mode 100644 index 0000000..8525297 --- /dev/null +++ b/packages/viewer-datagrid/test/js/superstore.spec.ts @@ -0,0 +1,276 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { shadow_type, test } from "@perspective-dev/test"; +import { + compareContentsToSnapshot, + run_standard_tests, + runPerspectiveEventClickTest, +} from "@perspective-dev/test"; +import type { Page } from "@playwright/test"; +import * as prettier from "prettier"; + +async function getDatagridContents(page: Page): Promise { + const raw = await page.evaluate(async () => { + const datagrid = document.querySelector( + "perspective-viewer perspective-viewer-datagrid", + ) as any; + if (!datagrid) { + return "MISSING DATAGRID"; + } + + const regularTable = datagrid.shadowRoot.querySelector("regular-table"); + return regularTable?.innerHTML || ""; + }); + + return await prettier.format(raw, { + parser: "html", + }); +} + +test.describe("Datagrid with superstore data set", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.restore({ + plugin: "Datagrid", + }); + }); + }); + + run_standard_tests("perspective-viewer-datagrid", getDatagridContents); + + test("Row headers are printed correctly", async ({ page }) => { + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.restore({ + plugin: "Datagrid", + group_by: ["Ship Date"], + split_by: ["Ship Mode"], + columns: ["Sales", "Quantity", "Discount", "Profit"], + }); + }); + + await compareContentsToSnapshot(await getDatagridContents(page)); + }); + + test("Column headers are printed correctly, split_by a date column", async ({ + page, + }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.restore({ + plugin: "Datagrid", + columns: ["Sales", "Profit"], + group_by: ["State"], + split_by: ["New Col"], + expressions: { "New Col": "bucket(\"Order Date\",'Y')" }, + }); + }); + + await compareContentsToSnapshot(await getDatagridContents(page)); + }); + + test("A filtered-to-empty dataset with group_by and split_by does not error internally", async ({ + page, + }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.restore({ + plugin: "Datagrid", + columns: ["Sales", "Profit"], + group_by: ["State"], + split_by: ["Ship Mode"], + filter: [["State", "==", "Schmexas"]], + }); + }); + + await compareContentsToSnapshot(await getDatagridContents(page)); + }); + + test("An editable datagrid is editable through mouse interaction", async ({ + page, + }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.restore({ + plugin: "Datagrid", + plugin_config: { + edit_mode: "EDIT", + }, + columns: ["State", "City", "Customer ID"], + }); + + const { resolve, promise } = Promise.withResolvers(); + window.__promise__ = promise; + const view = await document + .querySelector("perspective-viewer")! + .getView(); + + await view.on_update(() => { + resolve(true); + }); + }); + + await shadow_type( + page, + "Test", + false, + "perspective-viewer-datagrid", + "table", + "tbody", + "tr", + "td", + ); + + const result = await page.evaluate(async () => { + await window.__promise__; + const view = await document + .querySelector("perspective-viewer")! + .getView(); + const json = await view.to_json_string({ end_row: 4 }); + return json; + }); + + test.expect(result).toEqual( + '[{"State":"Test","City":"Henderson","Customer ID":"CG-12520"},{"State":"Kentucky","City":"Henderson","Customer ID":"CG-12520"},{"State":"California","City":"Los Angeles","Customer ID":"DV-13045"},{"State":"Florida","City":"Fort Lauderdale","Customer ID":"SO-20335"}]', + ); + }); + + test("An editable datagrid gets contenteditable focus when a cell is clicked", async ({ + page, + }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + const td: any = await page.evaluateHandle(async () => { + await document.querySelector("perspective-viewer")!.restore({ + plugin: "Datagrid", + plugin_config: { + edit_mode: "EDIT", + }, + columns: ["State", "City", "Customer ID"], + }); + + return ( + document.querySelector("perspective-viewer-datagrid") as any + ).shadowRoot.querySelector("table tbody tr td"); + }); + + await td.click(); + await td.asElement().fill("Test"); + await page.evaluate(() => + (document.activeElement as HTMLElement | null)?.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; + }); + + test.expect(result).toEqual( + '[{"State":"Test","City":"Henderson","Customer ID":"CG-12520"},{"State":"Kentucky","City":"Henderson","Customer ID":"CG-12520"},{"State":"California","City":"Los Angeles","Customer ID":"DV-13045"},{"State":"Florida","City":"Fort Lauderdale","Customer ID":"SO-20335"}]', + ); + }); +}); + +test.describe("Datagrid regressions", () => { + // https://github.com/perspective-dev/perspective/issues/3097 + test("Sorting a column does not break viewport metadata", async ({ + page, + }) => { + await page.goto("/tools/test/src/html/basic-test.html"); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + const td: any = await page.evaluateHandle(async () => { + const elem = ( + document.querySelector("perspective-viewer-datagrid") as any + ).shadowRoot.querySelector("regular-table"); + elem.scrollLeft = 5000; + await elem.draw(); + return ( + document.querySelector("perspective-viewer-datagrid") as any + ).shadowRoot.querySelector("table thead tr th:nth-child(3)"); + }); + + await td.click(); + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.flush(); + const elem = ( + document.querySelector("perspective-viewer-datagrid") as any + ).shadowRoot.querySelector("regular-table"); + elem.scrollLeft = 0; + await elem.draw(); + }); + + await compareContentsToSnapshot(await getDatagridContents(page)); + }); +}); + +test.describe("Datagrid with superstore arrow data set", () => { + test.beforeEach(async ({ page }) => { + await page.goto( + "/tools/test/src/html/all-types-small-multi-arrow-test.html", + ); + await page.evaluate(async () => { + while (!(window as any)["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + await document.querySelector("perspective-viewer")!.restore({ + plugin: "Datagrid", + }); + }); + }); + + test( + `Filter based on date results in correct perspective-click event`, + runPerspectiveEventClickTest(), + ); +}); diff --git a/packages/viewer-datagrid/test/js/tsconfig.json b/packages/viewer-datagrid/test/js/tsconfig.json new file mode 100644 index 0000000..5cdea6d --- /dev/null +++ b/packages/viewer-datagrid/test/js/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "allowImportingTsExtensions": true + }, + "include": ["./**/*.ts"] +} diff --git a/packages/viewer-datagrid/tsconfig.json b/packages/viewer-datagrid/tsconfig.json new file mode 100644 index 0000000..2eeda12 --- /dev/null +++ b/packages/viewer-datagrid/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "ESNext", + "target": "ESNext", + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./dist/esm", + "rootDir": "./src/ts", + "moduleResolution": "bundler", + "skipLibCheck": true, + "resolveJsonModule": true, + "strict": true, + "noImplicitAny": true, + "esModuleInterop": true + }, + "include": ["./src/ts/**/*", "./types.d.ts", "./index.d.ts"] +} diff --git a/packages/viewer-datagrid/types.d.ts b/packages/viewer-datagrid/types.d.ts new file mode 100644 index 0000000..8d702d1 --- /dev/null +++ b/packages/viewer-datagrid/types.d.ts @@ -0,0 +1,21 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +declare module "*.html" { + const value: string; + export default value; +} + +declare module "*.css" { + const value: string; + export default value; +} diff --git a/packages/workspace/build.mjs b/packages/workspace/build.mjs new file mode 100644 index 0000000..f4eae23 --- /dev/null +++ b/packages/workspace/build.mjs @@ -0,0 +1,124 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { NodeModulesExternal } from "@perspective-dev/esbuild-plugin/external.js"; +import { WasmPlugin } from "@perspective-dev/esbuild-plugin/wasm.js"; +import { WorkerPlugin } from "@perspective-dev/esbuild-plugin/worker.js"; +import { ResolvePlugin } from "@perspective-dev/esbuild-plugin/resolve.js"; +import { build } from "@perspective-dev/esbuild-plugin/build.js"; +import { + bundle as bundleCss, + bundleAsync as bundleCssAsync, +} from "lightningcss"; +import * as fs from "node:fs"; +// import { createRequire } from "node:module"; +import { + inlineUrlVisitor, + resolveNPM, +} from "@perspective-dev/viewer/tools.mjs"; + +import "zx/globals"; + +// const _require = createRequire(import.meta.url); + +const BUILD = [ + { + entryPoints: ["src/ts/perspective-workspace.ts"], + define: { + global: "window", + }, + format: "esm", + plugins: [ + // Inlining `lumino` and importing the `.ts` source saves _50kb_ + NodeModulesExternal("@lumino"), + ], + loader: { + ".html": "text", + ".css": "text", + }, + external: ["*.wasm"], + outfile: "dist/esm/perspective-workspace.js", + }, + { + entryPoints: ["src/ts/perspective-workspace.ts"], + define: { + global: "window", + }, + plugins: [ + ResolvePlugin({ + "@perspective-dev/client": + "@perspective-dev/client/dist/esm/perspective.js", + "@perspective-dev/viewer": + "@perspective-dev/viewer/dist/esm/perspective-viewer.js", + }), + WasmPlugin(false), + WorkerPlugin({ inline: false }), + ], + format: "esm", + splitting: true, + loader: { + ".css": "text", + ".html": "text", + }, + outdir: "dist/cdn", + }, +]; + +async function build_all() { + fs.mkdirSync("dist/css", { recursive: true }); + const { code: wsCode } = await bundleCssAsync({ + filename: "./src/css/workspace.css", + resolver: resolveNPM(import.meta.url), + minify: true, + errorRecovery: true, + visitor: inlineUrlVisitor("./src/css/workspace.css"), + }); + + fs.writeFileSync("dist/css/workspace.css", wsCode); + const { code: injCode } = await bundleCssAsync({ + filename: "./src/css/injected.css", + resolver: resolveNPM(import.meta.url), + minify: true, + errorRecovery: true, + visitor: inlineUrlVisitor("./src/css/workspace.css"), + }); + + // Workspace themes — bundle with lightningcss (resolves @imports) + fs.writeFileSync("dist/css/injected.css", injCode); + const { code: proCode } = bundleCss({ + filename: "./src/themes/pro.css", + minify: true, + visitor: inlineUrlVisitor("./src/themes/pro.css"), + }); + + fs.writeFileSync("dist/css/pro.css", proCode); + const { code: proDarkCode } = bundleCss({ + filename: "./src/themes/pro-dark.css", + minify: true, + visitor: inlineUrlVisitor("./src/themes/pro-dark.css"), + }); + + fs.writeFileSync("dist/css/pro-dark.css", proDarkCode); + await Promise.all(BUILD.map(build)).catch(() => process.exit(1)); + try { + await $`tsc --project ./tsconfig.json`.stdio( + "inherit", + "inherit", + "inherit", + ); + } catch (e) { + console.error(e.stdout); + process.exit(1); + } +} + +build_all(); diff --git a/packages/workspace/clean.mjs b/packages/workspace/clean.mjs new file mode 100644 index 0000000..49a5253 --- /dev/null +++ b/packages/workspace/clean.mjs @@ -0,0 +1,15 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 "node:fs"; + +fs.rmSync("dist", { recursive: true, force: true }); diff --git a/packages/workspace/package.json b/packages/workspace/package.json new file mode 100644 index 0000000..9569e81 --- /dev/null +++ b/packages/workspace/package.json @@ -0,0 +1,56 @@ +{ + "name": "@perspective-dev/workspace", + "version": "4.5.2", + "description": "Perspective Workspace", + "files": [ + "dist/**/*", + "src/**/*", + "index.d.ts" + ], + "type": "module", + "exports": { + ".": "./dist/esm/perspective-workspace.js", + "./esm/": "./dist/esm/", + "./src/*": "./src/*", + "./dist/*": "./dist/*", + "./dist/themes/": "./src/themes/", + "./package.json": "./package.json" + }, + "unpkg": "./dist/cdn/perspective-workspace.js", + "jsdelivr": "./dist/cdn/perspective-workspace.js", + "scripts": { + "build": "node ./build.mjs", + "clean": "node ./clean.mjs" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/perspective-dev/perspective" + }, + "author": "", + "license": "Apache-2.0", + "dependencies": { + "@perspective-dev/client": "workspace:", + "@lumino/algorithm": ">=2 <3", + "@lumino/commands": ">=2 <3", + "@lumino/domutils": ">=2 <3", + "@lumino/messaging": ">=2 <3", + "@lumino/virtualdom": ">=2 <3", + "@lumino/widgets": ">=2 <3", + "@lumino/coreutils": ">=2 <3", + "@lumino/signaling": ">=2 <3", + "lodash": "catalog:" + }, + "devDependencies": { + "@perspective-dev/viewer": "workspace:", + "lightningcss": "catalog:", + "@perspective-dev/esbuild-plugin": "workspace:", + "@perspective-dev/test": "workspace:", + "@types/lodash": "catalog:", + "@types/react": "catalog:", + "typescript": "catalog:", + "zx": "catalog:" + } +} diff --git a/packages/workspace/src/css/dockpanel.css b/packages/workspace/src/css/dockpanel.css new file mode 100644 index 0000000..38f1932 --- /dev/null +++ b/packages/workspace/src/css/dockpanel.css @@ -0,0 +1,110 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ 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). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +:host { + .lm-DockPanel { + overflow: var(--dock-panel--overflow, hidden); + position: absolute; + background-color: var(--detail--background-color, transparent); + padding: var(--psp-workspace--spacing); + top: 0; + left: 0; + right: 0; + bottom: 0; + &[data-mode="single-document"] { + padding: 0px; + } + } + + .lm-Widget, + .lm-Widget { + cursor: inherit; + } + + .lm-DockPanel.ew, + .lm-DockPanel.ew .lm-Widget, + .lm-SplitPanel.ew, + .lm-SplitPanel.ew .lm-Widget { + cursor: ew-resize !important; + } + + .lm-DockPanel.ns, + .lm-DockPanel.ns .lm-Widget, + .lm-SplitPanel.ns, + .lm-SplitPanel.ns .lm-Widget { + cursor: ns-resize !important; + } + + .workspace-master-widget { + min-width: 100px !important; + } + + .lm-DockPanel.resizing ::slotted(perspective-viewer), + .lm-SplitPanel.resizing ::slotted(perspective-viewer) { + pointer-events: none; + } + + .widget-blur ::slotted(perspective-viewer) { + opacity: 0.5; + } + + .lm-DockPanel-handle.resizing { + background-color: rgba(0, 0, 0, 0.05); + } + + .perspective-scroll-panel { + overflow: auto !important; + } + + .lm-Panel { + min-height: 100%; + } + + .lm-DockPanel-handle { + background-color: none; + } + + .lm-SplitPanel-handle { + background-color: var( + --psp-workspace--split-panel-handle--background-color + ); + } + + .lm-SplitPanel-handle, + .lm-DockPanel-handle { + transition: background-color 0.3s ease-out; + &:hover { + background-color: rgba(0, 0, 0, 0.05); + } + } + + .lm-DockPanel-handle[data-orientation="horizontal"] { + width: 10px !important; + margin-left: -5px; + } + + .lm-DockPanel-handle[data-orientation="vertical"] { + height: 10px !important; + margin-top: -5px; + } + + .lm-DockPanel-overlay { + background: rgba(75, 75, 75, 0.2); + border: 1px dashed #666; + border-radius: 6px; + transition-property: top, left, right, bottom; + transition-duration: 50ms; + transition-timing-function: linear; + margin: 0px; + } +} diff --git a/packages/workspace/src/css/injected.css b/packages/workspace/src/css/injected.css new file mode 100644 index 0000000..96454e5 --- /dev/null +++ b/packages/workspace/src/css/injected.css @@ -0,0 +1,15 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ 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 "./viewer.css"; +@import "./menu.css"; diff --git a/packages/workspace/src/css/menu.css b/packages/workspace/src/css/menu.css new file mode 100644 index 0000000..a431917 --- /dev/null +++ b/packages/workspace/src/css/menu.css @@ -0,0 +1,130 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ 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 "@lumino/widgets/style/menu.css"; + +:host { + .lm-Menu { + font-size: 12px; + padding: 8px; + background-color: var(--psp--background-color); + color: var(--psp--color); + border: 1px solid var(--psp-inactive--color); + border-radius: 3px; + max-width: 350px; + top: 0; + left: 0; + } + + .lm-Menu.perspective-workspace-menu { + .lm-Menu-item { + padding-left: 37px !important; + } + } + + .lm-Menu-item.lm-mod-active { + background-color: var(--psp--color); + color: var(--psp--background-color); + } + + .lm-Menu-item.lm-mod-disabled { + opacity: 0.5; + } + + .lm-Menu-itemIcon { + width: 24px; + height: 12px; + margin-right: 4px; + } + + .lm-Menu-itemLabel { + flex: 1 1; + padding: 4px 2px 4px 2px; + } + + .lm-Menu-itemMnemonic { + text-decoration: underline; + } + + .lm-Menu-itemShortcut { + padding: 4px 0px; + } + + .lm-Menu-itemSubmenuIcon { + width: 24px; + height: 18px; + } + + .lm-Menu-item { + display: flex; + align-items: center; + outline: none; + + margin: 0 -8px; + padding: 0 8px; + } + + .lm-Menu-item[data-type="separator"] > div { + padding: 0; + height: 9px; + } + + .lm-Menu-item[data-type="separator"] > div::after { + content: ""; + display: block; + position: relative; + top: 4px; + border-top: 1px solid; + } + + .lm-MenuBar-menu { + transform: translateY(-1px); + } + + .lm-MenuBar-item { + padding: 10px 32px; + border-left: 1px solid transparent; + border-right: 1px solid transparent; + color: #737373 !important; + } + + .lm-MenuBar-item.lm-mod-active { + background: rgba(0, 0, 0, 0.2); + } + + .lm-MenuBar.lm-mod-active .lm-MenuBar-item.lm-mod-active { + z-index: 10001; + background: rgba(0, 0, 0, 0.2); + /* border-left: 1px solid #c0c0c0; */ + /* border-right: 1px solid #c0c0c0; */ + } + + .lm-Menu-itemIcon:before { + content: attr(content); + font-family: + "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", "Consolas", + "Liberation Mono", monospace; + } + + [data-type="submenu"] .lm-Menu-itemSubmenuIcon:before { + content: ">"; + font-family: + "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", "Consolas", + "Liberation Mono", monospace; + height: 18px; + } + + .lm-mod-drag-image.lm-TabBar-tab { + display: none; + } +} diff --git a/packages/workspace/src/css/tabbar.css b/packages/workspace/src/css/tabbar.css new file mode 100644 index 0000000..fcc85cf --- /dev/null +++ b/packages/workspace/src/css/tabbar.css @@ -0,0 +1,378 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ 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). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +:host { + .lm-TabBar-tabLabel { + background-color: transparent; + border: none; + color: var(--psp--color, #666); + cursor: pointer; + padding: 0px 6px; + margin: 0; + margin-left: 15.5px; + margin-right: auto; + flex: 0 1 auto; + border-radius: 3px; + &:hover { + background-color: var(--psp--color); + color: var(--psp--background-color); + } + } + + /* .p-TabBar-tabLabel[value="[untitled]"] { */ + /* color: #ddd !important; */ + /* } */ + + .lm-TabBar-tabLabel:focus { + outline: none; + color: var(--workspace-secondary--color, #666) !important; + cursor: text; + } + + .lm-TabBar-tab.lm-mod-closable > .lm-TabBar-tabCloseIcon:before { + content: var(--close-button--content, "\2715"); + } + + .lm-TabBar-tab > .lm-TabBar-tabConfigIcon:before { + content: var(--open-settings-button--content, "\1F527"); + font-family: var(--settings--font-family, "Arial"); + transition: color 0.5s ease; + } + + .lm-TabBar-tab.lm-mod-current.settings_open + > .lm-TabBar-tabConfigIcon:before { + content: var(--close-settings-button--content, "\1F527"); + } + + .lm-TabBar-tab.lm-mod-current.linked > .lm-TabBar-tabConfigIcon:before { + color: #22a0ce; + } + + .lm-TabBar-tab > .lm-TabBar-tabConfigIcon { + opacity: 0; + pointer-events: none; + } + + .lm-TabBar-tab.lm-mod-current > .lm-TabBar-tabConfigIcon { + opacity: 1; + pointer-events: all; + } + + .lm-mod-current { + .lm-TabBar-tabConfigIcon, + .lm-TabBar-tabCloseIcon { + color: inherit; + transition: color 0.2s ease-out; + } + + .lm-TabBar-tabConfigIcon:hover, + .lm-TabBar-tabCloseIcon:hover { + color: #1a7da1; + transition: color 0.2s ease-out; + } + } + + .lm-BoxPanel { + overflow: scroll !important; + } + + .lm-TabBar, + .lm-TabBar-tab { + position: relative; + + overflow: visible !important; + } + + .lm-TabBar-tabLabel:empty::after { + content: "untitled"; + color: #aaa; + } + + .lm-mod-current .lm-TabBar-tabLabel:empty::after { + color: #ddd; + } + + .lm-TabBar-tabLabel:empty::after { + content: "untitled"; + } + + .divider { + left: 32px; + bottom: 0; + position: absolute; + right: 32px; + + height: 1px; + background-color: var(--psp-workspace--tabbar--border-color); + margin-bottom: 0px; + } + + .lm-TabBar-tab .drag-handle { + pointer-events: all; + height: 12px; + width: 14px; + -webkit-mask-image: var(--psp-icon--column-drag-handle--mask-image); + mask-image: var(--psp-icon--column-drag-handle--mask-image); + padding: 12px 0 12px 12px; + background-repeat: no-repeat; + background-color: var(--psp--color, red); + content: ""; + display: inline-block; + -webkit-mask-size: cover; + mask-size: auto; + mask-position: center; + mask-repeat: no-repeat; + } + + .lm-TabBar { + /* z-index: -1; */ + pointer-events: none; + .lm-TabBar-tabCloseIcon { + pointer-events: all; + } + + .bookmarks-button { + pointer-events: all; + } + } + + .lm-TabBar-tab.lm-mod-hidden { + display: flex !important; + opacity: 0.5; + } + + .lm-TabBar-tab.lm-mod-current .divider { + display: block; + } + + .pfm-button-base { + background: white !important; + } + + .pfm-button-base:active { + background: #016bc6 !important; + } + + .lm-TabBar-content { + padding: 0px; + border-bottom: 0px solid #fff !important; + border-left: #eaeaea; + border-right: #eaeaea; + } + + .lm-TabBar-tab.lm-mod-current { + .shadow { + margin: -1px; + position: absolute; + width: calc(100% + 2px); + height: 200%; + pointer-events: none; + left: 0px; + } + + &.settings_open .shadow { + box-shadow: none; + } + } + + .lm-TabBar-tab { + /* margin: 0px 4px; */ + border-color: #eaeaea; + align-items: center; + } + + .lm-TabBar-content.inactive-blur > .lm-TabBar-tab { + background-color: var( + --workspace-inactive-blur--background-color, + transparent + ); + } + + .condensed .lm-TabBar-content { + & > .lm-TabBar-tab.lm-mod-current .lm-TabBar-tabConfigIcon { + display: block; + } + + & > .lm-TabBar-tab.lm-mod-current.settings_open { + .lm-TabBar-tabLabel { + margin-left: 0px; + } + } + } + + .lm-TabBar-content > .lm-TabBar-tab.lm-mod-current.settings_open { + border-width: 1px 1px 0px 1px !important; + border-color: var(--psp-workspace--tabbar--border-color, #ddd); + + .lm-TabBar-tabToolbar { + display: flex; + } + + .divider { + height: 1px; + margin-bottom: 0px; + background-color: var(--psp-workspace--tabbar--border-color, #ddd); + } + + &:last-child:first-child .divider { + transition: none; + } + } + + .lm-TabBar-content > .lm-TabBar-tab.lm-mod-current.perspective_updating { + .lm-TabBar-tabConfigIcon { + display: none; + } + + .lm-TabBar-tabLoadingIcon { + display: block; + } + } + + .lm-TabBar-content > .lm-TabBar-tab { + max-width: 100000px !important; + flex: 0 1 100000px !important; + background: none; + color: #737373; + height: 40px !important; + max-height: 40px !important; + margin-bottom: -40px; + transform: none !important; + transition: color 0.2s ease-out; + } + + .lm-TabBar-content > .lm-TabBar-tab { + color: #ccc; + } + + .lm-TabBar-content > .lm-TabBar-tab { + color: #ccc; + } + + .lm-TabBar-content > .lm-TabBar-tab:hover { + color: #ccc; + } + + .lm-TabBar-content > .lm-TabBar-tab.lm-mod-current { + color: var(--workspace-secondary--color, #666); + } + + .lm-TabBar-tab.lm-mod-current .lm-TabBar-tabLabel { + white-space: nowrap !important; + } + + .lm-TabBar-tabLabel { + font-family: inherit; + font-size: 12px; + line-height: 22px; + text-align: start; + white-space: normal !important; + word-break: break-all; + } + + .lm-TabBar-tabCloseIcon, + .lm-TabBar-tabConfigIcon { + cursor: pointer !important; + line-height: 22px !important; + padding-right: 16.5px !important; + } + + .lm-TabBar-tabConfigIcon { + padding-left: 8px; + padding-right: 8px !important; + } + + .lm-TabBar-tabLoadingIcon { + padding-left: 11px; + padding-right: 12px; + } + + .bottom .lm-TabBar-tab { + flex-basis: 10000px !important; + max-width: 10000px !important; + } + + .lm-TabBar-content > .lm-TabBar-tab.lm-mod-current { + color: var(--workspace-tabbar--color, #666) !important; + /* border: var(--psp-workspace--tabbar--border, 1px solid #ddd); */ + /* border-width: var(--psp-workspace--tabbar-tab--border-width); */ + /* box-shadow: 0 13px 0 -12px var(--psp-inactive--border-color); */ + /* border-radius: var(--psp-workspace--tabbar--border-radius, 6px) */ + /* var(--psp-workspace--tabbar--border-radius, 6px) 0 0; */ + /* background-color: var(--workspace-tabbar--background-color, white); */ + background: none; + height: 40px !important; + max-height: 40px !important; + margin-bottom: -40px; + border: 0 solid transparent; + border-width: 1px 1px 0px 1px; + } + + .bottom .lm-TabBar-tab.lm-mod-current { + border: none; + } + + ::-webkit-scrollbar-thumb { + background-color: #ccc; + } + + .perspective-workspace.context-menu * .lm-TabBar.context-focus { + opacity: 1; + } + + .perspective-workspace.context-menu * .lm-TabBar, + .perspective-workspace.context-menu + .lm-Widget.workspace-widget:not(.context-focus) + .viewer-container { + opacity: 0.2; + } + + .bookmarks-button { + margin-left: 11px; + margin-right: -13px; + height: 20px; + border-radius: 3px; + border: 1px solid var(--psp-inactive--color); + background-color: var(--psp--background-color); + + &:hover { + background-color: var(--psp--color); + border-color: var(--psp--color); + } + } + + .bookmarks { + background-repeat: no-repeat; + background-color: var(--psp--color); + content: ""; + display: inline-block; + -webkit-mask-size: cover; + mask-size: cover; + cursor: pointer; + width: 20px; + height: 12px; + -webkit-mask-image: var(--psp-icon--bookmarks--mask-image); + mask-image: var(--psp-icon--bookmarks--mask-image); + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-size: auto; + mask-size: auto; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + padding: 4px 6px; + + &:hover { + background-color: var(--psp--background-color); + } + } +} diff --git a/packages/workspace/src/css/viewer.css b/packages/workspace/src/css/viewer.css new file mode 100644 index 0000000..af50ef4 --- /dev/null +++ b/packages/workspace/src/css/viewer.css @@ -0,0 +1,112 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ 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). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +::slotted(.workspace-master-widget) { + --psp-status-bar--display: none; + --psp-label--config-button--content: var( + --open-settings-button--content, + "\1F527" + ) !important; +} + +.workspace-master-widget[settings] { + --psp-label--config-button--content: var( + --close-settings-button--content, + "\1F527" + ) !important; +} + +:not(.workspace-master-widget).widget-maximize { + --psp-label--config-button--content: var( + --open-settings-button--content, + "\1F527" + ) !important; +} + +:not(.workspace-master-widget).widget-maximize[settings] { + --psp-label--config-button--content: var( + --close-settings-button--content, + "\1F527" + ) !important; +} + +.lm-DockPanel-widget { + border: 1px solid #eaeaea; + border-width: 0px !important; + min-width: 300px; + min-height: 200px; +} + +:not.workspace-master-widget { + --psp-label--config-button--content: "" !important; +} + +.context-menu > perspective-viewer.context-focus { + opacity: 1; + filter: none; +} + +.context-menu > perspective-viewer { + filter: saturate(0); + + & > * { + pointer-events: none; + } +} + +:host { + --psp-icon--bookmarks--mask-image: url("../svg/bookmark-icon.svg"); +} + +::slotted(perspective-viewer) { + flex: 1; + position: relative; + display: block; + align-items: center; + justify-content: center; + flex-direction: column; + width: 100%; + height: 100%; + overflow: visible !important; + --psp-close-button--display: flex; +} + +::slotted(perspective-viewer.bookmarks-container:not([settings])) { + --psp-icon--status-ok--mask-image: var( + --psp-icon--bookmarks--mask-image + ) !important; + --psp-status-icon--ok--border-color: var(--psp-inactive--color); + --psp-status-icon--ok-hover--border-color: var(--psp--color); + --psp-status-icon--ok-hover--background-color: var(--psp--color); + --psp-status-icon--ok-hover--color: var(--psp--background-color); + --psp-status-icon--ok--cursor: pointer; + --psp-status-icon--pointer-events: all; +} + +::slotted(perspective-viewer::part(status-indicator)) { + background-color: red; + border: 1px solid red; +} + +::slotted(perspective-viewer:not(.widget-maximize)) { + --psp-status-bar--padding: 0 36px 0 8px; +} + +:host-context(.lm-mod-override-cursor) { + cursor: grabbing !important; +} + +:host-context(.lm-mod-override-cursor) ::slotted(perspective-viewer), +.context-menu ::slotted(perspective-viewer) { + --override-content-pointer-events: none; +} diff --git a/packages/workspace/src/css/widget.css b/packages/workspace/src/css/widget.css new file mode 100644 index 0000000..8c67d93 --- /dev/null +++ b/packages/workspace/src/css/widget.css @@ -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). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +.lm-DockPanel:not([data-mode="single-document"]) .viewer-container { + border: var( + --psp-workspace--tabbar--border, + 1px solid var(--psp-inactive--color) + ); + border-width: var(--psp-workspace--tabbar--border-width); + border-radius: var(--psp-workspace--tabbar--border-radius, 6px) + var(--psp-workspace--tabbar--border-radius, 6px) + var(--psp-workspace--tabbar--border-radius, 6px) + var(--psp-workspace--tabbar--border-radius, 6px); +} + +.viewer-container { + flex: 1; + height: 100%; + overflow: hidden; +} + +.perspective-scroll-panel { + .workspace-widget { + min-width: 300px; + min-height: 200px; + } +} + +.workspace-widget { + display: flex; + flex-direction: column; + border: 1px solid #eaedef; + border-width: 0px !important; +} diff --git a/packages/workspace/src/css/workspace.css b/packages/workspace/src/css/workspace.css new file mode 100644 index 0000000..0cb8c25 --- /dev/null +++ b/packages/workspace/src/css/workspace.css @@ -0,0 +1,84 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ 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 "@lumino/widgets/style/widget.css"; +@import "@lumino/widgets/style/accordionpanel.css"; +@import "@lumino/widgets/style/commandpalette.css"; +@import "@lumino/widgets/style/dockpanel.css"; +@import "@lumino/widgets/style/menu.css"; +@import "@lumino/widgets/style/menubar.css"; +@import "@lumino/widgets/style/scrollbar.css"; +@import "@lumino/widgets/style/splitpanel.css"; +@import "@lumino/widgets/style/tabbar.css"; +@import "@lumino/widgets/style/tabpanel.css"; + +/* @import "@lumino/widgets/style/widget.css"; +@import "@lumino/widgets/style/accordionpanel.css"; +@import "@lumino/widgets/style/commandpalette.css"; +@import "@lumino/widgets/style/dockpanel.css"; +@import "@lumino/widgets/style/menu.css"; +@import "@lumino/widgets/style/menubar.css"; +@import "@lumino/widgets/style/scrollbar.css"; +@import "@lumino/widgets/style/splitpanel.css"; +@import "@lumino/widgets/style/tabbar.css"; +@import "@lumino/widgets/style/tabpanel.css"; */ +@import "@lumino/widgets/style/index.css"; + +@import "./injected.css"; +@import "./tabbar.css"; +@import "./dockpanel.css"; +@import "./widget.css"; + +:host { + --psp-workspace--spacing: 3px; + + /* width: 100%; */ + /* height: 100%; */ + + .workspace { + width: 100%; + height: 100%; + } + + .lm-SplitPanel { + height: 100%; + width: 100%; + } + + div.lm-SplitPanel-handle { + background-color: var( + --psp-workspace--split-panel-handle--background-color + ); + } + + .master-panel { + background-color: var(--workspace-master--background-color, inherit); + padding: 0px 0px 0px 0px; + + .viewer-container { + border-width: 0px; + } + } + + .perspective-workspace.context-menu * .lm-SplitPanel-handle { + opacity: 0.2; + } + + .perspective-workspace.context-menu > .lm-SplitPanel-handle { + opacity: 0.2; + } + + .context-menu .lm-TabBar:not(.context-focus) { + pointer-events: none; + } +} diff --git a/packages/workspace/src/html/workspace.html b/packages/workspace/src/html/workspace.html new file mode 100644 index 0000000..ac80cf3 --- /dev/null +++ b/packages/workspace/src/html/workspace.html @@ -0,0 +1,11 @@ + + diff --git a/packages/workspace/src/svg/bookmark-icon.svg b/packages/workspace/src/svg/bookmark-icon.svg new file mode 100644 index 0000000..a9fbef4 --- /dev/null +++ b/packages/workspace/src/svg/bookmark-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/workspace/src/svg/drag-handle.svg b/packages/workspace/src/svg/drag-handle.svg new file mode 100644 index 0000000..5d872cd --- /dev/null +++ b/packages/workspace/src/svg/drag-handle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/workspace/src/themes/pro-dark.css b/packages/workspace/src/themes/pro-dark.css new file mode 100644 index 0000000..a8ee5a1 --- /dev/null +++ b/packages/workspace/src/themes/pro-dark.css @@ -0,0 +1,125 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ 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). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ */ + +perspective-workspace, +perspective-workspace[theme="Pro Dark"], +perspective-indicator[theme="Pro Dark"] { + --psp-theme-name: "Pro Dark"; +} + +perspective-workspace perspective-viewer { + --psp-status-bar--height: 39px; + --psp-plugin-selector--height: 47px; +} + +perspective-workspace perspective-viewer.widget-maximize { + --modal-panel--margin: -4px 0 -4px 0; + --psp-status-bar--border-radius: 6px 0 0 0; + --psp-main-column--margin: 3px 0 3px 3px; + --psp-main-column--border: 1px solid var(--psp-inactive--color); + --psp-main-column--border-width: 1px 0px 1px 1px; + --psp-main-column--border-radius: 6px 0 0 6px; + --settings-button--margin: 10px 0 0 0; +} + +perspective-workspace { + /* perspective-workspace-pro-base */ + font-family: + "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", "Consolas", + "Liberation Mono", monospace; + --open-settings-button--content: "expand_more"; + --close-settings-button--content: "expand_less"; + --close-button--content: "\2715"; + --master-divider--background-color: #243136; + --menu-maximize--content: "fullscreen"; + --menu-minimize--content: "fullscreen_exit"; + --menu-duplicate--content: "call_split"; + --menu-master--content: "cast"; + --menu-detail--content: "notes"; + --menu-export--content: "file_download"; + --menu-copy--content: "file_copy"; + --menu-reset--content: "autorenew"; + --menu-link--content: "link"; + --menu-unlink--content: "link_off"; + --menu-newmenu--content: "add"; + --menu-close--content: "close"; + --menu-new--content: "description"; + --menu-newview--content: "file_copy"; + + --psp-workspace--tabbar--border: 1px solid var(--psp-inactive--color); + --psp-workspace--tabbar--border-width: 0px 1px 1px 1px; + --psp-workspace--tabbar--border-radius: 6px; + --psp-workspace--tabbar--border-color: var(--psp-inactive--color); + --psp-workspace--tabbar-tab--border-width: 1px 1px 0px 1px; + + /* Workspace */ + --psp-icon--column-drag-handle--mask-image: url("../svg/drag-handle.svg"); + --psp-icon--bookmarks--mask-image: url("../svg/bookmark-icon.svg"); + + /* perspective-viewer-pro-dark--colors */ + background-color: #242526; + --psp-workspace--split-panel-handle--background-color: #242526; + color: white; + --psp--color: white; + --psp-active--color: #2770a9; + --psp-error--color: #ff9485; + --psp-inactive--color: #61656e; + --psp-inactive--border-color: #4c505b; + --psp--background-color: #242526; + --psp-active--background: rgba(39, 113, 170, 0.5); + --psp-expression--operator--color: #c5c9d0; + --psp-expression--function--color: #22a0ce; + --psp-expression--error--color: rgb(255, 136, 136); + --psp-calendar--filter: invert(1); + --psp-input--filter: invert(1); + --psp-warning--color: #242526; + --psp-warning--background: var(--psp--color); + + --psp-icon--select-arrow--mask-image: var( + --psp-icon--select-arrow-light--mask-image + ); + + --psp-icon--select-arrow-hover--mask-image: var( + --psp-icon--select-arrow-dark--mask-image + ); + + /* Syntax */ + --psp-code-editor--symbol--color: white; + --psp-code-editor--literal--color: #7dc3f0; + --psp-code-editor--operator--color: rgb(23, 166, 123); + --psp-code-editor--comment--color: rgb(204, 120, 48); + --psp-code-editor--column--color: #e18ee1; + + background-color: #000202; + color: white; + --workspace-tabbar--background-color: #242526; + --workspace-secondary--color: #c5c9d0; + --psp-workspace--tabbar--border: 1px solid var(--psp-inactive--color); + --psp-workspace--tabbar--border-width: 1px 1px 1px 1px; + --psp-workspace--tabbar--border-radius: 6px; + --psp-workspace--tabbar--border-color: var(--psp-inactive--color); + --psp-workspace--tabbar-tab--border-width: 1px 1px 0 1px; +} + +perspective-viewer[theme="Pro Dark"].workspace-master-widget { + --psp--background-color: #242526; +} + +perspective-workspace-menu { + font-family: + "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", "Consolas", + "Liberation Mono", monospace; + font-weight: 300; + background: #2a2c2f !important; + color: white !important; + border: 1px solid #3b3f46 !important; +} diff --git a/packages/workspace/src/themes/pro.css b/packages/workspace/src/themes/pro.css new file mode 100644 index 0000000..80d36b9 --- /dev/null +++ b/packages/workspace/src/themes/pro.css @@ -0,0 +1,121 @@ +/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ + * ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ + * ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ + * ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ + * ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ + * ┃ 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). ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ */ + +.lm-cursor-backdrop { + display: none; +} + +perspective-workspace, +perspective-workspace[theme="Pro Light"], +perspective-indicator[theme="Pro Light"] { + --psp-theme-name: "Pro Light"; +} + +perspective-workspace { + /* perspective-workspace-pro-base */ + font-family: + "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", "Consolas", + "Liberation Mono", monospace; + --open-settings-button--content: "expand_more"; + --close-settings-button--content: "expand_less"; + --close-button--content: "\2715"; + --master-divider--background-color: #243136; + --menu-maximize--content: "fullscreen"; + --menu-minimize--content: "fullscreen_exit"; + --menu-duplicate--content: "call_split"; + --menu-master--content: "cast"; + --menu-detail--content: "notes"; + --menu-export--content: "file_download"; + --menu-copy--content: "file_copy"; + --menu-reset--content: "autorenew"; + --menu-link--content: "link"; + --menu-unlink--content: "link_off"; + --menu-newmenu--content: "add"; + --menu-close--content: "close"; + --menu-new--content: "description"; + --menu-newview--content: "file_copy"; + --psp-workspace--split-panel-handle--background-color: #f2f4f6; + + --psp-workspace--tabbar--border: 1px solid var(--psp-inactive--color); + --psp-workspace--tabbar--border-width: 1px 1px 1px 1px; + --psp-workspace--tabbar--border-radius: 6px; + --psp-workspace--tabbar--border-color: var(--psp-inactive--color); + --psp-workspace--tabbar-tab--border-width: 1px 1px 0px 1px; + + /* Workspace */ + --psp-icon--column-drag-handle--mask-image: url("../svg/drag-handle.svg"); + --psp-icon--bookmarks--mask-image: url("../svg/bookmark-icon.svg"); + + /* perspective-viewer-pro--colors */ + color: #161616; + background-color: #dadada; + --psp--color: #161616; + --psp-inactive--color: #ababab; + --psp-inactive--border-color: #dadada; + + --psp-active--color: #2670a9; + --psp-error--color: #ff471e; + --psp--background-color: #ffffff; + --psp-icon-overflow-hint--color: rgba(0, 0, 0, 0.2); + --psp-select--background-color: none; + --psp-warning--background: #042121; + --psp-warning--color: #fdfffd; + + --psp-icon--select-arrow--mask-image: var( + --psp-icon--select-arrow-dark--mask-image + ); + + --psp-icon--select-arrow-hover--mask-image: var( + --psp-icon--select-arrow-light--mask-image + ); + + /* TODO deprecate me */ + --psp-icon-overflow-hint--color: #fdfffd; +} + +perspective-workspace perspective-viewer.widget-maximize { + /* --modal-panel--margin: -4px 0 -4px 0; */ + --psp-status-bar--border-radius: 6px 0 0 0; + --psp-main-column--margin: 3px 0 3px 3px; + --psp-main-column--border: 1px solid var(--psp-inactive--color); + --psp-main-column--border-width: 1px 0px 1px 1px; + --psp-main-column--border-radius: 6px 0 0 6px; + /* --settings-button--margin: 10px 0 0 0; */ +} + +perspective-workspace perspective-viewer { + --psp-status-bar--height: 39px; + --psp-plugin-selector--height: 47px; +} + +perspective-viewer[theme="Pro Light"].workspace-master-widget { + background-color: #f2f4f6; + --psp--background-color: #f2f4f6; + regular-table { + td, + th { + border-color: #e0e4e9; + } + } +} + +perspective-viewer { + --psp-icon--bookmarks--mask-image: url("../svg/bookmark-icon.svg"); +} + +perspective-workspace-menu { + font-family: + "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", "Consolas", + "Liberation Mono", monospace; + font-weight: 300; + color: #161616; +} diff --git a/packages/workspace/src/ts/extensions.ts b/packages/workspace/src/ts/extensions.ts new file mode 100644 index 0000000..647784e --- /dev/null +++ b/packages/workspace/src/ts/extensions.ts @@ -0,0 +1,82 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { HTMLPerspectiveWorkspaceElement } from "./perspective-workspace"; +import type * as React from "react"; + +type ReactPerspectiveWorkspaceAttributes = React.HTMLAttributes; + +type JsxPerspectiveWorkspaceElement = { + class?: string; +} & React.DetailedHTMLProps< + ReactPerspectiveWorkspaceAttributes, + HTMLPerspectiveWorkspaceElement +>; + +// React <19 + +declare global { + namespace JSX { + interface IntrinsicElements { + "perspective-workspace": JsxPerspectiveWorkspaceElement; + } + } +} + +// React >=19 + +// @ts-ignore +declare module "react/jsx-runtime" { + namespace JSX { + interface IntrinsicElements { + "perspective-workspace": JsxPerspectiveWorkspaceElement; + } + } +} + +// @ts-ignore +declare module "react/jsx-dev-runtime" { + namespace JSX { + interface IntrinsicElements { + "perspective-workspace": JsxPerspectiveWorkspaceElement; + } + } +} + +declare module "react" { + namespace JSX { + interface IntrinsicElements { + "perspective-workspace": JsxPerspectiveWorkspaceElement; + } + } +} + +// Custom Elements extensions + +declare global { + interface Document { + createElement( + tagName: "perspective-workspace", + options?: ElementCreationOptions, + ): HTMLPerspectiveWorkspaceElement; + querySelector(selectors: string): E | null; + querySelector( + selectors: "perspective-workspace", + ): HTMLPerspectiveWorkspaceElement | null; + } + + interface CustomElementRegistry { + get( + tagName: "perspective-workspace", + ): HTMLPerspectiveWorkspaceElement & typeof HTMLElement; + } +} diff --git a/packages/workspace/src/ts/external.d.ts b/packages/workspace/src/ts/external.d.ts new file mode 100644 index 0000000..b66c57a --- /dev/null +++ b/packages/workspace/src/ts/external.d.ts @@ -0,0 +1,21 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +declare module "*.css" { + const content: string; + export default content; +} + +declare module "*.html" { + const content: string; + export default content; +} diff --git a/packages/workspace/src/ts/external.js b/packages/workspace/src/ts/external.js new file mode 100644 index 0000000..988604a --- /dev/null +++ b/packages/workspace/src/ts/external.js @@ -0,0 +1,14 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// Reminder for future authors as to why this file exists: +// https://github.com/evanw/esbuild/issues/1663 diff --git a/packages/workspace/src/ts/perspective-workspace.ts b/packages/workspace/src/ts/perspective-workspace.ts new file mode 100644 index 0000000..8c42005 --- /dev/null +++ b/packages/workspace/src/ts/perspective-workspace.ts @@ -0,0 +1,266 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { MessageLoop } from "@lumino/messaging"; +import { Widget } from "@lumino/widgets"; +import type { HTMLPerspectiveViewerElement } from "@perspective-dev/viewer"; +import type * as psp from "@perspective-dev/client"; +import type * as psp_viewer from "@perspective-dev/viewer"; +import * as msg from "@lumino/messaging"; + +export { PerspectiveWorkspace, addViewer, genId } from "./workspace"; +export { PerspectiveViewerWidget } from "./workspace/widget"; +export * from "./extensions"; +import { PerspectiveWorkspace, PerspectiveWorkspaceConfig } from "./workspace"; +import { bindTemplate, CustomElementProto } from "./utils/custom_elements"; +import style from "../../dist/css/workspace.css"; +import template from "../html/workspace.html"; + +export { PerspectiveWorkspaceConfig }; + +/** + * A Custom Element for coordinating a set of `` light DOM + * children. `` is built on Lumino.js to allow a more + * app-like experience than ``, providing + * these features additionally: + * + * - Docking, arranging, tabbing and max/min-ing of ``s. + * - Trivial `` duplication. + * - Global Filter sidebar, for using selection state of a pivot table to filter + * siblings. + * - Total persistence/serializable state and child state. + * - Easy sharing/ownership of `Table()` among different ``. + * - A cool DOM-reactive API. + * + * There are a few ways to use this Custom Element. In plain HTML, you can + * express your initial view simply: + * + * ```html + * + * ``` + * + * You can also use the DOM API in Javascript: + * + * ```javascript + * const workspace = document.createElement("perspective-workspace"); + * document.body.appendChild(workspace); + * ``` + * + * This will yield a ` with the default layout. To load + * a `Table()`, add it to `tables` via the `Map()` API where it will be + * auto-wired into all matching ``s immediately: + * + * ```javascript + * workspace.tables.set("superstore", await worker.table(my_data)); + * ``` + * + * + */ +export class HTMLPerspectiveWorkspaceElement extends HTMLElement { + private workspace?: PerspectiveWorkspace; + private _resize_observer?: ResizeObserver; + + constructor() { + super(); + this.setAutoSize(true); + } + + /*************************************************************************** + * + * Public + * + */ + + /** + * Persists this `` to a token `Object`. This object + * is JSON serializable and describes the state of the Workspace and it's + * child `` elements. Some important keys: + * + * - `viewers`: The serialized state of `` children, + * named by `slot`. + * - `detail`: The main layout. + * - `master`: The contents of the Global Filter sidebar. + * + * While the `table` attribute is persisted for each `perspective-viewer`, + * `Table`s themselves must be added to the `tables` property `Map()` + * separately. + * + * @return {Object} A configuration token, compatible with + * `restore(config)`. + * @example + * // Save this layout to local storage + * const workspace = document.querySelector("perspective-workspace"); + * localStorage.set("CONFIG", JSON.stringify(workspace.save())); + */ + save(): Promise { + return this.workspace!.save(); + } + + /** + * Restore this `` to a previous state captured by + * `save()`. Calling this method will completely rewrite this element's + * `innerHTML`, but may reuse `` children depending + * on the `slot` attribute names. However, it should always be possible + * to recreate any given state from within the UI itself, as the attributes + * on `` itself create immutable views. + * + * While the `table` attribute is set for each `perspective-viewer`, + * `Table`s themselves must be added to the `tables` property `Map()` + * separately. + * @param {Object} config A configuration token, as returned by `save()`. + * @example + * // Restore this layout from local storage + * const workspace = document.querySelector("perspective-workspace"); + * workspace.restore(JSON.parse(localStorage.get("CONFIG")); + * + * // Add `Table` separately. + * workspace.tables.set("superstore", await worker.table(data)); + */ + async restore(layout: PerspectiveWorkspaceConfig) { + await this.workspace!.restore(layout); + } + + async clear() { + await this.restore({ + sizes: [], + master: undefined, + detail: { main: null }, + viewers: {}, + }); + } + + /** + * Await all asynchronous tasks for all viewers in this workspace. This is + * useful to make sure asynchonous side effects of synchronous methods calls + * are applied. + */ + async flush() { + if (!this.workspace) { + return; + } + + msg.MessageLoop.flush(); + await new Promise((x) => requestAnimationFrame(x)); + await this.workspace._mutex.lock(async () => { + await Promise.all( + Array.from(this.querySelectorAll("perspective-viewer")).map( + (x) => { + const psp_widget = x as HTMLPerspectiveViewerElement; + return psp_widget + .flush() + .then(() => psp_widget.flush()); + }, + ), + ); + }); + } + + /** + * Add a new viewer to the workspace for a given `ViewerConfigUpdateExt`. + * @param config + */ + async addViewer(config: psp_viewer.ViewerConfigUpdate) { + await this.workspace!.addViewer(config); + await this.flush(); + } + + async load(client: psp.Client | Promise) { + if (this.workspace) { + this.workspace.client.push(await client); + } else { + throw new Error("Workspace not mounted"); + } + } + + /** + * Invalidate this component's dimensions and recalculate. + */ + async resize() { + this.workspace!.update(); + await this.flush(); + } + + /** + * Set whether this workspace element should auto-size itself via a + * `ResizeObserver`. + */ + setAutoSize(is_auto_size: boolean) { + this._resize_observer?.unobserve(this); + this._resize_observer = undefined; + if (is_auto_size) { + this._resize_observer = new ResizeObserver((...args) => + this.workspace?.update(), + ); + + this._resize_observer.observe(this); + } + } + + connectedCallback() { + if (!this.workspace) { + const container = this.shadowRoot!.querySelector("#container")!; + this.workspace = new PerspectiveWorkspace(this); + this._register_light_dom_listener(); + MessageLoop.sendMessage(this.workspace, Widget.Msg.BeforeAttach); + container.insertBefore(this.workspace.node, null); + MessageLoop.sendMessage(this.workspace, Widget.Msg.AfterAttach); + } + } + + /*************************************************************************** + * + * Private + * + */ + + private _light_dom_changed() { + if (!this.workspace) { + return; + } + + this.workspace._mutex.lock(async () => { + const viewers = Array.from( + this.childNodes, + ) as HTMLPerspectiveViewerElement[]; + + for (const viewer of viewers) { + if (viewer.nodeType !== Node.ELEMENT_NODE) { + continue; + } + + if (viewer.tagName !== "PERSPECTIVE-VIEWER") { + console.warn("Not a "); + continue; + } + + let _task = this.workspace!.update_widget_for_viewer( + viewer as HTMLPerspectiveViewerElement, + ); + } + + this.workspace!.remove_unslotted_widgets(viewers); + this.workspace!.update_details_panel(viewers); + }); + } + + private _register_light_dom_listener() { + let observer = new MutationObserver(this._light_dom_changed.bind(this)); + let config = { attributes: false, childList: true, subtree: false }; + observer.observe(this, config); + this._light_dom_changed(); + } +} + +bindTemplate( + template, + style, +)(HTMLPerspectiveWorkspaceElement as unknown as CustomElementProto); diff --git a/packages/workspace/src/ts/utils/custom_elements.ts b/packages/workspace/src/ts/utils/custom_elements.ts new file mode 100644 index 0000000..515fae5 --- /dev/null +++ b/packages/workspace/src/ts/utils/custom_elements.ts @@ -0,0 +1,95 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Instantiate a Template DOM object from an HTML text string. + * + * Params + * ------ + * template : An HTML string representing a template. + * + * Returns + * ------- + * A Template DOM object. + */ +function importTemplate(template: string): HTMLTemplateElement { + const div = document.createElement("div"); + div.innerHTML = template; + return Array.prototype.slice.call(div.children)[0]; +} + +export interface CustomElementProto extends CustomElementConstructor { + connectedCallback(): void; +} + +/** + * A simple tool for creating Web Components v0. + * + * Params + * ------ + * template : An HTML string representing a template. Should have an 'id' + * attribute which will become the new Web Component's tag name. + * proto : The new Web Component's prototype object, as per spec. + */ +export function registerElement( + templateString: string, + styleString: string, + proto: CustomElementProto, +) { + const template = importTemplate(templateString); + if (styleString) { + template.innerHTML = + `` + template.innerHTML; + } + + const _perspective_element = class extends proto { + private _initialized: boolean; + private _initializing: boolean; + + constructor() { + super(); + this._initialized = false; + this._initializing = false; + } + + connectedCallback() { + if (this._initialized) { + return; + } + + this._initializing = true; + const node = document.importNode(template.content, true); + const root = this.attachShadow({ mode: "open" }); + root.appendChild(node); + if (proto.prototype.connectedCallback) { + proto.prototype.connectedCallback.call(this); + } + + this._initializing = false; + this._initialized = true; + } + + static get observedAttributes() { + return Object.getOwnPropertyNames(proto.prototype); + } + }; + + let name = template.getAttribute("id")!; + window.customElements.define(name, _perspective_element); +} + +export function bindTemplate(template: string, ...styleStrings: string[]) { + const style = styleStrings.map((x) => x.toString()).join("\n"); + return function (cls: CustomElementProto) { + return registerElement(template, style, cls); + }; +} diff --git a/packages/workspace/src/ts/utils/observable_map.ts b/packages/workspace/src/ts/utils/observable_map.ts new file mode 100644 index 0000000..cc7b27e --- /dev/null +++ b/packages/workspace/src/ts/utils/observable_map.ts @@ -0,0 +1,39 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +export class ObservableMap extends Map { + _set_listener?: (name: K, val: V) => void; + _delete_listener?: (name: K) => void; + + set(name: K, item: V) { + this._set_listener?.(name, item); + super.set(name, item); + return this; + } + + get(name: K): V { + return super.get(name)!; + } + + delete(name: K) { + this._delete_listener?.(name); + return super.delete(name); + } + + addSetListener(listener: (name: K, val: V) => void) { + this._set_listener = listener; + } + + addDeleteListener(listener: (name: K) => void) { + this._delete_listener = listener; + } +} diff --git a/packages/workspace/src/ts/workspace/commands.ts b/packages/workspace/src/ts/workspace/commands.ts new file mode 100644 index 0000000..6940965 --- /dev/null +++ b/packages/workspace/src/ts/workspace/commands.ts @@ -0,0 +1,272 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { CommandRegistry } from "@lumino/commands"; +import { Widget } from "@lumino/widgets"; +import { Signal } from "@lumino/signaling"; + +import type { + HTMLPerspectiveViewerCopyMenuElement, + HTMLPerspectiveViewerExportMenuElement, + ViewerConfigUpdate, +} from "@perspective-dev/viewer"; + +import type { PerspectiveWorkspace } from "./workspace"; +import { WorkspaceMenu } from "./menu"; + +export const createCommands = ( + workspace: PerspectiveWorkspace, + indicator: HTMLElement, +) => { + const commands = new CommandRegistry(); + + commands.addCommand("workspace:export", { + execute: async (args) => { + const menu = document.createElement( + "perspective-export-menu", + ) as unknown as HTMLPerspectiveViewerExportMenuElement; + + workspace.apply_indicator_theme(); + const widget = workspace.getWidgetByName( + args.widget_name as string, + )!; + + menu.__set_model(widget.viewer.__get_model()); + menu.open(indicator); + workspace.get_context_menu()?.init_overlay?.(); + menu.addEventListener("blur", () => { + const context_menu = workspace.get_context_menu()!; + const signal = context_menu.aboutToClose as Signal< + WorkspaceMenu, + any + >; + signal.emit({}); + }); + }, + isEnabled: (args) => { + if (workspace.get_context_menu()?.node.isConnected) { + const box = workspace + .get_context_menu() + ?.node.getBoundingClientRect(); + + if (box) { + indicator.style.top = box.top + "px"; + indicator.style.left = box.left + "px"; + } + } + + return true; + }, + // iconClass: "menu-export", + label: "Export", + mnemonic: 0, + }); + + commands.addCommand("workspace:copy", { + execute: async (args) => { + const menu = document.createElement( + "perspective-copy-menu", + ) as HTMLPerspectiveViewerCopyMenuElement; + + workspace.apply_indicator_theme(); + const widget = workspace.getWidgetByName( + args.widget_name as string, + )!; + menu.__set_model(widget.viewer.__get_model()); + + menu.open(indicator); + workspace.get_context_menu()?.init_overlay?.(); + menu.addEventListener("blur", () => { + ( + workspace.get_context_menu()?.aboutToClose as + | Signal + | undefined + )?.emit({}); + }); + }, + isEnabled: (_) => { + if (workspace.get_context_menu()?.node.isConnected) { + const box = workspace + .get_context_menu() + ?.node.getBoundingClientRect(); + + if (box) { + indicator.style.top = box.top + "px"; + indicator.style.left = box.left + "px"; + } + } + + return true; + }, + // iconClass: "menu-copy", + label: "Copy", + mnemonic: 0, + }); + + commands.addCommand("workspace:new", { + execute: async (args) => { + const widget = await workspace._createWidgetAndNode({ + config: { + table: args.table as string, + }, + slot: undefined, + }); + + workspace.get_dock_panel().addWidget(widget, { + mode: "split-right", + ref: workspace.getWidgetByName(args.widget_name as string), + }); + }, + // iconClass: "menu-new-tables", + label: (args) => { + return args.table as string; + }, + }); + + commands.addCommand("workspace:newview", { + execute: async (args) => { + const widget = workspace.getWidgetByName( + args.widget_name as string, + ); + + const target_widget = workspace.getWidgetByName( + args.target_widget_name as string, + )!; + + const config = (await target_widget.save()) as ViewerConfigUpdate; + const new_widget = await workspace._createWidgetAndNode({ + config, + slot: undefined, + }); + + workspace.get_dock_panel().addWidget(new_widget, { + mode: "split-right", + ref: widget, + }); + }, + // iconClass: "menu-new-tables", + isVisible: (args) => { + const target_widget = workspace.getWidgetByName( + args.target_widget_name as string, + )!; + + return target_widget.title.label !== ""; + }, + label: (args) => { + const target_widget = workspace.getWidgetByName( + args.target_widget_name as string, + )!; + + return target_widget.title.label || "untitled"; + }, + }); + + commands.addCommand("workspace:reset", { + execute: (args) => { + workspace + .getWidgetByName(args.widget_name as string)! + .viewer.reset(); + }, + label: "Reset", + mnemonic: 0, + }); + + commands.addCommand("workspace:settings", { + execute: async ({ widget_name }) => { + const widget = workspace.getWidgetByName(widget_name as string); + if (!widget) { + throw new Error(`No widget ${widget_name}`); + } + + // if (!widget.viewer.hasAttribute("settings")) { + // workspace._maximize(widget); + requestAnimationFrame(() => widget.viewer.toggleConfig()); + // } else { + // widget.viewer.toggleConfig(); + // } + }, + isVisible: (args) => { + const widget = workspace.getWidgetByName( + args.widget_name as string, + ); + + return widget?.parent! === (workspace.get_dock_panel() as Widget) + ? true + : false; + }, + label: (args) => { + const widget = workspace.getWidgetByName( + args.widget_name as string, + )!; + if (widget.viewer.hasAttribute("settings")) { + return "Close Settings"; + } else { + return "Open Settings"; + } + }, + mnemonic: 0, + }); + + commands.addCommand("workspace:duplicate", { + execute: ({ widget_name }) => + workspace.duplicate( + workspace.getWidgetByName(widget_name as string)!, + ), + // iconClass: "menu-duplicate", + isVisible: (args) => { + return workspace.getWidgetByName(args.widget_name as string) + ?.parent! === (workspace.get_dock_panel() as Widget) + ? true + : false; + }, + label: "Duplicate", + mnemonic: 0, + }); + + commands.addCommand("workspace:master", { + execute: (args) => + workspace.toggleMasterDetail( + workspace.getWidgetByName(args.widget_name as string)!, + ), + isVisible: (args) => { + return !!workspace.getWidgetByName(args.widget_name as string) + ?._is_pivoted; + }, + + label: (args) => { + return workspace.getWidgetByName(args.widget_name as string)! + .parent === workspace.get_dock_panel() + ? "Create Global Filter" + : "Remove Global Filter"; + }, + mnemonic: 0, + }); + + commands.addCommand("workspace:close", { + execute: (args) => { + workspace.getWidgetByName(args.widget_name as string)!.close(); + }, + // iconClass: "menu-close", + label: () => "Close", + mnemonic: 0, + }); + + commands.addCommand("workspace:help", { + // iconClass: "menu-close", + execute: () => {}, + label: "Shift+Click for Browser Menu", + isEnabled: () => false, + // mnemonic: 0, + }); + + return commands; +}; diff --git a/packages/workspace/src/ts/workspace/dockpanel.ts b/packages/workspace/src/ts/workspace/dockpanel.ts new file mode 100644 index 0000000..ae069ec --- /dev/null +++ b/packages/workspace/src/ts/workspace/dockpanel.ts @@ -0,0 +1,156 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { DockLayout, DockPanel, TabBar, Widget } from "@lumino/widgets"; +import { PerspectiveTabBar } from "./tabbar"; +import { PerspectiveTabBarRenderer } from "./tabbarrenderer"; +import { PerspectiveWorkspace } from "./workspace"; +import { PerspectiveViewerWidget } from "./widget"; + +class PerspectiveDockPanelRenderer extends DockPanel.Renderer { + _workspace: PerspectiveWorkspace; + + constructor(workspace: PerspectiveWorkspace) { + super(); + this._workspace = workspace; + } + + createTabBar() { + const tabbar = new PerspectiveTabBar(this._workspace, { + renderer: new PerspectiveTabBarRenderer(false), + }); + + tabbar.addClass("lm-DockPanel-tabBar"); + return tabbar; + } +} + +// @ts-ignore: extending a private member `_onTabDetachRequested` +export class PerspectiveDockPanel extends DockPanel { + _workspace: PerspectiveWorkspace; + constructor(workspace: PerspectiveWorkspace) { + super({ renderer: new PerspectiveDockPanelRenderer(workspace) }); + + // @ts-ignore: accessing a private member `_renderer` + this._renderer.dock = this; + this._workspace = workspace; + } + + _onTabDetachRequested( + sender: TabBar, + args: TabBar.ITabDetachRequestedArgs, + ) { + // @ts-ignore: accessing a private member `_onTabDetachRequested` + super._onTabDetachRequested(sender, args); + + // blur widget on when it's being moved + const widget = sender.titles[args.index].owner; + const layout = this.layout as DockLayout; + const old = layout.saveLayout(); + if (Array.from(layout.widgets()).length > 1) { + layout.removeWidget(widget); + } + + widget.addClass("widget-blur"); + document.body.classList.add("lm-mod-override-cursor"); + + // @ts-ignore: accessing a private member `_drag` + const drag = this._drag; + if (drag) { + drag.dragImage?.parentElement?.removeChild?.(drag.dragImage); + drag.dragImage = null; + drag._promise.then(() => { + if (!widget.node.isConnected) { + layout.restoreLayout(old); + } + + document.body.classList.remove("lm-mod-override-cursor"); + widget.removeClass("widget-blur"); + }); + } + } + + static getWidgets( + layout: DockPanel.ILayoutConfig, + ): PerspectiveViewerWidget[] { + if (!!layout.main) { + return PerspectiveDockPanel.getAreaWidgets(layout.main); + } else { + return []; + } + } + + static getAreaWidgets( + layout: DockLayout.AreaConfig, + ): PerspectiveViewerWidget[] { + if (layout?.hasOwnProperty("children")) { + const split_panel = layout as DockLayout.ISplitAreaConfig; + return split_panel.children.flatMap((widget) => + PerspectiveDockPanel.getAreaWidgets(widget), + ); + } else if (layout?.hasOwnProperty("widgets")) { + const tab_panel = layout as DockLayout.ITabAreaConfig; + return tab_panel.widgets as PerspectiveViewerWidget[]; + } + + return []; + } + + widgets(): IterableIterator { + return super.widgets() as IterableIterator; + } + + static async mapWidgets( + widgetFunc: (widget: any) => Promise, + layout: any, + ): Promise { + if (!!layout.main) { + layout.main = await PerspectiveDockPanel.mapAreaWidgets( + widgetFunc, + layout.main, + ); + } + + return layout; + } + + static async mapAreaWidgets( + widgetFunc: (widget: any) => Promise, + layout: DockLayout.AreaConfig, + ): Promise { + if (layout.hasOwnProperty("children")) { + const split_panel = layout as DockLayout.ISplitAreaConfig; + split_panel.children = await Promise.all( + split_panel.children.map((widget) => + PerspectiveDockPanel.mapAreaWidgets(widgetFunc, widget), + ), + ); + } else if (layout.hasOwnProperty("widgets")) { + const tab_panel = layout as DockLayout.ITabAreaConfig; + tab_panel.widgets = await Promise.all( + tab_panel.widgets.map(widgetFunc), + ); + } + + return layout; + } + + onAfterAttach() { + this.spacing = + parseInt( + window + .getComputedStyle(this._workspace.element) + .getPropertyValue("--psp-workspace--spacing"), + ) || 0; + 1; + } +} diff --git a/packages/workspace/src/ts/workspace/index.ts b/packages/workspace/src/ts/workspace/index.ts new file mode 100644 index 0000000..18e69aa --- /dev/null +++ b/packages/workspace/src/ts/workspace/index.ts @@ -0,0 +1,13 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +export * from "./workspace"; diff --git a/packages/workspace/src/ts/workspace/menu.ts b/packages/workspace/src/ts/workspace/menu.ts new file mode 100644 index 0000000..e046323 --- /dev/null +++ b/packages/workspace/src/ts/workspace/menu.ts @@ -0,0 +1,213 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { ElementExt } from "@lumino/domutils"; +import { MessageLoop } from "@lumino/messaging"; +import { h } from "@lumino/virtualdom"; +import { Menu, Widget } from "@lumino/widgets"; + +export class WorkspaceMenu extends Menu { + private _host: ShadowRoot; + private _workspace: HTMLElement; + init_overlay?: () => void; + + constructor( + host: ShadowRoot, + workspace: HTMLElement, + options: Menu.IOptions, + ) { + options.renderer = new MenuRenderer(); + super(options); + this._host = host; + this._workspace = workspace; + (this as any)._openChildMenu = this._overrideOpenChildMenu.bind(this); + } + + open(x: number, y: number, options?: Menu.IOpenOptions) { + options ||= {}; + options.host = this._host as any as HTMLElement; + const box = this._workspace.getBoundingClientRect(); + super.open(x, y, options); + const menu_box = this.node.getBoundingClientRect(); + if ( + menu_box.height + y > box.height && + menu_box.height + y < document.documentElement.clientHeight + ) { + this.node.style.top = `-${menu_box.height}`; + } + + if (menu_box.width + x > box.width) { + if (menu_box.width + x < document.documentElement.clientWidth) { + this.node.style.left = `-${menu_box.width + x - box.width}px`; + } else { + this.node.style.left = `-${ + document.documentElement.clientWidth - box.width + }px`; + } + } + } + + // Override this lumino private method because it will otherwise always + // attach to `document.body`. + private _overrideOpenChildMenu(activateFirst = false) { + const self = this as any; + let item = this.activeItem; + if (!item || item.type !== "submenu" || !item.submenu) { + self._closeChildMenu(); + return; + } + + let submenu = item.submenu; + if (submenu === self._childMenu) { + return; + } + + Menu.saveWindowData(); + self._closeChildMenu(); + self._childMenu = submenu; + self._childIndex = self._activeIndex; + (submenu as any)._parentMenu = this; + MessageLoop.sendMessage(this, Widget.Msg.UpdateRequest); + let itemNode = this.contentNode.children[self._activeIndex]; + openSubmenu(submenu, itemNode as HTMLElement, self._host); + if (activateFirst) { + submenu.activeIndex = -1; + submenu.activateNextItem(); + } + + submenu.activate(); + } +} + +class MenuRenderer extends Menu.Renderer { + formatLabel(data: Menu.IRenderData) { + let { label, mnemonic } = data.item; + if (mnemonic < 0 || mnemonic >= label.length) { + return label; + } + + let prefix = label.slice(0, mnemonic); + let suffix = label.slice(mnemonic + 1); + let char = label[mnemonic]; + let span = h.span( + { + className: "lm-Menu-itemMnemonic p-Menu-itemMnemonic", + }, + char, + ); + + return [prefix, span, suffix]; + } + + renderLabel(data: Menu.IRenderData) { + let content = this.formatLabel(data); + return h.div( + { + className: "lm-Menu-itemLabel p-Menu-itemLabel", + }, + content, + ); + } + + renderSubmenu(data: Menu.IRenderData) { + return h.div({ + className: "lm-Menu-itemSubmenuIcon" + " p-Menu-itemSubmenuIcon", + }); + } + + renderItem(data: Menu.IRenderData) { + let className = this.createItemClass(data); + let dataset = this.createItemDataset(data); + let aria = this.createItemARIA(data); + return h.li( + { + className, + dataset, + tabindex: "0", + onfocus: data.onfocus, + ...aria, + }, + this.renderLabel(data), + this.renderShortcut(data), + this.renderSubmenu(data), + ); + } +} + +// Prevent submenus from attaching outside the Shadow DOM. +// Forked from [Lumino](https://github.com/jupyterlab/lumino/blob/main/packages/widgets/src/menu.ts). +// [License](https://github.com/jupyterlab/lumino/blob/main/LICENSE) +export function openSubmenu( + submenu: Menu, + itemNode: HTMLElement, + host: HTMLElement, +): void { + const windowData = getWindowData(); + let px = windowData.pageXOffset; + let py = windowData.pageYOffset; + let cw = windowData.clientWidth; + let ch = windowData.clientHeight; + const hostData = (host as any).host.getBoundingClientRect(); + let hx = hostData.x; + let hy = hostData.y; + MessageLoop.sendMessage(submenu, Widget.Msg.UpdateRequest); + let maxHeight = ch; + let node = submenu.node; + let style = node.style; + style.opacity = "0"; + style.maxHeight = `${maxHeight}px`; + Widget.attach(submenu, host); + let { width, height } = node.getBoundingClientRect(); + let box = ElementExt.boxSizing(submenu.node); + let itemRect = itemNode.getBoundingClientRect(); + let x = itemRect.right - SUBMENU_OVERLAP - hx; + if (x + width > px + cw + hx) { + x = itemRect.left + SUBMENU_OVERLAP - width; + } + + let y = itemRect.top - box.borderTop - box.paddingTop - hy; + if (y + height > py + ch + hy) { + y = itemRect.bottom + box.borderBottom + box.paddingBottom - height; + } + + style.transform = `translate(${Math.max(0, x)}px, ${Math.max(0, y)}px`; + style.opacity = "1"; +} + +export const SUBMENU_OVERLAP = 3; + +let transientWindowDataCache: IWindowData | null = null; +let transientCacheCounter: number = 0; + +function getWindowData(): IWindowData { + if (transientCacheCounter > 0) { + transientCacheCounter--; + return transientWindowDataCache!; + } + return _getWindowData(); +} + +function _getWindowData(): IWindowData { + return { + pageXOffset: window.pageXOffset, + pageYOffset: window.pageYOffset, + clientWidth: document.documentElement.clientWidth, + clientHeight: document.documentElement.clientHeight, + }; +} + +interface IWindowData { + pageXOffset: number; + pageYOffset: number; + clientWidth: number; + clientHeight: number; +} diff --git a/packages/workspace/src/ts/workspace/tabbar.ts b/packages/workspace/src/ts/workspace/tabbar.ts new file mode 100644 index 0000000..3e8de50 --- /dev/null +++ b/packages/workspace/src/ts/workspace/tabbar.ts @@ -0,0 +1,243 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { ArrayExt } from "@lumino/algorithm"; +import { ElementExt } from "@lumino/domutils"; +import { TabBar } from "@lumino/widgets"; +import { TabBarItems, PerspectiveTabBarRenderer } from "./tabbarrenderer"; +import { VirtualDOM } from "@lumino/virtualdom"; +import { CommandRegistry } from "@lumino/commands"; +import { Menu } from "@lumino/widgets"; +import { PerspectiveWorkspace } from "./workspace"; +import { Message } from "@lumino/messaging"; +import { Title } from "@lumino/widgets"; +import { Signal } from "@lumino/signaling"; +import { ReadonlyJSONObject, ReadonlyJSONValue } from "@lumino/coreutils"; +import { WorkspaceMenu } from "./menu"; + +export class PerspectiveTabBar extends TabBar { + _workspace: PerspectiveWorkspace; + __content_node__?: HTMLElement; + _menu?: Menu; + __titles: string[]; + + constructor(workspace: PerspectiveWorkspace, options = {}) { + super(options); + this._addEventListeners(); + this.__content_node__ = undefined; + this._workspace = workspace; + this.__titles = []; + } + + get private_titles(): Title[] { + let titles: Title[] = (this as any)._titles; + return titles; + } + + onUpdateRequest(msg: Message) { + // NOT INERT! This is a lumino bug fix. + // lumino/virtualdom keeps a weakmap on contentNode which is later + // reset - this causes the diff to double some elements. Memoizing + // prevent collection from the weakmap. + this.__content_node__ = this.contentNode; + this.node.style.contain = ""; + + // super.onUpdateRequest(msg); + + let titles: Title[] = (this as any)._titles; + let renderer = this.renderer; + let currentTitle = this.currentTitle!; + + // Another hack. `TabBar` selects by index and I don't want to fork this + // logic, so insert empty divs until the indices match. + let content = new Array(); + for (let i = 0, n = titles.length; i < n; ++i) { + let title = titles[i]; + title.owner._titlebar = this; + let current = title === currentTitle; + let otherTitles = titles.filter((x) => x !== currentTitle); + let onClick; + if (otherTitles.length > 0) { + onClick = this.onClick.bind(this, otherTitles, i); + } + + if (current) { + content[i] = ( + renderer as unknown as PerspectiveTabBarRenderer + ).renderTab( + { + title, + zIndex: titles.length + 1, + current, + } as TabBar.IRenderData, + onClick, + ); + } else { + content[i] = ( + renderer as unknown as PerspectiveTabBarRenderer + ).renderInert(); + } + } + + VirtualDOM.render(content, this.contentNode); + this._check_shade(); + } + + onClick(otherTitles: Title[], index: number, event: MouseEvent) { + const commands = new CommandRegistry(); + this._menu = new WorkspaceMenu( + this._workspace.menu_elem.shadowRoot!, + this._workspace.element, + { + commands, + }, + ); + + this._menu.addClass("perspective-workspace-menu"); + this._menu.dataset.minwidth = this.__titles[index]; + for (const title of otherTitles) { + this._menu.addItem({ + command: "tabbar:switch", + args: { title } as unknown as ReadonlyJSONObject, + }); + } + + commands.addCommand("tabbar:switch", { + execute: async ({ title }) => { + const psp_title = title as any as Title; + const index = this.__titles.findIndex((t) => t === title); + this.currentTitle = psp_title; + ( + this.tabActivateRequested as unknown as Signal< + TabBar, + TabBar.ITabActivateRequestedArgs + > + ).emit({ + index, + title: psp_title, + }); + }, + label: ({ title }) => { + const psp_title = title as any as Title; + return psp_title.label || "untitled"; + }, + mnemonic: 0, + }); + + const box = ( + (event.target as HTMLElement).shadowRoot?.querySelector( + "#status_reconnect", + ) as HTMLElement + ).getBoundingClientRect(); + + const outer_box = this._workspace.element.getBoundingClientRect(); + this._menu.open(box.x - outer_box.x, box.y + box.height - outer_box.y); + this._menu.aboutToClose.connect(() => { + this._menu = undefined; + }); + + event.preventDefault(); + event.stopPropagation(); + } + + _check_shade() { + if ( + Array.from(this.contentNode.children).filter( + (x) => + x.classList.contains("settings_open") && + x.classList.contains("lm-mod-current"), + ).length > 0 + ) { + this.contentNode.classList.add("inactive-blur"); + } else { + this.contentNode.classList.remove("inactive-blur"); + } + } + + handleEvent(event: MouseEvent) { + this._menu?.close(); + this.retargetEvent(event); + switch (event.type) { + case "contextmenu": + const widget = this.currentTitle?.owner; + let parent = widget.parent; + + // TODO There is probably a better way to find the workspace + // relative to a widget command + while (parent && !(parent instanceof PerspectiveWorkspace)) { + parent = parent.parent; + } + + (parent as unknown as PerspectiveWorkspace).showContextMenu( + widget, + event, + ); + + if (!event.shiftKey) { + event.preventDefault(); + } + + break; + case "mousedown": + if ((event.target as HTMLElement).id === TabBarItems.Label) { + return; + } + break; + case "pointerdown": + if ((event.target as HTMLElement).id === TabBarItems.Label) { + const tabs = this.contentNode.children; + + // Find the index of the released tab. + const index = ArrayExt.findFirstIndex(tabs, (tab) => { + return ElementExt.hitTest( + tab, + event.clientX, + event.clientY, + ); + }); + + if (index < 0) { + break; + } + + const title = this.titles[index]; + this._workspace._maximize(title.owner); + requestAnimationFrame(() => + title.owner.viewer.toggleConfig(), + ); + + return; + } + break; + } + super.handleEvent(event); + } + + /** + * Shadow dom targets events at the host, not the clicked element, which + * Lumino dislikes. This makes the event look like it is not crossing + * the ShadowDom boundary. + * + */ + retargetEvent(event: MouseEvent) { + Object.defineProperty(event, "target", { + value: event.composedPath()[0], + enumerable: true, + }); + return event; + } + + _addEventListeners() { + this.node.addEventListener("dblclick", this); + this.node.addEventListener("contextmenu", this); + } +} diff --git a/packages/workspace/src/ts/workspace/tabbarrenderer.ts b/packages/workspace/src/ts/workspace/tabbarrenderer.ts new file mode 100644 index 0000000..bf99b8d --- /dev/null +++ b/packages/workspace/src/ts/workspace/tabbarrenderer.ts @@ -0,0 +1,74 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { h } from "@lumino/virtualdom"; +import { TabBar } from "@lumino/widgets"; + +export const TabBarItems = { + Config: "config", + Label: "label", +}; + +export const DEFAULT_TITLE = "untitled"; + +export class PerspectiveTabBarRenderer extends TabBar.Renderer { + maximized: boolean; + + constructor(maximized: boolean) { + super(); + this.maximized = maximized; + } + + renderLabel(data: { title: { label?: string } }) { + return h.span( + { + className: "lm-TabBar-tabLabel", + id: TabBarItems.Label, + }, + data.title.label || DEFAULT_TITLE, + ); + } + + renderInert() { + return h.div(); + } + + renderTab( + data: TabBar.IRenderData, + onclick?: (this: HTMLElement, event: MouseEvent) => any, + ) { + const title = data.title.caption; + const key = this.createTabKey(data); + const style = this.createTabStyle(data); + let className = this.createTabClass(data); + const dataset = this.createTabDataset(data); + const more: h.Child[] = []; + if (onclick) { + data.title.owner.setCallback(onclick); + data.title.owner.addClass("bookmarks-container"); + } else { + data.title.owner.setCallback(undefined); + data.title.owner.removeClass("bookmarks-container"); + } + + return h.li( + { key, className, title, style, dataset }, + // this.renderDragHandle(), + ); + } + + // renderDragHandle() { + // return h.div({ + // className: "drag-handle", + // }); + // } +} diff --git a/packages/workspace/src/ts/workspace/widget.ts b/packages/workspace/src/ts/workspace/widget.ts new file mode 100644 index 0000000..5ab037d --- /dev/null +++ b/packages/workspace/src/ts/workspace/widget.ts @@ -0,0 +1,107 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { Widget } from "@lumino/widgets"; +import { Message } from "@lumino/messaging"; + +import type * as psp_viewer from "@perspective-dev/viewer"; +import type * as psp from "@perspective-dev/client"; +import { PerspectiveTabBar } from "./tabbar"; + +interface IPerspectiveViewerWidgetOptions { + node: HTMLElement; + viewer: psp_viewer.HTMLPerspectiveViewerElement; + onAttach?: () => void; +} + +export class PerspectiveViewerWidget extends Widget { + viewer: psp_viewer.HTMLPerspectiveViewerElement; + _title: string; + _is_pivoted: boolean; + _restore_config?: () => Promise; + _onAttach?: () => void; + _titlebar?: PerspectiveTabBar; + _deleted: boolean; + _titlebar_callback?: (event: MouseEvent) => {}; + + constructor({ viewer, node, onAttach }: IPerspectiveViewerWidgetOptions) { + super({ node }); + this.viewer = viewer; + this._title = ""; + this._is_pivoted = false; + this._onAttach = onAttach; + this._deleted = false; + } + + get name(): string { + return this._title; + } + + toggleConfig(): Promise { + return this.viewer.toggleConfig(); + } + + async load(table: psp.Table | Promise) { + let promises = [this.viewer.load(table)]; + if (this._restore_config) { + promises.push(this._restore_config()); + } + + await Promise.all(promises); + } + + restore(config: psp_viewer.ViewerConfigUpdate) { + this._title = config.title as string; + this.title.label = config.title as string; + this._is_pivoted = (config.group_by?.length || 0) > 0; + return this.viewer.restore({ ...config }); + } + + async save() { + let config = await this.viewer.save(); + config["settings"] = false; + return config; + } + + addClass(name: string) { + super.addClass(name); + this.viewer?.classList?.add?.(name); + } + + removeClass(name: string) { + super.removeClass(name); + this.viewer?.classList?.remove?.(name); + } + + setCallback(callback?: (event: MouseEvent) => {}) { + this._titlebar_callback = callback; + } + + protected onAfterAttach(msg: Message) { + super.onAfterAttach(msg); + this._onAttach?.(); + } + + onCloseRequest(msg: Message) { + super.onCloseRequest(msg); + return (async () => { + if (this.viewer.parentElement) { + this.viewer.parentElement.removeChild(this.viewer); + } + + if (!this._deleted) { + await this.viewer.delete(); + this._deleted = true; + } + })(); + } +} diff --git a/packages/workspace/src/ts/workspace/workspace.ts b/packages/workspace/src/ts/workspace/workspace.ts new file mode 100644 index 0000000..f2ce3f5 --- /dev/null +++ b/packages/workspace/src/ts/workspace/workspace.ts @@ -0,0 +1,1313 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { find, toArray } from "@lumino/algorithm"; +import { CommandRegistry } from "@lumino/commands"; + +import { SplitPanel, Panel, DockPanel } from "@lumino/widgets"; +import uniqBy from "lodash/uniqBy"; +import { DebouncedFunc, DebouncedFuncLeading, isEqual } from "lodash"; +import type { + HTMLPerspectiveViewerElement, + ViewerConfig, +} from "@perspective-dev/viewer"; +import type * as psp from "@perspective-dev/client"; +import type * as psp_viewer from "@perspective-dev/viewer"; +import injectedStyles from "../../../dist/css/injected.css"; +import { PerspectiveDockPanel } from "./dockpanel"; +import { WorkspaceMenu } from "./menu"; +import { createCommands } from "./commands"; +import { PerspectiveViewerWidget } from "./widget"; +import type { Filter } from "@perspective-dev/client"; + +class AsyncMutex { + _lock: Promise | null; + + constructor() { + this._lock = null; + } + + lock
    (continuation: () => Promise): Promise { + if (this._lock !== null) { + return this._lock.then(() => this.lock(continuation)); + } + + this._lock = new Promise((x, y) => + continuation() + .then((z) => { + this._lock = null; + x(z); + }) + .catch((e) => { + this._lock = null; + y(e); + }), + ); + + return this._lock as Promise; + } +} + +export type PerspectiveSplitArea = { + type: "split-area"; + sizes: number[]; + orientation: "horizontal" | "vertical"; + children: PerspectiveLayout[]; +}; + +export type PerspectiveTabArea = { + type: "tab-area"; + currentIndex: number; + widgets: string[]; +}; + +export type PerspectiveLayout = PerspectiveSplitArea | PerspectiveTabArea; + +export interface PerspectiveWorkspaceConfig { + sizes: number[]; + viewers: Record; + detail: { main: PerspectiveLayout | null }; + master?: { + sizes: number[]; + widgets: string[]; + }; +} + +const DEFAULT_WORKSPACE_SIZE = [1, 3]; +let ID_COUNTER = 0; + +export function genId(workspace: PerspectiveWorkspaceConfig) { + let i = `PERSPECTIVE_GENERATED_ID_${ID_COUNTER++}`; + if (Object.keys(workspace.viewers).includes(i)) { + i = genId(workspace); + } + return i; +} + +/// This function takes a workspace config and viewer config and adds the +/// viewer config to the workspace config, returning a new workspace config. +/// This is a slightly different algorithm from the Lumino one, +/// which will be used on internal workspace actions (such as duplication). +/// It currently attaches the viewer using a split-right style, +/// (see Lumino docklayout.ts for documentation on insert modes). +export function addViewer( + workspace: PerspectiveWorkspaceConfig, + config: psp_viewer.ViewerConfigUpdate, + id: string, +): PerspectiveWorkspaceConfig { + const GOLDEN_RATIO = 0.618; + /// ensures that the sum of the input is 1 + /// keeps the relative size of the elements + function normalize(sizes: number[]) { + const sum = sizes.reduce((a, b) => a + b, 0); + return sum === 1 ? sizes : sizes.map((size) => size / sum); + } + + if (workspace.detail.main === null) { + return { + sizes: workspace.sizes, + viewers: { + ...workspace.viewers, + [id]: config, + }, + detail: { + main: { + type: "split-area", + sizes: [1], + orientation: "horizontal", + children: [ + { + type: "tab-area", + currentIndex: 0, + widgets: [id], + }, + ], + }, + }, + master: workspace.master, + }; + } else if ( + workspace.detail.main.type === "tab-area" || + (workspace.detail.main.type === "split-area" && + workspace.detail.main.orientation === "vertical") + ) { + return { + sizes: workspace.sizes, + viewers: { + ...workspace.viewers, + [id]: config, + }, + detail: { + main: { + type: "split-area", + sizes: [0.5, 0.5], + orientation: "horizontal", + children: [ + workspace.detail.main, + { + type: "tab-area", + currentIndex: 0, + widgets: [id], + }, + ], + }, + }, + master: workspace.master, + }; + } else if ( + workspace.detail.main.type === "split-area" && + workspace.detail.main.orientation === "horizontal" + ) { + return { + sizes: workspace.sizes, + viewers: { + ...workspace.viewers, + [id]: config, + }, + detail: { + main: { + type: "split-area", + sizes: normalize([ + ...normalize(workspace.detail.main.sizes), + GOLDEN_RATIO, + ]), + orientation: "horizontal", + children: [ + ...workspace.detail.main.children, + { + type: "tab-area", + currentIndex: 0, + widgets: [id], + }, + ], + }, + }, + master: workspace.master, + }; + } else { + throw new Error("Unknown workspace state"); + } +} + +export class PerspectiveWorkspace extends SplitPanel { + private dockpanel: PerspectiveDockPanel; + private detailPanel: Panel; + private masterPanel: SplitPanel; + client: psp.Client[]; + element: HTMLElement; + menu_elem: HTMLElement; + private listeners: WeakMap void>; + private indicator: HTMLElement; + private commands: CommandRegistry; + private _menu?: WorkspaceMenu; + private _minimizedLayoutSlots?: Promise; + private _minimizedLayout?: DockPanel.ILayoutConfig; + private _maximizedWidget?: PerspectiveViewerWidget; + private _last_updated_state?: PerspectiveWorkspaceConfig; + _mutex: AsyncMutex; + // private _context_menu?: Menu & { init_overlay?: () => void }; + + constructor(element: HTMLElement) { + super({ orientation: "horizontal" }); + this.addClass("perspective-workspace"); + this.dockpanel = new PerspectiveDockPanel(this); + this.detailPanel = new Panel(); + this.detailPanel.layout!.fitPolicy = "set-no-constraint"; + this.detailPanel.addClass("perspective-scroll-panel"); + this.detailPanel.addWidget(this.dockpanel); + this.masterPanel = new SplitPanel({ orientation: "vertical" }); + this.masterPanel.addClass("master-panel"); + this._mutex = new AsyncMutex(); + this.dockpanel.layoutModified.connect(() => { + this.workspaceUpdated(); + }); + + this.addWidget(this.detailPanel); + this.element = element; + this.listeners = new WeakMap(); + this.client = []; + this.indicator = this.init_indicator(); + this.commands = createCommands(this, this.indicator); + this.menu_elem = document.createElement("perspective-workspace-menu"); + this.menu_elem.attachShadow({ mode: "open" }); + this.menu_elem.shadowRoot!.innerHTML = ``; + + this.element.shadowRoot!.insertBefore( + this.menu_elem, + this.element.shadowRoot!.lastElementChild!, + ); + + element.addEventListener("contextmenu", (event) => + this.showContextMenu(null, event), + ); + } + + get_context_menu(): WorkspaceMenu | undefined { + return this._menu; + } + + get_dock_panel(): PerspectiveDockPanel { + return this.dockpanel; + } + + init_indicator() { + const exists = document.querySelector("body > perspective-indicator"); + if (exists) { + return exists as HTMLElement; + } + const indicator = document.createElement("perspective-indicator"); + indicator.style.position = "fixed"; + indicator.style.pointerEvents = "none"; + document.body.appendChild(indicator); + return indicator; + } + + apply_indicator_theme() { + const theme_name = JSON.parse( + window + .getComputedStyle(this.element) + .getPropertyValue("--theme-name") + .trim(), + ); + + this.indicator.setAttribute("theme", theme_name); + } + + /*************************************************************************** + * + * `` Public API + * + */ + + async save(): Promise { + return await this._mutex.lock(async () => { + const is_settings = this.dockpanel.mode === "single-document"; + let detail = is_settings + ? await this._minimizedLayoutSlots + : await PerspectiveDockPanel.mapWidgets( + async (widget) => + ( + widget as PerspectiveViewerWidget + ).viewer.getAttribute("slot"), + this.dockpanel.saveLayout(), + ); + + const layout: PerspectiveWorkspaceConfig = { + sizes: [...this.relativeSizes()], + detail: detail as { main: PerspectiveLayout }, + viewers: {}, + master: undefined as + | { widgets: string[]; sizes: number[] } + | undefined, + }; + + if (this.masterPanel.isAttached) { + const master = { + widgets: this.masterPanel.widgets.map( + (widget) => + ( + widget as PerspectiveViewerWidget + ).viewer.getAttribute("slot")!, + ), + sizes: [...this.masterPanel.relativeSizes()], + }; + + layout.master = master; + } + + // const viewers: Record = {}; + for (const widget of this.masterPanel.widgets) { + const psp_widget = widget as PerspectiveViewerWidget; + layout.viewers[psp_widget.viewer.getAttribute("slot")!] = + (await psp_widget.save()) as psp_viewer.ViewerConfigUpdate; + } + + const widgets = PerspectiveDockPanel.getWidgets( + is_settings + ? this._minimizedLayout! + : this.dockpanel.saveLayout(), + ); + + await Promise.all( + widgets.map(async (widget) => { + const psp_widget = widget as PerspectiveViewerWidget; + const slot = psp_widget.viewer.getAttribute("slot")!; + layout.viewers[slot] = + (await psp_widget.save()) as psp_viewer.ViewerConfigUpdate; + layout.viewers[slot]!.settings = false; + }), + ); + + return layout; + }); + } + + async restore(value: PerspectiveWorkspaceConfig) { + await this._mutex.lock(async () => { + const { + sizes, + master, + detail, + viewers: viewer_configs = {}, + } = structuredClone(value); + + if (master && master.widgets!.length > 0) { + this.setupMasterPanel(sizes || DEFAULT_WORKSPACE_SIZE); + } else { + if (this.masterPanel.isAttached) { + this.detailPanel.removeClass("has-master-panel"); + this.masterPanel.close(); + } + + this.addWidget(this.detailPanel); + } + + let tasks: Promise[] = []; + + // Using ES generators as context managers .. + for (const viewers of this._capture_viewers()) { + for (const widgets of this._capture_widgets()) { + for (const v of viewers) { + v.removeAttribute("class"); + } + + const callback = this._restore_callback.bind( + this, + viewer_configs, + viewers, + widgets, + ); + + if (detail) { + const detailLayout = + await PerspectiveDockPanel.mapWidgets( + (name: string) => + callback.bind(this, false)(name), + detail, + ); + + this.dockpanel.mode = "multiple-document"; + this.dockpanel.restoreLayout(detailLayout); + tasks = tasks.concat( + PerspectiveDockPanel.getWidgets(detailLayout).map( + (x) => + ( + x as PerspectiveViewerWidget + ).viewer.flush(), + ), + ); + } + + if (master) { + // tasks = tasks.concat( + + const tasks2: any[] = [], + names: string[] = []; + master.widgets!.map((name) => { + names.push(name); + tasks2.push(callback.bind(this, true)(name)); + return name; + }); + + // return name; + tasks.push( + Promise.all(tasks2).then((x) => { + master.widgets = master.widgets!.map((name) => { + const idx = names.indexOf(name); + const task = x[idx]; + return task; + }); + }), + ); + + // const widgets = await Promise.all(tasks); + + // ); + + master.sizes && + this.masterPanel.setRelativeSizes(master.sizes); + } + } + } + + await Promise.all(tasks); + }); + } + + *_capture_widgets() { + const widgets = this.getAllWidgets(); + yield widgets; + for (const widget of widgets) { + if (!widget.node.isConnected) { + widget.close(); + } + } + } + + *_capture_viewers() { + const viewers = Array.from( + this.element.children, + ) as HTMLPerspectiveViewerElement[]; + + yield viewers; + const ending_widgets = this.getAllWidgets(); + for (const viewer of viewers) { + let widget = ending_widgets.find((x) => { + const psp_widget = x as PerspectiveViewerWidget; + return psp_widget.viewer === viewer; + }); + + if ( + !widget && + Array.from(this.element.children).indexOf(viewer) > -1 + ) { + this.element.removeChild(viewer); + viewer.delete(); + viewer.free(); + } + } + } + + async _restore_callback( + viewers: Record, + starting_viewers: HTMLPerspectiveViewerElement[], + starting_widgets: PerspectiveViewerWidget[], + master: boolean, + widgetName: string, + ) { + let viewer_config; + viewer_config = viewers[widgetName]; + + let viewer = + !!widgetName && + starting_viewers.find((x) => x.getAttribute("slot") === widgetName); + + let widget; + if (viewer) { + widget = starting_widgets.find((x) => x.viewer === viewer); + if (widget) { + await widget.restore({ ...viewer_config }); + } else { + widget = await this._createWidget({ + config: { ...viewer_config }, + viewer, + }); + } + } else if (viewer_config) { + widget = await this._createWidgetAndNode({ + config: { ...viewer_config }, + slot: widgetName, + }); + } else { + throw new Error( + `Could not find or create for slot "${widgetName}"`, + ); + } + + if (master) { + widget.viewer.classList.add("workspace-master-widget"); + await widget.viewer.restore({ + plugin_config: { edit_mode: "SELECT_ROW_TREE" }, + }); + + widget.viewer.addEventListener( + "perspective-global-filter", + this.on_global_filter_callback, + ); + + widget.viewer.addEventListener( + "perspective-select", + this.on_select_callback, + ); + + // TODO remove event listener + this.masterPanel.addWidget(widget); + } + + return widget; + } + + _validate(table: any) { + if (!table || !("view" in table) || typeof table?.view !== "function") { + throw new Error( + "Only `perspective.Table()` instances can be added to `tables`", + ); + } + return table; + } + + _set_listener(name: string, table: psp.Table | Promise) { + if (table instanceof Promise) { + table = table.then(this._validate); + } else { + this._validate(table); + } + } + + _delete_listener(name: string) { + this.getAllWidgets().some((widget) => { + const psp_widget = widget as PerspectiveViewerWidget; + if (psp_widget.viewer.getAttribute("table") === name) { + psp_widget.viewer.eject(); + } + }); + } + + async update_widget_for_viewer(viewer: HTMLPerspectiveViewerElement) { + let slot_name = viewer.getAttribute("slot"); + if (!slot_name) { + slot_name = this._gen_id(); + viewer.setAttribute("slot", slot_name); + } + + const table_name = viewer.getAttribute("table"); + if (table_name) { + const slot = this.node.querySelector(`slot[name=${slot_name}]`); + if (!slot) { + console.warn( + `Undocked ${viewer.outerHTML}, creating default layout`, + ); + + const widget = await this._createWidget({ + // config: {}, + viewer, + }); + + this.dockpanel.addWidget(widget); + this.dockpanel.activateWidget(widget); + } + } else { + console.warn(`No table set for ${viewer.outerHTML}`); + } + } + + remove_unslotted_widgets(viewers: HTMLPerspectiveViewerElement[]) { + const widgets = this.getAllWidgets(); + for (const widget of widgets) { + const psp_widget = widget as PerspectiveViewerWidget; + let missing = viewers.indexOf(psp_widget.viewer) === -1; + if (missing) { + psp_widget.close(); + } + } + } + + update_details_panel(viewers: HTMLPerspectiveViewerElement[]) { + if (this.masterPanel.widgets.length === 0) { + this.masterPanel.close(); + } + } + + /*************************************************************************** + * + * Workspace-level contextmenu actions + * + */ + + async duplicate(widget: PerspectiveViewerWidget): Promise { + if (this.dockpanel.mode === "single-document") { + const _task = + await this._maximizedWidget!.viewer.toggleConfig(false); + + this._unmaximize(); + } + + const config = (await widget.save()) as psp_viewer.ViewerConfigUpdate; + config.settings = false; + config.title = config.title ? `${config.title} (*)` : ""; + const duplicate = await this._createWidgetAndNode({ + config, + slot: undefined, + }); + + this.dockpanel.addWidget(duplicate, { + mode: "split-right", + ref: widget, + }); + + await duplicate.viewer.flush(); + } + + toggleMasterDetail(widget: PerspectiveViewerWidget) { + const isGlobalFilter = widget.parent !== this.dockpanel; + this.element.dispatchEvent( + new CustomEvent("workspace-toggle-global-filter", { + detail: { + widget, + isGlobalFilter: !isGlobalFilter, + }, + }), + ); + + if (isGlobalFilter) { + this.makeDetail(widget); + } else { + if (this.dockpanel.mode === "single-document") { + this.toggleSingleDocument(widget); + } + this.makeMaster(widget); + } + } + + _maximize(widget: PerspectiveViewerWidget) { + widget.viewer.classList.add("widget-maximize"); + if (!this._minimizedLayout) { + this._minimizedLayout = this.dockpanel.saveLayout(); + this._minimizedLayoutSlots = PerspectiveDockPanel.mapWidgets( + async (widget: PerspectiveViewerWidget) => + widget.viewer.getAttribute("slot"), + this.dockpanel.saveLayout(), + ); + } + + this._maximizedWidget = widget; + this.dockpanel.mode = "single-document"; + this.dockpanel.activateWidget(widget); + } + + _unmaximize() { + this._maximizedWidget!.viewer.classList.remove("widget-maximize"); + this.dockpanel.mode = "multiple-document"; + this.dockpanel.restoreLayout(this._minimizedLayout!); + this._minimizedLayout = undefined; + } + + toggleSingleDocument(widget: PerspectiveViewerWidget) { + if (this.dockpanel.mode !== "single-document") { + this._maximize(widget); + } else { + this._unmaximize(); + } + } + + async _filterViewer( + viewer: HTMLPerspectiveViewerElement, + removeFilters: psp.Filter[], + insertFilters: psp.Filter[], + candidates: Set, + ) { + const config = await viewer.save(); + + await viewer.restore({ + filter: config.filter + .filter((x) => !removeFilters.find((y) => y[0] === x[0])) + .concat(insertFilters), + }); + } + + async onPerspectiveSelect( + filterFun: (config: ViewerConfig) => boolean, + event: CustomEvent, + ) { + const config = await ( + event.target as HTMLPerspectiveViewerElement + ).save(); + + if (!filterFun(config)) { + return; + } + + const candidates = new Set([ + ...(config["group_by"] || []), + ...(config["split_by"] || []), + ...(config.filter || []).map((x: psp.Filter) => x[0]), + ]); + + const removeFilters = ( + (event.detail.removeConfigs ?? []) as psp.ViewConfigUpdate[] + ).flatMap((x) => x.filter ?? []); + const insertFilters = ( + (event.detail.insertConfigs ?? []) as psp.ViewConfigUpdate[] + ).flatMap((x) => x.filter ?? []); + + toArray(this.dockpanel.widgets()).forEach((widget) => { + this._filterViewer( + (widget as PerspectiveViewerWidget).viewer, + removeFilters, + insertFilters, + candidates, + ); + }); + } + + private on_select_callback = this.onPerspectiveSelect.bind( + this, + (config) => config.plugin !== "Datagrid", + ); + + private on_global_filter_callback = this.onPerspectiveSelect.bind( + this, + // (config) => config.plugin === "Datagrid", + (config) => true, + ); + + async makeMaster(widget: PerspectiveViewerWidget) { + if (widget.viewer.hasAttribute("settings")) { + await widget.toggleConfig(); + } + + widget.viewer.classList.add("workspace-master-widget"); + await widget.viewer.restore({ + plugin_config: { edit_mode: "SELECT_ROW_TREE" }, + }); + if (!this.masterPanel.isAttached) { + this.detailPanel.close(); + this.setupMasterPanel(DEFAULT_WORKSPACE_SIZE); + } + + this.masterPanel.addWidget(widget); + widget.isHidden && widget.show(); + widget.viewer.restyleElement(); + + widget.viewer.addEventListener( + "perspective-global-filter", + this.on_global_filter_callback, + ); + + widget.viewer.addEventListener( + "perspective-select", + this.on_select_callback, + ); + } + + async makeDetail(widget: PerspectiveViewerWidget) { + widget.viewer.classList.remove("workspace-master-widget"); + await widget.viewer.restore({ + plugin_config: { edit_mode: "READ_ONLY" }, + }); + this.dockpanel.addWidget(widget, { mode: `split-left` }); + if (this.masterPanel.widgets.length === 0) { + this.detailPanel.close(); + this.masterPanel.close(); + this.detailPanel.removeClass("has-master-panel"); + this.addWidget(this.detailPanel); + } + + widget.viewer.restyleElement(); + widget.viewer.removeEventListener( + "perspective-global-filter", + this.on_global_filter_callback, + ); + + widget.viewer.removeEventListener( + "perspective-select", + this.on_select_callback, + ); + } + + /*************************************************************************** + * + * Context Menu + * + */ + + createContextMenu(widget: PerspectiveViewerWidget | null) { + this._menu = new WorkspaceMenu( + this.menu_elem.shadowRoot!, + this.element, + { + commands: this.commands, + }, + ); + + const tabbar = find( + this.dockpanel.tabBars(), + (bar) => bar.currentTitle?.owner === widget, + ); + + this._menu.init_overlay = () => { + if (widget) { + widget.addClass("context-focus"); + widget.viewer.classList.add("context-focus"); + tabbar && tabbar.node.classList.add("context-focus"); + this.element.classList.add("context-menu"); + this.addClass("context-menu"); + if ( + widget.viewer.classList.contains("workspace-master-widget") + ) { + this._menu!.node.classList.add("workspace-master-menu"); + } else { + this._menu!.node.classList.remove("workspace-master-menu"); + } + } + }; + + if (widget?.parent === this.dockpanel || widget === null) { + this._menu.addItem({ + type: "submenu", + command: "workspace:newmenu", + submenu: (() => { + const submenu = new WorkspaceMenu( + this.menu_elem.shadowRoot!, + this.element, + { + commands: this.commands, + }, + ); + + (async () => { + const names = await Promise.all( + this.client.map((c) => c.get_hosted_table_names()), + ).then((x) => x.flat()); + + for (const table of names) { + let args; + if (widget !== null) { + args = { + table, + widget_name: + widget.viewer.getAttribute("slot"), + }; + } else { + args = { table }; + } + + submenu.insertItem(0, { + command: "workspace:new", + args, + }); + } + })(); + + const widgets = PerspectiveDockPanel.getWidgets( + this.dockpanel.saveLayout(), + ); + + if (widgets.length > 0) { + submenu.addItem({ type: "separator" }); + } + + let seen = new Set(); + for (const target_widget of widgets) { + if (!seen.has(target_widget.title.label)) { + let args; + if (widget !== null) { + args = { + target_widget_name: + target_widget.viewer.getAttribute( + "slot", + ), + widget_name: + widget.viewer.getAttribute("slot"), + }; + } else { + args = { + target_widget_name: + target_widget.viewer.getAttribute( + "slot", + ), + }; + } + + submenu.addItem({ + command: "workspace:newview", + args, + }); + + seen.add(target_widget.title.label); + } + } + + submenu.title.label = "New Table"; + return submenu; + })(), + }); + } + + if (widget) { + const widget_name = widget.viewer.getAttribute("slot"); + if (widget?.parent === this.dockpanel) { + this._menu.addItem({ type: "separator" }); + } + + this._menu.addItem({ + command: "workspace:duplicate", + args: { widget_name }, + }); + + this._menu.addItem({ + command: "workspace:master", + args: { widget_name }, + }); + + this._menu.addItem({ type: "separator" }); + + this._menu.addItem({ + command: "workspace:settings", + args: { widget_name }, + }); + + this._menu.addItem({ + command: "workspace:reset", + args: { widget_name }, + }); + this._menu.addItem({ + command: "workspace:export", + args: { widget_name }, + }); + this._menu.addItem({ + command: "workspace:copy", + args: { widget_name }, + }); + + this._menu.addItem({ type: "separator" }); + + this._menu.addItem({ + command: "workspace:close", + args: { widget_name }, + }); + this._menu.addItem({ + command: "workspace:help", + }); + } + + this._menu.aboutToClose.connect(() => { + if (widget) { + this.element.classList.remove("context-menu"); + this.removeClass("context-menu"); + widget.removeClass("context-focus"); + tabbar?.node?.classList.remove("context-focus"); + } + }); + + return this._menu; + } + + showContextMenu(widget: PerspectiveViewerWidget | null, event: MouseEvent) { + if (!event.shiftKey) { + const menu = this.createContextMenu(widget); + menu.init_overlay?.(); + const rect = this.element.getBoundingClientRect(); + menu.open(event.clientX - rect.x, event.clientY - rect.y, { + host: this.menu_elem.shadowRoot as unknown as HTMLElement, + }); + + event.preventDefault(); + event.stopPropagation(); + } + } + + /*************************************************************************** + * + * Context Menu + * + */ + + clearLayout() { + this.getAllWidgets().forEach((widget) => widget.close()); + this.widgets.forEach((widget) => widget.close()); + this.detailPanel.close(); + if (this.masterPanel.isAttached) { + this.detailPanel.removeClass("has-master-panel"); + this.masterPanel.close(); + } + } + + setupMasterPanel(sizes: number[]) { + this.detailPanel.addClass("has-master-panel"); + this.addWidget(this.masterPanel); + this.addWidget(this.detailPanel); + this.setRelativeSizes(sizes); + } + + async addViewer( + config: psp_viewer.ViewerConfigUpdate, + is_global_filter?: boolean, + ) { + await this._mutex.lock(async () => { + if (this.dockpanel.mode === "single-document") { + const _task = this._maximizedWidget!.viewer.toggleConfig(false); + this._unmaximize(); + } + + const widget = await this._createWidgetAndNode({ config }); + if (is_global_filter) { + if (!this.masterPanel.isAttached) { + this.setupMasterPanel(DEFAULT_WORKSPACE_SIZE); + } + + this.masterPanel.addWidget(widget); + } else { + if (!this.detailPanel.isAttached) { + this.addWidget(this.detailPanel); + } + this.dockpanel.addWidget(widget, { mode: "split-right" }); + } + + this.update(); + }); + } + + /********************************************************************* + * Widget helper methods + */ + + async _createWidgetAndNode({ + config, + slot: slotname, + }: { + config: psp_viewer.ViewerConfigUpdate; + slot?: string; + }) { + const node = this._createNode(slotname); + const table = config.table; + const viewer = document.createElement("perspective-viewer"); + viewer.setAttribute( + "slot", + node!.querySelector("slot")!.getAttribute("name")!, + ); + + if (table) { + viewer.setAttribute("table", table); + } + + for (const client of this.client) { + const tables = await client.get_hosted_table_names(); + if (table && tables.indexOf(table) > -1) { + await viewer.load(client); + return await this._createWidget({ + config, + elem: node as HTMLElement, + viewer, + }); + } + } + + throw new Error(`Table "${table}" not found`); + } + + _gen_id() { + let genId = `PERSPECTIVE_GENERATED_ID_${ID_COUNTER++}`; + if (this.element.querySelector(`[slot=${genId}]`)) { + genId = this._gen_id(); + } + return genId; + } + + _createNode(slotname?: string): HTMLElement { + let node = this.node.querySelector(`slot[name=${slotname}]`); + if (slotname === undefined || !node) { + const slot = document.createElement("slot"); + slotname = slotname || this._gen_id(); + slot.setAttribute("name", slotname); + const div = document.createElement("div"); + div.classList.add("viewer-container"); + div.appendChild(slot); + node = document.createElement("div"); + node.classList.add("workspace-widget"); + node.appendChild(div); + } else { + node = node.parentElement!.parentElement; + } + + return node as HTMLElement; + } + + async _createWidget({ + config, + elem, + viewer, + }: { + config?: psp_viewer.ViewerConfigUpdate; + elem?: Element; + viewer: HTMLPerspectiveViewerElement; + }) { + let node: HTMLElement = elem as HTMLElement; + if (!node) { + const slotname = viewer.getAttribute("slot") || undefined; + node = this.node.querySelector(`slot[name=${slotname}]`)!; + if (!node) { + node = this._createNode(slotname)!; + } else { + node = node.parentElement!.parentElement!; + } + } + + const onAttach = () => { + if (widget.viewer.parentElement !== this.element) { + this.element.appendChild(widget.viewer); + } + + const event = new CustomEvent("workspace-new-view", { + detail: { config, widget }, + }); + + this.element.dispatchEvent(event); + }; + + const widget = new PerspectiveViewerWidget({ node, viewer, onAttach }); + if (config) { + await widget.restore(config); + } + + widget.title.closable = true; + this._addWidgetEventListeners(widget); + return widget; + } + + _addWidgetEventListeners(widget: PerspectiveViewerWidget) { + if (this.listeners.has(widget)) { + this.listeners.get(widget)!(); + } + + const contextMenu = (event: MouseEvent) => + this.showContextMenu(widget, event); + + const updated = async (event: CustomEvent) => { + this.workspaceUpdated(); + // Sometimes plugins or other external code fires this event and + // does not populate this field! + const config = + typeof event.detail === "undefined" + ? await widget.viewer.save() + : event.detail; + + widget.title.label = config.title; + widget._title = config.title; + widget._is_pivoted = config.group_by?.length > 0; + }; + + widget.node.addEventListener("contextmenu", contextMenu); + + // Settings + const settings_before = (event: CustomEvent) => { + if (event.detail && this.dockpanel.mode !== "single-document") { + this._maximize(widget); + } + }; + + const settings_after = (event: CustomEvent) => { + if (!event.detail && this.dockpanel.mode === "single-document") { + this._unmaximize(); + } + }; + + widget.viewer.addEventListener( + "perspective-status-indicator-click", + (event) => { + widget._titlebar_callback?.(event as MouseEvent); + }, + ); + + widget.viewer.addEventListener( + "perspective-toggle-settings-before", + settings_before, + ); + + widget.viewer.addEventListener( + "perspective-toggle-settings", + settings_after, + ); + + const delete_before = () => { + if (!widget._deleted) { + widget._deleted = true; + widget.close(); + } + }; + + const delete_after = (event: CustomEvent) => { + widget._titlebar?.handleEvent(event.detail as PointerEvent); + }; + + widget.viewer.addEventListener( + "perspective-table-delete-before", + delete_before, + ); + + widget.viewer.addEventListener( + "perspective-statusbar-pointerdown", + delete_after, + ); + + // @ts-ignore + widget.viewer.addEventListener("perspective-config-update", updated); + + this.listeners.set(widget, () => { + widget.node.removeEventListener("contextmenu", contextMenu); + widget.viewer.removeEventListener( + "perspective-table-delete-before", + delete_before, + ); + + widget.viewer.removeEventListener( + "perspective-table-delete", + delete_after, + ); + + widget.viewer.removeEventListener( + "perspective-toggle-settings", + settings_before, + ); + + widget.viewer.removeEventListener( + "perspective-toggle-settings", + settings_after, + ); + + // @ts-ignore + widget.viewer.removeEventListener( + "perspective-config-update", + updated, + ); + }); + } + + getWidgetByName(name: string): PerspectiveViewerWidget | null { + return ( + this.getAllWidgets().find( + (x) => x.viewer.getAttribute("slot") === name, + ) || null + ); + } + + getAllWidgets(): PerspectiveViewerWidget[] { + return [ + ...(this.masterPanel.widgets as PerspectiveViewerWidget[]), + ...toArray(this.dockpanel.widgets()), + ] as PerspectiveViewerWidget[]; + } + + /*************************************************************************** + * + * `workspace-layout-update` event + * + */ + + _throttle?: DebouncedFuncLeading<() => Promise>; + + async workspaceUpdated() { + // if (!this._throttle) { + // this._throttle = throttle(async () => { + const layout = await this.save(); + if (layout) { + if (this._last_updated_state) { + if (isEqual(this._last_updated_state, layout)) { + return; + } + } + + this._last_updated_state = + layout as any as PerspectiveWorkspaceConfig; + + this.element.dispatchEvent( + new CustomEvent("workspace-layout-update", { + detail: { layout }, + }), + ); + } + // }, 0); + // } + + // this._throttle(); + } +} diff --git a/packages/workspace/test/csv/superstore.csv b/packages/workspace/test/csv/superstore.csv new file mode 100644 index 0000000..4172673 --- /dev/null +++ b/packages/workspace/test/csv/superstore.csv @@ -0,0 +1,100 @@ +Row ID,Order ID,Order Date,Ship Date,Ship Mode,Customer ID,Segment,Country,City,State,Postal Code,Region,Product ID,Category,Sub-Category,Sales,Quantity,Discount,Profit +1,CA-2013-152156,11/9/2013,11/12/2013,Second Class,CG-12520,Consumer,United States,Henderson,Kentucky,42420,South,FUR-BO-10001798,Furniture,Bookcases,261.96,2,0.0,41.9136 +2,CA-2013-152156,11/9/2013,11/12/2013,Second Class,CG-12520,Consumer,United States,Henderson,Kentucky,42420,South,FUR-CH-10000454,Furniture,Chairs,731.94,3,0.0,219.582 +3,CA-2013-138688,6/13/2013,6/17/2013,Second Class,DV-13045,Corporate,United States,Los Angeles,California,90036,West,OFF-LA-10000240,Office Supplies,Labels,14.62,2,0.0,6.8714 +4,US-2012-108966,10/11/2012,10/18/2012,Standard Class,SO-20335,Consumer,United States,Fort Lauderdale,Florida,33311,South,FUR-TA-10000577,Furniture,Tables,957.5775,5,0.45,-383.031 +5,US-2012-108966,10/11/2012,10/18/2012,Standard Class,SO-20335,Consumer,United States,Fort Lauderdale,Florida,33311,South,OFF-ST-10000760,Office Supplies,Storage,22.368,2,0.2,2.5164 +6,CA-2011-115812,6/9/2011,6/14/2011,Standard Class,BH-11710,Consumer,United States,Los Angeles,California,90032,West,FUR-FU-10001487,Furniture,Furnishings,48.86,7,0.0,14.1694 +7,CA-2011-115812,6/9/2011,6/14/2011,Standard Class,BH-11710,Consumer,United States,Los Angeles,California,90032,West,OFF-AR-10002833,Office Supplies,Art,7.28,4,0.0,1.9656 +8,CA-2011-115812,6/9/2011,6/14/2011,Standard Class,BH-11710,Consumer,United States,Los Angeles,California,90032,West,TEC-PH-10002275,Technology,Phones,907.152,6,0.2,90.7152 +9,CA-2011-115812,6/9/2011,6/14/2011,Standard Class,BH-11710,Consumer,United States,Los Angeles,California,90032,West,OFF-BI-10003910,Office Supplies,Binders,18.504,3,0.2,5.7825 +10,CA-2011-115812,6/9/2011,6/14/2011,Standard Class,BH-11710,Consumer,United States,Los Angeles,California,90032,West,OFF-AP-10002892,Office Supplies,Appliances,114.9,5,0.0,34.47 +11,CA-2011-115812,6/9/2011,6/14/2011,Standard Class,BH-11710,Consumer,United States,Los Angeles,California,90032,West,FUR-TA-10001539,Furniture,Tables,1706.184,9,0.2,85.3092 +12,CA-2011-115812,6/9/2011,6/14/2011,Standard Class,BH-11710,Consumer,United States,Los Angeles,California,90032,West,TEC-PH-10002033,Technology,Phones,911.424,4,0.2,68.3568 +13,CA-2014-114412,4/16/2014,4/21/2014,Standard Class,AA-10480,Consumer,United States,Concord,North Carolina,28027,South,OFF-PA-10002365,Office Supplies,Paper,15.552,3,0.2,5.4432 +14,CA-2013-161389,12/6/2013,12/11/2013,Standard Class,IM-15070,Consumer,United States,Seattle,Washington,98103,West,OFF-BI-10003656,Office Supplies,Binders,407.976,3,0.2,132.5922 +15,US-2012-118983,11/22/2012,11/26/2012,Standard Class,HP-14815,Home Office,United States,Fort Worth,Texas,76106,Central,OFF-AP-10002311,Office Supplies,Appliances,68.81,5,0.8,-123.858 +16,US-2012-118983,11/22/2012,11/26/2012,Standard Class,HP-14815,Home Office,United States,Fort Worth,Texas,76106,Central,OFF-BI-10000756,Office Supplies,Binders,2.544,3,0.8,-3.816 +17,CA-2011-105893,11/11/2011,11/18/2011,Standard Class,PK-19075,Consumer,United States,Madison,Wisconsin,53711,Central,OFF-ST-10004186,Office Supplies,Storage,665.88,6,0.0,13.3176 +18,CA-2011-167164,5/13/2011,5/15/2011,Second Class,AG-10270,Consumer,United States,West Jordan,Utah,84084,West,OFF-ST-10000107,Office Supplies,Storage,55.5,2,0.0,9.99 +19,CA-2011-143336,8/27/2011,9/1/2011,Second Class,ZD-21925,Consumer,United States,San Francisco,California,94109,West,OFF-AR-10003056,Office Supplies,Art,8.56,2,0.0,2.4824 +20,CA-2011-143336,8/27/2011,9/1/2011,Second Class,ZD-21925,Consumer,United States,San Francisco,California,94109,West,TEC-PH-10001949,Technology,Phones,213.48,3,0.2,16.011 +21,CA-2011-143336,8/27/2011,9/1/2011,Second Class,ZD-21925,Consumer,United States,San Francisco,California,94109,West,OFF-BI-10002215,Office Supplies,Binders,22.72,4,0.2,7.384 +22,CA-2013-137330,12/10/2013,12/14/2013,Standard Class,KB-16585,Corporate,United States,Fremont,Nebraska,68025,Central,OFF-AR-10000246,Office Supplies,Art,19.46,7,0.0,5.0596 +23,CA-2013-137330,12/10/2013,12/14/2013,Standard Class,KB-16585,Corporate,United States,Fremont,Nebraska,68025,Central,OFF-AP-10001492,Office Supplies,Appliances,60.34,7,0.0,15.6884 +24,US-2014-156909,7/17/2014,7/19/2014,Second Class,SF-20065,Consumer,United States,Philadelphia,Pennsylvania,19140,East,FUR-CH-10002774,Furniture,Chairs,71.372,2,0.3,-1.0196 +25,CA-2012-106320,9/25/2012,9/30/2012,Standard Class,EB-13870,Consumer,United States,Orem,Utah,84057,West,FUR-TA-10000577,Furniture,Tables,1044.63,3,0.0,240.2649 +26,CA-2013-121755,1/16/2013,1/20/2013,Second Class,EH-13945,Consumer,United States,Los Angeles,California,90049,West,OFF-BI-10001634,Office Supplies,Binders,11.648,2,0.2,4.2224 +27,CA-2013-121755,1/16/2013,1/20/2013,Second Class,EH-13945,Consumer,United States,Los Angeles,California,90049,West,TEC-AC-10003027,Technology,Accessories,90.57,3,0.0,11.7741 +28,US-2012-150630,9/17/2012,9/21/2012,Standard Class,TB-21520,Consumer,United States,Philadelphia,Pennsylvania,19140,East,FUR-BO-10004834,Furniture,Bookcases,3083.43,7,0.5,-1665.0522 +29,US-2012-150630,9/17/2012,9/21/2012,Standard Class,TB-21520,Consumer,United States,Philadelphia,Pennsylvania,19140,East,OFF-BI-10000474,Office Supplies,Binders,9.618,2,0.7,-7.0532 +30,US-2012-150630,9/17/2012,9/21/2012,Standard Class,TB-21520,Consumer,United States,Philadelphia,Pennsylvania,19140,East,FUR-FU-10004848,Furniture,Furnishings,124.2,3,0.2,15.525 +31,US-2012-150630,9/17/2012,9/21/2012,Standard Class,TB-21520,Consumer,United States,Philadelphia,Pennsylvania,19140,East,OFF-EN-10001509,Office Supplies,Envelopes,3.264,2,0.2,1.1016 +32,US-2012-150630,9/17/2012,9/21/2012,Standard Class,TB-21520,Consumer,United States,Philadelphia,Pennsylvania,19140,East,OFF-AR-10004042,Office Supplies,Art,86.304,6,0.2,9.7092 +33,US-2012-150630,9/17/2012,9/21/2012,Standard Class,TB-21520,Consumer,United States,Philadelphia,Pennsylvania,19140,East,OFF-BI-10001525,Office Supplies,Binders,6.858,6,0.7,-5.715 +34,US-2012-150630,9/17/2012,9/21/2012,Standard Class,TB-21520,Consumer,United States,Philadelphia,Pennsylvania,19140,East,OFF-AR-10001683,Office Supplies,Art,15.76,2,0.2,3.546 +35,CA-2014-107727,10/20/2014,10/24/2014,Second Class,MA-17560,Home Office,United States,Houston,Texas,77095,Central,OFF-PA-10000249,Office Supplies,Paper,29.472,3,0.2,9.9468 +36,CA-2013-117590,12/9/2013,12/11/2013,First Class,GH-14485,Corporate,United States,Richardson,Texas,75080,Central,TEC-PH-10004977,Technology,Phones,1097.544,7,0.2,123.4737 +37,CA-2013-117590,12/9/2013,12/11/2013,First Class,GH-14485,Corporate,United States,Richardson,Texas,75080,Central,FUR-FU-10003664,Furniture,Furnishings,190.92,5,0.6,-147.963 +38,CA-2012-117415,12/27/2012,12/31/2012,Standard Class,SN-20710,Home Office,United States,Houston,Texas,77041,Central,OFF-EN-10002986,Office Supplies,Envelopes,113.328,9,0.2,35.415 +39,CA-2012-117415,12/27/2012,12/31/2012,Standard Class,SN-20710,Home Office,United States,Houston,Texas,77041,Central,FUR-BO-10002545,Furniture,Bookcases,532.3992,3,0.32,-46.9764 +40,CA-2012-117415,12/27/2012,12/31/2012,Standard Class,SN-20710,Home Office,United States,Houston,Texas,77041,Central,FUR-CH-10004218,Furniture,Chairs,212.058,3,0.3,-15.147 +41,CA-2012-117415,12/27/2012,12/31/2012,Standard Class,SN-20710,Home Office,United States,Houston,Texas,77041,Central,TEC-PH-10000486,Technology,Phones,371.168,4,0.2,41.7564 +42,CA-2014-120999,9/11/2014,9/16/2014,Standard Class,LC-16930,Corporate,United States,Naperville,Illinois,60540,Central,TEC-PH-10004093,Technology,Phones,147.168,4,0.2,16.5564 +43,CA-2013-101343,7/18/2013,7/23/2013,Standard Class,RA-19885,Corporate,United States,Los Angeles,California,90049,West,OFF-ST-10003479,Office Supplies,Storage,77.88,2,0.0,3.894 +44,CA-2014-139619,9/20/2014,9/24/2014,Standard Class,ES-14080,Corporate,United States,Melbourne,Florida,32935,South,OFF-ST-10003282,Office Supplies,Storage,95.616,2,0.2,9.5616 +45,CA-2013-118255,3/12/2013,3/14/2013,First Class,ON-18715,Corporate,United States,Eagan,Minnesota,55122,Central,TEC-AC-10000171,Technology,Accessories,45.98,2,0.0,19.7714 +46,CA-2013-118255,3/12/2013,3/14/2013,First Class,ON-18715,Corporate,United States,Eagan,Minnesota,55122,Central,OFF-BI-10003291,Office Supplies,Binders,17.46,2,0.0,8.2062 +47,CA-2011-146703,10/20/2011,10/25/2011,Second Class,PO-18865,Consumer,United States,Westland,Michigan,48185,Central,OFF-ST-10001713,Office Supplies,Storage,211.96,4,0.0,8.4784 +48,CA-2013-169194,6/21/2013,6/26/2013,Standard Class,LH-16900,Consumer,United States,Dover,Delaware,19901,East,TEC-AC-10002167,Technology,Accessories,45.0,3,0.0,4.95 +49,CA-2013-169194,6/21/2013,6/26/2013,Standard Class,LH-16900,Consumer,United States,Dover,Delaware,19901,East,TEC-PH-10003988,Technology,Phones,21.8,2,0.0,6.104 +50,CA-2012-115742,4/18/2012,4/22/2012,Standard Class,DP-13000,Consumer,United States,New Albany,Indiana,47150,Central,OFF-BI-10004410,Office Supplies,Binders,38.22,6,0.0,17.9634 +51,CA-2012-115742,4/18/2012,4/22/2012,Standard Class,DP-13000,Consumer,United States,New Albany,Indiana,47150,Central,OFF-LA-10002762,Office Supplies,Labels,75.18,6,0.0,35.3346 +52,CA-2012-115742,4/18/2012,4/22/2012,Standard Class,DP-13000,Consumer,United States,New Albany,Indiana,47150,Central,FUR-FU-10001706,Furniture,Furnishings,6.16,2,0.0,2.9568 +53,CA-2012-115742,4/18/2012,4/22/2012,Standard Class,DP-13000,Consumer,United States,New Albany,Indiana,47150,Central,FUR-CH-10003061,Furniture,Chairs,89.99,1,0.0,17.0981 +54,CA-2013-105816,12/12/2013,12/18/2013,Standard Class,JM-15265,Corporate,United States,New York City,New York,10024,East,OFF-FA-10000304,Office Supplies,Fasteners,15.26,7,0.0,6.2566 +55,CA-2013-105816,12/12/2013,12/18/2013,Standard Class,JM-15265,Corporate,United States,New York City,New York,10024,East,TEC-PH-10002447,Technology,Phones,1029.95,5,0.0,298.6855 +56,CA-2013-111682,6/18/2013,6/19/2013,First Class,TB-21055,Consumer,United States,Troy,New York,12180,East,OFF-ST-10000604,Office Supplies,Storage,208.56,6,0.0,52.14 +57,CA-2013-111682,6/18/2013,6/19/2013,First Class,TB-21055,Consumer,United States,Troy,New York,12180,East,OFF-PA-10001569,Office Supplies,Paper,32.4,5,0.0,15.552 +58,CA-2013-111682,6/18/2013,6/19/2013,First Class,TB-21055,Consumer,United States,Troy,New York,12180,East,FUR-CH-10003968,Furniture,Chairs,319.41,5,0.1,7.098 +59,CA-2013-111682,6/18/2013,6/19/2013,First Class,TB-21055,Consumer,United States,Troy,New York,12180,East,OFF-PA-10000587,Office Supplies,Paper,14.56,2,0.0,6.9888 +60,CA-2013-111682,6/18/2013,6/19/2013,First Class,TB-21055,Consumer,United States,Troy,New York,12180,East,TEC-AC-10002167,Technology,Accessories,30.0,2,0.0,3.3 +61,CA-2013-111682,6/18/2013,6/19/2013,First Class,TB-21055,Consumer,United States,Troy,New York,12180,East,OFF-BI-10001460,Office Supplies,Binders,48.48,4,0.2,16.362 +62,CA-2013-111682,6/18/2013,6/19/2013,First Class,TB-21055,Consumer,United States,Troy,New York,12180,East,OFF-AR-10001868,Office Supplies,Art,1.68,1,0.0,0.84 +63,CA-2012-135545,11/24/2012,11/30/2012,Standard Class,KM-16720,Consumer,United States,Los Angeles,California,90004,West,TEC-AC-10004633,Technology,Accessories,13.98,2,0.0,6.1512 +64,CA-2012-135545,11/24/2012,11/30/2012,Standard Class,KM-16720,Consumer,United States,Los Angeles,California,90004,West,OFF-BI-10001078,Office Supplies,Binders,25.824,6,0.2,9.3612 +65,CA-2012-135545,11/24/2012,11/30/2012,Standard Class,KM-16720,Consumer,United States,Los Angeles,California,90004,West,OFF-PA-10003892,Office Supplies,Paper,146.73,3,0.0,68.9631 +66,CA-2012-135545,11/24/2012,11/30/2012,Standard Class,KM-16720,Consumer,United States,Los Angeles,California,90004,West,FUR-FU-10000397,Furniture,Furnishings,79.76,4,0.0,22.3328 +67,US-2012-164175,4/30/2012,5/5/2012,Standard Class,PS-18970,Home Office,United States,Chicago,Illinois,60610,Central,FUR-CH-10001146,Furniture,Chairs,213.115,5,0.3,-15.2225 +68,CA-2011-106376,12/5/2011,12/10/2011,Standard Class,BS-11590,Corporate,United States,Gilbert,Arizona,85234,West,OFF-AR-10002671,Office Supplies,Art,1113.024,8,0.2,111.3024 +69,CA-2011-106376,12/5/2011,12/10/2011,Standard Class,BS-11590,Corporate,United States,Gilbert,Arizona,85234,West,TEC-PH-10002726,Technology,Phones,167.968,4,0.2,62.988 +70,CA-2013-119823,6/5/2013,6/7/2013,First Class,KD-16270,Consumer,United States,Springfield,Virginia,22153,South,OFF-PA-10000482,Office Supplies,Paper,75.88,2,0.0,35.6636 +71,CA-2013-106075,9/19/2013,9/24/2013,Standard Class,HM-14980,Consumer,United States,New York City,New York,10009,East,OFF-BI-10004654,Office Supplies,Binders,4.616,1,0.2,1.731 +72,CA-2014-114440,9/15/2014,9/18/2014,Second Class,TB-21520,Consumer,United States,Jackson,Michigan,49201,Central,OFF-PA-10004675,Office Supplies,Paper,19.05,3,0.0,8.763 +73,US-2012-134026,4/26/2012,5/2/2012,Standard Class,JE-15745,Consumer,United States,Memphis,Tennessee,38109,South,FUR-CH-10000513,Furniture,Chairs,831.936,8,0.2,-114.3912 +74,US-2012-134026,4/26/2012,5/2/2012,Standard Class,JE-15745,Consumer,United States,Memphis,Tennessee,38109,South,FUR-FU-10003708,Furniture,Furnishings,97.04,2,0.2,1.213 +75,US-2012-134026,4/26/2012,5/2/2012,Standard Class,JE-15745,Consumer,United States,Memphis,Tennessee,38109,South,OFF-ST-10004123,Office Supplies,Storage,72.784,1,0.2,-18.196 +76,US-2014-118038,12/10/2014,12/12/2014,First Class,KB-16600,Corporate,United States,Houston,Texas,77041,Central,OFF-BI-10004182,Office Supplies,Binders,1.248,3,0.8,-1.9344 +77,US-2014-118038,12/10/2014,12/12/2014,First Class,KB-16600,Corporate,United States,Houston,Texas,77041,Central,FUR-FU-10000260,Furniture,Furnishings,9.708,3,0.6,-5.8248 +78,US-2014-118038,12/10/2014,12/12/2014,First Class,KB-16600,Corporate,United States,Houston,Texas,77041,Central,OFF-ST-10000615,Office Supplies,Storage,27.24,3,0.2,2.724 +79,US-2011-147606,11/26/2011,12/1/2011,Second Class,JE-15745,Consumer,United States,Houston,Texas,77070,Central,FUR-FU-10003194,Furniture,Furnishings,19.3,5,0.6,-14.475 +80,CA-2013-127208,6/13/2013,6/16/2013,First Class,SC-20770,Corporate,United States,Decatur,Alabama,35601,South,OFF-AP-10002118,Office Supplies,Appliances,208.16,1,0.0,56.2032 +81,CA-2013-127208,6/13/2013,6/16/2013,First Class,SC-20770,Corporate,United States,Decatur,Alabama,35601,South,OFF-BI-10002309,Office Supplies,Binders,16.74,3,0.0,8.0352 +82,CA-2011-139451,10/12/2011,10/16/2011,Standard Class,DN-13690,Consumer,United States,San Francisco,California,94122,West,OFF-AR-10002053,Office Supplies,Art,14.9,5,0.0,4.172 +83,CA-2011-139451,10/12/2011,10/16/2011,Standard Class,DN-13690,Consumer,United States,San Francisco,California,94122,West,OFF-ST-10002370,Office Supplies,Storage,21.39,1,0.0,6.2031 +84,CA-2012-149734,9/3/2012,9/8/2012,Standard Class,JC-16105,Corporate,United States,Durham,North Carolina,27707,South,OFF-EN-10000927,Office Supplies,Envelopes,200.984,7,0.2,62.8075 +85,US-2014-119662,11/14/2014,11/17/2014,First Class,CS-12400,Home Office,United States,Chicago,Illinois,60623,Central,OFF-ST-10003656,Office Supplies,Storage,230.376,3,0.2,-48.9549 +86,CA-2014-140088,5/29/2014,5/31/2014,Second Class,PO-18865,Consumer,United States,Columbia,South Carolina,29203,South,FUR-CH-10000863,Furniture,Chairs,301.96,2,0.0,33.2156 +87,CA-2014-155558,10/27/2014,11/3/2014,Standard Class,PG-18895,Consumer,United States,Rochester,Minnesota,55901,Central,TEC-AC-10001998,Technology,Accessories,19.99,1,0.0,6.7966 +88,CA-2014-155558,10/27/2014,11/3/2014,Standard Class,PG-18895,Consumer,United States,Rochester,Minnesota,55901,Central,OFF-LA-10000134,Office Supplies,Labels,6.16,2,0.0,2.9568 +89,CA-2013-159695,4/6/2013,4/11/2013,Second Class,GM-14455,Home Office,United States,Houston,Texas,77095,Central,OFF-ST-10003442,Office Supplies,Storage,158.368,7,0.2,13.8572 +90,CA-2013-109806,9/18/2013,9/23/2013,Standard Class,JS-15685,Corporate,United States,Los Angeles,California,90036,West,OFF-AR-10004930,Office Supplies,Art,20.1,3,0.0,6.633 +91,CA-2013-109806,9/18/2013,9/23/2013,Standard Class,JS-15685,Corporate,United States,Los Angeles,California,90036,West,TEC-PH-10004093,Technology,Phones,73.584,2,0.2,8.2782 +92,CA-2013-109806,9/18/2013,9/23/2013,Standard Class,JS-15685,Corporate,United States,Los Angeles,California,90036,West,OFF-PA-10000304,Office Supplies,Paper,6.48,1,0.0,3.1104 +93,CA-2012-149587,1/31/2012,2/5/2012,Second Class,KB-16315,Consumer,United States,Minneapolis,Minnesota,55407,Central,OFF-PA-10003177,Office Supplies,Paper,12.96,2,0.0,6.2208 +94,CA-2012-149587,1/31/2012,2/5/2012,Second Class,KB-16315,Consumer,United States,Minneapolis,Minnesota,55407,Central,FUR-FU-10003799,Furniture,Furnishings,53.34,3,0.0,16.5354 +95,CA-2012-149587,1/31/2012,2/5/2012,Second Class,KB-16315,Consumer,United States,Minneapolis,Minnesota,55407,Central,OFF-BI-10002852,Office Supplies,Binders,32.96,2,0.0,16.1504 +96,US-2014-109484,11/7/2014,11/13/2014,Standard Class,RB-19705,Home Office,United States,Portland,Oregon,97206,West,OFF-BI-10004738,Office Supplies,Binders,5.682,1,0.7,-3.788 +97,CA-2014-161018,11/10/2014,11/12/2014,Second Class,PN-18775,Home Office,United States,New York City,New York,10009,East,FUR-FU-10000629,Furniture,Furnishings,96.53,7,0.0,40.5426 +98,CA-2014-157833,6/18/2014,6/21/2014,First Class,KD-16345,Consumer,United States,San Francisco,California,94122,West,OFF-BI-10001721,Office Supplies,Binders,51.312,3,0.2,17.9592 +99,CA-2013-149223,9/7/2013,9/12/2013,Standard Class,ER-13855,Corporate,United States,Saint Paul,Minnesota,55106,Central,OFF-AP-10000358,Office Supplies,Appliances,77.88,6,0.0,22.5852 \ No newline at end of file diff --git a/packages/workspace/test/js/context_menu.spec.js b/packages/workspace/test/js/context_menu.spec.js new file mode 100644 index 0000000..441f8fa --- /dev/null +++ b/packages/workspace/test/js/context_menu.spec.js @@ -0,0 +1,136 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; + +test.beforeEach(async ({ page }) => { + await page.goto("/tools/test/src/html/workspace-test.html"); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); +}); + +test.describe("Context menu", () => { + test("shows tables in the New Table submenu", async ({ page }) => { + await page.evaluate(async () => { + await window.__WORKER__.table("x\n1\n2\n", { + name: "test_table_1", + }); + + await window.__WORKER__.table("y\n3\n4\n", { + name: "test_table_2", + }); + }); + + // Right-click on the workspace to open context menu + const workspace = page.locator("perspective-workspace"); + await workspace.click({ + button: "right", + position: { x: 100, y: 100 }, + }); + + // Wait for the context menu to appear in the shadow DOM + const shadowHost = page.locator("perspective-workspace"); + const menu = shadowHost.locator(".lm-Menu").first(); + await expect(menu).toBeVisible(); + + // Click on "New Table" to open the submenu + const newTableItem = menu.locator( + ".lm-Menu-item:has(.lm-Menu-itemLabel:text('New Table'))", + ); + await newTableItem.hover(); + + // Wait for submenu to appear + const submenu = shadowHost.locator(".lm-Menu").nth(1); + await expect(submenu).toBeVisible(); + + // Get the submenu content with table entries + const submenuContent = submenu.locator(".lm-Menu-content"); + + // Wait for the table items to be populated (they are added asynchronously) + // We expect at least our two test tables plus the default "superstore" table + await expect(submenuContent.locator("> .lm-Menu-item")).toHaveCount(3, { + timeout: 5000, + }); + + const menuItems = submenuContent.locator("> .lm-Menu-item"); + const itemCount = await menuItems.count(); + + // Get the labels of the first items (the tables) + const labels = []; + for (let i = 0; i < itemCount; i++) { + const label = await menuItems + .nth(i) + .locator(".lm-Menu-itemLabel") + .textContent(); + labels.push(label); + } + + // Verify our test tables appear in the menu + expect(labels).toContain("test_table_1"); + expect(labels).toContain("test_table_2"); + expect(labels).toContain("superstore"); + }); + + test("context menu table entries have correct structure", async ({ + page, + }) => { + // Create two tables and load the workspace + await page.evaluate(async () => { + await window.__WORKER__.table("a\n1\n", { name: "alpha" }); + await window.__WORKER__.table("b\n2\n", { name: "beta" }); + }); + + // Right-click on the workspace + const workspace = page.locator("perspective-workspace"); + await workspace.click({ + button: "right", + position: { x: 100, y: 100 }, + }); + + // Open the "New Table" submenu + const shadowHost = page.locator("perspective-workspace"); + const menu = shadowHost.locator(".lm-Menu").first(); + await expect(menu).toBeVisible(); + + const newTableItem = menu.locator( + ".lm-Menu-item:has(.lm-Menu-itemLabel:text('New Table'))", + ); + await newTableItem.hover(); + + const submenu = shadowHost.locator(".lm-Menu").nth(1); + await expect(submenu).toBeVisible(); + + // Verify the DOM structure of table entries + const submenuContent = submenu.locator(".lm-Menu-content"); + + // Wait for table items to be populated + await expect(submenuContent.locator("> .lm-Menu-item")).toHaveCount(3, { + timeout: 5000, + }); + + const firstItem = submenuContent.locator("> .lm-Menu-item").first(); + + // Check that the menu item has the expected Lumino classes + await expect(firstItem).toHaveClass(/lm-Menu-item/); + + // Check that it contains a label element + const labelElement = firstItem.locator(".lm-Menu-itemLabel"); + await expect(labelElement).toBeVisible(); + + // Verify the label contains one of our table names + const labelText = await labelElement.textContent(); + expect(["alpha", "beta", "superstore"]).toContain(labelText); + }); +}); diff --git a/packages/workspace/test/js/delete.spec.js b/packages/workspace/test/js/delete.spec.js new file mode 100644 index 0000000..e6994ad --- /dev/null +++ b/packages/workspace/test/js/delete.spec.js @@ -0,0 +1,81 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test } from "@perspective-dev/test"; + +test.beforeEach(async ({ page }) => { + await page.goto("/tools/test/src/html/workspace-test.html"); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); +}); + +test.describe("Workspace table functions", () => { + test("flush() waits until delete is resolved", async ({ page }) => { + const config = { + viewers: { + One: { table: "superstore", name: "One" }, + }, + detail: { + main: { + currentIndex: 0, + type: "tab-area", + widgets: ["One"], + }, + }, + }; + + await page.evaluate(async (config) => { + const workspace = document.getElementById("workspace"); + const table = await window.__WORKER__.open_table("superstore"); + await workspace.restore(config); + const viewer_removed = + workspace.querySelector("perspective-viewer"); + + workspace.removeChild(viewer_removed); + await viewer_removed.flush(); + await table.delete(); + }, config); + }); + + test("flush() waits until restore is applied and delete is resolved", async ({ + page, + }) => { + const config = { + viewers: { + One: { table: "superstore", name: "One" }, + }, + detail: { + main: { + currentIndex: 0, + type: "tab-area", + widgets: ["One"], + }, + }, + }; + + await page.evaluate(async (config) => { + const workspace = document.getElementById("workspace"); + const table = await window.__WORKER__.open_table("superstore"); + workspace.restore(config); // no await + await workspace.flush(); + const viewer_removed = + workspace.querySelector("perspective-viewer"); + + workspace.removeChild(viewer_removed); + await viewer_removed.flush(); + await table.delete(); + }, config); + }); +}); diff --git a/packages/workspace/test/js/dom.spec.js b/packages/workspace/test/js/dom.spec.js new file mode 100644 index 0000000..f9d5720 --- /dev/null +++ b/packages/workspace/test/js/dom.spec.js @@ -0,0 +1,78 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test } from "@perspective-dev/test"; +import { + compareLightDOMContents, + compareShadowDOMContents, +} from "@perspective-dev/test"; + +async function setupTestWorkspace(page) { + await page.goto("/tools/test/src/html/workspace-test.html"); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + await page.evaluate(async () => { + const viewer = document.createElement("perspective-viewer"); + viewer.setAttribute("table", "superstore"); + viewer.setAttribute("name", "one"); + viewer.setAttribute("slot", "one"); + const viewer2 = document.createElement("perspective-viewer"); + viewer2.setAttribute("table", "superstore"); + viewer2.setAttribute("name", "two"); + viewer2.setAttribute("slot", "two"); + const workspace = document.getElementById("workspace"); + workspace.appendChild(viewer); + workspace.appendChild(viewer2); + await workspace.flush(); + }); + + await page.evaluate(async () => { + const viewer = document.body.querySelector( + 'perspective-viewer[name="one"]', + ); + const workspace = document.getElementById("workspace"); + workspace.removeChild(viewer); + await workspace.flush(); + }); +} + +test.describe("Workspace DOM", () => { + test.describe("Light DOM", () => { + test.describe("removeChild", () => { + test("Remove One", async ({ page }) => { + await setupTestWorkspace(page); + + await compareLightDOMContents( + page, + "workspace-light-remove-one-child.txt", + ); + }); + }); + }); + + test.describe("Shadow DOM", () => { + test.describe("removeChild", () => { + test("Remove One", async ({ page }) => { + await setupTestWorkspace(page); + + await compareShadowDOMContents( + page, + "workspace-dark-remove-one-child.txt", + ); + }); + }); + }); +}); diff --git a/packages/workspace/test/js/global_filter.spec.js b/packages/workspace/test/js/global_filter.spec.js new file mode 100644 index 0000000..d178c4c --- /dev/null +++ b/packages/workspace/test/js/global_filter.spec.js @@ -0,0 +1,472 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@playwright/test"; +import { + compareLightDOMContents, + compareShadowDOMContents, +} from "@perspective-dev/test"; + +test.beforeEach(async ({ page }) => { + await page.goto("/tools/test/src/html/workspace-test.html"); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + await page.evaluate(async () => { + const { PerspectiveSelectDetail } = await import( + "/node_modules/@perspective-dev/viewer/dist/cdn/perspective-viewer.js" + ); + window.PerspectiveSelectDetail = PerspectiveSelectDetail; + }); +}); + +function tests(context, compare) { + // TODO: Implement this correctly + test.skip("treemap filters work", async ({ page }) => { + const config = { + viewers: { + One: { + table: "superstore", + name: "Test", + group_by: ["State"], + columns: ["Sales"], + plugin: "Treemap", + }, + Two: { table: "superstore", name: "One" }, + }, + master: { + widgets: ["One"], + }, + detail: { + main: { + currentIndex: 0, + type: "tab-area", + widgets: ["Two"], + }, + }, + }; + + const cfg = await page.evaluate(async (config) => { + const workspace = document.getElementById("workspace"); + await workspace.restore(config); + await workspace.flush(); + const timer = Promise.withResolvers(); + workspace.addEventListener("workspace-layout-update", (x) => { + timer.resolve(); + }); + + document + .querySelector("perspective-viewer-charts-treemap") + .shadowRoot.querySelector("g.treemap > g") + .dispatchEvent(new Event("click")); + + await timer.promise; + return await workspace.save(); + }, config); + + expect(cfg.viewers.Two.filter).toEqual([["State", "==", "Alabama"]]); + return compare(page, `${context}-treemap-filters-work.txt`); + }); + + test("Datagrid filters work", async ({ page }) => { + const config = { + viewers: { + One: { + table: "superstore", + name: "Test", + group_by: ["State"], + columns: ["Sales"], + plugin: "Datagrid", + }, + Two: { table: "superstore", name: "One" }, + }, + master: { + widgets: ["One"], + }, + detail: { + main: { + currentIndex: 0, + type: "tab-area", + widgets: ["Two"], + }, + }, + }; + + async function awaitConfigChange() { + return await page.evaluate(async () => { + let resolve; + const timer = new Promise((x) => { + resolve = x; + }); + + workspace.addEventListener("workspace-layout-update", resolve); + await timer; + workspace.removeEventListener( + "workspace-layout-update", + resolve, + ); + + return await workspace.save(); + }); + } + + await page.evaluate(async (config) => { + const workspace = document.getElementById("workspace"); + await workspace.restore(config); + await workspace.flush(); + }, config); + + let cfgPromise = awaitConfigChange(); + await page + .locator(".workspace-master-widget perspective-viewer-datagrid") + .locator("tbody tr:nth-child(6) th:last-of-type") + .click(); + + let cfg = await cfgPromise; + expect(cfg.viewers.Two.filter).toEqual([["State", "==", "Colorado"]]); + + cfgPromise = awaitConfigChange(); + await page + .locator(".workspace-master-widget perspective-viewer-datagrid") + .locator("tbody tr:nth-child(6) th:last-of-type") + .click(); + + cfg = await cfgPromise; + expect(cfg.viewers.Two.filter).toEqual([]); + + return compare(page, `${context}-datagrid-filters-work.txt`); + }); + + test("removeConfigs removes a programmatically applied filter from slave viewers", async ({ + page, + }) => { + const config = { + viewers: { + One: { + table: "superstore", + name: "Test", + group_by: ["State"], + columns: ["Sales"], + plugin: "Datagrid", + }, + Two: { table: "superstore", name: "One" }, + }, + master: { + widgets: ["One"], + }, + detail: { + main: { + currentIndex: 0, + type: "tab-area", + widgets: ["Two"], + }, + }, + }; + + async function awaitConfigChange() { + return await page.evaluate(async () => { + let resolve; + const timer = new Promise((x) => { + resolve = x; + }); + + workspace.addEventListener("workspace-layout-update", resolve); + await timer; + workspace.removeEventListener( + "workspace-layout-update", + resolve, + ); + + return await workspace.save(); + }); + } + + await page.evaluate(async (config) => { + const workspace = document.getElementById("workspace"); + await workspace.restore(config); + await workspace.flush(); + }, config); + + // Apply a filter for "Category" via programmatic dispatch. + // "Category" is not in the master's group_by/split_by/filter, so it + // would not be cleared by the candidates mechanism on deselect. + let cfgPromise = awaitConfigChange(); + await page.evaluate(async () => { + const masterViewer = document.querySelector( + ".workspace-master-widget", + ); + masterViewer.dispatchEvent( + new CustomEvent("perspective-global-filter", { + bubbles: true, + composed: true, + detail: new PerspectiveSelectDetail( + true, + {}, + ["Category"], + [], + [{ filter: [["Category", "==", "Furniture"]] }], + ), + }), + ); + }); + + let cfg = await cfgPromise; + expect(cfg.viewers.Two.filter).toEqual([ + ["Category", "==", "Furniture"], + ]); + + // Use removeConfigs to explicitly clear the Category filter from + // slave viewers. + cfgPromise = awaitConfigChange(); + await page.evaluate(async () => { + const masterViewer = document.querySelector( + ".workspace-master-widget", + ); + masterViewer.dispatchEvent( + new CustomEvent("perspective-global-filter", { + bubbles: true, + composed: true, + detail: new PerspectiveSelectDetail( + true, + {}, + [], + [{ filter: [["Category", "==", "Furniture"]] }], + [], + ), + }), + ); + }); + + cfg = await cfgPromise; + expect(cfg.viewers.Two.filter).toEqual([]); + }); + + test("removeConfigs preserves other slave filters while clearing targeted column", async ({ + page, + }) => { + const config = { + viewers: { + One: { + table: "superstore", + name: "Test", + group_by: ["State"], + columns: ["Sales"], + plugin: "Datagrid", + }, + Two: { table: "superstore", name: "One" }, + }, + master: { + widgets: ["One"], + }, + detail: { + main: { + currentIndex: 0, + type: "tab-area", + widgets: ["Two"], + }, + }, + }; + + async function awaitConfigChange() { + return await page.evaluate(async () => { + let resolve; + const timer = new Promise((x) => { + resolve = x; + }); + + workspace.addEventListener("workspace-layout-update", resolve); + await timer; + workspace.removeEventListener( + "workspace-layout-update", + resolve, + ); + + return await workspace.save(); + }); + } + + await page.evaluate(async (config) => { + const workspace = document.getElementById("workspace"); + await workspace.restore(config); + await workspace.flush(); + }, config); + + // Apply filters for both "Category" and "Segment" via programmatic + // dispatch. + let cfgPromise = awaitConfigChange(); + await page.evaluate(async () => { + const masterViewer = document.querySelector( + ".workspace-master-widget", + ); + masterViewer.dispatchEvent( + new CustomEvent("perspective-global-filter", { + bubbles: true, + composed: true, + detail: new PerspectiveSelectDetail( + true, + {}, + ["Category", "Segment"], + [], + [ + { + filter: [ + ["Category", "==", "Furniture"], + ["Segment", "==", "Consumer"], + ], + }, + ], + ), + }), + ); + }); + + let cfg = await cfgPromise; + expect(cfg.viewers.Two.filter).toEqual([ + ["Category", "==", "Furniture"], + ["Segment", "==", "Consumer"], + ]); + + // Remove only "Category"; "Segment" filter should be preserved. + cfgPromise = awaitConfigChange(); + await page.evaluate(async () => { + const masterViewer = document.querySelector( + ".workspace-master-widget", + ); + masterViewer.dispatchEvent( + new CustomEvent("perspective-global-filter", { + bubbles: true, + composed: true, + detail: new PerspectiveSelectDetail( + true, + {}, + [], + [{ filter: [["Category", "==", "Furniture"]] }], + [], + ), + }), + ); + }); + + cfg = await cfgPromise; + // Category is cleared, Segment is preserved. + expect(cfg.viewers.Two.filter).toEqual([["Segment", "==", "Consumer"]]); + }); + + test("Child classes of datagrid behave the same way", async ({ page }) => { + const config = { + viewers: { + One: { + table: "superstore", + name: "Test", + group_by: ["State"], + columns: ["Sales"], + plugin: "My Datagrid", + }, + Two: { table: "superstore", name: "One" }, + }, + master: { + widgets: ["One"], + }, + detail: { + main: { + currentIndex: 0, + type: "tab-area", + widgets: ["Two"], + }, + }, + }; + + await page.evaluate(async () => { + class MyGrid extends customElements.get( + "perspective-viewer-datagrid", + ) { + get_static_config() { + return { + ...super.get_static_config(), + name: "My Datagrid", + }; + } + } + customElements.define("my-grid", MyGrid); + customElements.get("perspective-viewer").registerPlugin("my-grid"); + }); + + async function awaitConfigChange() { + return await page.evaluate(async () => { + let resolve; + const timer = new Promise((x) => { + resolve = x; + }); + + workspace.addEventListener("workspace-layout-update", resolve); + await timer; + workspace.removeEventListener( + "workspace-layout-update", + resolve, + ); + + return await workspace.save(); + }); + } + + await page.evaluate(async (config) => { + const workspace = document.getElementById("workspace"); + await workspace.restore(config); + await workspace.flush(); + }, config); + + let cfgPromise = awaitConfigChange(); + await page + .locator(".workspace-master-widget my-grid") + .locator("tbody tr:nth-child(6) th:last-of-type") + + .click(); + let cfg = await cfgPromise; + + expect(cfg.viewers.Two.filter).toEqual([["State", "==", "Colorado"]]); + + cfgPromise = awaitConfigChange(); + await page + .locator(".workspace-master-widget my-grid") + .locator("tbody tr:nth-child(6) th:last-of-type") + .click({ + delay: 10, + }); + cfg = await cfgPromise; + + // console.log("OLD", cfg.viewers.Two.filter); + // await page.evaluate(async () => { + // await new Promise((r) => setTimeout(r, 5000)); + // }); + // cfg = await page.evaluate(async () => { + // const cfg = await workspace.save(); + // console.log("NEW", JSON.stringify(cfg.viewers.Two.filter)); + // return cfg; + // }); + + expect(cfg.viewers.Two.filter).toEqual([]); + + return compare(page, `${context}-my-datagrid-filters-work.txt`); + }); +} + +test.describe("Workspace global filters", () => { + test.describe("Light DOM", () => { + tests("light-dom", compareLightDOMContents); + }); + + test.describe("Shadow DOM", () => { + tests("shadow-dom", compareShadowDOMContents); + }); +}); diff --git a/packages/workspace/test/js/html.spec.js b/packages/workspace/test/js/html.spec.js new file mode 100644 index 0000000..03fa9bf --- /dev/null +++ b/packages/workspace/test/js/html.spec.js @@ -0,0 +1,117 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test } from "@perspective-dev/test"; +import { + compareLightDOMContents, + compareShadowDOMContents, +} from "@perspective-dev/test"; + +async function createOneWorkspace(page) { + // await new Promise((x) => setTimeout(x, 2000)); + await page.evaluate(async () => { + document.querySelector("perspective-workspace").innerHTML = ` + + `; + + const workspace = document.body.querySelector("perspective-workspace"); + await workspace.load(window.__WORKER__); + await workspace.flush(); + }); +} + +async function createMultipleViewers(page) { + await page.evaluate(async () => { + document.querySelector("perspective-workspace").innerHTML = ` + + + `; + + const workspace = document.body.querySelector("perspective-workspace"); + await workspace.flush(); + }); +} + +async function createMultipleViewersWithNames(page) { + await page.evaluate(async () => { + document.querySelector("perspective-workspace").innerHTML = ` + + + `; + + const workspace = document.body.querySelector("perspective-workspace"); + await workspace.flush(); + }); +} + +test.beforeEach(async ({ page }) => { + await page.goto("/tools/test/src/html/workspace-test.html"); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); +}); + +test.describe("Workspace HTML", () => { + test.describe("Light DOM", () => { + test("Create One", async ({ page }) => { + await createOneWorkspace(page); + await compareLightDOMContents( + page, + "workspace-html-light-create-one.txt", + ); + }); + + test("Create Multiple", async ({ page }) => { + await createMultipleViewers(page); + await compareLightDOMContents( + page, + "workspace-html-light-create-multiple.txt", + ); + }); + + test("Create Multiple with names", async ({ page }) => { + await createMultipleViewersWithNames(page); + await compareLightDOMContents( + page, + "workspace-html-light-create-multiple-with-names.txt", + ); + }); + }); + + test.describe("Shadow DOM", () => { + test("Create One", async ({ page }) => { + await createOneWorkspace(page); + await compareShadowDOMContents( + page, + "workspace-html-shadow-create-one.txt", + ); + }); + + test("Create Multiple", async ({ page }) => { + await createMultipleViewers(page); + await compareShadowDOMContents( + page, + "workspace-html-shadow-create-multiple.txt", + ); + }); + + test("Create Multiple with names", async ({ page }) => { + await createMultipleViewersWithNames(page); + await compareShadowDOMContents( + page, + "workspace-html-shadow-create-multiple-with-names.txt", + ); + }); + }); +}); diff --git a/packages/workspace/test/js/restore.spec.js b/packages/workspace/test/js/restore.spec.js new file mode 100644 index 0000000..f35ba3e --- /dev/null +++ b/packages/workspace/test/js/restore.spec.js @@ -0,0 +1,260 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import { + compareLightDOMContents, + compareShadowDOMContents, +} from "@perspective-dev/test"; + +test.beforeEach(async ({ page }) => { + await page.goto("/tools/test/src/html/workspace-test.html"); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); +}); + +function tests(context, compare) { + test("restore workspace with detail only", async ({ page }) => { + const config = { + viewers: { + One: { table: "superstore", name: "One" }, + }, + detail: { + main: { + currentIndex: 0, + type: "tab-area", + widgets: ["One"], + }, + }, + }; + + await page.evaluate(async (config) => { + const workspace = document.getElementById("workspace"); + await workspace.restore(config); + }, config); + + await page.evaluate(async () => { + await workspace.flush(); + }); + + return compare( + page, + `${context}-restore-workspace-with-detail-only.txt`, + ); + }); + + test("restore workspace with master and detail", async ({ page }) => { + const config = { + viewers: { + One: { + table: "superstore", + name: "Test", + group_by: ["State"], + columns: ["Sales", "Profit"], + }, + Two: { table: "superstore", name: "One" }, + }, + master: { + widgets: ["One"], + }, + detail: { + main: { + currentIndex: 0, + type: "tab-area", + widgets: ["Two"], + }, + }, + }; + + await page.evaluate(async (config) => { + const workspace = document.getElementById("workspace"); + await workspace.restore(config); + }, config); + + await page.evaluate(async () => { + const workspace = document.getElementById("workspace"); + await workspace.flush(); + }); + + return compare( + page, + `${context}-restore-workspace-with-master-and-detail.txt`, + ); + }); + + test("restore workspace is symmetric with addViewer", async ({ page }) => { + const initial = await page.evaluate(async (config) => { + const workspace = document.getElementById("workspace"); + await workspace.addViewer({ table: "superstore" }); + await workspace.flush(); + const result = await workspace.save(); + return result; + }); + + const second = await page.evaluate(async (initial) => { + const workspace = document.getElementById("workspace"); + await workspace.restore(initial); + await workspace.flush(); + return await workspace.save(); + }, initial); + + expect(initial).toEqual(second); + + return compare( + page, + `${context}-restore-workspace-is-symmetric-with-addviewer.txt`, + ); + }); + + test.describe("Toggle master/detail", () => { + test("restore a blank view removes master panel", async ({ page }) => { + const config = { + detail: { + main: { + type: "tab-area", + widgets: ["PERSPECTIVE_GENERATED_ID_1"], + currentIndex: 0, + }, + }, + master: { widgets: ["PERSPECTIVE_GENERATED_ID_0"], sizes: [1] }, + viewers: { + PERSPECTIVE_GENERATED_ID_0: { + table: "superstore", + }, + PERSPECTIVE_GENERATED_ID_1: { + table: "superstore", + }, + }, + }; + + const empty_config = { + detail: {}, + viewers: {}, + }; + + // const x = await page.evaluate(async (config) => { + // const workspace = document.getElementById("workspace"); + // await workspace.restore(config); + // await workspace.flush(); + // return workspace.outerHTML; + // }, config); + + // test.expect(x).toEqual( + // ``, + // ); + + await page.evaluate(async (config) => { + const workspace = document.getElementById("workspace"); + await workspace.restore(config); + await workspace.flush(); + }, empty_config); + + return compare( + page, + `${context}-restore-a-blank-view-removes-master-panel.txt`, + ); + }); + + test("restore a view which reuses a master viewer, removes master panel", async ({ + page, + }) => { + const config = { + detail: { + main: { + type: "tab-area", + widgets: ["PERSPECTIVE_GENERATED_ID_1"], + currentIndex: 0, + }, + }, + master: { widgets: ["PERSPECTIVE_GENERATED_ID_0"], sizes: [1] }, + viewers: { + PERSPECTIVE_GENERATED_ID_0: { + table: "superstore", + title: "One", + }, + PERSPECTIVE_GENERATED_ID_1: { + table: "superstore", + title: "Two", + }, + }, + }; + + const config2 = { + detail: { + main: { + type: "split-area", + orientation: "horizontal", + children: [ + { + type: "tab-area", + widgets: ["PERSPECTIVE_GENERATED_ID_0"], + currentIndex: 0, + }, + { + type: "tab-area", + widgets: ["PERSPECTIVE_GENERATED_ID_1"], + currentIndex: 0, + }, + ], + sizes: [0.5, 0.5], + }, + }, + master: { widgets: [], sizes: [] }, + viewers: { + PERSPECTIVE_GENERATED_ID_0: { + table: "superstore", + title: "One", + }, + PERSPECTIVE_GENERATED_ID_1: { + table: "superstore", + title: "Two", + }, + }, + }; + + // const x = await page.evaluate(async (config) => { + // const workspace = document.getElementById("workspace"); + // await workspace.restore(config); + // await workspace.flush(); + // return workspace.outerHTML; + // }, config); + + // test.expect(x).toEqual( + // ``, + // ); + + await page.evaluate(async (config) => { + const workspace = document.getElementById("workspace"); + await workspace.restore(config); + await workspace.flush(); + }, config2); + + return compare( + page, + `${context}-restore-which-reseats-master-viewer.txt`, + ); + }); + }); +} + +test.describe("Workspace restore", () => { + test.describe("Light DOM", () => { + tests("light-dom", compareLightDOMContents); + }); + + test.describe("Shadow DOM", () => { + tests("shadow-dom", compareShadowDOMContents); + }); +}); diff --git a/packages/workspace/test/js/table.spec.js b/packages/workspace/test/js/table.spec.js new file mode 100644 index 0000000..d6d0e9b --- /dev/null +++ b/packages/workspace/test/js/table.spec.js @@ -0,0 +1,164 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import { + compareLightDOMContents, + compareShadowDOMContents, +} from "@perspective-dev/test"; + +test.beforeEach(async ({ page }) => { + await page.goto("/tools/test/src/html/workspace-test.html"); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); +}); + +function tests(context, compare) { + // TODO: 3.x `Client` has no exact `replaceTable` + test.skip("replaceTable() frees the `Table` before resolution", async ({ + page, + }) => { + const config = { + viewers: { + One: { table: "superstore", name: "One" }, + }, + detail: { + main: { + currentIndex: 0, + type: "tab-area", + widgets: ["One"], + }, + }, + }; + + await page.evaluate(async (config) => { + const workspace = document.getElementById("workspace"); + await workspace.restore(config); + await workspace.replaceTable( + "superstore", + window.__WORKER__.table("x\n1"), + ); + await window.__TABLE__.delete(); + }, config); + + await page.evaluate(async () => { + await workspace.flush(); + }); + + return compare(page, `${context}-replace-table-frees-table.txt`); + }); + + test.skip("replaceTable() works when previous table errored", async ({ + page, + }) => { + const config = { + viewers: { + One: { table: "errored", name: "One" }, + }, + detail: { + main: { + currentIndex: 0, + type: "tab-area", + widgets: ["One"], + }, + }, + }; + + const result = await page.evaluate(async (config) => { + const workspace = document.getElementById("workspace"); + await workspace.addTable( + "errored", + new Promise((_, reject) => setTimeout(reject, 50)), + ); + + try { + await workspace.restore(config); + } catch (e) { + console.error(e); + return e.toString(); + } + }, config); + + // NOTE This is the error message we expect when `restore()` is called + // without a `Table`, subject to change. + expect(result).toEqual( + "Error: Failed to construct table from JsValue(undefined)", + ); + await page.evaluate(async () => { + await workspace.replaceTable( + "errored", + window.__WORKER__.table("x\n1"), + ); + }); + + await page.evaluate(async () => { + await workspace.flush(); + }); + + return compare( + page, + `${context}-replace-table-works-with-errored-table.txt`, + ); + }); + + test("removeTable() smoke test", async ({ page }) => { + await page.evaluate(async () => { + await window.__WORKER__.table("x\n1\n", { name: "temptable" }); + const workspace = document.getElementById("workspace"); + await workspace.load(window.__WORKER__); + await workspace.restore({ + viewers: { + One: { table: "temptable", name: "One" }, + }, + detail: { + main: { + currentIndex: 0, + type: "tab-area", + widgets: ["One"], + }, + }, + }); + + await workspace.flush(); + }); + + await page.evaluate(async () => { + await workspace.restore({ + viewers: {}, + detail: { + main: { + currentIndex: 0, + type: "tab-area", + widgets: [], + }, + }, + }); + + await workspace.flush(); + }); + + return compare(page, `${context}-table-delete-works.txt`); + }); +} + +test.describe("Workspace table functions", () => { + test.describe("Light DOM", () => { + tests("light-dom", compareLightDOMContents); + }); + + test.describe("Shadow DOM", () => { + tests("shadow-dom", compareShadowDOMContents); + }); +}); diff --git a/packages/workspace/test/js/visibility.spec.js b/packages/workspace/test/js/visibility.spec.js new file mode 100644 index 0000000..7ee93b2 --- /dev/null +++ b/packages/workspace/test/js/visibility.spec.js @@ -0,0 +1,113 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test } from "@perspective-dev/test"; +import { + compareLightDOMContents, + compareShadowDOMContents, +} from "@perspective-dev/test"; + +const BAD_LAYOUT = { + sizes: [1], + detail: { + main: { + type: "tab-area", + widgets: [ + "PERSPECTIVE_GENERATED_ID_0", + "PERSPECTIVE_GENERATED_ID_1", + ], + currentIndex: 1, + }, + }, + mode: "globalFilters", + viewers: { + PERSPECTIVE_GENERATED_ID_0: { + plugin: "Sunburst", + plugin_config: {}, + settings: false, + theme: null, + group_by: ["State"], + split_by: [], + columns: ["Quantity", null, null], + filter: [], + sort: [], + expressions: {}, + aggregates: {}, + master: false, + name: "one", + table: "superstore", + linked: false, + }, + PERSPECTIVE_GENERATED_ID_1: { + plugin: "Sunburst", + plugin_config: {}, + settings: false, + theme: null, + group_by: ["State"], + split_by: [], + columns: ["Sales", null, null], + filter: [], + sort: [], + expressions: {}, + aggregates: {}, + master: false, + name: "two", + table: "superstore", + linked: false, + }, + }, +}; + +function tests(context, compare) { + test.describe("visibility", () => { + test("Sunburst charts do not loop forever when disconnected from DOM", async ({ + page, + }) => { + await page.evaluate(async (layout) => { + await window.workspace.restore(layout); + }, BAD_LAYOUT); + + await page.evaluate(async () => { + const viewer = document.body.querySelector( + "perspective-viewer[slot=PERSPECTIVE_GENERATED_ID_0]", + ); + const workspace = document.getElementById("workspace"); + workspace.removeChild(viewer); + await workspace.flush(); + }); + + return compare( + page, + `sunburst-charts-does-not-loop-${context}.txt`, + ); + }); + }); +} + +test.beforeEach(async ({ page }) => { + await page.goto("/tools/test/src/html/workspace-test.html"); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); +}); + +test.describe("Workspace Visibility", () => { + test.describe("Light DOM", () => { + tests("light-dom", compareLightDOMContents); + }); + + test.describe("Shadow DOM", () => { + tests("shadow-dom", compareShadowDOMContents); + }); +}); diff --git a/packages/workspace/tsconfig.json b/packages/workspace/tsconfig.json new file mode 100644 index 0000000..2dbb75d --- /dev/null +++ b/packages/workspace/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "jsx": "preserve", + "strict": true, + "module": "ESNext", + "target": "ESNext", + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./dist/esm", + "rootDir": "./src/ts", + "moduleResolution": "bundler" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..d9f82b7 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,10278 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +catalogs: + default: + '@clickhouse/client-web': + specifier: ^1.12.0 + version: 1.17.0 + '@duckdb/duckdb-wasm': + specifier: 1.33.1-dev18.0 + version: 1.33.1-dev18.0 + '@fontsource/roboto-mono': + specifier: 4.5.10 + version: 4.5.10 + '@iarna/toml': + specifier: 3.0.0 + version: 3.0.0 + '@playwright/experimental-ct-react': + specifier: '=1.58.0' + version: 1.58.0 + '@playwright/test': + specifier: '=1.58.0' + version: 1.58.0 + '@types/lodash': + specifier: ^4.17.20 + version: 4.17.20 + '@types/node': + specifier: '>=22' + version: 24.9.1 + '@types/react': + specifier: '>17 <20' + version: 19.2.2 + '@types/react-dom': + specifier: '>17 <20' + version: 18.3.7 + '@types/stoppable': + specifier: '>=1' + version: 1.1.3 + '@types/ws': + specifier: '>=8' + version: 8.18.1 + '@zip.js/zip.js': + specifier: ^2.7.54 + version: 2.8.8 + apache-arrow: + specifier: 17.0.0 + version: 17.0.0 + arraybuffer-loader: + specifier: '>=1.0.8 <2' + version: 1.0.8 + auto-changelog: + specifier: ^2.5.0 + version: 2.5.0 + chalk: + specifier: '>=5' + version: 5.6.2 + commander: + specifier: '>=14 <15' + version: 14.0.1 + css-loader: + specifier: '>=7 <8' + version: 7.1.2 + dotenv: + specifier: '>=17' + version: 17.2.3 + esbuild: + specifier: ^0.25.5 + version: 0.25.11 + eslint: + specifier: ^9.0.0 + version: 9.39.4 + fs-extra: + specifier: '>=11 <12' + version: 11.3.2 + glob-gitignore: + specifier: ^1.0.15 + version: 1.0.15 + html-webpack-plugin: + specifier: '>=5 <6' + version: 5.6.4 + http-server: + specifier: ^14.1.1 + version: 14.1.1 + husky: + specifier: '>=9' + version: 9.1.7 + inquirer: + specifier: '>=12 <13' + version: 12.10.0 + lightningcss: + specifier: ^1.29.0 + version: 1.32.0 + lodash: + specifier: ^4.17.20 + version: 4.17.21 + microtime: + specifier: '>=3 <4' + version: 3.1.1 + moment: + specifier: ^2.30.1 + version: 2.30.1 + npm-run-all: + specifier: ^4.1.5 + version: 4.1.5 + octokit: + specifier: ^1.8.1 + version: 1.8.1 + prettier: + specifier: '>=3 <4' + version: 3.6.2 + pro_self_extracting_wasm: + specifier: 0.0.9 + version: 0.0.9 + puppeteer: + specifier: '>=24' + version: 24.26.1 + react: + specifier: '>17 <20' + version: 18.3.1 + react-dom: + specifier: '>17 <20' + version: 18.3.1 + regular-table: + specifier: '=0.8.4' + version: 0.8.4 + stoppable: + specifier: '=1.1.0' + version: 1.1.0 + style-loader: + specifier: '>=4 < 5' + version: 4.0.0 + superstore-arrow: + specifier: 3.2.0 + version: 3.2.0 + tar: + specifier: ^7.4.3 + version: 7.5.1 + tsx: + specifier: ^4.20.3 + version: 4.20.6 + typedoc: + specifier: ^0.28.7 + version: 0.28.14 + typescript: + specifier: '>=5 <6' + version: 5.9.3 + typescript-eslint: + specifier: ^8.0.0 + version: 8.59.1 + 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 + webpack-cli: + specifier: '>=5 <6' + version: 5.1.4 + ws: + specifier: ^8.17.0 + version: 8.18.3 + zx: + specifier: '>=8 <9' + version: 8.8.5 + +overrides: + d3-array: '>=3 <4' + d3-dispatch: '>=3 <4' + d3-format: '>=3 <4' + d3-scale: '>=4 <5' + d3-selection: '>=3 <4' + d3-transition: '>=3 <4' + +importers: + + .: + devDependencies: + '@fontsource/roboto-mono': + specifier: 'catalog:' + version: 4.5.10 + '@perspective-dev/client': + specifier: workspace:^ + version: link:rust/perspective-js + '@perspective-dev/jupyterlab': + specifier: workspace:^ + version: link:packages/jupyterlab + '@perspective-dev/react': + specifier: workspace:^ + version: link:packages/react + '@perspective-dev/server': + specifier: workspace:^ + version: link:rust/perspective-server + '@perspective-dev/test': + specifier: workspace:^ + version: link:tools/test + '@perspective-dev/viewer': + specifier: workspace:^ + version: link:rust/perspective-viewer + '@perspective-dev/viewer-charts': + specifier: workspace:^ + version: link:packages/viewer-charts + '@perspective-dev/viewer-datagrid': + specifier: workspace:^ + version: link:packages/viewer-datagrid + '@perspective-dev/workspace': + specifier: workspace:^ + version: link:packages/workspace + '@playwright/experimental-ct-react': + specifier: 'catalog:' + version: 1.58.0(@types/node@24.9.1)(jiti@1.21.7)(less@4.4.2)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(vite@6.4.1(@types/node@24.9.1)(jiti@1.21.7)(less@4.4.2)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(yaml@2.8.1) + '@playwright/test': + specifier: 'catalog:' + version: 1.58.0 + chalk: + specifier: 'catalog:' + version: 5.6.2 + dotenv: + specifier: 'catalog:' + version: 17.2.3 + eslint: + specifier: 'catalog:' + version: 9.39.4(jiti@1.21.7) + husky: + specifier: 'catalog:' + version: 9.1.7 + npm-run-all: + specifier: 'catalog:' + version: 4.1.5 + prettier: + specifier: 'catalog:' + version: 3.6.2 + tsx: + specifier: 'catalog:' + version: 4.20.6 + typescript-eslint: + specifier: 'catalog:' + version: 8.59.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + + docs: + dependencies: + '@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-charts': + specifier: 'workspace:' + version: link:../packages/viewer-charts + '@perspective-dev/viewer-datagrid': + specifier: 'workspace:' + version: link:../packages/viewer-datagrid + blocks: + specifier: 'workspace:' + version: link:../examples/blocks + prismjs: + specifier: ^1.29.0 + version: 1.30.0 + puppeteer: + specifier: 'catalog:' + version: 24.26.1(typescript@5.9.3) + superstore-arrow: + specifier: 'catalog:' + version: 3.2.0 + devDependencies: + '@types/prismjs': + specifier: ^1.26.0 + version: 1.26.6 + '@zip.js/zip.js': + specifier: 'catalog:' + version: 2.8.8 + esbuild: + specifier: 'catalog:' + version: 0.25.11 + lightningcss: + specifier: 'catalog:' + version: 1.32.0 + typescript: + specifier: 'catalog:' + version: 5.9.3 + + examples/blocks: + dependencies: + '@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-charts': + specifier: 'workspace:' + version: link:../../packages/viewer-charts + '@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: + '@zip.js/zip.js': + specifier: 'catalog:' + version: 2.8.8 + esbuild: + specifier: 'catalog:' + version: 0.25.11 + + examples/esbuild-clickhouse-virtual: + dependencies: + '@clickhouse/client-web': + specifier: 'catalog:' + version: 1.17.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-charts': + specifier: workspace:^ + version: link:../../packages/viewer-charts + '@perspective-dev/viewer-datagrid': + specifier: workspace:^ + version: link:../../packages/viewer-datagrid + devDependencies: + esbuild: + specifier: 'catalog:' + version: 0.25.11 + zx: + specifier: 'catalog:' + version: 8.8.5 + + examples/esbuild-duckdb-virtual: + dependencies: + '@duckdb/duckdb-wasm': + specifier: 'catalog:' + version: 1.33.1-dev18.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-charts': + specifier: workspace:^ + version: link:../../packages/viewer-charts + '@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': + specifier: 'workspace:' + version: link:../../rust/perspective-js + '@perspective-dev/viewer': + specifier: 'workspace:' + version: link:../../rust/perspective-viewer + '@perspective-dev/viewer-charts': + specifier: 'workspace:' + version: link:../../packages/viewer-charts + '@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 + http-server: + specifier: 'catalog:' + version: 14.1.1 + + examples/esbuild-remote: + dependencies: + '@perspective-dev/client': + specifier: 'workspace:' + version: link:../../rust/perspective-js + '@perspective-dev/viewer': + specifier: 'workspace:' + version: link:../../rust/perspective-viewer + '@perspective-dev/viewer-charts': + specifier: 'workspace:' + version: link:../../packages/viewer-charts + '@perspective-dev/viewer-datagrid': + specifier: 'workspace:' + version: link:../../packages/viewer-datagrid + ws: + specifier: 'catalog:' + version: 8.18.3 + devDependencies: + esbuild: + specifier: 'catalog:' + version: 0.25.11 + + examples/python-aiohttp: {} + + examples/python-clickhouse-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-charts': + specifier: workspace:^ + version: link:../../packages/viewer-charts + '@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-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-charts': + specifier: workspace:^ + version: link:../../packages/viewer-charts + '@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-polars-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-charts': + specifier: workspace:^ + version: link:../../packages/viewer-charts + '@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: + dependencies: + '@perspective-dev/client': + specifier: 'workspace:' + version: link:../../rust/perspective-js + '@perspective-dev/viewer': + specifier: 'workspace:' + version: link:../../rust/perspective-viewer + '@perspective-dev/viewer-charts': + specifier: 'workspace:' + version: link:../../packages/viewer-charts + '@perspective-dev/viewer-datagrid': + specifier: 'workspace:' + version: link:../../packages/viewer-datagrid + '@perspective-dev/workspace': + specifier: 'workspace:' + version: link:../../packages/workspace + superstore-arrow: + specifier: ^3.2.0 + version: 3.2.0 + devDependencies: + npm-run-all: + specifier: ^4.1.3 + version: 4.1.5 + + examples/python-tornado-streaming: + dependencies: + '@perspective-dev/client': + specifier: 'workspace:' + version: link:../../rust/perspective-js + '@perspective-dev/viewer': + specifier: 'workspace:' + version: link:../../rust/perspective-viewer + '@perspective-dev/viewer-charts': + specifier: 'workspace:' + version: link:../../packages/viewer-charts + '@perspective-dev/viewer-datagrid': + specifier: 'workspace:' + version: link:../../packages/viewer-datagrid + '@perspective-dev/workspace': + specifier: 'workspace:' + version: link:../../packages/workspace + superstore-arrow: + specifier: ^3.2.0 + version: 3.2.0 + devDependencies: + npm-run-all: + specifier: ^4.1.3 + version: 4.1.5 + + examples/react-example: + dependencies: + '@perspective-dev/client': + specifier: 'workspace:' + version: link:../../rust/perspective-js + '@perspective-dev/react': + specifier: 'workspace:' + version: link:../../packages/react + '@perspective-dev/viewer': + specifier: 'workspace:' + version: link:../../rust/perspective-viewer + '@perspective-dev/viewer-charts': + specifier: 'workspace:' + version: link:../../packages/viewer-charts + '@perspective-dev/viewer-datagrid': + specifier: 'workspace:' + version: link:../../packages/viewer-datagrid + react: + specifier: 'catalog:' + version: 18.3.1 + react-dom: + specifier: 'catalog:' + version: 18.3.1(react@18.3.1) + superstore-arrow: + specifier: 'catalog:' + version: 3.2.0 + devDependencies: + '@types/react': + specifier: 'catalog:' + version: 19.2.2 + '@types/react-dom': + specifier: 'catalog:' + version: 18.3.7(@types/react@19.2.2) + esbuild: + specifier: 'catalog:' + version: 0.25.11 + http-server: + specifier: 'catalog:' + version: 14.1.1 + + examples/rust-axum: + dependencies: + superstore-arrow: + specifier: 'catalog:' + version: 3.2.0 + + examples/vite-example: + dependencies: + '@perspective-dev/client': + specifier: 'workspace:' + version: link:../../rust/perspective-js + '@perspective-dev/viewer': + specifier: 'workspace:' + version: link:../../rust/perspective-viewer + '@perspective-dev/viewer-charts': + specifier: 'workspace:' + version: link:../../packages/viewer-charts + '@perspective-dev/viewer-datagrid': + specifier: 'workspace:' + version: link:../../packages/viewer-datagrid + superstore-arrow: + specifier: 'catalog:' + version: 3.2.0 + devDependencies: + vite: + specifier: 'catalog:' + version: 6.4.1(@types/node@24.9.1)(jiti@1.21.7)(less@4.4.2)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + + examples/webpack-example: + dependencies: + '@perspective-dev/client': + specifier: 'workspace:' + version: link:../../rust/perspective-js + '@perspective-dev/viewer': + specifier: 'workspace:' + version: link:../../rust/perspective-viewer + '@perspective-dev/viewer-charts': + specifier: 'workspace:' + version: link:../../packages/viewer-charts + '@perspective-dev/viewer-datagrid': + specifier: 'workspace:' + version: link:../../packages/viewer-datagrid + devDependencies: + arraybuffer-loader: + specifier: 'catalog:' + version: 1.0.8 + css-loader: + specifier: 'catalog:' + version: 7.1.2(webpack@5.102.1) + html-webpack-plugin: + specifier: 'catalog:' + version: 5.6.4(webpack@5.102.1) + style-loader: + specifier: 'catalog:' + version: 4.0.0(webpack@5.102.1) + superstore-arrow: + specifier: 'catalog:' + version: 3.2.0 + webpack: + specifier: 'catalog:' + version: 5.102.1(webpack-cli@5.1.4) + webpack-cli: + specifier: 'catalog:' + version: 5.1.4(webpack@5.102.1) + + packages/cli: + dependencies: + '@perspective-dev/client': + specifier: 'workspace:' + version: link:../../rust/perspective-js + '@perspective-dev/viewer': + specifier: 'workspace:' + version: link:../../rust/perspective-viewer + '@perspective-dev/viewer-charts': + specifier: 'workspace:' + version: link:../viewer-charts + '@perspective-dev/viewer-datagrid': + specifier: 'workspace:' + version: link:../viewer-datagrid + '@perspective-dev/workspace': + specifier: 'workspace:' + version: link:../workspace + commander: + specifier: 'catalog:' + version: 14.0.1 + puppeteer: + specifier: 'catalog:' + version: 24.26.1(typescript@5.9.3) + devDependencies: + '@perspective-dev/test': + specifier: 'workspace:' + version: link:../../tools/test + + packages/jupyterlab: + dependencies: + '@jupyter-widgets/base': + specifier: '>2 <5' + version: 4.1.7(react@18.3.1) + '@jupyterlab/application': + specifier: '>2 <5' + version: 4.4.10(react@18.3.1) + '@lumino/application': + specifier: <3 + version: 2.4.4 + '@lumino/widgets': + specifier: <3 + version: 2.7.1 + '@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-charts': + specifier: 'workspace:' + version: link:../viewer-charts + '@perspective-dev/viewer-datagrid': + specifier: 'workspace:' + version: link:../viewer-datagrid + devDependencies: + '@jupyterlab/builder': + specifier: ^4 + version: 4.4.10 + '@perspective-dev/esbuild-plugin': + specifier: 'workspace:' + version: link:../../tools/esbuild-plugin + '@perspective-dev/test': + specifier: 'workspace:' + version: link:../../tools/test + copy-webpack-plugin: + specifier: ~12 + version: 12.0.2(webpack@5.102.1(webpack-cli@5.1.4)) + lightningcss: + specifier: 'catalog:' + version: 1.32.0 + zx: + specifier: ^8.1.8 + version: 8.8.5 + + packages/react: + dependencies: + '@perspective-dev/client': + specifier: 'workspace:' + version: link:../../rust/perspective-js + '@perspective-dev/viewer': + specifier: 'workspace:' + version: link:../../rust/perspective-viewer + '@perspective-dev/workspace': + specifier: 'workspace:' + version: link:../workspace + '@types/react': + specifier: 'catalog:' + version: 19.2.2 + react: + specifier: 'catalog:' + version: 18.3.1 + react-dom: + specifier: 'catalog:' + version: 18.3.1(react@18.3.1) + devDependencies: + '@perspective-dev/esbuild-plugin': + specifier: 'workspace:' + version: link:../../tools/esbuild-plugin + superstore-arrow: + specifier: 'catalog:' + version: 3.2.0 + typedoc: + specifier: 'catalog:' + version: 0.28.14(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + zx: + specifier: 'catalog:' + version: 8.8.5 + + packages/viewer-charts: + devDependencies: + '@perspective-dev/client': + specifier: 'workspace:' + version: link:../../rust/perspective-js + '@perspective-dev/esbuild-plugin': + specifier: 'workspace:' + version: link:../../tools/esbuild-plugin + '@perspective-dev/test': + specifier: 'workspace:' + version: link:../../tools/test + '@perspective-dev/viewer': + specifier: 'workspace:' + version: link:../../rust/perspective-viewer + '@types/node': + specifier: 'catalog:' + version: 24.9.1 + lightningcss: + specifier: 'catalog:' + version: 1.32.0 + typescript: + specifier: 'catalog:' + version: 5.9.3 + webpack-glsl-minify: + specifier: 1.5.0 + version: 1.5.0 + + packages/viewer-datagrid: + dependencies: + '@perspective-dev/client': + specifier: 'workspace:' + version: link:../../rust/perspective-js + '@perspective-dev/viewer': + specifier: 'workspace:' + version: link:../../rust/perspective-viewer + regular-table: + specifier: 'catalog:' + version: 0.8.4 + devDependencies: + '@perspective-dev/esbuild-plugin': + specifier: 'workspace:' + version: link:../../tools/esbuild-plugin + '@perspective-dev/test': + specifier: 'workspace:' + version: link:../../tools/test + lightningcss: + specifier: 'catalog:' + version: 1.32.0 + prettier: + specifier: 'catalog:' + version: 3.6.2 + typescript: + specifier: 'catalog:' + version: 5.9.3 + zx: + specifier: 'catalog:' + version: 8.8.5 + + packages/workspace: + dependencies: + '@lumino/algorithm': + specifier: '>=2 <3' + version: 2.0.3 + '@lumino/commands': + specifier: '>=2 <3' + version: 2.3.2 + '@lumino/coreutils': + specifier: '>=2 <3' + version: 2.2.1 + '@lumino/domutils': + specifier: '>=2 <3' + version: 2.0.3 + '@lumino/messaging': + specifier: '>=2 <3' + version: 2.0.3 + '@lumino/signaling': + specifier: '>=2 <3' + version: 2.1.4 + '@lumino/virtualdom': + specifier: '>=2 <3' + version: 2.0.3 + '@lumino/widgets': + specifier: '>=2 <3' + version: 2.7.1 + '@perspective-dev/client': + specifier: 'workspace:' + version: link:../../rust/perspective-js + lodash: + specifier: 'catalog:' + version: 4.17.21 + devDependencies: + '@perspective-dev/esbuild-plugin': + specifier: 'workspace:' + version: link:../../tools/esbuild-plugin + '@perspective-dev/test': + specifier: 'workspace:' + version: link:../../tools/test + '@perspective-dev/viewer': + specifier: 'workspace:' + version: link:../../rust/perspective-viewer + '@types/lodash': + specifier: 'catalog:' + version: 4.17.20 + '@types/react': + specifier: 'catalog:' + version: 19.2.2 + lightningcss: + specifier: 'catalog:' + version: 1.32.0 + typescript: + specifier: 'catalog:' + version: 5.9.3 + zx: + specifier: 'catalog:' + version: 8.8.5 + + rust/metadata: {} + + rust/perspective: {} + + rust/perspective-js: + dependencies: + '@perspective-dev/server': + specifier: 'workspace:' + version: link:../perspective-server + pro_self_extracting_wasm: + specifier: 'catalog:' + version: 0.0.9 + stoppable: + specifier: 'catalog:' + version: 1.1.0 + ws: + specifier: 'catalog:' + version: 8.18.3 + devDependencies: + '@clickhouse/client-web': + specifier: 'catalog:' + version: 1.17.0 + '@duckdb/duckdb-wasm': + specifier: 'catalog:' + version: 1.33.1-dev18.0 + '@perspective-dev/esbuild-plugin': + specifier: 'workspace:' + version: link:../../tools/esbuild-plugin + '@perspective-dev/metadata': + specifier: 'workspace:' + version: link:../metadata + '@perspective-dev/test': + specifier: 'workspace:' + version: link:../../tools/test + '@playwright/experimental-ct-react': + specifier: 'catalog:' + version: 1.58.0(@types/node@24.9.1)(jiti@1.21.7)(less@4.4.2)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(vite@6.4.1(@types/node@24.9.1)(jiti@1.21.7)(less@4.4.2)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(yaml@2.8.1) + '@playwright/test': + specifier: 'catalog:' + version: 1.58.0 + '@types/node': + specifier: 'catalog:' + version: 24.9.1 + '@types/stoppable': + specifier: 'catalog:' + version: 1.1.3 + '@types/ws': + specifier: 'catalog:' + version: 8.18.1 + apache-arrow: + specifier: 'catalog:' + version: 17.0.0 + lodash: + specifier: 'catalog:' + version: 4.17.21 + moment: + specifier: 'catalog:' + version: 2.30.1 + superstore-arrow: + specifier: 'catalog:' + version: 3.2.0 + typedoc: + specifier: 'catalog:' + version: 0.28.14(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + web-worker: + specifier: 'catalog:' + version: 1.4.1 + zx: + specifier: 'catalog:' + version: 8.8.5 + + rust/perspective-python: + dependencies: + '@perspective-dev/jupyterlab': + specifier: workspace:* + version: link:../../packages/jupyterlab + devDependencies: + '@iarna/toml': + specifier: 'catalog:' + version: 3.0.0 + '@perspective-dev/scripts': + specifier: workspace:* + version: link:../../tools/scripts + superstore-arrow: + specifier: 'catalog:' + version: 3.2.0 + tar: + specifier: 'catalog:' + version: 7.5.1 + zx: + specifier: 'catalog:' + version: 8.8.5 + + rust/perspective-server: + devDependencies: + pro_self_extracting_wasm: + specifier: 'catalog:' + version: 0.0.9 + typescript: + specifier: 'catalog:' + version: 5.9.3 + zx: + specifier: 'catalog:' + version: 8.8.5 + + rust/perspective-viewer: + dependencies: + '@perspective-dev/client': + specifier: 'workspace:' + version: link:../perspective-js + pro_self_extracting_wasm: + specifier: 'catalog:' + version: 0.0.9 + devDependencies: + '@perspective-dev/esbuild-plugin': + specifier: 'workspace:' + version: link:../../tools/esbuild-plugin + '@perspective-dev/metadata': + specifier: 'workspace:' + version: link:../metadata + '@perspective-dev/test': + specifier: 'workspace:' + version: link:../../tools/test + '@playwright/experimental-ct-react': + specifier: 'catalog:' + version: 1.58.0(@types/node@24.9.1)(jiti@1.21.7)(less@4.4.2)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(vite@6.4.1(@types/node@24.9.1)(jiti@1.21.7)(less@4.4.2)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(yaml@2.8.1) + '@playwright/test': + specifier: 'catalog:' + version: 1.58.0 + '@types/node': + specifier: 'catalog:' + version: 24.9.1 + '@types/react': + specifier: 'catalog:' + version: 19.2.2 + '@types/react-dom': + specifier: 'catalog:' + version: 18.3.7(@types/react@19.2.2) + lightningcss: + specifier: 'catalog:' + version: 1.32.0 + prettier: + specifier: 'catalog:' + version: 3.6.2 + typedoc: + specifier: 'catalog:' + version: 0.28.14(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + zx: + specifier: 'catalog:' + version: 8.8.5 + + tools/bench: + dependencies: + perspective-0-10-0: + specifier: npm:@finos/perspective@0.10.0 + version: '@finos/perspective@0.10.0' + perspective-0-9-0: + specifier: npm:@finos/perspective@0.9.0 + version: '@finos/perspective@0.9.0' + perspective-1-0-0: + specifier: npm:@finos/perspective@1.0.0 + version: '@finos/perspective@1.0.0' + perspective-1-1-0: + specifier: npm:@finos/perspective@1.1.0 + version: '@finos/perspective@1.1.0' + perspective-2-1-0: + specifier: npm:@finos/perspective@2.1.0 + version: '@finos/perspective@2.1.0' + perspective-2-10-0: + specifier: npm:@finos/perspective@2.10.0 + version: '@finos/perspective@2.10.0' + perspective-2-2-0: + specifier: npm:@finos/perspective@2.2.0 + version: '@finos/perspective@2.2.0' + perspective-2-3-0: + specifier: npm:@finos/perspective@2.3.0 + version: '@finos/perspective@2.3.0' + perspective-2-4-0: + specifier: npm:@finos/perspective@2.4.0 + version: '@finos/perspective@2.4.0' + perspective-2-5-0: + specifier: npm:@finos/perspective@2.5.0 + version: '@finos/perspective@2.5.0' + perspective-2-6-0: + specifier: npm:@finos/perspective@2.6.0 + version: '@finos/perspective@2.6.0' + perspective-2-7-0: + specifier: npm:@finos/perspective@2.7.0 + version: '@finos/perspective@2.7.0' + perspective-2-8-0: + specifier: npm:@finos/perspective@2.8.0 + version: '@finos/perspective@2.8.0' + perspective-2-9-0: + specifier: npm:@finos/perspective@2.9.0 + version: '@finos/perspective@2.9.0' + perspective-3-0-0: + specifier: npm:@finos/perspective@3.0.0 + version: '@finos/perspective@3.0.0' + perspective-3-1-0: + specifier: npm:@finos/perspective@3.1.0 + version: '@finos/perspective@3.1.0' + perspective-3-2-0: + specifier: npm:@finos/perspective@3.2.0 + version: '@finos/perspective@3.2.0' + perspective-3-3-0: + specifier: npm:@finos/perspective@3.3.0 + version: '@finos/perspective@3.3.0' + perspective-3-4-0: + specifier: npm:@finos/perspective@3.4.0 + version: '@finos/perspective@3.4.0' + perspective-3-5-0: + specifier: npm:@finos/perspective@3.5.0 + version: '@finos/perspective@3.5.0' + perspective-3-6-0: + specifier: npm:@finos/perspective@3.6.0 + version: '@finos/perspective@3.6.0' + perspective-3-7-0: + specifier: npm:@finos/perspective@3.7.0 + version: '@finos/perspective@3.7.0' + perspective-3-8-0: + specifier: npm:@finos/perspective@3.8.0 + version: '@finos/perspective@3.8.0' + perspective-4-0-0: + specifier: npm:@perspective-dev/client@4.0.0 + version: '@perspective-dev/client@4.0.0' + perspective-4-1-0: + specifier: npm:@perspective-dev/client@4.1.0 + version: '@perspective-dev/client@4.1.0' + perspective-4-2-0: + specifier: npm:@perspective-dev/client@4.2.0 + version: '@perspective-dev/client@4.2.0' + perspective-4-3-0: + specifier: npm:@perspective-dev/client@4.3.0 + version: '@perspective-dev/client@4.3.0' + perspective-4-4-0: + specifier: npm:@perspective-dev/client@4.4.0 + version: '@perspective-dev/client@4.4.0' + perspective-4-5-0: + specifier: npm:@perspective-dev/client@4.5.0 + version: '@perspective-dev/client@4.5.0' + perspective-4-5-1: + specifier: npm:@perspective-dev/client@4.5.1 + version: '@perspective-dev/client@4.5.1' + devDependencies: + '@perspective-dev/client': + specifier: 'workspace:' + version: link:../../rust/perspective-js + '@perspective-dev/viewer-charts': + specifier: 'workspace:' + version: link:../../packages/viewer-charts + '@playwright/test': + specifier: 'catalog:' + version: 1.58.0 + microtime: + specifier: 'catalog:' + version: 3.1.1 + superstore-arrow: + specifier: 'catalog:' + version: 3.2.0 + zx: + specifier: 'catalog:' + version: 8.8.5 + + tools/esbuild-plugin: + dependencies: + esbuild: + specifier: 'catalog:' + version: 0.25.11 + + tools/scripts: + devDependencies: + '@iarna/toml': + specifier: 'catalog:' + version: 3.0.0 + '@playwright/experimental-ct-react': + specifier: 'catalog:' + version: 1.58.0(@types/node@24.9.1)(jiti@1.21.7)(less@4.4.2)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(vite@6.4.1(@types/node@24.9.1)(jiti@1.21.7)(less@4.4.2)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(yaml@2.8.1) + '@playwright/test': + specifier: 'catalog:' + version: 1.58.0 + auto-changelog: + specifier: 'catalog:' + version: 2.5.0 + fs-extra: + specifier: 'catalog:' + version: 11.3.2 + glob-gitignore: + specifier: 'catalog:' + version: 1.0.15 + inquirer: + specifier: 'catalog:' + version: 12.10.0(@types/node@24.9.1) + octokit: + specifier: 'catalog:' + version: 1.8.1 + tsx: + specifier: 'catalog:' + version: 4.20.6 + typescript: + specifier: 'catalog:' + version: 5.9.3 + zx: + specifier: 'catalog:' + version: 8.8.5 + + tools/test: + dependencies: + '@playwright/experimental-ct-react': + specifier: 'catalog:' + version: 1.58.0(@types/node@24.9.1)(jiti@1.21.7)(less@4.4.2)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(vite@6.4.1(@types/node@24.9.1)(jiti@1.21.7)(less@4.4.2)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(yaml@2.8.1) + '@playwright/test': + specifier: 'catalog:' + version: 1.58.0 + prettier: + specifier: 'catalog:' + version: 3.6.2 + react: + specifier: 'catalog:' + version: 18.3.1 + react-dom: + specifier: 'catalog:' + version: 18.3.1(react@18.3.1) + superstore-arrow: + specifier: 'catalog:' + version: 3.2.0 + tsx: + specifier: 'catalog:' + version: 4.20.6 + zx: + specifier: 'catalog:' + version: 8.8.5 + +packages: + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.5': + resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.5': + resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.5': + resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.27.1': + resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.5': + resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + + '@clickhouse/client-common@1.17.0': + resolution: {integrity: sha512-MiwwgXViFAQA2YZkN4ymF1ynzG0K49KeSX9/iOcmJetWkxqSekDdpyp1GjwATWa9R215uQ+hGzJtJujeQVZZIw==} + + '@clickhouse/client-web@1.17.0': + resolution: {integrity: sha512-rady6FKTSKfB4DL3dO1Y9R4XMykc0WxY8Yex4RWYgsNuYWkvP8WQqQjEgHQZqVUYvIA6jth6AMBSKAnZLo4p7A==} + + '@codemirror/state@6.5.2': + resolution: {integrity: sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==} + + '@discoveryjs/json-ext@0.5.7': + resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} + engines: {node: '>=10.0.0'} + + '@duckdb/duckdb-wasm@1.33.1-dev18.0': + resolution: {integrity: sha512-BVF+CYmOsrMRY8rnJa0USmC5PujPs2eZovQrSEeHs85J/MZ9mSblhzDih9BPu4MgwUxK9dYbYxKkBIJ4jv3EKw==} + + '@esbuild/aix-ppc64@0.25.11': + resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.11': + resolution: {integrity: sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.11': + resolution: {integrity: sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.11': + resolution: {integrity: sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.11': + resolution: {integrity: sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.11': + resolution: {integrity: sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.11': + resolution: {integrity: sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.11': + resolution: {integrity: sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.11': + resolution: {integrity: sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.11': + resolution: {integrity: sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.11': + resolution: {integrity: sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.11': + resolution: {integrity: sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.11': + resolution: {integrity: sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.11': + resolution: {integrity: sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.11': + resolution: {integrity: sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.11': + resolution: {integrity: sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.11': + resolution: {integrity: sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.11': + resolution: {integrity: sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.11': + resolution: {integrity: sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.11': + resolution: {integrity: sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.11': + resolution: {integrity: sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.11': + resolution: {integrity: sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.11': + resolution: {integrity: sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.11': + resolution: {integrity: sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.11': + resolution: {integrity: sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.11': + resolution: {integrity: sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@finos/perspective@0.10.0': + resolution: {integrity: sha512-PmsUiaOjChJaivDxGLNH8vAw8lG98OLlPjKCvci3iAFAEclGKMhSkHP/qYDU+NUZSPqsvk2Rf66TFsK8aiy/jg==} + engines: {node: '>=12'} + + '@finos/perspective@0.9.0': + resolution: {integrity: sha512-CkQ8thiCcSLpIr08aMYaYaMcHgk0Cj2PS1TPpz0hbwlR27UZeItgqrR4LNb3Fl4O6lZ3DaBQXIlqV05sP9yvGg==} + engines: {node: '>=12'} + + '@finos/perspective@1.0.0': + resolution: {integrity: sha512-WTi0hxM+EpWTpzqKp5w+ulGepUX6JGp+cwitSdCVHNsodRfatUNwVzdliVD5O8f2UMrkKMP4Dqwrm2WmxAj8Bg==} + engines: {node: '>=12'} + + '@finos/perspective@1.1.0': + resolution: {integrity: sha512-UHqkIKXmJ+q++hhVrVe9uFuNXifeyImHinUOi1Vyw172GpWZE1w8b53zRRk9P2U3WtYm7x7Jrhp1ILqkZ5F+aw==} + engines: {node: '>=12'} + + '@finos/perspective@2.1.0': + resolution: {integrity: sha512-NgDJtM7BbPEry574qQuSkzKEDdhSBSJecFeMyTOOlfzQ8cldR9xMg7/Lo5/NQWWi5cKuyzTWjTphOn6bP2M6YA==} + engines: {node: '>=14'} + + '@finos/perspective@2.10.0': + resolution: {integrity: sha512-8g6hsKhpS2gR7GYQWksNDm8kpymHZiSbJkC4HAp1dI95RArksr4U9CUAOnALgw8JI9B5CYI6b1SWQxbzCsTQ6A==} + engines: {node: '>=14'} + + '@finos/perspective@2.2.0': + resolution: {integrity: sha512-OPSFrwnaUyDnAL4jQb4kICMDBO6tqZ1vGGgd9xgTssB0VToIXObPMzlNAFtuWDL7qwiyxupqMYkjXMhoRGY7pA==} + engines: {node: '>=14'} + + '@finos/perspective@2.3.0': + resolution: {integrity: sha512-SnsdYdYHXKarG/mFIUcZmUiTKo9yccnbd+r0a9BRUcY3p9VuDPdp8fnPanoXLKi1xUkIz7RJSSTVpbPL1T+jWg==} + engines: {node: '>=14'} + + '@finos/perspective@2.4.0': + resolution: {integrity: sha512-JPYGAZq0WmcYUKXnyqdgZW+UPvgSAiWHmYx6I+ZVbjz1TIuuPFZ5Yw1n/VumUrjNGn/XNl7WnUUovh31Tez7tQ==} + engines: {node: '>=14'} + + '@finos/perspective@2.5.0': + resolution: {integrity: sha512-4YUs0oLvvGG4HQzqLJQCkRic+FW4h623etmcjBSuqcC3tLT+OMFxNZ8Z3NjS6uvdFu6vPTO7fAA0RnTdZKhXKg==} + engines: {node: '>=14'} + + '@finos/perspective@2.6.0': + resolution: {integrity: sha512-3YIBv4h1wQl8FBuabWLIV6H5aS1ufDC4/xjsrp3bIL5y5dgYwV5Uqr3gFj6cpmbWn0jNaFb6pEVm3Scn2QyDNw==} + engines: {node: '>=14'} + + '@finos/perspective@2.7.0': + resolution: {integrity: sha512-/9UHAnH1iW8ANnfplDLGMS60dx3R8UWRoL0dKTx9A3jNbeP2l+5wd0twsal00FqR/wgUomd3+tbbkzOIMsNclA==} + engines: {node: '>=14'} + + '@finos/perspective@2.8.0': + resolution: {integrity: sha512-D2LSwwFqs4mcZjwBBCNn7JdidK+Th29ZA6rdA1LMKn4pvedok0YzKii0beKTzovhVeKthlcVROKQpFY3DEjptw==} + engines: {node: '>=14'} + + '@finos/perspective@2.9.0': + resolution: {integrity: sha512-O40EzleYRpmX2k8xwbcJfWlgi4QDJ2cUJlmEA0wG1LLVmSOQD7vFeQK9H2v6xQBwn6+/andpDLglrzDYCEBOHg==} + engines: {node: '>=14'} + + '@finos/perspective@3.0.0': + resolution: {integrity: sha512-myWGlfxN97vV3qvv34QAxCH389Oo2SH0EGN4eyrGdZSb+UlFjv9HR1TYM18GRhdM1ASg7jFwnIvGDcw7oM+qNA==} + + '@finos/perspective@3.1.0': + resolution: {integrity: sha512-nyO4NWU1+C4C0SOYDdHM7p01uPl9eeJUYMG+YNy+LNOg5xGSpNZ4CI4iT9+1N7mSKLl9mrly8OAoRQXEHoDKTA==} + + '@finos/perspective@3.2.0': + resolution: {integrity: sha512-shV80NUE44V6AX2jhEAD4XS4uhMyv5A22SJ9kbop/ZKAC3Z9rbrmJffQqymZCgM2457fkjy/GxIcwcDur7GgFQ==} + + '@finos/perspective@3.3.0': + resolution: {integrity: sha512-GYzXPqHnhrWQz930f9OejVvsBs5I6jjJLvbK/xfUK2AUijMhzAUsPI70uEKUFwsy5VMA6sxWPl/UQB9W9IBGDw==} + + '@finos/perspective@3.4.0': + resolution: {integrity: sha512-7FlOdnotYkJOc5/+yerpcSv9VH+1e2fs53ZiOAm0/id2cke9RySbm30NpHICGq13SQW5J3ezByZElx3V72A8mQ==} + + '@finos/perspective@3.5.0': + resolution: {integrity: sha512-J4Q6aGq541iFeNeCrnW6tuq1ELfg0hhILH8qYoI9oJUSx1O/kwackunHOkxcPxW6zeaT0vUfHZaJjS7u1dYTuA==} + + '@finos/perspective@3.6.0': + resolution: {integrity: sha512-MOXifjSsPNO39Bc0LiXp1OvV6ujNes4EoFxJ58edzCutbeSWf0yknXyEBmwR0PB/QWNHx1Czl3cnnJHXgRPn3Q==} + + '@finos/perspective@3.7.0': + resolution: {integrity: sha512-UCtIGE6fuT7A7ljS9R5EmDfN7PZd5DwMg3iAikB/WPVE7dOtulEbvbsnkUMKa78zL8aN5hAGR4h4hxWMlv7fnw==} + + '@finos/perspective@3.8.0': + resolution: {integrity: sha512-9pot9YJq1RDdIRJlffp97ktbd0Sxk74mnoSOcf6U/luWceyEnfceQYHwJqUPR1IMNb9H1ZC6NQTbU4WRUT3hAQ==} + + '@fontsource/roboto-mono@4.5.10': + resolution: {integrity: sha512-KrJdmkqz6DszT2wV/bbhXef4r0hV3B0vw2mAqei8A2kRnvq+gcJLmmIeQ94vu9VEXrUQzos5M9lH1TAAXpRphw==} + + '@fortawesome/fontawesome-free@5.15.4': + resolution: {integrity: sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg==} + engines: {node: '>=6'} + + '@gerrit0/mini-shiki@3.13.1': + resolution: {integrity: sha512-fDWM5QQc70jwBIt/WYMybdyXdyBmoJe7r1hpM+V/bHnyla79sygVDK2/LlVxIPc4n5FA3B5Wzt7AQH2+psNphg==} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@iarna/toml@3.0.0': + resolution: {integrity: sha512-td6ZUkz2oS3VeleBcN+m//Q6HlCFCPrnI0FZhrt/h4XqLEdOyYp2u21nd8MdsR+WJy5r9PTDaHTDDfhf4H4l6Q==} + + '@inquirer/ansi@1.0.1': + resolution: {integrity: sha512-yqq0aJW/5XPhi5xOAL1xRCpe1eh8UFVgYFpFsjEqmIR8rKLyP+HINvFXwUaxYICflJrVlxnp7lLN6As735kVpw==} + engines: {node: '>=18'} + + '@inquirer/checkbox@4.3.0': + resolution: {integrity: sha512-5+Q3PKH35YsnoPTh75LucALdAxom6xh5D1oeY561x4cqBuH24ZFVyFREPe14xgnrtmGu3EEt1dIi60wRVSnGCw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.19': + resolution: {integrity: sha512-wQNz9cfcxrtEnUyG5PndC8g3gZ7lGDBzmWiXZkX8ot3vfZ+/BLjR8EvyGX4YzQLeVqtAlY/YScZpW7CW8qMoDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.0': + resolution: {integrity: sha512-Uv2aPPPSK5jeCplQmQ9xadnFx2Zhj9b5Dj7bU6ZeCdDNNY11nhYy4btcSdtDguHqCT2h5oNeQTcUNSGGLA7NTA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.21': + resolution: {integrity: sha512-MjtjOGjr0Kh4BciaFShYpZ1s9400idOdvQ5D7u7lE6VztPFoyLcVNE5dXBmEEIQq5zi4B9h2kU+q7AVBxJMAkQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.21': + resolution: {integrity: sha512-+mScLhIcbPFmuvU3tAGBed78XvYHSvCl6dBiYMlzCLhpr0bzGzd8tfivMMeqND6XZiaZ1tgusbUHJEfc6YzOdA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.2': + resolution: {integrity: sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.14': + resolution: {integrity: sha512-DbFgdt+9/OZYFM+19dbpXOSeAstPy884FPy1KjDu4anWwymZeOYhMY1mdFri172htv6mvc/uvIAAi7b7tvjJBQ==} + engines: {node: '>=18'} + + '@inquirer/input@4.2.5': + resolution: {integrity: sha512-7GoWev7P6s7t0oJbenH0eQ0ThNdDJbEAEtVt9vsrYZ9FulIokvd823yLyhQlWHJPGce1wzP53ttfdCZmonMHyA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.21': + resolution: {integrity: sha512-5QWs0KGaNMlhbdhOSCFfKsW+/dcAVC2g4wT/z2MCiZM47uLgatC5N20kpkDQf7dHx+XFct/MJvvNGy6aYJn4Pw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.21': + resolution: {integrity: sha512-xxeW1V5SbNFNig2pLfetsDb0svWlKuhmr7MPJZMYuDnCTkpVBI+X/doudg4pznc1/U+yYmWFFOi4hNvGgUo7EA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.9.0': + resolution: {integrity: sha512-X7/+dG9SLpSzRkwgG5/xiIzW0oMrV3C0HOa7YHG1WnrLK+vCQHfte4k/T80059YBdei29RBC3s+pSMvPJDU9/A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.9': + resolution: {integrity: sha512-AWpxB7MuJrRiSfTKGJ7Y68imYt8P9N3Gaa7ySdkFj1iWjr6WfbGAhdZvw/UnhFXTHITJzxGUI9k8IX7akAEBCg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.2.0': + resolution: {integrity: sha512-a5SzB/qrXafDX1Z4AZW3CsVoiNxcIYCzYP7r9RzrfMpaLpB+yWi5U8BWagZyLmwR0pKbbL5umnGRd0RzGVI8bQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.4.0': + resolution: {integrity: sha512-kaC3FHsJZvVyIjYBs5Ih8y8Bj4P/QItQWrZW22WJax7zTN+ZPXVGuOM55vzbdCP9zKUiBd9iEJVdesujfF+cAA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.9': + resolution: {integrity: sha512-QPaNt/nmE2bLGQa9b7wwyRJoLZ7pN6rcyXvzU0YCmivmJyq1BVo94G98tStRWkoD1RgDX5C+dPlhhHzNdu/W/w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@jupyter-widgets/base@4.1.7': + resolution: {integrity: sha512-cWg0Bb+QKmyHPnCpvF+/3u+ZU0jkTQ62qGr56ReujzCCIIRoXo3GP81TdzzrDTGM9tTZP/i91sUyC+7Od8b4Ow==} + + '@jupyter/react-components@0.16.7': + resolution: {integrity: sha512-BKIPkJ9V011uhtdq1xBOu2M3up59CqsRbDS4aq8XhnHR4pwqfRV6k6irE5YBOETCoIwWZZ5RZO+cJcZ3DcsT5A==} + + '@jupyter/web-components@0.16.7': + resolution: {integrity: sha512-1a8awgvvP9J9pCV5vBRuQxdBk29764qiMJsJYEndrWH3cB/FlaO+sZIBm4OTf56Eqdgl8R3/ZSLM1+3mgXOkPg==} + + '@jupyter/ydoc@3.1.0': + resolution: {integrity: sha512-+hLNUBZr8Zz8NiuaoYKINDURDJHbjFGxg8EcSU52y+rBe412TsoFxfPSng4eP7w3cZFrVlm7D+8K0nAMHxj0ZQ==} + + '@jupyterlab/application@4.4.10': + resolution: {integrity: sha512-nM6d32hNmsesFCQZyeTS7JxJOTchAaG0Cwr9WGrWSkxjVKYDThxmPeYWMkmnWsCJLnhfqaPmFJ/LHQ9ucETDQQ==} + + '@jupyterlab/apputils@4.5.10': + resolution: {integrity: sha512-Nhq0l7qDbnZoeNho39CipW0In7rh2hycMcRjk6XJSygp8SQei3i4oZ2GN0glu0LA//RrrqIxDKEcQv37aHytug==} + + '@jupyterlab/builder@4.4.10': + resolution: {integrity: sha512-GONEp96Vnp6HAyxM0VATu2IT6HsqguFAMa5gzJO761hXYiGGwjSHUHZXzCEqWgISyXpMFpplYwFz4a5WGWP59w==} + hasBin: true + + '@jupyterlab/codeeditor@4.4.10': + resolution: {integrity: sha512-6Ug23mk+ZyeZ39lKddXwGBiGk0Yq9DLqlWRd0twz8pSjqMq5f9e3oZSa4eQZC9Lcx4PMDIX60cEIIH4LazLi6Q==} + + '@jupyterlab/coreutils@6.4.10': + resolution: {integrity: sha512-B4DRg+F9fBctq/KENdbzUproHtQB0FxJX4NkCDFGGqzoEi+LEbghYi3XH/qSJeTKbwDxX+FEJsN995n53HWScQ==} + + '@jupyterlab/docregistry@4.4.10': + resolution: {integrity: sha512-pT4Eeme+DLFdsJYzajzQZFz5yiLd4ZK8rGqA9Vmw1bn0IOXowfuCA6Qes8N3O2ZmduUvd8HK21OGw+q8/3+STQ==} + + '@jupyterlab/nbformat@4.4.10': + resolution: {integrity: sha512-saSsowYR9r5nQ7ixWpRJXAzvRwPWeEhQf+1ZbE0Sue3gBelPQPPlAI1Dvzvyjxx1R6HPImFVa7gQ+ZFjKXpQdw==} + + '@jupyterlab/observables@5.4.10': + resolution: {integrity: sha512-I3Wqs9PtAdtOcQQmKvSUKM+v4N0EaYL0qJQemPzkBaYN+7QSkCe+JFnfmq/S+h5Su4bl+lCxxc9g3ByrhQOfQw==} + + '@jupyterlab/rendermime-interfaces@3.12.10': + resolution: {integrity: sha512-FQPzyU/5t87Kl9YdLKzvxvFHxVSgoIK5mH+LQs+/58AWWvuAF9Kiy5C1Vdrw/1n4PaWe0PBe8S7dYbpzhVNxgA==} + + '@jupyterlab/rendermime@4.4.10': + resolution: {integrity: sha512-DhOcWTdFWVfnFiBWjFkwTSiXR2WYq6ElckxExUgAiizdDMTxE8gEsbMaqoag6PDp794gcFGdxOnYYu52Tz+JcA==} + + '@jupyterlab/services@7.4.10': + resolution: {integrity: sha512-XkDaKl4kyG9K49IU6/7zAhhseqiFNroWU9fxaSRYQIkxToMsg9242yW+TqU52AiJL+ptha1ucwniLclhUNKPZw==} + + '@jupyterlab/settingregistry@4.4.10': + resolution: {integrity: sha512-qzi2lExFIfDbfzu0wiGV6O6J5vGH+lViVhT1b4N6HJXRXliUXJFVBq7uDNKRAW6KOhn+ITTV0dq0H9eZKuciig==} + peerDependencies: + react: '>=16' + + '@jupyterlab/statedb@4.4.10': + resolution: {integrity: sha512-C2BqHGt1jOmenueaesYF9Udxq2a+kFJb1X1/DaeP8Z/ESAzMjp+iUtlR4OrTzFuAUDRHqB7j8Cevo2sjNPg9jQ==} + + '@jupyterlab/statusbar@4.4.10': + resolution: {integrity: sha512-hCKcBbLUlrw7dfjZ9qZkhHn0kr0GIgUSDCu1vo4fRDrPG8Ej9oJvTvidkXRwohkBTYm01wWWqZRr/AyO18x8MA==} + + '@jupyterlab/translation@4.4.10': + resolution: {integrity: sha512-7kbM80YyOkrpCszqHq6TzM/vaWHFL+pQIaA6+sNKuQlWtYG+KligY7oKEd9cjS/P0P7/IozU1bSf03waaHBYEg==} + + '@jupyterlab/ui-components@4.4.10': + resolution: {integrity: sha512-F5c4uk8aphNpam2czUcmrlCSmoWIhowN6FCmyZRRLNC6kMOMWw6RjXlfJK5T08GbEZsYvdPx73Ilig2NH3qZOA==} + peerDependencies: + react: ^18.2.0 + + '@lumino/algorithm@2.0.3': + resolution: {integrity: sha512-DIcF7cIrGEC1Wh8DNjdwaL7IdcNs4Jj1VjO/90oHefeQPszKgc6DSfCxvbQiRanKR6tl/JL7tq4ZRPZES2oVAA==} + + '@lumino/application@2.4.4': + resolution: {integrity: sha512-sAWiNY0mJckQA/Q8Ul4yAwgTcm/kWN7c2YHXUMUgLbuEhZ/l0V0bU9LAXiYOPcrUJFt0OaEyzipL8YeRMpwMgw==} + + '@lumino/collections@2.0.3': + resolution: {integrity: sha512-Y9Wtvuk8SD6xcKgkqe3SUx5RywOcRz6DyWE4f5FvBT3OdANq76p9r0wtf8OKPt4BGE41kAQR+bdq3k+MfHlUsQ==} + + '@lumino/commands@2.3.2': + resolution: {integrity: sha512-aAFEiUpp2hrkQU82Z85w1L80g0iDzsQRncLBa+pqVR/k0k1lz6H9F6xZ1ff+lBumZKKtsxBxNomvd0hfxLLqGw==} + + '@lumino/coreutils@2.2.1': + resolution: {integrity: sha512-yij4TnxDIum7xfFUsVvZB0oLv4shs2mNbn3juwtEIsruvVBPmurNzKX0Y8z2QetbP2AZ6MSFtBzEKsihf0H0VA==} + + '@lumino/disposable@2.1.4': + resolution: {integrity: sha512-qTJiDbglPE2QnG4x4gtBcRbcfKQibxyyinNGKcNDrcK2TGTbbhK5PpMQ8d70l2V2Xw2pb/LfksBAg5pxkJ/G4A==} + + '@lumino/domutils@2.0.3': + resolution: {integrity: sha512-bXAbZg3mf2ZDNdBBpCGDike3U+osRGHePTh8H2Ud2KwaN4g/5IryFJm/TiO4K5IYs91bWF2Zqhf3FsdbZKHlGw==} + + '@lumino/dragdrop@2.1.6': + resolution: {integrity: sha512-N9aqdOYl5HTuTAIVvjTXxlPKK3e9JAj5yEtJ4nA/tNE7J8C9y3BRQ4fBAtk7O0z/8yx8tO8a0j5oSt2zAfvYuA==} + + '@lumino/keyboard@2.0.3': + resolution: {integrity: sha512-bU2OxAR8a9eNBdV0YFjU6/lVVpbOw1gM7yHOuDGDdNu4J0UpKapFoR9gopNGSaHTmTwDtx9RHdFfIAgHwjZ+VQ==} + + '@lumino/messaging@2.0.3': + resolution: {integrity: sha512-//NAE+FG9UWSoXs4i5imduGY1XDvhjMGNF82aMyFTv8wVU6bdRfRmj0xLlQ8ixzA0eXPOFL4ugWZNdlu48P1+Q==} + + '@lumino/polling@2.1.4': + resolution: {integrity: sha512-gSkxlIJ/4/esY2G7bsRrY9A4KimDMHTo0shaD+MCbhd09fZMCWJoDMcA447/dykB1rM5NXgugNLjpdGGL/e8cw==} + + '@lumino/properties@2.0.3': + resolution: {integrity: sha512-zkXIU5uYz/ScHCHGl5Bt4gMYsfPxZEduZd80zqDslBWvTIMro3NnzLe66NMnecbdr5N3hDJagYyA8//Qy3XjiA==} + + '@lumino/signaling@2.1.4': + resolution: {integrity: sha512-nC5Z6d9om369Jkh1Vp3b7C89hV4cjr1fQDVcxhemyKXwc9r6VW7FpKixC+jElcAknP5KLj1FAa8Np+K06mMkEA==} + + '@lumino/virtualdom@2.0.3': + resolution: {integrity: sha512-q2C8eBxPvvOOQjN3KuxZ+vJi082JH/GF7KwMdaWsy5g+7wjKdnXPuLQFTBLOrVqIzmbxBDlLeFr93CEhdQXcyQ==} + + '@lumino/widgets@2.7.1': + resolution: {integrity: sha512-gGq3zB1260gG1aK1m3SkqVoWZ/yfUxCKZvUOmRKLIcZPJUs33bgessm4P65oY5C1eAXalU4erLmKBiBaOn5gtw==} + + '@marijn/find-cluster-break@1.0.2': + resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + + '@microsoft/fast-colors@5.3.1': + resolution: {integrity: sha512-72RZXVfCbwQzvo5sXXkuLXLT7rMeYaSf5r/6ewQiv/trBtqpWRm4DEH2EilHw/iWTBKOXs1qZNQndgUMa5n4LA==} + + '@microsoft/fast-element@1.14.0': + resolution: {integrity: sha512-zXvuSOzvsu8zDTy9eby8ix8VqLop2rwKRgp++ZN2kTCsoB3+QJVoaGD2T/Cyso2ViZQFXNpiNCVKfnmxBvmWkQ==} + + '@microsoft/fast-foundation@2.50.0': + resolution: {integrity: sha512-8mFYG88Xea1jZf2TI9Lm/jzZ6RWR8x29r24mGuLojNYqIR2Bl8+hnswoV6laApKdCbGMPKnsAL/O68Q0sRxeVg==} + + '@microsoft/fast-web-utilities@5.4.1': + resolution: {integrity: sha512-ReWYncndjV3c8D8iq9tp7NcFNc1vbVHvcBFPME2nNFKNbS1XCesYZGlIlf3ot5EmuOXPlrzUHOWzQ2vFpIkqDg==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@octokit/app@12.0.7': + resolution: {integrity: sha512-NqgLlaaf7Yy1s5ghhiiBRGzstICpBYnVX5ce3Klk3iKaGeXJDBLVyrJ6e6sYOiTXolFK56Nx5QWS6oUBgP6rSw==} + + '@octokit/auth-app@3.6.1': + resolution: {integrity: sha512-6oa6CFphIYI7NxxHrdVOzhG7hkcKyGyYocg7lNDSJVauVOLtylg8hNJzoUyPAYKKK0yUeoZamE/lMs2tG+S+JA==} + + '@octokit/auth-oauth-app@4.3.4': + resolution: {integrity: sha512-OYOTSSINeUAiLMk1uelaGB/dEkReBqHHr8+hBejzMG4z1vA4c7QSvDAS0RVZSr4oD4PEUPYFzEl34K7uNrXcWA==} + + '@octokit/auth-oauth-device@3.1.4': + resolution: {integrity: sha512-6sHE/++r+aEFZ/BKXOGPJcH/nbgbBjS1A4CHfq/PbPEwb0kZEt43ykW98GBO/rYBPAYaNpCPvXfGwzgR9yMCXg==} + + '@octokit/auth-oauth-device@4.0.5': + resolution: {integrity: sha512-XyhoWRTzf2ZX0aZ52a6Ew5S5VBAfwwx1QnC2Np6Et3MWQpZjlREIcbcvVZtkNuXp6Z9EeiSLSDUqm3C+aMEHzQ==} + engines: {node: '>= 14'} + + '@octokit/auth-oauth-user@1.3.0': + resolution: {integrity: sha512-3QC/TAdk7onnxfyZ24BnJRfZv8TRzQK7SEFUS9vLng4Vv6Hv6I64ujdk/CUkREec8lhrwU764SZ/d+yrjjqhaQ==} + + '@octokit/auth-oauth-user@2.1.2': + resolution: {integrity: sha512-kkRqNmFe7s5GQcojE3nSlF+AzYPpPv7kvP/xYEnE57584pixaFBH8Vovt+w5Y3E4zWUEOxjdLItmBTFAWECPAg==} + engines: {node: '>= 14'} + + '@octokit/auth-token@2.5.0': + resolution: {integrity: sha512-r5FVUJCOLl19AxiuZD2VRZ/ORjp/4IN98Of6YJoJOkY75CIBuYfmiNHGrDwXr+aLGG55igl9QrxX3hbiXlLb+g==} + + '@octokit/auth-unauthenticated@2.1.0': + resolution: {integrity: sha512-+baofLfSL0CAv3CfGQ9rxiZZQEX8VNJMGuuS4PgrMRBUL52Ho5+hQYb63UJQshw7EXYMPDZxbXznc0y33cbPqw==} + + '@octokit/core@3.6.0': + resolution: {integrity: sha512-7RKRKuA4xTjMhY+eG3jthb3hlZCsOwg3rztWh75Xc+ShDWOfDDATWbeZpAHBNRpm4Tv9WgBMOy1zEJYXG6NJ7Q==} + + '@octokit/endpoint@6.0.12': + resolution: {integrity: sha512-lF3puPwkQWGfkMClXb4k/eUT/nZKQfxinRWJrdZaJO85Dqwo/G0yOC434Jr2ojwafWJMYqFGFa5ms4jJUgujdA==} + + '@octokit/endpoint@7.0.6': + resolution: {integrity: sha512-5L4fseVRUsDFGR00tMWD/Trdeeihn999rTMGRMC1G/Ldi1uWlWJzI98H4Iak5DB/RVvQuyMYKqSK/R6mbSOQyg==} + engines: {node: '>= 14'} + + '@octokit/graphql@4.8.0': + resolution: {integrity: sha512-0gv+qLSBLKF0z8TKaSKTsS39scVKF9dbMxJpj3U0vC7wjNWFuIpL/z76Qe2fiuCbDRcJSavkXsVtMS6/dtQQsg==} + + '@octokit/oauth-app@3.7.1': + resolution: {integrity: sha512-NTmFuB4jcwnxj7xlipHuuX9DRprfb7vHGSBIizIygx2u8LlNYqGvHYWNgw3TpxRxYrFA+SMIfjoVgrtnYpdbrA==} + + '@octokit/oauth-authorization-url@4.3.3': + resolution: {integrity: sha512-lhP/t0i8EwTmayHG4dqLXgU+uPVys4WD/qUNvC+HfB1S1dyqULm5Yx9uKc1x79aP66U1Cb4OZeW8QU/RA9A4XA==} + + '@octokit/oauth-authorization-url@5.0.0': + resolution: {integrity: sha512-y1WhN+ERDZTh0qZ4SR+zotgsQUE1ysKnvBt1hvDRB2WRzYtVKQjn97HEPzoehh66Fj9LwNdlZh+p6TJatT0zzg==} + engines: {node: '>= 14'} + + '@octokit/oauth-methods@1.2.6': + resolution: {integrity: sha512-nImHQoOtKnSNn05uk2o76om1tJWiAo4lOu2xMAHYsNr0fwopP+Dv+2MlGvaMMlFjoqVd3fF3X5ZDTKCsqgmUaQ==} + + '@octokit/oauth-methods@2.0.6': + resolution: {integrity: sha512-l9Uml2iGN2aTWLZcm8hV+neBiFXAQ9+3sKiQe/sgumHlL6HDg0AQ8/l16xX/5jJvfxueqTW5CWbzd0MjnlfHZw==} + engines: {node: '>= 14'} + + '@octokit/openapi-types@12.11.0': + resolution: {integrity: sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==} + + '@octokit/openapi-types@18.1.1': + resolution: {integrity: sha512-VRaeH8nCDtF5aXWnjPuEMIYf1itK/s3JYyJcWFJT8X9pSNnBtriDf7wlEWsGuhPLl4QIH4xM8fqTXDwJ3Mu6sw==} + + '@octokit/plugin-paginate-rest@2.21.3': + resolution: {integrity: sha512-aCZTEf0y2h3OLbrgKkrfFdjRL6eSOo8komneVQJnYecAxIej7Bafor2xhuDJOIFau4pk0i/P28/XgtbyPF0ZHw==} + peerDependencies: + '@octokit/core': '>=2' + + '@octokit/plugin-rest-endpoint-methods@5.16.2': + resolution: {integrity: sha512-8QFz29Fg5jDuTPXVtey05BLm7OB+M8fnvE64RNegzX7U+5NUXcOcnpTIK0YfSHBg8gYd0oxIq3IZTe9SfPZiRw==} + peerDependencies: + '@octokit/core': '>=3' + + '@octokit/plugin-retry@3.0.9': + resolution: {integrity: sha512-r+fArdP5+TG6l1Rv/C9hVoty6tldw6cE2pRHNGmFPdyfrc696R6JjrQ3d7HdVqGwuzfyrcaLAKD7K8TX8aehUQ==} + + '@octokit/plugin-throttling@3.7.0': + resolution: {integrity: sha512-qrKT1Yl/KuwGSC6/oHpLBot3ooC9rq0/ryDYBCpkRtoj+R8T47xTMDT6Tk2CxWopFota/8Pi/2SqArqwC0JPow==} + peerDependencies: + '@octokit/core': ^3.5.0 + + '@octokit/request-error@2.1.0': + resolution: {integrity: sha512-1VIvgXxs9WHSjicsRwq8PlR2LR2x6DwsJAaFgzdi0JfJoGSO8mYI/cHJQ+9FbN21aa+DrgNLnwObmyeSC8Rmpg==} + + '@octokit/request-error@3.0.3': + resolution: {integrity: sha512-crqw3V5Iy2uOU5Np+8M/YexTlT8zxCfI+qu+LxUB7SZpje4Qmx3mub5DfEKSO8Ylyk0aogi6TYdf6kxzh2BguQ==} + engines: {node: '>= 14'} + + '@octokit/request@5.6.3': + resolution: {integrity: sha512-bFJl0I1KVc9jYTe9tdGGpAMPy32dLBXXo1dS/YwSCTL/2nd9XeHsY616RE3HPXDVk+a+dBuzyz5YdlXwcDTr2A==} + + '@octokit/request@6.2.8': + resolution: {integrity: sha512-ow4+pkVQ+6XVVsekSYBzJC0VTVvh/FCTUUgTsboGq+DTeWdyIFV8WSCdo0RIxk6wSkBTHqIK1mYuY7nOBXOchw==} + engines: {node: '>= 14'} + + '@octokit/types@6.41.0': + resolution: {integrity: sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==} + + '@octokit/types@9.3.2': + resolution: {integrity: sha512-D4iHGTdAnEEVsB8fl95m1hiz7D5YiRdQ9b/OEb3BYRVwbLsGHcRVPz+u+BgRLNk0Q0/4iZCBqDN96j2XNxfXrA==} + + '@octokit/webhooks-methods@2.0.0': + resolution: {integrity: sha512-35cfQ4YWlnZnmZKmIxlGPUPLtbkF8lr/A/1Sk1eC0ddLMwQN06dOuLc+dI3YLQS+T+MoNt3DIQ0NynwgKPilig==} + + '@octokit/webhooks-types@5.8.0': + resolution: {integrity: sha512-8adktjIb76A7viIdayQSFuBEwOzwhDC+9yxZpKNHjfzrlostHCw0/N7JWpWMObfElwvJMk2fY2l1noENCk9wmw==} + + '@octokit/webhooks@9.26.3': + resolution: {integrity: sha512-DLGk+gzeVq5oK89Bo601txYmyrelMQ7Fi5EnjHE0Xs8CWicy2xkmnJMKptKJrBJpstqbd/9oeDFi/Zj2pudBDQ==} + + '@perspective-dev/client@4.0.0': + resolution: {integrity: sha512-n3IHMIU+WeraAxAcyBMG/Wipegm8PBcDL3+haZecriBvV4v+7EzNvZd8l6pQNbYa+ah5eD2BCgmvZ8Mg/dxUug==} + + '@perspective-dev/client@4.1.0': + resolution: {integrity: sha512-23VTzOmmBo1KynLTygFuM/RuOszSgIbvG68I3MNCS/w5BOoG4XGt+4WuZWTr13c93b+n3dGK18E4YF7hy82nwg==} + + '@perspective-dev/client@4.2.0': + resolution: {integrity: sha512-Gb9RFOoKnGAJDR0rZA9OBC8CY3Sv3zsEJfrmUhmnFWC21erk66DhppFXKYm3ggU2ob/yhCNA/yWMUv4n8OBqVQ==} + + '@perspective-dev/client@4.3.0': + resolution: {integrity: sha512-LhVDyYzZrSmtuU04qX+MzpnA1BKPEW9nMi4RPE6y3KiGAWwFFqEFgZKF233U8/Fx3v2URCijcwmXn0qTVfoPMQ==} + + '@perspective-dev/client@4.4.0': + resolution: {integrity: sha512-AvfWskJJbfOZl6k2Gfntw/c2a+KDnCy5GfDvEQohdXUNMg+0uec//QuL0ZaqclQA/WfRvm4k+YHIUiBhT8EYew==} + + '@perspective-dev/client@4.5.0': + resolution: {integrity: sha512-eTJqHvmZOFvr+QDOZlmg6vmYuL7CtDAawFSGWhzuYw50PdC0osdKIvQmcoTY5eUcekNRCrOwGaGlp+KihpCAkA==} + + '@perspective-dev/client@4.5.1': + resolution: {integrity: sha512-Yb/huLDHEQ9FFeTloipHKM2oVtgTkHLIEqh971eznOc0sNQGg9Sd7flju8xlKe8KMGe3VCo7/wlvxr4GqBi1Bw==} + + '@perspective-dev/server@4.3.0': + resolution: {integrity: sha512-UH3ADscynozVx42RF07DTBmPE/0PUwH+SS0cgvMLuLfBjtvnPm6msfOU0tHlgFnNZOHJO0q6RZ9WI5fD6wF07A==} + + '@perspective-dev/server@4.4.1': + resolution: {integrity: sha512-eOWr0qy2T2KwClbYgRjfDrcnkR023gHn/7J/tQEPa6rEYTfqXwicWsObFU4mZHi3rTySxYf6qsY6O+SJIM+BzA==} + + '@perspective-dev/server@4.5.1': + resolution: {integrity: sha512-VzR1Z+SdUlB4K1hTHFxVE7PLxFXtTb008fBGMg/uo3Y3qDbJh/9IcZjOwvpI86k6QUZ49VR2w3BDGdh7YzF/8A==} + + '@playwright/experimental-ct-core@1.58.0': + resolution: {integrity: sha512-YZsjApZmRE78Kp2E6OtAvFFVheUyZDfrlZMf+lfnSshmYHrrJUy3bhdCe7EPCWsE12XfCVVAv6G0btiyAx8d0w==} + engines: {node: '>=18'} + + '@playwright/experimental-ct-react@1.58.0': + resolution: {integrity: sha512-hm3Vddy1zNrTFfh2qwjuKvz8lY9oJm2iQkSITSMat4tztK5KxfwJrRyLGeZNCuzAy3bCWGk80Rb0ZfQ3Vitw+A==} + engines: {node: '>=18'} + hasBin: true + + '@playwright/test@1.58.0': + resolution: {integrity: sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==} + engines: {node: '>=18'} + hasBin: true + + '@puppeteer/browsers@2.10.12': + resolution: {integrity: sha512-mP9iLFZwH+FapKJLeA7/fLqOlSUwYpMwjR1P5J23qd4e7qGJwecJccJqHYrjw33jmIZYV4dtiTHPD/J+1e7cEw==} + engines: {node: '>=18'} + hasBin: true + + '@rjsf/core@5.24.13': + resolution: {integrity: sha512-ONTr14s7LFIjx2VRFLuOpagL76sM/HPy6/OhdBfq6UukINmTIs6+aFN0GgcR0aXQHFDXQ7f/fel0o/SO05Htdg==} + engines: {node: '>=14'} + peerDependencies: + '@rjsf/utils': ^5.24.x + react: ^16.14.0 || >=17 + + '@rjsf/utils@5.24.13': + resolution: {integrity: sha512-rNF8tDxIwTtXzz5O/U23QU73nlhgQNYJ+Sv5BAwQOIyhIE2Z3S5tUiSVMwZHt0julkv/Ryfwi+qsD4FiE5rOuw==} + engines: {node: '>=14'} + peerDependencies: + react: ^16.14.0 || >=17 + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.52.5': + resolution: {integrity: sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.52.5': + resolution: {integrity: sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.52.5': + resolution: {integrity: sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.52.5': + resolution: {integrity: sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.52.5': + resolution: {integrity: sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.52.5': + resolution: {integrity: sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.52.5': + resolution: {integrity: sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.52.5': + resolution: {integrity: sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.52.5': + resolution: {integrity: sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.52.5': + resolution: {integrity: sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.52.5': + resolution: {integrity: sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.52.5': + resolution: {integrity: sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.52.5': + resolution: {integrity: sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.52.5': + resolution: {integrity: sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.52.5': + resolution: {integrity: sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.52.5': + resolution: {integrity: sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.52.5': + resolution: {integrity: sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openharmony-arm64@4.52.5': + resolution: {integrity: sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.52.5': + resolution: {integrity: sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.52.5': + resolution: {integrity: sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.52.5': + resolution: {integrity: sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.52.5': + resolution: {integrity: sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==} + cpu: [x64] + os: [win32] + + '@shikijs/engine-oniguruma@3.13.0': + resolution: {integrity: sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg==} + + '@shikijs/langs@3.13.0': + resolution: {integrity: sha512-672c3WAETDYHwrRP0yLy3W1QYB89Hbpj+pO4KhxK6FzIrDI2FoEXNiNCut6BQmEApYLfuYfpgOZaqbY+E9b8wQ==} + + '@shikijs/themes@3.13.0': + resolution: {integrity: sha512-Vxw1Nm1/Od8jyA7QuAenaV78BG2nSr3/gCGdBkLpfLscddCkzkL36Q5b67SrLLfvAJTOUzW39x4FHVCFriPVgg==} + + '@shikijs/types@3.13.0': + resolution: {integrity: sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@sindresorhus/merge-streams@2.3.0': + resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} + engines: {node: '>=18'} + + '@swc/helpers@0.5.17': + resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} + + '@tootallnate/quickjs-emscripten@0.23.0': + resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + + '@types/aws-lambda@8.10.156': + resolution: {integrity: sha512-LElQP+QliVWykC7OF8dNr04z++HJCMO2lF7k9HuKoSDARqhcjHq8MzbrRwujCSDeBHIlvaimbuY/tVZL36KXFQ==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/backbone@1.4.23': + resolution: {integrity: sha512-B/hN/DAJdWFOusEkEoa5xgfVuxJJPOR/6JQ2uwURPDyKL24PuC76IR6EcSRJ6lvGWtxHRQYMJQhJYm6KpMDtGQ==} + + '@types/btoa-lite@1.0.2': + resolution: {integrity: sha512-ZYbcE2x7yrvNFJiU7xJGrpF/ihpkM7zKgw8bha3LNJSesvTtUNxbpzaT7WXBIryf6jovisrxTBvymxMeLLj1Mg==} + + '@types/command-line-args@5.2.3': + resolution: {integrity: sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==} + + '@types/command-line-usage@5.0.4': + resolution: {integrity: sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==} + + '@types/eslint-scope@3.7.7': + resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} + + '@types/eslint@9.6.1': + resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/html-minifier-terser@6.1.0': + resolution: {integrity: sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==} + + '@types/jquery@3.5.33': + resolution: {integrity: sha512-SeyVJXlCZpEki5F0ghuYe+L+PprQta6nRZqhONt9F13dWBtR/ftoaIbdRQ7cis7womE+X2LKhsDdDtkkDhJS6g==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/lodash@4.17.20': + resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==} + + '@types/lru-cache@5.1.1': + resolution: {integrity: sha512-ssE3Vlrys7sdIzs5LOxCzTVMsU7i9oa/IaW92wF32JFb3CVczqOkru2xspuKczHEbG3nvmPY7IFqVmGGHdNbYw==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@20.19.23': + resolution: {integrity: sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==} + + '@types/node@24.9.1': + resolution: {integrity: sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==} + + '@types/prismjs@1.26.6': + resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.26': + resolution: {integrity: sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==} + + '@types/react@19.2.2': + resolution: {integrity: sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==} + + '@types/sizzle@2.3.10': + resolution: {integrity: sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==} + + '@types/source-list-map@0.1.6': + resolution: {integrity: sha512-5JcVt1u5HDmlXkwOD2nslZVllBBc7HDuOICfiZah2Z0is8M8g+ddAEawbmd3VjedfDHBzxCaXLs07QEmb7y54g==} + + '@types/stoppable@1.1.3': + resolution: {integrity: sha512-7wGKIBJGE4ZxFjk9NkjAxZMLlIXroETqP1FJCdoSvKmEznwmBxQFmTB1dsCkAvVcNemuSZM5qkkd9HE/NL2JTw==} + + '@types/underscore@1.13.0': + resolution: {integrity: sha512-L6LBgy1f0EFQZ+7uSA57+n2g/s4Qs5r06Vwrwn0/nuK1de+adz00NWaztRQ30aEqw5qOaWbPI8u2cGQ52lj6VA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/webpack-sources@0.1.12': + resolution: {integrity: sha512-+vRVqE3LzMLLVPgZHUeI8k1YmvgEky+MOir5fQhKvFxpB8uZ0CFnGqxkRAmf8jvNhUBQzhuGZpIMNWZDeEyDIA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + + '@typescript-eslint/eslint-plugin@8.59.1': + resolution: {integrity: sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.59.1': + resolution: {integrity: sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.59.1': + resolution: {integrity: sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.59.1': + resolution: {integrity: sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.59.1': + resolution: {integrity: sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.59.1': + resolution: {integrity: sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.59.1': + resolution: {integrity: sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.59.1': + resolution: {integrity: sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.59.1': + resolution: {integrity: sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.59.1': + resolution: {integrity: sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@webassemblyjs/ast@1.14.1': + resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} + + '@webassemblyjs/floating-point-hex-parser@1.13.2': + resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} + + '@webassemblyjs/helper-api-error@1.13.2': + resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} + + '@webassemblyjs/helper-buffer@1.14.1': + resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} + + '@webassemblyjs/helper-numbers@1.13.2': + resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': + resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} + + '@webassemblyjs/helper-wasm-section@1.14.1': + resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} + + '@webassemblyjs/ieee754@1.13.2': + resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} + + '@webassemblyjs/leb128@1.13.2': + resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} + + '@webassemblyjs/utf8@1.13.2': + resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} + + '@webassemblyjs/wasm-edit@1.14.1': + resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} + + '@webassemblyjs/wasm-gen@1.14.1': + resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} + + '@webassemblyjs/wasm-opt@1.14.1': + resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} + + '@webassemblyjs/wasm-parser@1.14.1': + resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} + + '@webassemblyjs/wast-printer@1.14.1': + resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + + '@webpack-cli/configtest@2.1.1': + resolution: {integrity: sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw==} + engines: {node: '>=14.15.0'} + peerDependencies: + webpack: 5.x.x + webpack-cli: 5.x.x + + '@webpack-cli/info@2.0.2': + resolution: {integrity: sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A==} + engines: {node: '>=14.15.0'} + peerDependencies: + webpack: 5.x.x + webpack-cli: 5.x.x + + '@webpack-cli/serve@2.0.5': + resolution: {integrity: sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ==} + engines: {node: '>=14.15.0'} + peerDependencies: + webpack: 5.x.x + webpack-cli: 5.x.x + webpack-dev-server: '*' + peerDependenciesMeta: + webpack-dev-server: + optional: true + + '@xtuc/ieee754@1.2.0': + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + + '@xtuc/long@4.2.2': + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + + '@zip.js/zip.js@2.8.8': + resolution: {integrity: sha512-v0KutehhSAuaoFAFGLp+V4+UiZ1mIxQ8vNOYMD7k9ZJaBbtQV49MYlg568oRLiuwWDg2Di58Iw3Q0ESNWR+5JA==} + engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=18.0.0'} + + abab@2.0.6: + resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} + deprecated: Use your platform's native atob() and btoa() methods instead + + acorn-import-phases@1.0.4: + resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} + engines: {node: '>=10.13.0'} + peerDependencies: + acorn: ^8.14.0 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-keywords@3.5.2: + resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} + peerDependencies: + ajv: ^6.9.1 + + ajv-keywords@5.1.0: + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + apache-arrow@17.0.0: + resolution: {integrity: sha512-X0p7auzdnGuhYMVKYINdQssS4EcKec9TCXyez/qtJt32DrIMGbzqiaMiQ0X6fQlQpw8Fl0Qygcv4dfRAr5Gu9Q==} + hasBin: true + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-back@3.1.0: + resolution: {integrity: sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==} + engines: {node: '>=6'} + + array-back@6.2.2: + resolution: {integrity: sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==} + engines: {node: '>=12.17'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + arraybuffer-loader@1.0.8: + resolution: {integrity: sha512-CwUVCcxCgcgZUu2w741OV6Xj1tvRVQebq22RCyGXiLgJOJ4e4M/59EPYdtK2MLfIN28t1TDvuh2ojstNq3Kh5g==} + engines: {node: '>= 4.0.0'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + ast-types@0.13.4: + resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} + engines: {node: '>=4'} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + async-limiter@1.0.1: + resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + auto-changelog@2.5.0: + resolution: {integrity: sha512-UTnLjT7I9U2U/xkCUH5buDlp8C7g0SGChfib+iDrJkamcj5kaMqNKHNfbKJw1kthJUq8sUo3i3q2S6FzO/l/wA==} + engines: {node: '>=8.3'} + hasBin: true + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + aws-lambda@1.0.7: + resolution: {integrity: sha512-9GNFMRrEMG5y3Jvv+V4azWvc+qNWdWLTjDdhf/zgMlz8haaaLWv0xeAIWxz9PuWUBawsVxy0zZotjCdR3Xq+2w==} + hasBin: true + + aws-sdk@2.1692.0: + resolution: {integrity: sha512-x511uiJ/57FIsbgUe5csJ13k3uzu25uWQE+XqfBis/sB0SFoiElJWXRkgEAUh0U6n40eT3ay5Ue4oPkRMu1LYw==} + engines: {node: '>= 10.0.0'} + deprecated: The AWS SDK for JavaScript (v2) has reached end-of-support, and no longer receives updates. Please migrate your code to use AWS SDK for JavaScript (v3). More info https://a.co/cUPnyil + + b4a@1.7.3: + resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + backbone@1.2.3: + resolution: {integrity: sha512-1/eXj4agG79UDN7TWnZXcGD6BJrBwLZKCX7zYcBIy9jWf4mrtVkw7IE1VOYFnrKahsmPF9L55Tib9IQRvk027w==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + bare-events@2.8.1: + resolution: {integrity: sha512-oxSAxTS1hRfnyit2CL5QpAOS5ixfBjj6ex3yTNvXyY/kE719jQ/IjuESJBK2w5v4wwQRAHGseVJXx9QBYOtFGQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.5.0: + resolution: {integrity: sha512-GljgCjeupKZJNetTqxKaQArLK10vpmK28or0+RwWjEl5Rk+/xG3wkpmkv+WrcBm3q1BwHKlnhXzR8O37kcvkXQ==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.6.2: + resolution: {integrity: sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.0: + resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} + + bare-stream@2.7.0: + resolution: {integrity: sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==} + peerDependencies: + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.3.1: + resolution: {integrity: sha512-v2yl0TnaZTdEnelkKtXZGnotiV6qATBlnNuUMrHl6v9Lmmrh9mw9RYyImPU7/4RahumSwQS1k2oKXcRfXcbjJw==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.8.20: + resolution: {integrity: sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ==} + hasBin: true + + basic-auth@2.0.1: + resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} + engines: {node: '>= 0.8'} + + basic-ftp@5.0.5: + resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} + engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.1, please upgrade + + before-after-hook@2.2.3: + resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} + + big.js@5.2.2: + resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==} + + bn.js@5.2.2: + resolution: {integrity: sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + bottleneck@2.19.5: + resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.27.0: + resolution: {integrity: sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + btoa-lite@1.0.0: + resolution: {integrity: sha512-gvW7InbIyF8AicrqWoptdW08pUxuhq8BEgowNajy9RhiE86fmGAGl+bLKo6oB8QP0CkqHLowfN0oJdKC/J6LbA==} + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer-pipe@0.0.3: + resolution: {integrity: sha512-GlxfuD/NrKvCNs0Ut+7b1IHjylfdegMBxQIlZHj7bObKVQBxB5S84gtm2yu1mQ8/sSggceWBDPY0cPXgvX2MuA==} + + buffer@4.9.2: + resolution: {integrity: sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camel-case@4.1.2: + resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} + + caniuse-lite@1.0.30001751: + resolution: {integrity: sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==} + + chalk-template@0.4.0: + resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==} + engines: {node: '>=12'} + + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chardet@2.1.0: + resolution: {integrity: sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + chrome-trace-event@1.0.4: + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} + + chromium-bidi@10.5.1: + resolution: {integrity: sha512-rlj6OyhKhVTnk4aENcUme3Jl9h+cq4oXu4AzBcvr8RMmT6BR4a3zSNT9dbIfXr9/BS6ibzRyDhowuw4n2GgzsQ==} + peerDependencies: + devtools-protocol: '*' + + clean-css@5.3.3: + resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==} + engines: {node: '>= 10.0'} + + clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone-deep@4.0.1: + resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} + engines: {node: '>=6'} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + command-line-args@5.2.1: + resolution: {integrity: sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==} + engines: {node: '>=4.0.0'} + + command-line-usage@7.0.3: + resolution: {integrity: sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q==} + engines: {node: '>=12.20.0'} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + + commander@14.0.1: + resolution: {integrity: sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==} + engines: {node: '>=20'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@3.0.2: + resolution: {integrity: sha512-Gar0ASD4BDyKC4hl4DwHqDrmvjoxWKZigVnAbn5H1owvm4CxCPdb0HQDehwNYMJpla5+M2tPmPARzhtYuwpHow==} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + + compute-gcd@1.2.1: + resolution: {integrity: sha512-TwMbxBNz0l71+8Sc4czv13h4kEqnchV9igQZBi6QUaz09dnz13juGnnaWWJTRsP3brxOoxeB4SA2WELLw1hCtg==} + + compute-lcm@1.1.2: + resolution: {integrity: sha512-OFNPdQAXnQhDSKioX8/XYT6sdUlXwpeMjfd6ApxMJfyZ4GxmLR1xvMERctlYhlHwIiz6CSpBc2+qYKjHGZw4TQ==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + copy-anything@2.0.6: + resolution: {integrity: sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==} + + copy-webpack-plugin@12.0.2: + resolution: {integrity: sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA==} + engines: {node: '>= 18.12.0'} + peerDependencies: + webpack: ^5.1.0 + + core-js@3.46.0: + resolution: {integrity: sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==} + + corser@2.0.1: + resolution: {integrity: sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==} + engines: {node: '>= 0.4.0'} + + cosmiconfig@9.0.0: + resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cross-spawn@6.0.6: + resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==} + engines: {node: '>=4.8'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-loader@6.11.0: + resolution: {integrity: sha512-CTJ+AEQJjq5NzLga5pE39qdiSV56F8ywCIsqNIRF0r7BDgWsN25aazToqAFg7ZrtA/U016xudB3ffgweORxX7g==} + engines: {node: '>= 12.13.0'} + peerDependencies: + '@rspack/core': 0.x || 1.x + webpack: ^5.0.0 + peerDependenciesMeta: + '@rspack/core': + optional: true + webpack: + optional: true + + css-loader@7.1.2: + resolution: {integrity: sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA==} + engines: {node: '>= 18.12.0'} + peerDependencies: + '@rspack/core': 0.x || 1.x + webpack: ^5.27.0 + peerDependenciesMeta: + '@rspack/core': + optional: true + webpack: + optional: true + + css-select@4.3.0: + resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.0.10: + resolution: {integrity: sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==} + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + data-uri-to-buffer@6.0.2: + resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} + engines: {node: '>= 14'} + + data-urls@2.0.0: + resolution: {integrity: sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==} + engines: {node: '>=10'} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + degenerator@5.0.1: + resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} + engines: {node: '>= 14'} + + deprecation@2.3.1: + resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detectie@1.0.0: + resolution: {integrity: sha512-1zThEupxHYmA+HBWT1BDjxwvppzC8mMBNlt+94rSqrZKvbew//VdHOFsvFO1eG8kdwEJub05qR8vfurXPu3UVg==} + + devtools-protocol@0.0.1508733: + resolution: {integrity: sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==} + + dom-converter@0.2.0: + resolution: {integrity: sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==} + + dom-serializer@1.4.1: + resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@4.3.1: + resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} + engines: {node: '>= 4'} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@2.8.0: + resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dot-case@3.0.4: + resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + duplicate-package-checker-webpack-plugin@3.0.0: + resolution: {integrity: sha512-aO50/qPC7X2ChjRFniRiscxBLT/K01bALqfcDaf8Ih5OqQ1N4iT/Abx9Ofu3/ms446vHTm46FACIuJUmgUQcDQ==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + electron-to-chromium@1.5.240: + resolution: {integrity: sha512-OBwbZjWgrCOH+g6uJsA2/7Twpas2OlepS9uvByJjR2datRDuKGYeD+nP8lBBks2qnB7bGJNHDUx7c/YLaT3QMQ==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emojis-list@3.0.0: + resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==} + engines: {node: '>= 4'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + enhanced-resolve@5.18.3: + resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} + engines: {node: '>=10.13.0'} + + entities@2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + envinfo@7.19.0: + resolution: {integrity: sha512-DoSM9VyG6O3vqBf+p3Gjgr/Q52HYBBtO3v+4koAxt1MnWr+zEnxE+nke/yXS4lt2P4SYCHQ4V3f1i88LQVOpAw==} + engines: {node: '>=4'} + hasBin: true + + errno@0.1.8: + resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==} + hasBin: true + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-abstract@1.24.0: + resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + esbuild@0.25.11: + resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + + eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + events@1.1.1: + resolution: {integrity: sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==} + engines: {node: '>=0.4.x'} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + exenv-es6@1.1.1: + resolution: {integrity: sha512-vlVu3N8d6yEMpMsEm+7sUBAI81aqYYuEvfK0jNqmdb/OPXzzH7QWDDnVjMvDSY47JdHEqx/dfC/q8WkfoTmpGQ==} + + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.7.4: + resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-replace@3.0.0: + resolution: {integrity: sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==} + engines: {node: '>=4.0.0'} + + find-root@1.1.0: + resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flat@5.0.2: + resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} + hasBin: true + + flatbuffers@1.12.0: + resolution: {integrity: sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==} + + flatbuffers@24.12.23: + resolution: {integrity: sha512-dLVCAISd5mhls514keQzmEG6QHmUUsNuWsb4tFafIUwvvgDjXhtfAYSKOzt5SWOy+qByV5pbsDZ+Vb7HUOBEdA==} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + free-style@3.1.0: + resolution: {integrity: sha512-vJujYSIyT30iDoaoeigNAxX4yB1RUrh+N2ZMhIElMr3BvCuGXOw7XNJMEEJkDUeamK2Rnb/IKFGKRKlTWIGRWA==} + + fromentries@1.3.2: + resolution: {integrity: sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + fs-extra@11.3.2: + resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} + engines: {node: '>=14.14'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + + get-uri@6.0.5: + resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} + engines: {node: '>= 14'} + + glob-gitignore@1.0.15: + resolution: {integrity: sha512-22pvDWt2hMPfL3UF6lWcZpP+VIwBekJyj6xyb1DpeSALJm+n/0gI9lWD30kvA/h3bgPqYeAX7xGONzmyHrSfqQ==} + engines: {node: '>= 6'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + + glob@7.1.7: + resolution: {integrity: sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + globby@14.1.0: + resolution: {integrity: sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==} + engines: {node: '>=18'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + handlebars@4.7.8: + resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + engines: {node: '>=0.4.7'} + hasBin: true + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + hosted-git-info@2.8.9: + resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} + + html-encoding-sniffer@3.0.0: + resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} + engines: {node: '>=12'} + + html-minifier-terser@6.1.0: + resolution: {integrity: sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==} + engines: {node: '>=12'} + hasBin: true + + html-webpack-plugin@5.6.4: + resolution: {integrity: sha512-V/PZeWsqhfpE27nKeX9EO2sbR+D17A+tLf6qU+ht66jdUsN0QLKJN27Z+1+gHrVMKgndBahes0PU6rRihDgHTw==} + engines: {node: '>=10.13.0'} + peerDependencies: + '@rspack/core': 0.x || 1.x + webpack: ^5.20.0 + peerDependenciesMeta: + '@rspack/core': + optional: true + webpack: + optional: true + + htmlparser2@6.1.0: + resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==} + + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + http-proxy@1.18.1: + resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} + engines: {node: '>=8.0.0'} + + http-server@14.1.1: + resolution: {integrity: sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==} + engines: {node: '>=12'} + hasBin: true + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.0: + resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} + engines: {node: '>=0.10.0'} + + icss-utils@5.1.0: + resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + ieee754@1.1.13: + resolution: {integrity: sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + image-size@0.5.5: + resolution: {integrity: sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==} + engines: {node: '>=0.10.0'} + hasBin: true + + import-cwd@3.0.0: + resolution: {integrity: sha512-4pnzH16plW+hgvRECbDWpQl3cqtvSofHWh44met7ESfZ8UZOWWddm8hEyDTqREJ9RbYHY8gi8DqmaelApoOGMg==} + engines: {node: '>=8'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-from@3.0.0: + resolution: {integrity: sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ==} + engines: {node: '>=8'} + + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + inquirer@12.10.0: + resolution: {integrity: sha512-K/epfEnDBZj2Q3NMDcgXWZye3nhSPeoJnOh8lcKWrldw54UEZfS4EmAMsAsmVbl7qKi+vjAsy39Sz4fbgRMewg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + interpret@3.1.1: + resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==} + engines: {node: '>=10.13.0'} + + ip-address@10.0.1: + resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} + engines: {node: '>= 12'} + + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-object@2.0.4: + resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} + engines: {node: '>=0.10.0'} + + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + is-what@3.14.1: + resolution: {integrity: sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isobject@3.0.1: + resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} + engines: {node: '>=0.10.0'} + + isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + + jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + jmespath@0.16.0: + resolution: {integrity: sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==} + engines: {node: '>= 0.6.0'} + + jquery@3.7.1: + resolution: {integrity: sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-bignum@0.0.3: + resolution: {integrity: sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==} + engines: {node: '>=0.8'} + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-better-errors@1.0.2: + resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-compare@0.2.2: + resolution: {integrity: sha512-c4WYmDKyJXhs7WWvAWm3uIYnfyWFoIp+JEoX34rctVvEkMYCPGhXtvmFFXiffBbxfZsvQ0RNnV5H7GvDF5HCqQ==} + + json-schema-merge-allof@0.8.1: + resolution: {integrity: sha512-CTUKmIlPJbsWfzRRnOXz+0MjIqvnleIXwFTzz+t9T86HnYX/Rozria6ZVGLktAU9e+NygNljveP+yxqtQp/Q4w==} + engines: {node: '>=12.0.0'} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + + jsonpointer@5.0.1: + resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} + engines: {node: '>=0.10.0'} + + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + + jwa@1.4.2: + resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + leb128@0.0.5: + resolution: {integrity: sha512-elbNtfmu3GndZbesVF6+iQAfVjOXW9bM/aax9WwMlABZW+oK9sbAZEXoewaPHmL34sxa8kVwWsru8cNE/yn2gg==} + + less@4.4.2: + resolution: {integrity: sha512-j1n1IuTX1VQjIy3tT7cyGbX7nvQOsFLoIqobZv4ttI5axP923gA44zUj6miiA6R5Aoms4sEGVIIcucXUbRI14g==} + engines: {node: '>=14'} + hasBin: true + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lib0@0.2.114: + resolution: {integrity: sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==} + engines: {node: '>=16'} + hasBin: true + + license-webpack-plugin@2.3.21: + resolution: {integrity: sha512-rVaYU9TddZN3ao8M/0PrRSCdTp2EW6VQymlgsuScld1vef0Ou7fALx3ePe83KLP3xAEDcPK5fkqUVqGBnbz1zQ==} + peerDependencies: + webpack: '*' + peerDependenciesMeta: + webpack: + optional: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + + load-json-file@4.0.0: + resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==} + engines: {node: '>=4'} + + loader-runner@4.3.1: + resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} + engines: {node: '>=6.11.5'} + + loader-utils@1.4.2: + resolution: {integrity: sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==} + engines: {node: '>=4.0.0'} + + loader-utils@2.0.4: + resolution: {integrity: sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==} + engines: {node: '>=8.9.0'} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash-es@4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.escape@4.0.1: + resolution: {integrity: sha512-nXEOnb/jK9g0DYMr1/Xvq6l5xMD7GDG55+GSYIYmS0G4tBk/hURD4JR9WCavs04t33WmJx9kCyp9vJ+mr4BOUw==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lower-case@2.0.2: + resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + + lunr@2.3.9: + resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + + make-array@1.0.5: + resolution: {integrity: sha512-sgK2SAzxT19rWU+qxKUcn6PAh/swiIiz2F8C2cZjLc1z4iwYIfdoihqFIDQ8BDzAGtWPYJ6Sr13K1j/DXynDLA==} + engines: {node: '>=0.10.0'} + + make-dir@2.1.0: + resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} + engines: {node: '>=6'} + + markdown-it@14.1.0: + resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} + hasBin: true + + markdown-to-jsx@7.7.17: + resolution: {integrity: sha512-7mG/1feQ0TX5I7YyMZVDgCC/y2I3CiEhIRQIhyov9nGBP5eoVrOXXHuL5ZP8GRfxVZKRiXWJgwXkb9It+nQZfQ==} + engines: {node: '>= 10'} + peerDependencies: + react: '>= 0.14.0' + peerDependenciesMeta: + react: + optional: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + + memorystream@0.3.1: + resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} + engines: {node: '>= 0.10.0'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + microtime@3.1.1: + resolution: {integrity: sha512-to1r7o24cDsud9IhN6/8wGmMx5R2kT0w2Xwm5okbYI3d1dk6Xv0m+Z+jg2vS9pt+ocgQHTCtgs/YuyJhySzxNg==} + engines: {node: '>= 14.13.0'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mini-css-extract-plugin@2.9.4: + resolution: {integrity: sha512-ZWYT7ln73Hptxqxk2DxPU9MmapXRhxkJD6tkSR04dnQxm8BGu2hzgKLugK5yySD97u/8yy7Ma7E76k9ZdvtjkQ==} + engines: {node: '>= 12.13.0'} + peerDependencies: + webpack: ^5.0.0 + + mini-svg-data-uri@1.4.4: + resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} + hasBin: true + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + needle@3.3.1: + resolution: {integrity: sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==} + engines: {node: '>= 4.4.x'} + hasBin: true + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + netmask@2.0.2: + resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} + engines: {node: '>= 0.4.0'} + + nice-try@1.0.5: + resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} + + no-case@3.0.4: + resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + + node-addon-api@5.1.0: + resolution: {integrity: sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + node-releases@2.0.26: + resolution: {integrity: sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==} + + normalize-package-data@2.5.0: + resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-run-all@4.1.5: + resolution: {integrity: sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==} + engines: {node: '>= 4'} + hasBin: true + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + octokit@1.8.1: + resolution: {integrity: sha512-xBLKFIivbl7wnLwxzLYuDO/JDNYxdyxoSjFrl/QMrY/fwGGQYYklvKUDTUyGMU0aXPrQtJ0IZnG3BXpCkDQzWg==} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + opener@1.5.2: + resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} + hasBin: true + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + pac-proxy-agent@7.2.0: + resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} + engines: {node: '>= 14'} + + pac-resolver@7.0.1: + resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} + engines: {node: '>= 14'} + + param-case@3.0.4: + resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-github-url@1.0.3: + resolution: {integrity: sha512-tfalY5/4SqGaV/GIGzWyHnFjlpTPTNpENR9Ea2lLldSJ8EWXMsvacWucqY3m3I4YPtas15IxTLQVQ5NSYXPrww==} + engines: {node: '>= 0.10'} + hasBin: true + + parse-json@4.0.0: + resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} + engines: {node: '>=4'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-node-version@1.0.1: + resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==} + engines: {node: '>= 0.10'} + + parse-srcset@1.0.2: + resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + + pascal-case@3.1.2: + resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@2.0.1: + resolution: {integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==} + engines: {node: '>=4'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-type@3.0.0: + resolution: {integrity: sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==} + engines: {node: '>=4'} + + path-type@6.0.0: + resolution: {integrity: sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==} + engines: {node: '>=18'} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pidtree@0.3.1: + resolution: {integrity: sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==} + engines: {node: '>=0.10'} + hasBin: true + + pify@3.0.0: + resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} + engines: {node: '>=4'} + + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + playwright-core@1.58.0: + resolution: {integrity: sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.0: + resolution: {integrity: sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==} + engines: {node: '>=18'} + hasBin: true + + portfinder@1.0.38: + resolution: {integrity: sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==} + engines: {node: '>= 10.12'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-modules-extract-imports@3.1.0: + resolution: {integrity: sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules-local-by-default@4.2.0: + resolution: {integrity: sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules-scope@3.2.1: + resolution: {integrity: sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules-values@4.0.0: + resolution: {integrity: sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-selector-parser@7.1.0: + resolution: {integrity: sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + + pretty-error@4.0.0: + resolution: {integrity: sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==} + + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + + pro_self_extracting_wasm@0.0.9: + resolution: {integrity: sha512-95/dZfLmlGc/6Xp7gqvRBgXF8M+osw/Xtalz1U/Va8MpSC1TiR7rM4lEvAs1p/q4v/EZk6bow3tKEclbMGsSFQ==} + hasBin: true + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + proxy-agent@6.5.0: + resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} + engines: {node: '>= 14'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + prr@1.0.1: + resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} + + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + punycode@1.3.2: + resolution: {integrity: sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + puppeteer-core@24.26.1: + resolution: {integrity: sha512-YHZdo3chJ5b9pTYVnuDuoI3UX/tWJFJyRZvkLbThGy6XeHWC+0KI8iN0UMCkvde5l/YOk3huiVZ/PvwgSbwdrA==} + engines: {node: '>=18'} + + puppeteer@24.26.1: + resolution: {integrity: sha512-3RG2UqclzMFolM2fS4bN8t5/EjZ0VwEoAGVxG8PMGeprjLzj+x0U4auH7MQ4B6ftW+u1JUnTTN8ab4ABPdl4mA==} + engines: {node: '>=18'} + hasBin: true + + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + engines: {node: '>=0.6'} + + querystring@0.2.0: + resolution: {integrity: sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==} + engines: {node: '>=0.4.x'} + deprecated: The querystring API is considered Legacy. new code should use the URLSearchParams API instead. + + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + read-pkg@3.0.0: + resolution: {integrity: sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==} + engines: {node: '>=4'} + + rechoir@0.8.0: + resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} + engines: {node: '>= 10.13.0'} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + regular-table@0.8.4: + resolution: {integrity: sha512-VmV3kxmkXX0BehXSFBeF6FMBqyYdciY6d/wI6/5hvXomH7K9nfGASOmitF3WeLsL9eiiattwR+o3tcu4En/Xaw==} + engines: {node: '>=16'} + + relateurl@0.2.7: + resolution: {integrity: sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==} + engines: {node: '>= 0.10'} + + renderkid@3.0.0: + resolution: {integrity: sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup@4.52.5: + resolution: {integrity: sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-async@4.0.6: + resolution: {integrity: sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==} + engines: {node: '>=0.12.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-array-concat@1.1.3: + resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + engines: {node: '>=0.4'} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sanitize-html@2.12.1: + resolution: {integrity: sha512-Plh+JAn0UVDpBRP/xEjsk+xDCoOvMBwQUf/K+/cBAVuTbtX8bj2VB7S1sL1dssVpykqp0/KPSesHrqXtokVBpA==} + + sax@1.2.1: + resolution: {integrity: sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==} + + sax@1.4.1: + resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + schema-utils@2.7.1: + resolution: {integrity: sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==} + engines: {node: '>= 8.9.0'} + + schema-utils@3.3.0: + resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} + engines: {node: '>= 10.13.0'} + + schema-utils@4.3.3: + resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} + engines: {node: '>= 10.13.0'} + + secure-compare@3.0.1: + resolution: {integrity: sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==} + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + shallow-clone@3.0.1: + resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} + engines: {node: '>=8'} + + shebang-command@1.2.0: + resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} + engines: {node: '>=0.10.0'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@1.0.0: + resolution: {integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==} + engines: {node: '>=0.10.0'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.7: + resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + + source-list-map@2.0.1: + resolution: {integrity: sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-loader@1.0.2: + resolution: {integrity: sha512-oX8d6ndRjN+tVyjj6PlXSyFPhDdVAPsZA30nD3/II8g4uOv8fCz0DMn5sy8KtVbDfKQxOpGwGJnK3xIW3tauDw==} + engines: {node: '>= 10.13.0'} + peerDependencies: + webpack: ^4.0.0 || ^5.0.0 + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + spdx-correct@3.2.0: + resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + + spdx-license-ids@3.0.22: + resolution: {integrity: sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + stoppable@1.1.0: + resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} + engines: {node: '>=4', npm: '>=6'} + + streamx@2.23.0: + resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string.prototype.padend@3.1.6: + resolution: {integrity: sha512-XZpspuSB7vJWhvJc9DLSlrXl1mcA2BdoY5jjnS135ydXqLoqhs96JjDtCkjJEQHvfqZIp9hBuBMgI589peyx9Q==} + engines: {node: '>= 0.4'} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + style-loader@3.3.4: + resolution: {integrity: sha512-0WqXzrsMTyb8yjZJHDqwmnwRJvhALK9LfRtRc6B4UTWe8AijYLZYZ9thuJTZc2VfQWINADW/j+LiJnfy2RoC1w==} + engines: {node: '>= 12.13.0'} + peerDependencies: + webpack: ^5.0.0 + + style-loader@4.0.0: + resolution: {integrity: sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==} + engines: {node: '>= 18.12.0'} + peerDependencies: + webpack: ^5.27.0 + + superstore-arrow@3.2.0: + resolution: {integrity: sha512-k3A2L3CRs/+EEVRTsTBo2aqJl97dsZDG8N1STxVKC5cqjAcLyYda0Y8w+GavXPGW+ADlRWmTIUEj3ITE411OUw==} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tabbable@5.3.3: + resolution: {integrity: sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA==} + + table-layout@4.1.1: + resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==} + engines: {node: '>=12.17'} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + tar-fs@3.1.1: + resolution: {integrity: sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==} + + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + + tar@7.5.1: + resolution: {integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==} + engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + terser-webpack-plugin@5.3.14: + resolution: {integrity: sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@swc/core': '*' + esbuild: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@swc/core': + optional: true + esbuild: + optional: true + uglify-js: + optional: true + + terser@5.44.0: + resolution: {integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==} + engines: {node: '>=10'} + hasBin: true + + text-decoder@1.2.3: + resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} + + text-encoding-utf-8@1.0.2: + resolution: {integrity: sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tr46@2.1.0: + resolution: {integrity: sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==} + engines: {node: '>=8'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.20.6: + resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + typed-query-selector@2.12.0: + resolution: {integrity: sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==} + + typedoc@0.28.14: + resolution: {integrity: sha512-ftJYPvpVfQvFzpkoSfHLkJybdA/geDJ8BGQt/ZnkkhnBYoYW6lBgPQXu6vqLxO4X75dA55hX8Af847H5KXlEFA==} + engines: {node: '>= 18', pnpm: '>= 10'} + hasBin: true + peerDependencies: + typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x + + typescript-eslint@8.59.1: + resolution: {integrity: sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + typestyle@2.4.0: + resolution: {integrity: sha512-/d1BL6Qi+YlMLEydnUEB8KL/CAjAN8cyt3/UyGnOyBrWf7bLGcR/6yhmsaUstO2IcYwZfagjE7AIzuI2vUW9mg==} + + typical@4.0.0: + resolution: {integrity: sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==} + engines: {node: '>=8'} + + typical@7.3.0: + resolution: {integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==} + engines: {node: '>=12.17'} + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + underscore@1.13.7: + resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + union@0.5.0: + resolution: {integrity: sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==} + engines: {node: '>= 0.8.0'} + + universal-github-app-jwt@1.2.0: + resolution: {integrity: sha512-dncpMpnsKBk0eetwfN8D8OUHGfiDhhJ+mtsbMl+7PfW7mYjiH8LIcqRmYMtzYLgSh47HjfdBtrBwIQ/gizKR3g==} + + universal-user-agent@6.0.1: + resolution: {integrity: sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + update-browserslist-db@1.1.4: + resolution: {integrity: sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + url-join@4.0.1: + resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} + + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + + url@0.10.3: + resolution: {integrity: sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + util.inherits@1.0.3: + resolution: {integrity: sha512-gMirHcfcq5D87nXDwbZqf5vl65S0mpMZBsHXJsXOO3Hc3G+JoQLwgaJa1h+PL7h3WhocnuLqoe8CuvMlztkyCA==} + engines: {node: '>=4'} + + util@0.12.5: + resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + + utila@0.4.0: + resolution: {integrity: sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==} + + uuid@8.0.0: + resolution: {integrity: sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==} + hasBin: true + + validate-npm-package-license@3.0.4: + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + + validate.io-array@1.0.6: + resolution: {integrity: sha512-DeOy7CnPEziggrOO5CZhVKJw6S3Yi7e9e65R1Nl/RTN1vTQKnzjfvks0/8kQ40FP/dsjRAOd4hxmJ7uLa6vxkg==} + + validate.io-function@1.0.2: + resolution: {integrity: sha512-LlFybRJEriSuBnUhQyG5bwglhh50EpTL2ul23MPIuR1odjO7XaMLFV8vHGwp7AZciFxtYOeiSCT5st+XSPONiQ==} + + validate.io-integer-array@1.0.0: + resolution: {integrity: sha512-mTrMk/1ytQHtCY0oNO3dztafHYyGU88KL+jRxWuzfOmQb+4qqnWmI+gykvGp8usKZOM0H7keJHEbRaFiYA0VrA==} + + validate.io-integer@1.0.5: + resolution: {integrity: sha512-22izsYSLojN/P6bppBqhgUDjCkr5RY2jd+N2a3DCAUey8ydvrZ/OkGvFPR7qfOpwR2LC5p4Ngzxz36g5Vgr/hQ==} + + validate.io-number@1.0.3: + resolution: {integrity: sha512-kRAyotcbNaSYoDnXvb4MHg/0a1egJdLwS6oJ38TJY7aw9n93Fl/3blIXdyYvPOp55CNxywooG/3BcrwNrBpcSg==} + + vite@6.4.1: + resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + watchpack@2.4.4: + resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==} + engines: {node: '>=10.13.0'} + + 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==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webidl-conversions@6.1.0: + resolution: {integrity: sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==} + engines: {node: '>=10.4'} + + webpack-cli@5.1.4: + resolution: {integrity: sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==} + engines: {node: '>=14.15.0'} + hasBin: true + peerDependencies: + '@webpack-cli/generators': '*' + webpack: 5.x.x + webpack-bundle-analyzer: '*' + webpack-dev-server: '*' + peerDependenciesMeta: + '@webpack-cli/generators': + optional: true + webpack-bundle-analyzer: + optional: true + webpack-dev-server: + optional: true + + webpack-glsl-minify@1.5.0: + resolution: {integrity: sha512-Q2zVxqWzCIRSw72Nf2qesAAOr/XdS3QUnKf6zoXwQsnnVHeWs89eVZ3YRcc5E0iO2vt6inxfthPfb8PdTK+TgA==} + hasBin: true + + webpack-merge@5.10.0: + resolution: {integrity: sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==} + engines: {node: '>=10.0.0'} + + webpack-sources@1.4.3: + resolution: {integrity: sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==} + + webpack-sources@3.3.3: + resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} + engines: {node: '>=10.13.0'} + + webpack@5.102.1: + resolution: {integrity: sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + + whatwg-encoding@2.0.0: + resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} + engines: {node: '>=12'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@2.3.0: + resolution: {integrity: sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + whatwg-url@8.7.0: + resolution: {integrity: sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==} + engines: {node: '>=10'} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.19: + resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} + engines: {node: '>= 0.4'} + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wildcard@2.0.1: + resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + wordwrapjs@5.1.1: + resolution: {integrity: sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==} + engines: {node: '>=12.17'} + + worker-loader@3.0.8: + resolution: {integrity: sha512-XQyQkIFeRVC7f7uRhFdNMe/iJOdO6zxAaR3EWbDp45v3mDhrTi+++oswKNxShUNjPC/1xUp5DB29YKLhFo129g==} + engines: {node: '>= 10.13.0'} + peerDependencies: + webpack: ^4.0.0 || ^5.0.0 + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@6.2.3: + resolution: {integrity: sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + + y-protocols@1.0.6: + resolution: {integrity: sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.0.0 + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yaml@2.8.1: + resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + + yjs@13.6.27: + resolution: {integrity: sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zx@8.8.5: + resolution: {integrity: sha512-SNgDF5L0gfN7FwVOdEFguY3orU5AkfFZm9B5YSHog/UDHv+lvmd82ZAsOenOkQixigwH2+yyH198AwNdKhj+RA==} + engines: {node: '>= 12.17.0'} + hasBin: true + +snapshots: + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.5': {} + + '@babel/core@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.5 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.27.0 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.27.1': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.4': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/runtime@7.28.4': {} + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@babel/traverse@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@clickhouse/client-common@1.17.0': {} + + '@clickhouse/client-web@1.17.0': + dependencies: + '@clickhouse/client-common': 1.17.0 + + '@codemirror/state@6.5.2': + dependencies: + '@marijn/find-cluster-break': 1.0.2 + + '@discoveryjs/json-ext@0.5.7': {} + + '@duckdb/duckdb-wasm@1.33.1-dev18.0': + dependencies: + apache-arrow: 17.0.0 + qs: 6.15.0 + + '@esbuild/aix-ppc64@0.25.11': + optional: true + + '@esbuild/android-arm64@0.25.11': + optional: true + + '@esbuild/android-arm@0.25.11': + optional: true + + '@esbuild/android-x64@0.25.11': + optional: true + + '@esbuild/darwin-arm64@0.25.11': + optional: true + + '@esbuild/darwin-x64@0.25.11': + optional: true + + '@esbuild/freebsd-arm64@0.25.11': + optional: true + + '@esbuild/freebsd-x64@0.25.11': + optional: true + + '@esbuild/linux-arm64@0.25.11': + optional: true + + '@esbuild/linux-arm@0.25.11': + optional: true + + '@esbuild/linux-ia32@0.25.11': + optional: true + + '@esbuild/linux-loong64@0.25.11': + optional: true + + '@esbuild/linux-mips64el@0.25.11': + optional: true + + '@esbuild/linux-ppc64@0.25.11': + optional: true + + '@esbuild/linux-riscv64@0.25.11': + optional: true + + '@esbuild/linux-s390x@0.25.11': + optional: true + + '@esbuild/linux-x64@0.25.11': + optional: true + + '@esbuild/netbsd-arm64@0.25.11': + optional: true + + '@esbuild/netbsd-x64@0.25.11': + optional: true + + '@esbuild/openbsd-arm64@0.25.11': + optional: true + + '@esbuild/openbsd-x64@0.25.11': + optional: true + + '@esbuild/openharmony-arm64@0.25.11': + optional: true + + '@esbuild/sunos-x64@0.25.11': + optional: true + + '@esbuild/win32-arm64@0.25.11': + optional: true + + '@esbuild/win32-ia32@0.25.11': + optional: true + + '@esbuild/win32-x64@0.25.11': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))': + dependencies: + eslint: 9.39.4(jiti@1.21.7) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@finos/perspective@0.10.0': + dependencies: + '@babel/runtime': 7.28.4 + core-js: 3.46.0 + d3-array: 3.2.4 + detectie: 1.0.0 + flatbuffers: 1.12.0 + lodash: 4.17.21 + text-encoding-utf-8: 1.0.2 + tslib: 1.14.1 + ws: 6.2.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@finos/perspective@0.9.0': + dependencies: + '@babel/runtime': 7.28.4 + core-js: 3.46.0 + d3-array: 3.2.4 + detectie: 1.0.0 + flatbuffers: 1.12.0 + lodash: 4.17.21 + text-encoding-utf-8: 1.0.2 + tslib: 1.14.1 + ws: 6.2.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@finos/perspective@1.0.0': + dependencies: + '@babel/runtime': 7.28.4 + core-js: 3.46.0 + d3-array: 3.2.4 + detectie: 1.0.0 + flatbuffers: 1.12.0 + lodash: 4.17.21 + text-encoding-utf-8: 1.0.2 + tslib: 1.14.1 + ws: 6.2.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@finos/perspective@1.1.0': + dependencies: + fflate: 0.7.4 + ws: 6.2.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@finos/perspective@2.1.0': + dependencies: + fflate: 0.7.4 + ws: 6.2.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@finos/perspective@2.10.0': + dependencies: + stoppable: 1.1.0 + ws: 6.2.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@finos/perspective@2.2.0': + dependencies: + fflate: 0.7.4 + ws: 6.2.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@finos/perspective@2.3.0': + dependencies: + fflate: 0.7.4 + ws: 6.2.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@finos/perspective@2.4.0': + dependencies: + fflate: 0.7.4 + ws: 6.2.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@finos/perspective@2.5.0': + dependencies: + fflate: 0.7.4 + ws: 6.2.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@finos/perspective@2.6.0': + dependencies: + fflate: 0.7.4 + stoppable: 1.1.0 + ws: 6.2.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@finos/perspective@2.7.0': + dependencies: + stoppable: 1.1.0 + ws: 6.2.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@finos/perspective@2.8.0': + dependencies: + stoppable: 1.1.0 + ws: 6.2.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@finos/perspective@2.9.0': + dependencies: + stoppable: 1.1.0 + ws: 6.2.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@finos/perspective@3.0.0': + dependencies: + stoppable: 1.1.0 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@finos/perspective@3.1.0': + dependencies: + stoppable: 1.1.0 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@finos/perspective@3.2.0': + dependencies: + stoppable: 1.1.0 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@finos/perspective@3.3.0': + dependencies: + stoppable: 1.1.0 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@finos/perspective@3.4.0': + dependencies: + stoppable: 1.1.0 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@finos/perspective@3.5.0': + dependencies: + stoppable: 1.1.0 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@finos/perspective@3.6.0': + dependencies: + stoppable: 1.1.0 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@finos/perspective@3.7.0': + dependencies: + stoppable: 1.1.0 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@finos/perspective@3.8.0': + dependencies: + pro_self_extracting_wasm: 0.0.9 + stoppable: 1.1.0 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + + '@fontsource/roboto-mono@4.5.10': {} + + '@fortawesome/fontawesome-free@5.15.4': {} + + '@gerrit0/mini-shiki@3.13.1': + dependencies: + '@shikijs/engine-oniguruma': 3.13.0 + '@shikijs/langs': 3.13.0 + '@shikijs/themes': 3.13.0 + '@shikijs/types': 3.13.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@iarna/toml@3.0.0': {} + + '@inquirer/ansi@1.0.1': {} + + '@inquirer/checkbox@4.3.0(@types/node@24.9.1)': + dependencies: + '@inquirer/ansi': 1.0.1 + '@inquirer/core': 10.3.0(@types/node@24.9.1) + '@inquirer/figures': 1.0.14 + '@inquirer/type': 3.0.9(@types/node@24.9.1) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.9.1 + + '@inquirer/confirm@5.1.19(@types/node@24.9.1)': + dependencies: + '@inquirer/core': 10.3.0(@types/node@24.9.1) + '@inquirer/type': 3.0.9(@types/node@24.9.1) + optionalDependencies: + '@types/node': 24.9.1 + + '@inquirer/core@10.3.0(@types/node@24.9.1)': + dependencies: + '@inquirer/ansi': 1.0.1 + '@inquirer/figures': 1.0.14 + '@inquirer/type': 3.0.9(@types/node@24.9.1) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.9.1 + + '@inquirer/editor@4.2.21(@types/node@24.9.1)': + dependencies: + '@inquirer/core': 10.3.0(@types/node@24.9.1) + '@inquirer/external-editor': 1.0.2(@types/node@24.9.1) + '@inquirer/type': 3.0.9(@types/node@24.9.1) + optionalDependencies: + '@types/node': 24.9.1 + + '@inquirer/expand@4.0.21(@types/node@24.9.1)': + dependencies: + '@inquirer/core': 10.3.0(@types/node@24.9.1) + '@inquirer/type': 3.0.9(@types/node@24.9.1) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.9.1 + + '@inquirer/external-editor@1.0.2(@types/node@24.9.1)': + dependencies: + chardet: 2.1.0 + iconv-lite: 0.7.0 + optionalDependencies: + '@types/node': 24.9.1 + + '@inquirer/figures@1.0.14': {} + + '@inquirer/input@4.2.5(@types/node@24.9.1)': + dependencies: + '@inquirer/core': 10.3.0(@types/node@24.9.1) + '@inquirer/type': 3.0.9(@types/node@24.9.1) + optionalDependencies: + '@types/node': 24.9.1 + + '@inquirer/number@3.0.21(@types/node@24.9.1)': + dependencies: + '@inquirer/core': 10.3.0(@types/node@24.9.1) + '@inquirer/type': 3.0.9(@types/node@24.9.1) + optionalDependencies: + '@types/node': 24.9.1 + + '@inquirer/password@4.0.21(@types/node@24.9.1)': + dependencies: + '@inquirer/ansi': 1.0.1 + '@inquirer/core': 10.3.0(@types/node@24.9.1) + '@inquirer/type': 3.0.9(@types/node@24.9.1) + optionalDependencies: + '@types/node': 24.9.1 + + '@inquirer/prompts@7.9.0(@types/node@24.9.1)': + dependencies: + '@inquirer/checkbox': 4.3.0(@types/node@24.9.1) + '@inquirer/confirm': 5.1.19(@types/node@24.9.1) + '@inquirer/editor': 4.2.21(@types/node@24.9.1) + '@inquirer/expand': 4.0.21(@types/node@24.9.1) + '@inquirer/input': 4.2.5(@types/node@24.9.1) + '@inquirer/number': 3.0.21(@types/node@24.9.1) + '@inquirer/password': 4.0.21(@types/node@24.9.1) + '@inquirer/rawlist': 4.1.9(@types/node@24.9.1) + '@inquirer/search': 3.2.0(@types/node@24.9.1) + '@inquirer/select': 4.4.0(@types/node@24.9.1) + optionalDependencies: + '@types/node': 24.9.1 + + '@inquirer/rawlist@4.1.9(@types/node@24.9.1)': + dependencies: + '@inquirer/core': 10.3.0(@types/node@24.9.1) + '@inquirer/type': 3.0.9(@types/node@24.9.1) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.9.1 + + '@inquirer/search@3.2.0(@types/node@24.9.1)': + dependencies: + '@inquirer/core': 10.3.0(@types/node@24.9.1) + '@inquirer/figures': 1.0.14 + '@inquirer/type': 3.0.9(@types/node@24.9.1) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.9.1 + + '@inquirer/select@4.4.0(@types/node@24.9.1)': + dependencies: + '@inquirer/ansi': 1.0.1 + '@inquirer/core': 10.3.0(@types/node@24.9.1) + '@inquirer/figures': 1.0.14 + '@inquirer/type': 3.0.9(@types/node@24.9.1) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.9.1 + + '@inquirer/type@3.0.9(@types/node@24.9.1)': + optionalDependencies: + '@types/node': 24.9.1 + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@jupyter-widgets/base@4.1.7(react@18.3.1)': + dependencies: + '@jupyterlab/services': 7.4.10(react@18.3.1) + '@lumino/coreutils': 2.2.1 + '@lumino/messaging': 2.0.3 + '@lumino/widgets': 2.7.1 + '@types/backbone': 1.4.23 + '@types/lodash': 4.17.20 + backbone: 1.2.3 + base64-js: 1.5.1 + jquery: 3.7.1 + lodash: 4.17.21 + transitivePeerDependencies: + - bufferutil + - react + - utf-8-validate + + '@jupyter/react-components@0.16.7': + dependencies: + '@jupyter/web-components': 0.16.7 + react: 18.3.1 + + '@jupyter/web-components@0.16.7': + dependencies: + '@microsoft/fast-colors': 5.3.1 + '@microsoft/fast-element': 1.14.0 + '@microsoft/fast-foundation': 2.50.0 + '@microsoft/fast-web-utilities': 5.4.1 + + '@jupyter/ydoc@3.1.0': + dependencies: + '@jupyterlab/nbformat': 4.4.10 + '@lumino/coreutils': 2.2.1 + '@lumino/disposable': 2.1.4 + '@lumino/signaling': 2.1.4 + y-protocols: 1.0.6(yjs@13.6.27) + yjs: 13.6.27 + + '@jupyterlab/application@4.4.10(react@18.3.1)': + dependencies: + '@fortawesome/fontawesome-free': 5.15.4 + '@jupyterlab/apputils': 4.5.10 + '@jupyterlab/coreutils': 6.4.10 + '@jupyterlab/docregistry': 4.4.10 + '@jupyterlab/rendermime': 4.4.10(react@18.3.1) + '@jupyterlab/rendermime-interfaces': 3.12.10 + '@jupyterlab/services': 7.4.10(react@18.3.1) + '@jupyterlab/statedb': 4.4.10 + '@jupyterlab/translation': 4.4.10(react@18.3.1) + '@jupyterlab/ui-components': 4.4.10(react@18.3.1) + '@lumino/algorithm': 2.0.3 + '@lumino/application': 2.4.4 + '@lumino/commands': 2.3.2 + '@lumino/coreutils': 2.2.1 + '@lumino/disposable': 2.1.4 + '@lumino/messaging': 2.0.3 + '@lumino/polling': 2.1.4 + '@lumino/properties': 2.0.3 + '@lumino/signaling': 2.1.4 + '@lumino/widgets': 2.7.1 + transitivePeerDependencies: + - bufferutil + - react + - utf-8-validate + + '@jupyterlab/apputils@4.5.10': + dependencies: + '@jupyterlab/coreutils': 6.4.10 + '@jupyterlab/observables': 5.4.10 + '@jupyterlab/rendermime-interfaces': 3.12.10 + '@jupyterlab/services': 7.4.10(react@18.3.1) + '@jupyterlab/settingregistry': 4.4.10(react@18.3.1) + '@jupyterlab/statedb': 4.4.10 + '@jupyterlab/statusbar': 4.4.10 + '@jupyterlab/translation': 4.4.10(react@18.3.1) + '@jupyterlab/ui-components': 4.4.10(react@18.3.1) + '@lumino/algorithm': 2.0.3 + '@lumino/commands': 2.3.2 + '@lumino/coreutils': 2.2.1 + '@lumino/disposable': 2.1.4 + '@lumino/domutils': 2.0.3 + '@lumino/messaging': 2.0.3 + '@lumino/signaling': 2.1.4 + '@lumino/virtualdom': 2.0.3 + '@lumino/widgets': 2.7.1 + '@types/react': 18.3.26 + react: 18.3.1 + sanitize-html: 2.12.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@jupyterlab/builder@4.4.10': + dependencies: + '@lumino/algorithm': 2.0.3 + '@lumino/application': 2.4.4 + '@lumino/commands': 2.3.2 + '@lumino/coreutils': 2.2.1 + '@lumino/disposable': 2.1.4 + '@lumino/domutils': 2.0.3 + '@lumino/dragdrop': 2.1.6 + '@lumino/messaging': 2.0.3 + '@lumino/properties': 2.0.3 + '@lumino/signaling': 2.1.4 + '@lumino/virtualdom': 2.0.3 + '@lumino/widgets': 2.7.1 + ajv: 8.17.1 + commander: 9.5.0 + css-loader: 6.11.0(webpack@5.102.1) + duplicate-package-checker-webpack-plugin: 3.0.0 + fs-extra: 10.1.0 + glob: 7.1.7 + license-webpack-plugin: 2.3.21(webpack@5.102.1) + mini-css-extract-plugin: 2.9.4(webpack@5.102.1) + mini-svg-data-uri: 1.4.4 + path-browserify: 1.0.1 + process: 0.11.10 + source-map-loader: 1.0.2(webpack@5.102.1) + style-loader: 3.3.4(webpack@5.102.1) + supports-color: 7.2.0 + terser-webpack-plugin: 5.3.14(webpack@5.102.1) + webpack: 5.102.1(webpack-cli@5.1.4) + webpack-cli: 5.1.4(webpack@5.102.1) + webpack-merge: 5.10.0 + worker-loader: 3.0.8(webpack@5.102.1) + transitivePeerDependencies: + - '@rspack/core' + - '@swc/core' + - '@webpack-cli/generators' + - esbuild + - uglify-js + - webpack-bundle-analyzer + - webpack-dev-server + + '@jupyterlab/codeeditor@4.4.10': + dependencies: + '@codemirror/state': 6.5.2 + '@jupyter/ydoc': 3.1.0 + '@jupyterlab/apputils': 4.5.10 + '@jupyterlab/coreutils': 6.4.10 + '@jupyterlab/nbformat': 4.4.10 + '@jupyterlab/observables': 5.4.10 + '@jupyterlab/statusbar': 4.4.10 + '@jupyterlab/translation': 4.4.10(react@18.3.1) + '@jupyterlab/ui-components': 4.4.10(react@18.3.1) + '@lumino/coreutils': 2.2.1 + '@lumino/disposable': 2.1.4 + '@lumino/dragdrop': 2.1.6 + '@lumino/messaging': 2.0.3 + '@lumino/signaling': 2.1.4 + '@lumino/widgets': 2.7.1 + react: 18.3.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@jupyterlab/coreutils@6.4.10': + dependencies: + '@lumino/coreutils': 2.2.1 + '@lumino/disposable': 2.1.4 + '@lumino/signaling': 2.1.4 + minimist: 1.2.8 + path-browserify: 1.0.1 + url-parse: 1.5.10 + + '@jupyterlab/docregistry@4.4.10': + dependencies: + '@jupyter/ydoc': 3.1.0 + '@jupyterlab/apputils': 4.5.10 + '@jupyterlab/codeeditor': 4.4.10 + '@jupyterlab/coreutils': 6.4.10 + '@jupyterlab/observables': 5.4.10 + '@jupyterlab/rendermime': 4.4.10(react@18.3.1) + '@jupyterlab/rendermime-interfaces': 3.12.10 + '@jupyterlab/services': 7.4.10(react@18.3.1) + '@jupyterlab/translation': 4.4.10(react@18.3.1) + '@jupyterlab/ui-components': 4.4.10(react@18.3.1) + '@lumino/algorithm': 2.0.3 + '@lumino/coreutils': 2.2.1 + '@lumino/disposable': 2.1.4 + '@lumino/messaging': 2.0.3 + '@lumino/properties': 2.0.3 + '@lumino/signaling': 2.1.4 + '@lumino/widgets': 2.7.1 + react: 18.3.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@jupyterlab/nbformat@4.4.10': + dependencies: + '@lumino/coreutils': 2.2.1 + + '@jupyterlab/observables@5.4.10': + dependencies: + '@lumino/algorithm': 2.0.3 + '@lumino/coreutils': 2.2.1 + '@lumino/disposable': 2.1.4 + '@lumino/messaging': 2.0.3 + '@lumino/signaling': 2.1.4 + + '@jupyterlab/rendermime-interfaces@3.12.10': + dependencies: + '@lumino/coreutils': 2.2.1 + '@lumino/widgets': 2.7.1 + + '@jupyterlab/rendermime@4.4.10(react@18.3.1)': + dependencies: + '@jupyterlab/apputils': 4.5.10 + '@jupyterlab/coreutils': 6.4.10 + '@jupyterlab/nbformat': 4.4.10 + '@jupyterlab/observables': 5.4.10 + '@jupyterlab/rendermime-interfaces': 3.12.10 + '@jupyterlab/services': 7.4.10(react@18.3.1) + '@jupyterlab/translation': 4.4.10(react@18.3.1) + '@lumino/coreutils': 2.2.1 + '@lumino/messaging': 2.0.3 + '@lumino/signaling': 2.1.4 + '@lumino/widgets': 2.7.1 + lodash.escape: 4.0.1 + transitivePeerDependencies: + - bufferutil + - react + - utf-8-validate + + '@jupyterlab/services@7.4.10(react@18.3.1)': + dependencies: + '@jupyter/ydoc': 3.1.0 + '@jupyterlab/coreutils': 6.4.10 + '@jupyterlab/nbformat': 4.4.10 + '@jupyterlab/settingregistry': 4.4.10(react@18.3.1) + '@jupyterlab/statedb': 4.4.10 + '@lumino/coreutils': 2.2.1 + '@lumino/disposable': 2.1.4 + '@lumino/polling': 2.1.4 + '@lumino/properties': 2.0.3 + '@lumino/signaling': 2.1.4 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - react + - utf-8-validate + + '@jupyterlab/settingregistry@4.4.10(react@18.3.1)': + dependencies: + '@jupyterlab/nbformat': 4.4.10 + '@jupyterlab/statedb': 4.4.10 + '@lumino/commands': 2.3.2 + '@lumino/coreutils': 2.2.1 + '@lumino/disposable': 2.1.4 + '@lumino/signaling': 2.1.4 + '@rjsf/utils': 5.24.13(react@18.3.1) + ajv: 8.17.1 + json5: 2.2.3 + react: 18.3.1 + + '@jupyterlab/statedb@4.4.10': + dependencies: + '@lumino/commands': 2.3.2 + '@lumino/coreutils': 2.2.1 + '@lumino/disposable': 2.1.4 + '@lumino/properties': 2.0.3 + '@lumino/signaling': 2.1.4 + + '@jupyterlab/statusbar@4.4.10': + dependencies: + '@jupyterlab/ui-components': 4.4.10(react@18.3.1) + '@lumino/algorithm': 2.0.3 + '@lumino/coreutils': 2.2.1 + '@lumino/disposable': 2.1.4 + '@lumino/messaging': 2.0.3 + '@lumino/signaling': 2.1.4 + '@lumino/widgets': 2.7.1 + react: 18.3.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@jupyterlab/translation@4.4.10(react@18.3.1)': + dependencies: + '@jupyterlab/coreutils': 6.4.10 + '@jupyterlab/rendermime-interfaces': 3.12.10 + '@jupyterlab/services': 7.4.10(react@18.3.1) + '@jupyterlab/statedb': 4.4.10 + '@lumino/coreutils': 2.2.1 + transitivePeerDependencies: + - bufferutil + - react + - utf-8-validate + + '@jupyterlab/ui-components@4.4.10(react@18.3.1)': + dependencies: + '@jupyter/react-components': 0.16.7 + '@jupyter/web-components': 0.16.7 + '@jupyterlab/coreutils': 6.4.10 + '@jupyterlab/observables': 5.4.10 + '@jupyterlab/rendermime-interfaces': 3.12.10 + '@jupyterlab/translation': 4.4.10(react@18.3.1) + '@lumino/algorithm': 2.0.3 + '@lumino/commands': 2.3.2 + '@lumino/coreutils': 2.2.1 + '@lumino/disposable': 2.1.4 + '@lumino/messaging': 2.0.3 + '@lumino/polling': 2.1.4 + '@lumino/properties': 2.0.3 + '@lumino/signaling': 2.1.4 + '@lumino/virtualdom': 2.0.3 + '@lumino/widgets': 2.7.1 + '@rjsf/core': 5.24.13(@rjsf/utils@5.24.13(react@18.3.1))(react@18.3.1) + '@rjsf/utils': 5.24.13(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + typestyle: 2.4.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@lumino/algorithm@2.0.3': {} + + '@lumino/application@2.4.4': + dependencies: + '@lumino/commands': 2.3.2 + '@lumino/coreutils': 2.2.1 + '@lumino/widgets': 2.7.1 + + '@lumino/collections@2.0.3': + dependencies: + '@lumino/algorithm': 2.0.3 + + '@lumino/commands@2.3.2': + dependencies: + '@lumino/algorithm': 2.0.3 + '@lumino/coreutils': 2.2.1 + '@lumino/disposable': 2.1.4 + '@lumino/domutils': 2.0.3 + '@lumino/keyboard': 2.0.3 + '@lumino/signaling': 2.1.4 + '@lumino/virtualdom': 2.0.3 + + '@lumino/coreutils@2.2.1': + dependencies: + '@lumino/algorithm': 2.0.3 + + '@lumino/disposable@2.1.4': + dependencies: + '@lumino/signaling': 2.1.4 + + '@lumino/domutils@2.0.3': {} + + '@lumino/dragdrop@2.1.6': + dependencies: + '@lumino/coreutils': 2.2.1 + '@lumino/disposable': 2.1.4 + + '@lumino/keyboard@2.0.3': {} + + '@lumino/messaging@2.0.3': + dependencies: + '@lumino/algorithm': 2.0.3 + '@lumino/collections': 2.0.3 + + '@lumino/polling@2.1.4': + dependencies: + '@lumino/coreutils': 2.2.1 + '@lumino/disposable': 2.1.4 + '@lumino/signaling': 2.1.4 + + '@lumino/properties@2.0.3': {} + + '@lumino/signaling@2.1.4': + dependencies: + '@lumino/algorithm': 2.0.3 + '@lumino/coreutils': 2.2.1 + + '@lumino/virtualdom@2.0.3': + dependencies: + '@lumino/algorithm': 2.0.3 + + '@lumino/widgets@2.7.1': + dependencies: + '@lumino/algorithm': 2.0.3 + '@lumino/commands': 2.3.2 + '@lumino/coreutils': 2.2.1 + '@lumino/disposable': 2.1.4 + '@lumino/domutils': 2.0.3 + '@lumino/dragdrop': 2.1.6 + '@lumino/keyboard': 2.0.3 + '@lumino/messaging': 2.0.3 + '@lumino/properties': 2.0.3 + '@lumino/signaling': 2.1.4 + '@lumino/virtualdom': 2.0.3 + + '@marijn/find-cluster-break@1.0.2': {} + + '@microsoft/fast-colors@5.3.1': {} + + '@microsoft/fast-element@1.14.0': {} + + '@microsoft/fast-foundation@2.50.0': + dependencies: + '@microsoft/fast-element': 1.14.0 + '@microsoft/fast-web-utilities': 5.4.1 + tabbable: 5.3.3 + tslib: 1.14.1 + + '@microsoft/fast-web-utilities@5.4.1': + dependencies: + exenv-es6: 1.1.1 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + + '@octokit/app@12.0.7': + dependencies: + '@octokit/auth-app': 3.6.1 + '@octokit/auth-unauthenticated': 2.1.0 + '@octokit/core': 3.6.0 + '@octokit/oauth-app': 3.7.1 + '@octokit/plugin-paginate-rest': 2.21.3(@octokit/core@3.6.0) + '@octokit/types': 6.41.0 + '@octokit/webhooks': 9.26.3 + transitivePeerDependencies: + - encoding + + '@octokit/auth-app@3.6.1': + dependencies: + '@octokit/auth-oauth-app': 4.3.4 + '@octokit/auth-oauth-user': 1.3.0 + '@octokit/request': 5.6.3 + '@octokit/request-error': 2.1.0 + '@octokit/types': 6.41.0 + '@types/lru-cache': 5.1.1 + deprecation: 2.3.1 + lru-cache: 6.0.0 + universal-github-app-jwt: 1.2.0 + universal-user-agent: 6.0.1 + transitivePeerDependencies: + - encoding + + '@octokit/auth-oauth-app@4.3.4': + dependencies: + '@octokit/auth-oauth-device': 3.1.4 + '@octokit/auth-oauth-user': 2.1.2 + '@octokit/request': 5.6.3 + '@octokit/types': 6.41.0 + '@types/btoa-lite': 1.0.2 + btoa-lite: 1.0.0 + universal-user-agent: 6.0.1 + transitivePeerDependencies: + - encoding + + '@octokit/auth-oauth-device@3.1.4': + dependencies: + '@octokit/oauth-methods': 2.0.6 + '@octokit/request': 6.2.8 + '@octokit/types': 6.41.0 + universal-user-agent: 6.0.1 + transitivePeerDependencies: + - encoding + + '@octokit/auth-oauth-device@4.0.5': + dependencies: + '@octokit/oauth-methods': 2.0.6 + '@octokit/request': 6.2.8 + '@octokit/types': 9.3.2 + universal-user-agent: 6.0.1 + transitivePeerDependencies: + - encoding + + '@octokit/auth-oauth-user@1.3.0': + dependencies: + '@octokit/auth-oauth-device': 3.1.4 + '@octokit/oauth-methods': 1.2.6 + '@octokit/request': 5.6.3 + '@octokit/types': 6.41.0 + btoa-lite: 1.0.0 + universal-user-agent: 6.0.1 + transitivePeerDependencies: + - encoding + + '@octokit/auth-oauth-user@2.1.2': + dependencies: + '@octokit/auth-oauth-device': 4.0.5 + '@octokit/oauth-methods': 2.0.6 + '@octokit/request': 6.2.8 + '@octokit/types': 9.3.2 + btoa-lite: 1.0.0 + universal-user-agent: 6.0.1 + transitivePeerDependencies: + - encoding + + '@octokit/auth-token@2.5.0': + dependencies: + '@octokit/types': 6.41.0 + + '@octokit/auth-unauthenticated@2.1.0': + dependencies: + '@octokit/request-error': 2.1.0 + '@octokit/types': 6.41.0 + + '@octokit/core@3.6.0': + dependencies: + '@octokit/auth-token': 2.5.0 + '@octokit/graphql': 4.8.0 + '@octokit/request': 5.6.3 + '@octokit/request-error': 2.1.0 + '@octokit/types': 6.41.0 + before-after-hook: 2.2.3 + universal-user-agent: 6.0.1 + transitivePeerDependencies: + - encoding + + '@octokit/endpoint@6.0.12': + dependencies: + '@octokit/types': 6.41.0 + is-plain-object: 5.0.0 + universal-user-agent: 6.0.1 + + '@octokit/endpoint@7.0.6': + dependencies: + '@octokit/types': 9.3.2 + is-plain-object: 5.0.0 + universal-user-agent: 6.0.1 + + '@octokit/graphql@4.8.0': + dependencies: + '@octokit/request': 5.6.3 + '@octokit/types': 6.41.0 + universal-user-agent: 6.0.1 + transitivePeerDependencies: + - encoding + + '@octokit/oauth-app@3.7.1': + dependencies: + '@octokit/auth-oauth-app': 4.3.4 + '@octokit/auth-oauth-user': 1.3.0 + '@octokit/auth-unauthenticated': 2.1.0 + '@octokit/core': 3.6.0 + '@octokit/oauth-authorization-url': 4.3.3 + '@octokit/oauth-methods': 1.2.6 + '@types/aws-lambda': 8.10.156 + fromentries: 1.3.2 + universal-user-agent: 6.0.1 + optionalDependencies: + aws-lambda: 1.0.7 + transitivePeerDependencies: + - encoding + + '@octokit/oauth-authorization-url@4.3.3': {} + + '@octokit/oauth-authorization-url@5.0.0': {} + + '@octokit/oauth-methods@1.2.6': + dependencies: + '@octokit/oauth-authorization-url': 4.3.3 + '@octokit/request': 5.6.3 + '@octokit/request-error': 2.1.0 + '@octokit/types': 6.41.0 + btoa-lite: 1.0.0 + transitivePeerDependencies: + - encoding + + '@octokit/oauth-methods@2.0.6': + dependencies: + '@octokit/oauth-authorization-url': 5.0.0 + '@octokit/request': 6.2.8 + '@octokit/request-error': 3.0.3 + '@octokit/types': 9.3.2 + btoa-lite: 1.0.0 + transitivePeerDependencies: + - encoding + + '@octokit/openapi-types@12.11.0': {} + + '@octokit/openapi-types@18.1.1': {} + + '@octokit/plugin-paginate-rest@2.21.3(@octokit/core@3.6.0)': + dependencies: + '@octokit/core': 3.6.0 + '@octokit/types': 6.41.0 + + '@octokit/plugin-rest-endpoint-methods@5.16.2(@octokit/core@3.6.0)': + dependencies: + '@octokit/core': 3.6.0 + '@octokit/types': 6.41.0 + deprecation: 2.3.1 + + '@octokit/plugin-retry@3.0.9': + dependencies: + '@octokit/types': 6.41.0 + bottleneck: 2.19.5 + + '@octokit/plugin-throttling@3.7.0(@octokit/core@3.6.0)': + dependencies: + '@octokit/core': 3.6.0 + '@octokit/types': 6.41.0 + bottleneck: 2.19.5 + + '@octokit/request-error@2.1.0': + dependencies: + '@octokit/types': 6.41.0 + deprecation: 2.3.1 + once: 1.4.0 + + '@octokit/request-error@3.0.3': + dependencies: + '@octokit/types': 9.3.2 + deprecation: 2.3.1 + once: 1.4.0 + + '@octokit/request@5.6.3': + dependencies: + '@octokit/endpoint': 6.0.12 + '@octokit/request-error': 2.1.0 + '@octokit/types': 6.41.0 + is-plain-object: 5.0.0 + node-fetch: 2.7.0 + universal-user-agent: 6.0.1 + transitivePeerDependencies: + - encoding + + '@octokit/request@6.2.8': + dependencies: + '@octokit/endpoint': 7.0.6 + '@octokit/request-error': 3.0.3 + '@octokit/types': 9.3.2 + is-plain-object: 5.0.0 + node-fetch: 2.7.0 + universal-user-agent: 6.0.1 + transitivePeerDependencies: + - encoding + + '@octokit/types@6.41.0': + dependencies: + '@octokit/openapi-types': 12.11.0 + + '@octokit/types@9.3.2': + dependencies: + '@octokit/openapi-types': 18.1.1 + + '@octokit/webhooks-methods@2.0.0': {} + + '@octokit/webhooks-types@5.8.0': {} + + '@octokit/webhooks@9.26.3': + dependencies: + '@octokit/request-error': 2.1.0 + '@octokit/webhooks-methods': 2.0.0 + '@octokit/webhooks-types': 5.8.0 + aggregate-error: 3.1.0 + + '@perspective-dev/client@4.0.0': + dependencies: + '@perspective-dev/server': 4.3.0 + pro_self_extracting_wasm: 0.0.9 + stoppable: 1.1.0 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + + '@perspective-dev/client@4.1.0': + dependencies: + '@perspective-dev/server': 4.3.0 + pro_self_extracting_wasm: 0.0.9 + stoppable: 1.1.0 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + + '@perspective-dev/client@4.2.0': + dependencies: + '@perspective-dev/server': 4.3.0 + pro_self_extracting_wasm: 0.0.9 + stoppable: 1.1.0 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + + '@perspective-dev/client@4.3.0': + dependencies: + '@perspective-dev/server': 4.4.1 + pro_self_extracting_wasm: 0.0.9 + stoppable: 1.1.0 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + + '@perspective-dev/client@4.4.0': + dependencies: + '@perspective-dev/server': 4.4.1 + pro_self_extracting_wasm: 0.0.9 + stoppable: 1.1.0 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + + '@perspective-dev/client@4.5.0': + dependencies: + '@perspective-dev/server': 4.5.1 + pro_self_extracting_wasm: 0.0.9 + stoppable: 1.1.0 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + + '@perspective-dev/client@4.5.1': + dependencies: + '@perspective-dev/server': 4.5.1 + pro_self_extracting_wasm: 0.0.9 + stoppable: 1.1.0 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + + '@perspective-dev/server@4.3.0': {} + + '@perspective-dev/server@4.4.1': {} + + '@perspective-dev/server@4.5.1': {} + + '@playwright/experimental-ct-core@1.58.0(@types/node@24.9.1)(jiti@1.21.7)(less@4.4.2)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)': + dependencies: + playwright: 1.58.0 + playwright-core: 1.58.0 + vite: 6.4.1(@types/node@24.9.1)(jiti@1.21.7)(less@4.4.2)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + '@playwright/experimental-ct-react@1.58.0(@types/node@24.9.1)(jiti@1.21.7)(less@4.4.2)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(vite@6.4.1(@types/node@24.9.1)(jiti@1.21.7)(less@4.4.2)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(yaml@2.8.1)': + dependencies: + '@playwright/experimental-ct-core': 1.58.0(@types/node@24.9.1)(jiti@1.21.7)(less@4.4.2)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + '@vitejs/plugin-react': 4.7.0(vite@6.4.1(@types/node@24.9.1)(jiti@1.21.7)(less@4.4.2)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - vite + - yaml + + '@playwright/test@1.58.0': + dependencies: + playwright: 1.58.0 + + '@puppeteer/browsers@2.10.12': + dependencies: + debug: 4.4.3 + extract-zip: 2.0.1 + progress: 2.0.3 + proxy-agent: 6.5.0 + semver: 7.7.3 + tar-fs: 3.1.1 + yargs: 17.7.2 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + + '@rjsf/core@5.24.13(@rjsf/utils@5.24.13(react@18.3.1))(react@18.3.1)': + dependencies: + '@rjsf/utils': 5.24.13(react@18.3.1) + lodash: 4.17.21 + lodash-es: 4.17.21 + markdown-to-jsx: 7.7.17(react@18.3.1) + prop-types: 15.8.1 + react: 18.3.1 + + '@rjsf/utils@5.24.13(react@18.3.1)': + dependencies: + json-schema-merge-allof: 0.8.1 + jsonpointer: 5.0.1 + lodash: 4.17.21 + lodash-es: 4.17.21 + react: 18.3.1 + react-is: 18.3.1 + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.52.5': + optional: true + + '@rollup/rollup-android-arm64@4.52.5': + optional: true + + '@rollup/rollup-darwin-arm64@4.52.5': + optional: true + + '@rollup/rollup-darwin-x64@4.52.5': + optional: true + + '@rollup/rollup-freebsd-arm64@4.52.5': + optional: true + + '@rollup/rollup-freebsd-x64@4.52.5': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.52.5': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.52.5': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.52.5': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.52.5': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.52.5': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.52.5': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.52.5': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.52.5': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.52.5': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.52.5': + optional: true + + '@rollup/rollup-linux-x64-musl@4.52.5': + optional: true + + '@rollup/rollup-openharmony-arm64@4.52.5': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.52.5': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.52.5': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.52.5': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.52.5': + optional: true + + '@shikijs/engine-oniguruma@3.13.0': + dependencies: + '@shikijs/types': 3.13.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.13.0': + dependencies: + '@shikijs/types': 3.13.0 + + '@shikijs/themes@3.13.0': + dependencies: + '@shikijs/types': 3.13.0 + + '@shikijs/types@3.13.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@sindresorhus/merge-streams@2.3.0': {} + + '@swc/helpers@0.5.17': + dependencies: + tslib: 2.8.1 + + '@tootallnate/quickjs-emscripten@0.23.0': {} + + '@types/aws-lambda@8.10.156': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/backbone@1.4.23': + dependencies: + '@types/jquery': 3.5.33 + '@types/underscore': 1.13.0 + + '@types/btoa-lite@1.0.2': {} + + '@types/command-line-args@5.2.3': {} + + '@types/command-line-usage@5.0.4': {} + + '@types/eslint-scope@3.7.7': + dependencies: + '@types/eslint': 9.6.1 + '@types/estree': 1.0.8 + + '@types/eslint@9.6.1': + dependencies: + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + + '@types/estree@1.0.8': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/html-minifier-terser@6.1.0': {} + + '@types/jquery@3.5.33': + dependencies: + '@types/sizzle': 2.3.10 + + '@types/json-schema@7.0.15': {} + + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 24.9.1 + + '@types/lodash@4.17.20': {} + + '@types/lru-cache@5.1.1': {} + + '@types/ms@2.1.0': {} + + '@types/node@20.19.23': + dependencies: + undici-types: 6.21.0 + + '@types/node@24.9.1': + dependencies: + undici-types: 7.16.0 + + '@types/prismjs@1.26.6': {} + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@19.2.2)': + dependencies: + '@types/react': 19.2.2 + + '@types/react@18.3.26': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.1.3 + + '@types/react@19.2.2': + dependencies: + csstype: 3.1.3 + + '@types/sizzle@2.3.10': {} + + '@types/source-list-map@0.1.6': {} + + '@types/stoppable@1.1.3': + dependencies: + '@types/node': 24.9.1 + + '@types/underscore@1.13.0': {} + + '@types/unist@3.0.3': {} + + '@types/webpack-sources@0.1.12': + dependencies: + '@types/node': 24.9.1 + '@types/source-list-map': 0.1.6 + source-map: 0.6.1 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.9.1 + + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 24.9.1 + optional: true + + '@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/type-utils': 8.59.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.1 + eslint: 9.39.4(jiti@1.21.7) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.1 + debug: 4.4.3 + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3) + '@typescript-eslint/types': 8.59.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.59.1': + dependencies: + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/visitor-keys': 8.59.1 + + '@typescript-eslint/tsconfig-utils@8.59.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.59.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4(jiti@1.21.7) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.59.1': {} + + '@typescript-eslint/typescript-estree@8.59.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3) + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/visitor-keys': 8.59.1 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.3 + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.59.1': + dependencies: + '@typescript-eslint/types': 8.59.1 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@24.9.1)(jiti@1.21.7)(less@4.4.2)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': + dependencies: + '@babel/core': 7.28.5 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.1(@types/node@24.9.1)(jiti@1.21.7)(less@4.4.2)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + transitivePeerDependencies: + - supports-color + + '@webassemblyjs/ast@1.14.1': + dependencies: + '@webassemblyjs/helper-numbers': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + + '@webassemblyjs/floating-point-hex-parser@1.13.2': {} + + '@webassemblyjs/helper-api-error@1.13.2': {} + + '@webassemblyjs/helper-buffer@1.14.1': {} + + '@webassemblyjs/helper-numbers@1.13.2': + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.13.2 + '@webassemblyjs/helper-api-error': 1.13.2 + '@xtuc/long': 4.2.2 + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} + + '@webassemblyjs/helper-wasm-section@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/wasm-gen': 1.14.1 + + '@webassemblyjs/ieee754@1.13.2': + dependencies: + '@xtuc/ieee754': 1.2.0 + + '@webassemblyjs/leb128@1.13.2': + dependencies: + '@xtuc/long': 4.2.2 + + '@webassemblyjs/utf8@1.13.2': {} + + '@webassemblyjs/wasm-edit@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/helper-wasm-section': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-opt': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + '@webassemblyjs/wast-printer': 1.14.1 + + '@webassemblyjs/wasm-gen@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wasm-opt@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + + '@webassemblyjs/wasm-parser@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-api-error': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wast-printer@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@xtuc/long': 4.2.2 + + '@webpack-cli/configtest@2.1.1(webpack-cli@5.1.4(webpack@5.102.1))(webpack@5.102.1)': + dependencies: + webpack: 5.102.1(webpack-cli@5.1.4) + webpack-cli: 5.1.4(webpack@5.102.1) + + '@webpack-cli/info@2.0.2(webpack-cli@5.1.4(webpack@5.102.1))(webpack@5.102.1)': + dependencies: + webpack: 5.102.1(webpack-cli@5.1.4) + webpack-cli: 5.1.4(webpack@5.102.1) + + '@webpack-cli/serve@2.0.5(webpack-cli@5.1.4(webpack@5.102.1))(webpack@5.102.1)': + dependencies: + webpack: 5.102.1(webpack-cli@5.1.4) + webpack-cli: 5.1.4(webpack@5.102.1) + + '@xtuc/ieee754@1.2.0': {} + + '@xtuc/long@4.2.2': {} + + '@zip.js/zip.js@2.8.8': {} + + abab@2.0.6: {} + + acorn-import-phases@1.0.4(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + agent-base@7.1.4: {} + + aggregate-error@3.1.0: + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + + ajv-formats@2.1.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv-keywords@3.5.2(ajv@6.12.6): + dependencies: + ajv: 6.12.6 + + ajv-keywords@5.1.0(ajv@8.17.1): + dependencies: + ajv: 8.17.1 + fast-deep-equal: 3.1.3 + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.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 + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + optional: true + + argparse@2.0.1: {} + + array-back@3.1.0: {} + + array-back@6.2.2: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + arraybuffer-loader@1.0.8: + dependencies: + loader-utils: 1.4.2 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + ast-types@0.13.4: + dependencies: + tslib: 2.8.1 + + async-function@1.0.0: {} + + async-limiter@1.0.1: {} + + async@3.2.6: {} + + auto-changelog@2.5.0: + dependencies: + commander: 7.2.0 + handlebars: 4.7.8 + import-cwd: 3.0.0 + node-fetch: 2.7.0 + parse-github-url: 1.0.3 + semver: 7.7.3 + transitivePeerDependencies: + - encoding + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + aws-lambda@1.0.7: + dependencies: + aws-sdk: 2.1692.0 + commander: 3.0.2 + js-yaml: 3.14.1 + watchpack: 2.4.4 + optional: true + + aws-sdk@2.1692.0: + dependencies: + buffer: 4.9.2 + events: 1.1.1 + ieee754: 1.1.13 + jmespath: 0.16.0 + querystring: 0.2.0 + sax: 1.2.1 + url: 0.10.3 + util: 0.12.5 + uuid: 8.0.0 + xml2js: 0.6.2 + optional: true + + b4a@1.7.3: {} + + backbone@1.2.3: + dependencies: + underscore: 1.13.7 + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + bare-events@2.8.1: {} + + bare-fs@4.5.0: + dependencies: + bare-events: 2.8.1 + bare-path: 3.0.0 + bare-stream: 2.7.0(bare-events@2.8.1) + bare-url: 2.3.1 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + optional: true + + bare-os@3.6.2: + optional: true + + bare-path@3.0.0: + dependencies: + bare-os: 3.6.2 + optional: true + + bare-stream@2.7.0(bare-events@2.8.1): + dependencies: + streamx: 2.23.0 + optionalDependencies: + bare-events: 2.8.1 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + optional: true + + bare-url@2.3.1: + dependencies: + bare-path: 3.0.0 + optional: true + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.8.20: {} + + basic-auth@2.0.1: + dependencies: + safe-buffer: 5.1.2 + + basic-ftp@5.0.5: {} + + before-after-hook@2.2.3: {} + + big.js@5.2.2: {} + + bn.js@5.2.2: {} + + boolbase@1.0.0: {} + + bottleneck@2.19.5: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.27.0: + dependencies: + baseline-browser-mapping: 2.8.20 + caniuse-lite: 1.0.30001751 + electron-to-chromium: 1.5.240 + node-releases: 2.0.26 + update-browserslist-db: 1.1.4(browserslist@4.27.0) + + btoa-lite@1.0.0: {} + + buffer-crc32@0.2.13: {} + + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + buffer-pipe@0.0.3: + dependencies: + safe-buffer: 5.2.1 + + buffer@4.9.2: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + isarray: 1.0.0 + optional: true + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camel-case@4.1.2: + dependencies: + pascal-case: 3.1.2 + tslib: 2.8.1 + + caniuse-lite@1.0.30001751: {} + + chalk-template@0.4.0: + dependencies: + chalk: 4.1.2 + + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + chardet@2.1.0: {} + + chownr@3.0.0: {} + + chrome-trace-event@1.0.4: {} + + chromium-bidi@10.5.1(devtools-protocol@0.0.1508733): + dependencies: + devtools-protocol: 0.0.1508733 + mitt: 3.0.1 + zod: 3.25.76 + + clean-css@5.3.3: + dependencies: + source-map: 0.6.1 + + clean-stack@2.2.0: {} + + cli-width@4.1.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone-deep@4.0.1: + dependencies: + is-plain-object: 2.0.4 + kind-of: 6.0.3 + shallow-clone: 3.0.1 + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.3: {} + + color-name@1.1.4: {} + + colorette@2.0.20: {} + + command-line-args@5.2.1: + dependencies: + array-back: 3.1.0 + find-replace: 3.0.0 + lodash.camelcase: 4.3.0 + typical: 4.0.0 + + command-line-usage@7.0.3: + dependencies: + array-back: 6.2.2 + chalk-template: 0.4.0 + table-layout: 4.1.1 + typical: 7.3.0 + + commander@10.0.1: {} + + commander@14.0.1: {} + + commander@2.20.3: {} + + commander@3.0.2: + optional: true + + commander@7.2.0: {} + + commander@8.3.0: {} + + commander@9.5.0: {} + + compute-gcd@1.2.1: + dependencies: + validate.io-array: 1.0.6 + validate.io-function: 1.0.2 + validate.io-integer-array: 1.0.0 + + compute-lcm@1.1.2: + dependencies: + compute-gcd: 1.2.1 + validate.io-array: 1.0.6 + validate.io-function: 1.0.2 + validate.io-integer-array: 1.0.0 + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + copy-anything@2.0.6: + dependencies: + is-what: 3.14.1 + optional: true + + copy-webpack-plugin@12.0.2(webpack@5.102.1(webpack-cli@5.1.4)): + dependencies: + fast-glob: 3.3.3 + glob-parent: 6.0.2 + globby: 14.1.0 + normalize-path: 3.0.0 + schema-utils: 4.3.3 + serialize-javascript: 6.0.2 + webpack: 5.102.1(webpack-cli@5.1.4) + + core-js@3.46.0: {} + + corser@2.0.1: {} + + cosmiconfig@9.0.0(typescript@5.9.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.0 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 + + cross-spawn@6.0.6: + dependencies: + nice-try: 1.0.5 + path-key: 2.0.1 + semver: 5.7.2 + shebang-command: 1.2.0 + which: 1.3.1 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-loader@6.11.0(webpack@5.102.1): + dependencies: + icss-utils: 5.1.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-modules-extract-imports: 3.1.0(postcss@8.5.6) + postcss-modules-local-by-default: 4.2.0(postcss@8.5.6) + postcss-modules-scope: 3.2.1(postcss@8.5.6) + postcss-modules-values: 4.0.0(postcss@8.5.6) + postcss-value-parser: 4.2.0 + semver: 7.7.3 + optionalDependencies: + webpack: 5.102.1(webpack-cli@5.1.4) + + css-loader@7.1.2(webpack@5.102.1): + dependencies: + icss-utils: 5.1.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-modules-extract-imports: 3.1.0(postcss@8.5.6) + postcss-modules-local-by-default: 4.2.0(postcss@8.5.6) + postcss-modules-scope: 3.2.1(postcss@8.5.6) + postcss-modules-values: 4.0.0(postcss@8.5.6) + postcss-value-parser: 4.2.0 + semver: 7.7.3 + optionalDependencies: + webpack: 5.102.1(webpack-cli@5.1.4) + + css-select@4.3.0: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 4.3.1 + domutils: 2.8.0 + nth-check: 2.1.1 + + css-what@6.2.2: {} + + cssesc@3.0.0: {} + + csstype@3.0.10: {} + + csstype@3.1.3: {} + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + data-uri-to-buffer@6.0.2: {} + + data-urls@2.0.0: + dependencies: + abab: 2.0.6 + whatwg-mimetype: 2.3.0 + whatwg-url: 8.7.0 + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + degenerator@5.0.1: + dependencies: + ast-types: 0.13.4 + escodegen: 2.1.0 + esprima: 4.0.1 + + deprecation@2.3.1: {} + + detect-libc@2.1.2: {} + + detectie@1.0.0: {} + + devtools-protocol@0.0.1508733: {} + + dom-converter@0.2.0: + dependencies: + utila: 0.4.0 + + dom-serializer@1.4.1: + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + entities: 2.2.0 + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@4.3.1: + dependencies: + domelementtype: 2.3.0 + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@2.8.0: + dependencies: + dom-serializer: 1.4.1 + domelementtype: 2.3.0 + domhandler: 4.3.1 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dot-case@3.0.4: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + + dotenv@17.2.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + duplicate-package-checker-webpack-plugin@3.0.0: + dependencies: + chalk: 2.4.2 + find-root: 1.1.0 + lodash: 4.17.21 + semver: 5.7.2 + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + electron-to-chromium@1.5.240: {} + + emoji-regex@8.0.0: {} + + emojis-list@3.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + enhanced-resolve@5.18.3: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + entities@2.2.0: {} + + entities@4.5.0: {} + + env-paths@2.2.1: {} + + envinfo@7.19.0: {} + + errno@0.1.8: + dependencies: + prr: 1.0.1 + optional: true + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-abstract@1.24.0: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.3 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.19 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + esbuild@0.25.11: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.11 + '@esbuild/android-arm': 0.25.11 + '@esbuild/android-arm64': 0.25.11 + '@esbuild/android-x64': 0.25.11 + '@esbuild/darwin-arm64': 0.25.11 + '@esbuild/darwin-x64': 0.25.11 + '@esbuild/freebsd-arm64': 0.25.11 + '@esbuild/freebsd-x64': 0.25.11 + '@esbuild/linux-arm': 0.25.11 + '@esbuild/linux-arm64': 0.25.11 + '@esbuild/linux-ia32': 0.25.11 + '@esbuild/linux-loong64': 0.25.11 + '@esbuild/linux-mips64el': 0.25.11 + '@esbuild/linux-ppc64': 0.25.11 + '@esbuild/linux-riscv64': 0.25.11 + '@esbuild/linux-s390x': 0.25.11 + '@esbuild/linux-x64': 0.25.11 + '@esbuild/netbsd-arm64': 0.25.11 + '@esbuild/netbsd-x64': 0.25.11 + '@esbuild/openbsd-arm64': 0.25.11 + '@esbuild/openbsd-x64': 0.25.11 + '@esbuild/openharmony-arm64': 0.25.11 + '@esbuild/sunos-x64': 0.25.11 + '@esbuild/win32-arm64': 0.25.11 + '@esbuild/win32-ia32': 0.25.11 + '@esbuild/win32-x64': 0.25.11 + + escalade@3.2.0: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@4.0.0: {} + + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + + eslint-scope@5.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4(jiti@1.21.7): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 1.21.7 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 4.2.1 + + esprima@4.0.1: {} + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@4.3.0: {} + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + eventemitter3@4.0.7: {} + + events-universal@1.0.1: + dependencies: + bare-events: 2.8.1 + transitivePeerDependencies: + - bare-abort-controller + + events@1.1.1: + optional: true + + events@3.3.0: {} + + exenv-es6@1.1.1: {} + + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-fifo@1.3.2: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-uri@3.1.0: {} + + fastest-levenshtein@1.0.16: {} + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fflate@0.7.4: {} + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-replace@3.0.0: + dependencies: + array-back: 3.1.0 + + find-root@1.1.0: {} + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flat@5.0.2: {} + + flatbuffers@1.12.0: {} + + flatbuffers@24.12.23: {} + + flatted@3.4.2: {} + + follow-redirects@1.15.11: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + free-style@3.1.0: {} + + fromentries@1.3.2: {} + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + + fs-extra@11.3.2: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + + fs.realpath@1.0.0: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.2 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@5.2.0: + dependencies: + pump: 3.0.3 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@4.13.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + get-uri@6.0.5: + dependencies: + basic-ftp: 5.0.5 + data-uri-to-buffer: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + glob-gitignore@1.0.15: + dependencies: + glob: 7.2.3 + ignore: 5.3.2 + lodash: 4.17.21 + make-array: 1.0.5 + util.inherits: 1.0.3 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob-to-regexp@0.4.1: {} + + glob@7.1.7: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@14.0.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + globby@14.1.0: + dependencies: + '@sindresorhus/merge-streams': 2.3.0 + fast-glob: 3.3.3 + ignore: 7.0.5 + path-type: 6.0.0 + slash: 5.1.0 + unicorn-magic: 0.3.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + handlebars@4.7.8: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + + has-bigints@1.1.0: {} + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + hosted-git-info@2.8.9: {} + + html-encoding-sniffer@3.0.0: + dependencies: + whatwg-encoding: 2.0.0 + + html-minifier-terser@6.1.0: + dependencies: + camel-case: 4.1.2 + clean-css: 5.3.3 + commander: 8.3.0 + he: 1.2.0 + param-case: 3.0.4 + relateurl: 0.2.7 + terser: 5.44.0 + + html-webpack-plugin@5.6.4(webpack@5.102.1): + dependencies: + '@types/html-minifier-terser': 6.1.0 + html-minifier-terser: 6.1.0 + lodash: 4.17.21 + pretty-error: 4.0.0 + tapable: 2.3.0 + optionalDependencies: + webpack: 5.102.1(webpack-cli@5.1.4) + + htmlparser2@6.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + domutils: 2.8.0 + entities: 2.2.0 + + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + http-proxy@1.18.1: + dependencies: + eventemitter3: 4.0.7 + follow-redirects: 1.15.11 + requires-port: 1.0.0 + transitivePeerDependencies: + - debug + + http-server@14.1.1: + dependencies: + basic-auth: 2.0.1 + chalk: 4.1.2 + corser: 2.0.1 + he: 1.2.0 + html-encoding-sniffer: 3.0.0 + http-proxy: 1.18.1 + mime: 1.6.0 + minimist: 1.2.8 + opener: 1.5.2 + portfinder: 1.0.38 + secure-compare: 3.0.1 + union: 0.5.0 + url-join: 4.0.1 + transitivePeerDependencies: + - debug + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + husky@9.1.7: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.0: + dependencies: + safer-buffer: 2.1.2 + + icss-utils@5.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + ieee754@1.1.13: + optional: true + + ieee754@1.2.1: + optional: true + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + image-size@0.5.5: + optional: true + + import-cwd@3.0.0: + dependencies: + import-from: 3.0.0 + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-from@3.0.0: + dependencies: + resolve-from: 5.0.0 + + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + inquirer@12.10.0(@types/node@24.9.1): + dependencies: + '@inquirer/ansi': 1.0.1 + '@inquirer/core': 10.3.0(@types/node@24.9.1) + '@inquirer/prompts': 7.9.0(@types/node@24.9.1) + '@inquirer/type': 3.0.9(@types/node@24.9.1) + mute-stream: 2.0.0 + run-async: 4.0.6 + rxjs: 7.8.2 + optionalDependencies: + '@types/node': 24.9.1 + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + + internmap@2.0.3: {} + + interpret@3.1.1: {} + + ip-address@10.0.1: {} + + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + optional: true + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-arrayish@0.2.1: {} + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-fullwidth-code-point@3.0.0: {} + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-plain-object@2.0.4: + dependencies: + isobject: 3.0.1 + + is-plain-object@5.0.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.19 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-what@3.14.1: + optional: true + + isarray@1.0.0: + optional: true + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + isobject@3.0.1: {} + + isomorphic.js@0.2.5: {} + + jest-worker@27.5.1: + dependencies: + '@types/node': 24.9.1 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jiti@1.21.7: + optional: true + + jmespath@0.16.0: + optional: true + + jquery@3.7.1: {} + + js-tokens@4.0.0: {} + + js-yaml@3.14.1: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + optional: true + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-bignum@0.0.3: {} + + json-buffer@3.0.1: {} + + json-parse-better-errors@1.0.2: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-compare@0.2.2: + dependencies: + lodash: 4.17.21 + + json-schema-merge-allof@0.8.1: + dependencies: + compute-lcm: 1.1.2 + json-schema-compare: 0.2.2 + lodash: 4.17.21 + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + json5@2.2.3: {} + + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsonpointer@5.0.1: {} + + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.3 + + jwa@1.4.2: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.2 + safe-buffer: 5.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kind-of@6.0.3: {} + + leb128@0.0.5: + dependencies: + bn.js: 5.2.2 + buffer-pipe: 0.0.3 + + less@4.4.2: + dependencies: + copy-anything: 2.0.6 + parse-node-version: 1.0.1 + tslib: 2.8.1 + optionalDependencies: + errno: 0.1.8 + graceful-fs: 4.2.11 + image-size: 0.5.5 + make-dir: 2.1.0 + mime: 1.6.0 + needle: 3.3.1 + source-map: 0.6.1 + optional: true + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lib0@0.2.114: + dependencies: + isomorphic.js: 0.2.5 + + license-webpack-plugin@2.3.21(webpack@5.102.1): + dependencies: + '@types/webpack-sources': 0.1.12 + webpack-sources: 1.4.3 + optionalDependencies: + webpack: 5.102.1(webpack-cli@5.1.4) + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lines-and-columns@1.2.4: {} + + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + + load-json-file@4.0.0: + dependencies: + graceful-fs: 4.2.11 + parse-json: 4.0.0 + pify: 3.0.0 + strip-bom: 3.0.0 + + loader-runner@4.3.1: {} + + loader-utils@1.4.2: + dependencies: + big.js: 5.2.2 + emojis-list: 3.0.0 + json5: 1.0.2 + + loader-utils@2.0.4: + dependencies: + big.js: 5.2.2 + emojis-list: 3.0.0 + json5: 2.2.3 + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash-es@4.17.21: {} + + lodash.camelcase@4.3.0: {} + + lodash.escape@4.0.1: {} + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.merge@4.6.2: {} + + lodash.once@4.1.1: {} + + lodash@4.17.21: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lower-case@2.0.2: + dependencies: + tslib: 2.8.1 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + + lru-cache@7.18.3: {} + + lunr@2.3.9: {} + + make-array@1.0.5: {} + + make-dir@2.1.0: + dependencies: + pify: 4.0.1 + semver: 5.7.2 + optional: true + + markdown-it@14.1.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + markdown-to-jsx@7.7.17(react@18.3.1): + optionalDependencies: + react: 18.3.1 + + math-intrinsics@1.1.0: {} + + mdurl@2.0.0: {} + + memorystream@0.3.1: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + microtime@3.1.1: + dependencies: + node-addon-api: 5.1.0 + node-gyp-build: 4.8.4 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + mini-css-extract-plugin@2.9.4(webpack@5.102.1): + dependencies: + schema-utils: 4.3.3 + tapable: 2.3.0 + webpack: 5.102.1(webpack-cli@5.1.4) + + mini-svg-data-uri@1.4.4: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + minimist@1.2.8: {} + + minipass@7.1.2: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.2 + + mitt@3.0.1: {} + + moment@2.30.1: {} + + ms@2.1.3: {} + + mute-stream@2.0.0: {} + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + needle@3.3.1: + dependencies: + iconv-lite: 0.6.3 + sax: 1.4.1 + optional: true + + neo-async@2.6.2: {} + + netmask@2.0.2: {} + + nice-try@1.0.5: {} + + no-case@3.0.4: + dependencies: + lower-case: 2.0.2 + tslib: 2.8.1 + + node-addon-api@5.1.0: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-gyp-build@4.8.4: {} + + node-releases@2.0.26: {} + + normalize-package-data@2.5.0: + dependencies: + hosted-git-info: 2.8.9 + resolve: 1.22.11 + semver: 5.7.2 + validate-npm-package-license: 3.0.4 + + normalize-path@3.0.0: {} + + npm-run-all@4.1.5: + dependencies: + ansi-styles: 3.2.1 + chalk: 2.4.2 + cross-spawn: 6.0.6 + memorystream: 0.3.1 + minimatch: 3.1.2 + pidtree: 0.3.1 + read-pkg: 3.0.0 + shell-quote: 1.8.3 + string.prototype.padend: 3.1.6 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + octokit@1.8.1: + dependencies: + '@octokit/app': 12.0.7 + '@octokit/core': 3.6.0 + '@octokit/oauth-app': 3.7.1 + '@octokit/plugin-paginate-rest': 2.21.3(@octokit/core@3.6.0) + '@octokit/plugin-rest-endpoint-methods': 5.16.2(@octokit/core@3.6.0) + '@octokit/plugin-retry': 3.0.9 + '@octokit/plugin-throttling': 3.7.0(@octokit/core@3.6.0) + '@octokit/types': 6.41.0 + transitivePeerDependencies: + - encoding + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + opener@1.5.2: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-try@2.2.0: {} + + pac-proxy-agent@7.2.0: + dependencies: + '@tootallnate/quickjs-emscripten': 0.23.0 + agent-base: 7.1.4 + debug: 4.4.3 + get-uri: 6.0.5 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + pac-resolver: 7.0.1 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + pac-resolver@7.0.1: + dependencies: + degenerator: 5.0.1 + netmask: 2.0.2 + + param-case@3.0.4: + dependencies: + dot-case: 3.0.4 + tslib: 2.8.1 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-github-url@1.0.3: {} + + parse-json@4.0.0: + dependencies: + error-ex: 1.3.4 + json-parse-better-errors: 1.0.2 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.27.1 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-node-version@1.0.1: + optional: true + + parse-srcset@1.0.2: {} + + pascal-case@3.1.2: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@2.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-type@3.0.0: + dependencies: + pify: 3.0.0 + + path-type@6.0.0: {} + + pend@1.2.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + pidtree@0.3.1: {} + + pify@3.0.0: {} + + pify@4.0.1: + optional: true + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + playwright-core@1.58.0: {} + + playwright@1.58.0: + dependencies: + playwright-core: 1.58.0 + optionalDependencies: + fsevents: 2.3.2 + + portfinder@1.0.38: + dependencies: + async: 3.2.6 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + possible-typed-array-names@1.1.0: {} + + postcss-modules-extract-imports@3.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-modules-local-by-default@4.2.0(postcss@8.5.6): + dependencies: + icss-utils: 5.1.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-selector-parser: 7.1.0 + postcss-value-parser: 4.2.0 + + postcss-modules-scope@3.2.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 7.1.0 + + postcss-modules-values@4.0.0(postcss@8.5.6): + dependencies: + icss-utils: 5.1.0(postcss@8.5.6) + postcss: 8.5.6 + + postcss-selector-parser@7.1.0: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier@3.6.2: {} + + pretty-error@4.0.0: + dependencies: + lodash: 4.17.21 + renderkid: 3.0.0 + + prismjs@1.30.0: {} + + pro_self_extracting_wasm@0.0.9: + dependencies: + http-server: 14.1.1 + leb128: 0.0.5 + zx: 8.8.5 + transitivePeerDependencies: + - debug + - supports-color + + process@0.11.10: {} + + progress@2.0.3: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + proxy-agent@6.5.0: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 7.18.3 + pac-proxy-agent: 7.2.0 + proxy-from-env: 1.1.0 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + proxy-from-env@1.1.0: {} + + prr@1.0.1: + optional: true + + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode.js@2.3.1: {} + + punycode@1.3.2: + optional: true + + punycode@2.3.1: {} + + puppeteer-core@24.26.1: + dependencies: + '@puppeteer/browsers': 2.10.12 + chromium-bidi: 10.5.1(devtools-protocol@0.0.1508733) + debug: 4.4.3 + devtools-protocol: 0.0.1508733 + typed-query-selector: 2.12.0 + webdriver-bidi-protocol: 0.3.8 + ws: 8.18.3 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - utf-8-validate + + puppeteer@24.26.1(typescript@5.9.3): + dependencies: + '@puppeteer/browsers': 2.10.12 + chromium-bidi: 10.5.1(devtools-protocol@0.0.1508733) + cosmiconfig: 9.0.0(typescript@5.9.3) + devtools-protocol: 0.0.1508733 + puppeteer-core: 24.26.1 + typed-query-selector: 2.12.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - typescript + - utf-8-validate + + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + + querystring@0.2.0: + optional: true + + querystringify@2.2.0: {} + + queue-microtask@1.2.3: {} + + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-is@16.13.1: {} + + react-is@18.3.1: {} + + react-refresh@0.17.0: {} + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + read-pkg@3.0.0: + dependencies: + load-json-file: 4.0.0 + normalize-package-data: 2.5.0 + path-type: 3.0.0 + + rechoir@0.8.0: + dependencies: + resolve: 1.22.11 + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + regular-table@0.8.4: {} + + relateurl@0.2.7: {} + + renderkid@3.0.0: + dependencies: + css-select: 4.3.0 + dom-converter: 0.2.0 + htmlparser2: 6.1.0 + lodash: 4.17.21 + strip-ansi: 6.0.1 + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + requires-port@1.0.0: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rollup@4.52.5: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.52.5 + '@rollup/rollup-android-arm64': 4.52.5 + '@rollup/rollup-darwin-arm64': 4.52.5 + '@rollup/rollup-darwin-x64': 4.52.5 + '@rollup/rollup-freebsd-arm64': 4.52.5 + '@rollup/rollup-freebsd-x64': 4.52.5 + '@rollup/rollup-linux-arm-gnueabihf': 4.52.5 + '@rollup/rollup-linux-arm-musleabihf': 4.52.5 + '@rollup/rollup-linux-arm64-gnu': 4.52.5 + '@rollup/rollup-linux-arm64-musl': 4.52.5 + '@rollup/rollup-linux-loong64-gnu': 4.52.5 + '@rollup/rollup-linux-ppc64-gnu': 4.52.5 + '@rollup/rollup-linux-riscv64-gnu': 4.52.5 + '@rollup/rollup-linux-riscv64-musl': 4.52.5 + '@rollup/rollup-linux-s390x-gnu': 4.52.5 + '@rollup/rollup-linux-x64-gnu': 4.52.5 + '@rollup/rollup-linux-x64-musl': 4.52.5 + '@rollup/rollup-openharmony-arm64': 4.52.5 + '@rollup/rollup-win32-arm64-msvc': 4.52.5 + '@rollup/rollup-win32-ia32-msvc': 4.52.5 + '@rollup/rollup-win32-x64-gnu': 4.52.5 + '@rollup/rollup-win32-x64-msvc': 4.52.5 + fsevents: 2.3.3 + + run-async@4.0.6: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-array-concat@1.1.3: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safer-buffer@2.1.2: {} + + sanitize-html@2.12.1: + dependencies: + deepmerge: 4.3.1 + escape-string-regexp: 4.0.0 + htmlparser2: 8.0.2 + is-plain-object: 5.0.0 + parse-srcset: 1.0.2 + postcss: 8.5.6 + + sax@1.2.1: + optional: true + + sax@1.4.1: + optional: true + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + schema-utils@2.7.1: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 6.12.6 + ajv-keywords: 3.5.2(ajv@6.12.6) + + schema-utils@3.3.0: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 6.12.6 + ajv-keywords: 3.5.2(ajv@6.12.6) + + schema-utils@4.3.3: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 8.17.1 + ajv-formats: 2.1.1(ajv@8.17.1) + ajv-keywords: 5.1.0(ajv@8.17.1) + + secure-compare@3.0.1: {} + + semver@5.7.2: {} + + semver@6.3.1: {} + + semver@7.7.3: {} + + serialize-javascript@6.0.2: + dependencies: + randombytes: 2.1.0 + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + + shallow-clone@3.0.1: + dependencies: + kind-of: 6.0.3 + + shebang-command@1.2.0: + dependencies: + shebang-regex: 1.0.0 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@1.0.0: {} + + shebang-regex@3.0.0: {} + + shell-quote@1.8.3: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@4.1.0: {} + + slash@5.1.0: {} + + smart-buffer@4.2.0: {} + + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + socks: 2.8.7 + transitivePeerDependencies: + - supports-color + + socks@2.8.7: + dependencies: + ip-address: 10.0.1 + smart-buffer: 4.2.0 + + source-list-map@2.0.1: {} + + source-map-js@1.2.1: {} + + source-map-loader@1.0.2(webpack@5.102.1): + dependencies: + data-urls: 2.0.0 + iconv-lite: 0.6.3 + loader-utils: 2.0.4 + schema-utils: 2.7.1 + source-map: 0.6.1 + webpack: 5.102.1(webpack-cli@5.1.4) + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + spdx-correct@3.2.0: + dependencies: + spdx-expression-parse: 3.0.1 + spdx-license-ids: 3.0.22 + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@3.0.1: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.22 + + spdx-license-ids@3.0.22: {} + + sprintf-js@1.0.3: + optional: true + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + stoppable@1.1.0: {} + + streamx@2.23.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.3 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string.prototype.padend@3.1.6: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-bom@3.0.0: {} + + strip-json-comments@3.1.1: {} + + style-loader@3.3.4(webpack@5.102.1): + dependencies: + webpack: 5.102.1(webpack-cli@5.1.4) + + style-loader@4.0.0(webpack@5.102.1): + dependencies: + webpack: 5.102.1(webpack-cli@5.1.4) + + superstore-arrow@3.2.0: {} + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tabbable@5.3.3: {} + + table-layout@4.1.1: + dependencies: + array-back: 6.2.2 + wordwrapjs: 5.1.1 + + tapable@2.3.0: {} + + tar-fs@3.1.1: + dependencies: + pump: 3.0.3 + tar-stream: 3.1.7 + optionalDependencies: + bare-fs: 4.5.0 + bare-path: 3.0.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + tar-stream@3.1.7: + dependencies: + b4a: 1.7.3 + fast-fifo: 1.3.2 + streamx: 2.23.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + tar@7.5.1: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.2 + minizlib: 3.1.0 + yallist: 5.0.0 + + terser-webpack-plugin@5.3.14(webpack@5.102.1): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + serialize-javascript: 6.0.2 + terser: 5.44.0 + webpack: 5.102.1(webpack-cli@5.1.4) + + terser@5.44.0: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.15.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + text-decoder@1.2.3: + dependencies: + b4a: 1.7.3 + transitivePeerDependencies: + - react-native-b4a + + text-encoding-utf-8@1.0.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tr46@0.0.3: {} + + tr46@2.1.0: + dependencies: + punycode: 2.3.1 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tslib@1.14.1: {} + + tslib@2.8.1: {} + + tsx@4.20.6: + dependencies: + esbuild: 0.25.11 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typed-query-selector@2.12.0: {} + + typedoc@0.28.14(typescript@5.9.3): + dependencies: + '@gerrit0/mini-shiki': 3.13.1 + lunr: 2.3.9 + markdown-it: 14.1.0 + minimatch: 9.0.5 + typescript: 5.9.3 + yaml: 2.8.1 + + typescript-eslint@8.59.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + typestyle@2.4.0: + dependencies: + csstype: 3.0.10 + free-style: 3.1.0 + + typical@4.0.0: {} + + typical@7.3.0: {} + + uc.micro@2.1.0: {} + + uglify-js@3.19.3: + optional: true + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + underscore@1.13.7: {} + + undici-types@6.21.0: {} + + undici-types@7.16.0: {} + + unicorn-magic@0.3.0: {} + + union@0.5.0: + dependencies: + qs: 6.14.0 + + universal-github-app-jwt@1.2.0: + dependencies: + '@types/jsonwebtoken': 9.0.10 + jsonwebtoken: 9.0.2 + + universal-user-agent@6.0.1: {} + + universalify@2.0.1: {} + + update-browserslist-db@1.1.4(browserslist@4.27.0): + dependencies: + browserslist: 4.27.0 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + url-join@4.0.1: {} + + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + + url@0.10.3: + dependencies: + punycode: 1.3.2 + querystring: 0.2.0 + optional: true + + util-deprecate@1.0.2: {} + + util.inherits@1.0.3: {} + + util@0.12.5: + dependencies: + inherits: 2.0.4 + is-arguments: 1.2.0 + is-generator-function: 1.1.2 + is-typed-array: 1.1.15 + which-typed-array: 1.1.19 + optional: true + + utila@0.4.0: {} + + uuid@8.0.0: + optional: true + + validate-npm-package-license@3.0.4: + dependencies: + spdx-correct: 3.2.0 + spdx-expression-parse: 3.0.1 + + validate.io-array@1.0.6: {} + + validate.io-function@1.0.2: {} + + validate.io-integer-array@1.0.0: + dependencies: + validate.io-array: 1.0.6 + validate.io-integer: 1.0.5 + + validate.io-integer@1.0.5: + dependencies: + validate.io-number: 1.0.3 + + validate.io-number@1.0.3: {} + + vite@6.4.1(@types/node@24.9.1)(jiti@1.21.7)(less@4.4.2)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1): + dependencies: + esbuild: 0.25.11 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.52.5 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.9.1 + fsevents: 2.3.3 + jiti: 1.21.7 + less: 4.4.2 + lightningcss: 1.32.0 + terser: 5.44.0 + tsx: 4.20.6 + yaml: 2.8.1 + + watchpack@2.4.4: + dependencies: + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + + web-worker@1.4.1: {} + + webdriver-bidi-protocol@0.3.8: {} + + webidl-conversions@3.0.1: {} + + webidl-conversions@6.1.0: {} + + webpack-cli@5.1.4(webpack@5.102.1): + dependencies: + '@discoveryjs/json-ext': 0.5.7 + '@webpack-cli/configtest': 2.1.1(webpack-cli@5.1.4(webpack@5.102.1))(webpack@5.102.1) + '@webpack-cli/info': 2.0.2(webpack-cli@5.1.4(webpack@5.102.1))(webpack@5.102.1) + '@webpack-cli/serve': 2.0.5(webpack-cli@5.1.4(webpack@5.102.1))(webpack@5.102.1) + colorette: 2.0.20 + commander: 10.0.1 + cross-spawn: 7.0.6 + envinfo: 7.19.0 + fastest-levenshtein: 1.0.16 + import-local: 3.2.0 + interpret: 3.1.1 + rechoir: 0.8.0 + webpack: 5.102.1(webpack-cli@5.1.4) + webpack-merge: 5.10.0 + + webpack-glsl-minify@1.5.0: + dependencies: + glob: 7.2.3 + yargs: 17.7.2 + + webpack-merge@5.10.0: + dependencies: + clone-deep: 4.0.1 + flat: 5.0.2 + wildcard: 2.0.1 + + webpack-sources@1.4.3: + dependencies: + source-list-map: 2.0.1 + source-map: 0.6.1 + + webpack-sources@3.3.3: {} + + webpack@5.102.1(webpack-cli@5.1.4): + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.15.0 + acorn-import-phases: 1.0.4(acorn@8.15.0) + browserslist: 4.27.0 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.18.3 + es-module-lexer: 1.7.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.1 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.0 + terser-webpack-plugin: 5.3.14(webpack@5.102.1) + watchpack: 2.4.4 + webpack-sources: 3.3.3 + optionalDependencies: + webpack-cli: 5.1.4(webpack@5.102.1) + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + + whatwg-encoding@2.0.0: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@2.3.0: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + whatwg-url@8.7.0: + dependencies: + lodash: 4.17.21 + tr46: 2.1.0 + webidl-conversions: 6.1.0 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.19 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.19: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wildcard@2.0.1: {} + + word-wrap@1.2.5: {} + + wordwrap@1.0.0: {} + + wordwrapjs@5.1.1: {} + + worker-loader@3.0.8(webpack@5.102.1): + dependencies: + loader-utils: 2.0.4 + schema-utils: 3.3.0 + webpack: 5.102.1(webpack-cli@5.1.4) + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + ws@6.2.3: + dependencies: + async-limiter: 1.0.1 + + ws@8.18.3: {} + + xml2js@0.6.2: + dependencies: + sax: 1.4.1 + xmlbuilder: 11.0.1 + optional: true + + xmlbuilder@11.0.1: + optional: true + + y-protocols@1.0.6(yjs@13.6.27): + dependencies: + lib0: 0.2.114 + yjs: 13.6.27 + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yallist@4.0.0: {} + + yallist@5.0.0: {} + + yaml@2.8.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + yjs@13.6.27: + dependencies: + lib0: 0.2.114 + + yocto-queue@0.1.0: {} + + yoctocolors-cjs@2.1.3: {} + + zod@3.25.76: {} + + zx@8.8.5: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..8cef746 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,98 @@ +packages: + - "tools/test" + - "tools/scripts" + - "tools/bench" + - "packages/client" + - "tools/esbuild-plugin" + - "packages/viewer-datagrid" + - "packages/viewer-charts" + - "packages/workspace" + - "packages/jupyterlab" + - "packages/react" + - "packages/cli" + - "rust/metadata" + - "rust/perspective" + - "rust/perspective-js" + - "rust/perspective-viewer" + - "rust/perspective-python" + - "rust/perspective-server" + - "examples/*" + - "docs" + +catalog: + # Dependencies + "@jupyter-widgets/base": ">2 <5" + "@jupyterlab/application": ">2 <5" + "@lumino/application": "<3" + "@lumino/widgets": "<3" + "chroma-js": ">=3 <4" + "d3-array": ">=3" + "d3-color": ">=3.1" + "d3-selection": ">=3" + "d3-svg-legend": ">=2" + "d3": "^7.9.0" + "ol": "^5.3.2" + "pro_self_extracting_wasm": "0.0.9" + "react-dom": ">17 <20" + "react": ">17 <20" + "regular-table": "=0.8.4" + "stoppable": "=1.1.0" + "ws": "^8.17.0" + + # Dev Dependencies + "@clickhouse/client-web": "^1.12.0" + "@duckdb/duckdb-wasm": "1.33.1-dev18.0" + "@fontsource/roboto-mono": "4.5.10" + "@iarna/toml": "3.0.0" + "@jupyterlab/builder": "^4" + "@playwright/experimental-ct-react": "=1.58.0" + "@playwright/test": "=1.58.0" + "lightningcss": "^1.29.0" + "@types/lodash": "^4.17.20" + "@types/node": ">=22" + "@types/react-dom": ">17 <20" + "@types/react": ">17 <20" + "@types/stoppable": ">=1" + "@types/ws": ">=8" + "@types/chroma-js": "^3.1.2" + "@zip.js/zip.js": "^2.7.54" + "apache-arrow": "17.0.0" + "arraybuffer-loader": ">=1.0.8 <2" + "auto-changelog": "^2.5.0" + "chalk": ">=5" + "commander": ">=14 <15" + "copy-webpack-plugin": "~12" + "css-loader": ">=7 <8" + "dotenv": ">=17" + "esbuild": "^0.25.5" + "eslint": "^9.0.0" + "express-ws": ">=5 <6" + "express": ">=5 <6" + "fs-extra": ">=11 <12" + "glob-gitignore": "^1.0.15" + "gradient-parser": ">=1 <2" + "html-webpack-plugin": ">=5 <6" + "http-server": "^14.1.1" + "husky": ">=9" + "inquirer": ">=12 <13" + "less": "^4.1.0" + "lodash": "^4.17.20" + "microtime": ">=3 <4" + "mkdirp": "^0.5.6" + "moment": "^2.30.1" + "npm-run-all": "^4.1.5" + "octokit": "^1.8.1" + "prettier": ">=3 <4" + "puppeteer": ">=24" + "style-loader": ">=4 < 5" + "superstore-arrow": "3.2.0" + "tar": "^7.4.3" + "tsx": "^4.20.3" + "typedoc": "^0.28.7" + "typescript": ">=5 <6" + "typescript-eslint": "^8.0.0" + "vite": ">=6 <7" + "webpack-cli": ">=5 <6" + "webpack": ">=5 <6" + "web-worker": "1.4.1" + "zx": ">=8 <9" diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..4e8277a --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,16 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +[toolchain] +channel = "nightly-2026-06-01" +components = ["rustfmt", "clippy", "rust-src"] +targets = ["wasm32-unknown-unknown", "wasm32-unknown-emscripten"] diff --git a/rust/bundle/Cargo.toml b/rust/bundle/Cargo.toml new file mode 100644 index 0000000..e066d7e --- /dev/null +++ b/rust/bundle/Cargo.toml @@ -0,0 +1,29 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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-bundle" +description = "A CLI utility to build and link persecptive-viewer" +edition = "2024" +publish = false + +[[bin]] +name = "bundle" +path = "main.rs" +bench = false + +[dependencies] +clap = { version = "4.4.8", features = ["derive"] } +wasm-bindgen-cli-support = "*" + +# https://github.com/brson/wasm-opt-rs/issues/154 +wasm-opt = { version = "0.116.1", default-features = false } diff --git a/rust/bundle/build.rs b/rust/bundle/build.rs new file mode 100644 index 0000000..1ea3ba7 --- /dev/null +++ b/rust/bundle/build.rs @@ -0,0 +1,18 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +fn main() { + println!( + "cargo:rustc-env=TARGET={}", + std::env::var("TARGET").unwrap() + ); +} diff --git a/rust/bundle/main.rs b/rust/bundle/main.rs new file mode 100644 index 0000000..4ddde81 --- /dev/null +++ b/rust/bundle/main.rs @@ -0,0 +1,139 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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::path::Path; +use std::process::{Command, exit}; + +use clap::*; + +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +struct BundleArgs { + /// Artifact name + artifact: String, + + /// Compile in release mode? + #[arg(short, long)] + release: bool, + + /// Extra features to build with + #[arg(long)] + features: Option, +} + +use wasm_bindgen_cli_support::{Bindgen, EncodeInto}; +use wasm_opt::{Feature, OptimizationOptions}; + +/// Run the packages `build` task with the appropriate flags. These can't be +/// defined in the `/.cargo/config.toml` because they would define this build +/// script's parameters also, and there is no way to reset e.g. the `target` +/// 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.env("RUSTFLAGS", rustflags) + .args(["build"]) + .args(["--lib"]) + .args(["--features", &features]) + .args(["--target", "wasm32-unknown-unknown"]) + .args(["-Z", "build-std=std,panic_abort"]); + + if is_release { + cmd.args(["--release"]); + } + + if let Some(pkg) = pkg { + cmd.args(["-p", pkg]); + } + + cmd.execute() +} + +/// Generate the `wasm-bindgen` JavaScript and WASM bindings. +fn bindgen(outdir: &Path, artifact: &str, is_release: bool) { + let input = Path::new("../target/wasm32-unknown-unknown") + .join(if is_release { "release" } else { "debug" }) + .join(format!("{artifact}.wasm")); + + Bindgen::new() + .web(true) + .unwrap() + .keep_debug(!is_release) + .input_path(input) + .encode_into(EncodeInto::Always) + .typescript(true) + // .reference_types(true) + .out_name(&format!("{}.wasm", artifact.replace('_', "-"))) + .generate(outdir) + .unwrap(); +} + +/// Run `wasm-opt` and output the new binary on top of the old one. +fn opt(outpath: &Path, is_release: bool) { + if is_release { + OptimizationOptions::new_optimize_for_size_aggressively() + .enable_feature(Feature::BulkMemory) + .enable_feature(Feature::ReferenceTypes) + .enable_feature(Feature::Simd) + .enable_feature(Feature::RelaxedSimd) + .enable_feature(Feature::TruncSat) + .run(outpath, outpath) + .unwrap(); + } +} + +fn main() { + let args = BundleArgs::parse(); + let outdir = Path::new("dist/wasm"); + let is_release = args.release; + let package = args.artifact.clone().replace('_', "-"); + let outpath = &Path::new(outdir).join(format!( + "{}.wasm", + args.artifact.replace("-js", "").replace('_', "-") + )); + + let features = args + .features + .unwrap_or_default() + .split(',') + .map(|x| x.to_string()) + .collect(); + + build(Some(package.as_str()), is_release, features); + bindgen(outdir, args.artifact.as_str(), is_release); + opt(outpath, is_release); +} + +trait SimpleCommand { + fn execute(&mut self); +} + +impl SimpleCommand for Command { + fn execute(&mut self) { + match self.status().ok().and_then(|x| x.code()) { + Some(0) => (), + Some(x) => exit(x), + None => exit(1), + } + } +} diff --git a/rust/lint/Cargo.toml b/rust/lint/Cargo.toml new file mode 100644 index 0000000..fda4176 --- /dev/null +++ b/rust/lint/Cargo.toml @@ -0,0 +1,30 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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-lint" +description = "A CLI utility to lint rust code" +version = "4.5.2" +edition = "2024" +publish = false + +[[bin]] +name = "lint" +path = "main.rs" +bench = false + +[features] +default = [] + +[dependencies] +yew-fmt = { version = "0.6.3", artifact = "bin", optional = true } +glob = "0.3.1" diff --git a/rust/lint/main.rs b/rust/lint/main.rs new file mode 100644 index 0000000..3e17aa8 --- /dev/null +++ b/rust/lint/main.rs @@ -0,0 +1,52 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +fn check_edition(val: String, edition: &mut bool) -> String { + if val == "--edition" { + *edition = false; + } + + val +} + +pub fn main() { + let mut needs_edition = true; + let args = std::env::args() + .skip(1) + .map(|x| check_edition(x, &mut needs_edition)) + .collect::>(); + + let edition_args = if needs_edition { + vec!["--edition", "2024"] + } else { + vec![] + }; + + let yewfmt_args = edition_args + .into_iter() + .map(|x| x.into()) + .chain(args) + .collect::>(); + + if let Some(cmd) = option_env!("CARGO_BIN_FILE_YEW_FMT") { + let exit_code = std::process::Command::new(cmd) + .args(yewfmt_args) + .spawn() + .expect("Could not spawn process") + .wait() + .expect("Process did not start"); + + std::process::exit(exit_code.code().unwrap()) + } else { + std::process::exit(1) + } +} diff --git a/rust/metadata/Cargo.toml b/rust/metadata/Cargo.toml new file mode 100644 index 0000000..d61f56a --- /dev/null +++ b/rust/metadata/Cargo.toml @@ -0,0 +1,45 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 = "metadata" +description = "A CLI utility for generating Perspective project metadata like TypeScript definitions." +edition = "2024" +publish = false + +[[bin]] +name = "perspective_metadata" +path = "main.rs" +bench = false + +[dependencies.cargo-bundle-licenses] +version = "4.2.0" +artifact = ["bin"] + +[dependencies.ts-rs] +version = "11.0.1" +features = ["serde-json-impl", "no-serde-warnings"] + +[dependencies.perspective-server] +path = "../perspective-server" +features = ["external-cpp", "disable-cpp"] + +[dependencies.perspective-client] +features = ["generate-proto", "omit_metadata", "protobuf-src"] +path = "../perspective-client" + +[dependencies.perspective-js] +path = "../perspective-js" +features = ["external-cpp"] + +[dependencies.perspective-viewer] +path = "../perspective-viewer" diff --git a/rust/metadata/build.mjs b/rust/metadata/build.mjs new file mode 100644 index 0000000..ae9d36e --- /dev/null +++ b/rust/metadata/build.mjs @@ -0,0 +1,27 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { execSync } from "child_process"; + +const INHERIT = { + stdio: "inherit", + stderr: "inherit", +}; + +function get_host() { + return /host\: (.+?)$/gm.exec(execSync(`rustc -vV`).toString())[1]; +} + +execSync( + `PSP_ROOT_DIR=../.. TS_RS_IMPORT_EXTENSION=js cargo run --target=${get_host()}`, + INHERIT, +); diff --git a/rust/metadata/main.rs b/rust/metadata/main.rs new file mode 100644 index 0000000..c3b9f20 --- /dev/null +++ b/rust/metadata/main.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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#![recursion_limit = "1024"] + +//! This module generates metadata for other crates: +//! +//! - `perspective-client` +//! - Add `protobuf-src` dependency +//! - Generate `proto.rs` protobuf client bindings. +//! - `perspective-js` +//! - TypeScript types +//! - Recurisvely set external proto on `perspective-client` +//! - `perspective-server` +//! - Copy `cpp` and `cmake` to local root +//! +//! The `metadata` binary must be run for these assets to be updated in +//! your local dev tree! + +use std::error::Error; +use std::fmt::Write; +use std::fs; + +use perspective_client::config::*; +use perspective_client::{ + ColumnWindow, DeleteOptions, JoinOptions, OnUpdateData, OnUpdateOptions, SystemInfo, + TableInitOptions, UpdateOptions, ViewWindow, +}; +use perspective_js::TypedArrayWindow; +use perspective_viewer::config::{PluginStaticConfig, ViewerConfig, ViewerConfigUpdate}; +use ts_rs::TS; + +pub fn generate_type_bindings_viewer() -> Result<(), Box> { + let path = std::env::current_dir()?.join("../perspective-viewer/src/ts/ts-rs"); + ViewerConfigUpdate::export_all_to(&path)?; + ViewerConfig::::export_all_to(&path)?; + PluginStaticConfig::export_all_to(&path)?; + OnUpdateData::export_all_to(&path)?; + Ok(()) +} + +fn generate_exprtk_docs() -> Result<(), Box> { + let mut txt = "
    \n\n# Perspective ExprTK Extensions\n\n".to_string(); + for rec in perspective_client::config::COMPLETIONS { + writeln!( + txt, + "- `{}` {}", + rec.insert_text, + rec.documentation.replace("\n", " "), + )?; + } + + fs::create_dir_all("../perspective-client/docs/")?; + fs::write("../perspective-client/docs/expression_gen.md", txt)?; + Ok(()) +} + +#[doc(hidden)] +pub fn generate_type_bindings_js() -> Result<(), Box> { + let path = std::env::current_dir()?.join("../perspective-js/src/ts/ts-rs"); + ColumnType::export_all_to(&path)?; + ColumnWindow::export_all_to(&path)?; + DeleteOptions::export_all_to(&path)?; + JoinOptions::export_all_to(&path)?; + OnUpdateData::export_all_to(&path)?; + OnUpdateOptions::export_all_to(&path)?; + SystemInfo::::export_all_to(&path)?; + TableInitOptions::export_all_to(&path)?; + TypedArrayWindow::export_all_to(&path)?; + UpdateOptions::export_all_to(&path)?; + ViewConfig::export_all_to(&path)?; + ViewConfigUpdate::export_all_to(&path)?; + ViewWindow::export_all_to(&path)?; + ViewWindow::export_all_to(&path)?; + Ok(()) +} + +#[doc(hidden)] +pub fn generate_python_cargo_licenses() -> Result<(), Box> { + use std::fs::File; + use std::process::{Command, Stdio}; + let python_dir = std::env::current_dir()?.join("../perspective-python"); + let bundler = env!("CARGO_BIN_FILE_CARGO_BUNDLE_LICENSES_cargo-bundle-licenses"); + let license_file = File::create(python_dir.join("LICENSE_THIRDPARTY_cargo.yml"))?; + Command::new(bundler) + .arg("--format=yaml") + .current_dir(python_dir) + .stdout(Stdio::from(license_file)) + .spawn()? + .wait()?; + Ok(()) +} + +fn main() -> Result<(), Box> { + generate_type_bindings_js()?; + generate_type_bindings_viewer()?; + generate_exprtk_docs()?; + generate_python_cargo_licenses()?; + Ok(()) +} diff --git a/rust/metadata/package.json b/rust/metadata/package.json new file mode 100644 index 0000000..017d348 --- /dev/null +++ b/rust/metadata/package.json @@ -0,0 +1,18 @@ +{ + "name": "@perspective-dev/metadata", + "version": "4.5.2", + "description": "Benchmark utility based on perspective", + "private": true, + "files": [ + "src/**/*" + ], + "repository": { + "type": "git", + "url": "https://github.com/perspective-dev/perspective/packages/metadata" + }, + "scripts": { + "build": "node build.mjs" + }, + "author": "", + "license": "Apache-2.0" +} diff --git a/rust/perspective-client/Cargo.toml b/rust/perspective-client/Cargo.toml new file mode 100644 index 0000000..f6f7b81 --- /dev/null +++ b/rust/perspective-client/Cargo.toml @@ -0,0 +1,97 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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-client" +version = "4.5.2" +authors = ["Andrew Stein "] +edition = "2024" +description = "A data visualization and analytics component, especially well-suited for large and/or streaming datasets." +repository = "https://github.com/perspective-dev/perspective" +license = "Apache-2.0" +homepage = "https://perspective-dev.github.io" +keywords = [] +links = "perspective_client" +include = [ + "src/**/*", + "Cargo.toml", + "package.json", + "build.rs", + "docs/**/*", + "perspective.proto", +] + +[features] +default = [] + +# Removes the `Send` restriction from core `Future` traits, allowing easier +# integration with wasm while respecting compat with rust-analyzer and metadata. +sendable = [] + +# Should the project build the `proto.rs` via protoc from source or assume it +# already exists? +generate-proto = [] + +# Should the project build protobuf from source? If not, building this crate +# requires PROTOC be set in the environment +protobuf-src = ["dep:protobuf-src"] + +# When generating metadata, we can't rely on its existence - so enable this +# to skip metadata generation. This currently only affects docs. +omit_metadata = [] + +# Should `talc` be used as a global allocator? Allows enhanced runtime metrics +# via `Client::system_info`. +talc-allocator = ["talc"] + +[lib] +crate-type = ["rlib"] +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.1.1", optional = true } + +[dependencies] +arrow-array = { version = "57.3.0", default-features = false } +arrow-ipc = { version = "57.3.0", default-features = false } +arrow-schema = { version = "57.3.0", default-features = false } +arrow-select = { version = "57.3.0", default-features = false } +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" } + +# `rand` dependency that needs features unified. +getrandom = { version = "0", features = ["wasm_js"] } +num-traits = "0.2.19" +rand = { version = ">=0.9,<1" } +serde = { version = "1.0", features = ["derive"] } +serde_bytes = { version = "0.11" } +serde_json = { version = "1.0.107", features = ["raw_value", "preserve_order"] } +talc = { version = "4.4.3", features = ["counters"], optional = true } +thiserror = { version = "1.0.55" } +tracing = { version = "0.1.36" } + +[dependencies.prost] +version = "0.12.3" +default-features = false +features = ["prost-derive", "std"] + +[dependencies.ts-rs] +version = "11.0.1" +features = ["serde-json-impl", "no-serde-warnings", "import-esm"] diff --git a/rust/perspective-client/build.rs b/rust/perspective-client/build.rs new file mode 100644 index 0000000..3fa3f6b --- /dev/null +++ b/rust/perspective-client/build.rs @@ -0,0 +1,92 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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::io::Result; +use std::path::Path; + +fn prost_build() -> Result<()> { + println!( + "cargo::metadata=PROTO_PATH={}", + std::env::var("CARGO_MANIFEST_DIR").unwrap() + ); + + // This source file is included at `publish` time, but not `build` time + // because it is initially generated from the `perspective.proto` definition + // in the source. + if std::env::var("CARGO_FEATURE_GENERATE_PROTO").is_ok() { + println!("cargo:warning=MESSAGE Building in development mode"); + // let root_dir_env = std::env::var("PSP_ROOT_DIR").expect("Must set + // PSP_ROOT_DIR"); let root_dir = Path::new(root_dir_env.as_str()); + // let proto_file = Path::join(root_dir, "cpp/protos/perspective.proto"); + let proto_file = Path::new("./perspective.proto"); + let include_path = proto_file + .parent() + .expect("Couldn't determine parent directory of proto_file") + .to_path_buf(); + + println!("cargo:rerun-if-changed={}", proto_file.to_str().unwrap()); + + // prost_build reads PROTOC from the environment. When the `protobuf-src` + // feature is enabled, the build script sets PROTOC to the one built by + // that crate. When protobuf-src is disabled, builders must set PROTOC + // in the environment to a protocol buffer compiler. + #[cfg(feature = "protobuf-src")] + unsafe { + std::env::set_var("PROTOC", protobuf_src::protoc()) + }; + #[cfg(not(feature = "protobuf-src"))] + if std::env::var("PROTOC").is_err() { + panic!( + "generate-proto is enabled and protobuf-src is disabled. PROTOC must be set in \ + the environment to the path of a protocol buffer compiler" + ) + } + + prost_build::Config::new() + // .bytes(["ViewToArrowResp.arrow", "from_arrow"]) + .type_attribute("ViewOnUpdateResp", "#[derive(ts_rs::TS)]") + .field_attribute("ViewOnUpdateResp.delta", "#[ts(as = \"Vec::\")]") + .field_attribute("ViewOnUpdateResp.delta", "#[serde(with = \"serde_bytes\")]") + .type_attribute("ColumnType", "#[derive(ts_rs::TS)]") + .type_attribute( + "JoinType", + "#[derive(serde::Deserialize, ts_rs::TS)] #[serde(rename_all = \"snake_case\")]", + ) + .field_attribute("ViewToArrowResp.arrow", "#[serde(skip)]") + .field_attribute("from_arrow", "#[serde(skip)]") + .type_attribute(".", "#[derive(serde::Serialize)]") + .type_attribute("ViewPort", "#[derive(serde::Deserialize)]") + .type_attribute("ViewDimensionsResp", "#[derive(serde::Deserialize)]") + .type_attribute("TableValidateExprResp", "#[derive(serde::Deserialize)]") + .type_attribute( + "ColumnType", + "#[derive(serde::Deserialize)] #[serde(rename_all = \"snake_case\")]", + ) + .type_attribute("ExprValidationError", "#[derive(serde::Deserialize)]") + .compile_protos(&[proto_file], &[include_path]) + .unwrap(); + + std::fs::copy( + std::env::var("OUT_DIR").unwrap() + "/perspective.proto.rs", + "src/rust/proto.rs", + )?; + + std::fs::remove_file(std::env::var("OUT_DIR").unwrap() + "/perspective.proto.rs")?; + } + + Ok(()) +} + +fn main() -> Result<()> { + prost_build()?; + Ok(()) +} diff --git a/rust/perspective-client/package.json b/rust/perspective-client/package.json new file mode 100644 index 0000000..7584d65 --- /dev/null +++ b/rust/perspective-client/package.json @@ -0,0 +1,15 @@ +{ + "name": "@perspective-dev/client-rust", + "version": "4.5.2", + "description": "", + "private": true, + "repository": { + "type": "git", + "url": "https://github.com/perspective-dev/perspective" + }, + "type": "module", + "license": "Apache-2.0", + "scripts": { + "clean": "rm src/rust/proto.rs" + } +} diff --git a/rust/perspective-client/perspective.proto b/rust/perspective-client/perspective.proto new file mode 100644 index 0000000..515bc09 --- /dev/null +++ b/rust/perspective-client/perspective.proto @@ -0,0 +1,584 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +syntax = "proto3"; + +import "google/protobuf/struct.proto"; + +package perspective.proto; + +option optimize_for = LITE_RUNTIME; + +//////////////////////////////////////////////////////////////////////////////// +// +// Common + +enum StatusCode { + SERVER_ERROR = 0; + VIEW_NOT_FOUND = 1; + TRANSPORT_ERROR = 2; +} + +// Recoverable, user-readable error reporting from the engine. +message ServerError { + string message = 1; + StatusCode status_code = 2; +} + +message Schema { + repeated KeyTypePair schema = 1; + message KeyTypePair { + string name = 1; + ColumnType type = 2; + } +} + +// The data type constructors Perspective supports. +message MakeTableData { + oneof data { + Schema from_schema = 1; + string from_csv = 2; + bytes from_arrow = 3; + string from_rows = 4; + string from_cols = 5; + string from_view = 6; + string from_ndjson = 7; + }; +} + +// Filter type scalars - this is _not_ the same as a Columns scalar, as this +// value is used in the view config and must be JSON safe! +message Scalar { + oneof scalar { + bool bool = 1; + // int64 date = 2; // TODO these are the wrong type + // int64 datetime = 3; + double float = 4; + // int32 int = 5; + string string = 6; + google.protobuf.NullValue null = 7; + } +} + +// View types +enum ColumnType { + STRING = 0; + DATE = 1; + DATETIME = 2; + INTEGER = 3; + FLOAT = 4; + BOOLEAN = 5; +} + +// Options for requresting a slice of data, starting with the rectangular +// viewport. +message ViewPort { + optional uint32 start_row = 1; + optional uint32 start_col = 2; + optional uint32 end_row = 3; + optional uint32 end_col = 4; + optional bool emit_legacy_row_path_names = 5; +// optional bool id = 5; +// optional bool index = 3; +// optional bool formatted = 6; +// optional bool leaves_only = 7; +// optional bool compression = 3; +} + +// TODO This belongs in features +enum SortOp { + SORT_NONE = 0; + SORT_ASC = 1; + SORT_DESC = 2; + SORT_COL_ASC = 3; + SORT_COL_DESC = 4; + SORT_ASC_ABS = 5; + SORT_DESC_ABS = 6; + SORT_COL_ASC_ABS = 7; + SORT_COL_DESC_ABS = 8; +} + + + +//////////////////////////////////////////////////////////////////////////////// +// +// RPC + +message Request { + uint32 msg_id = 1; + string entity_id = 2; + oneof client_req { + // Minimum Virtual API (theoretical). + GetFeaturesReq get_features_req = 3; + GetHostedTablesReq get_hosted_tables_req = 4; + RemoveHostedTablesUpdateReq remove_hosted_tables_update_req = 37; + TableMakePortReq table_make_port_req = 5; + TableMakeViewReq table_make_view_req = 6; + TableSchemaReq table_schema_req = 7; + TableSizeReq table_size_req = 8; + TableValidateExprReq table_validate_expr_req = 9; + ViewColumnPathsReq view_column_paths_req = 10; + ViewDeleteReq view_delete_req = 11; + ViewDimensionsReq view_dimensions_req = 12; + ViewExpressionSchemaReq view_expression_schema_req = 13; + ViewGetConfigReq view_get_config_req = 14; + ViewSchemaReq view_schema_req = 15; + ViewToArrowReq view_to_arrow_req = 16; + + // Optional (we can enable real-time/autocomplete/etc with these, but + // not required). + ServerSystemInfoReq server_system_info_req = 17; + ViewCollapseReq view_collapse_req = 18; + ViewExpandReq view_expand_req = 19; + ViewGetMinMaxReq view_get_min_max_req = 20; + ViewOnUpdateReq view_on_update_req = 21; + ViewRemoveOnUpdateReq view_remove_on_update_req = 22; + ViewSetDepthReq view_set_depth_req = 23; + ViewToColumnsStringReq view_to_columns_string_req = 24; + ViewToCSVReq view_to_csv_req = 25; + ViewToRowsStringReq view_to_rows_string_req = 26; + ViewToNdjsonStringReq view_to_ndjson_string_req = 36; + + // External (we don't need these for viewer, but the developer may). + MakeTableReq make_table_req = 27; + TableDeleteReq table_delete_req = 28; + TableOnDeleteReq table_on_delete_req = 29; + TableRemoveDeleteReq table_remove_delete_req = 30; + TableRemoveReq table_remove_req = 31; + TableReplaceReq table_replace_req = 32; + TableUpdateReq table_update_req = 33; + ViewOnDeleteReq view_on_delete_req = 34; + ViewRemoveDeleteReq view_remove_delete_req = 35; + MakeJoinTableReq make_join_table_req = 38; + } +} + +message Response { + uint32 msg_id = 1; + string entity_id = 2; + oneof client_resp { + GetFeaturesResp get_features_resp = 3; + GetHostedTablesResp get_hosted_tables_resp = 4; + RemoveHostedTablesUpdateResp remove_hosted_tables_update_resp = 37; + TableMakePortResp table_make_port_resp = 5; + TableMakeViewResp table_make_view_resp = 6; + TableSchemaResp table_schema_resp = 7; + TableSizeResp table_size_resp = 8; + TableValidateExprResp table_validate_expr_resp = 9; + ViewColumnPathsResp view_column_paths_resp = 10; + ViewDeleteResp view_delete_resp = 11; + ViewDimensionsResp view_dimensions_resp = 12; + ViewExpressionSchemaResp view_expression_schema_resp = 13; + ViewGetConfigResp view_get_config_resp = 14; + ViewSchemaResp view_schema_resp = 15; + ViewToArrowResp view_to_arrow_resp = 16; + ServerSystemInfoResp server_system_info_resp = 17; + ViewCollapseResp view_collapse_resp = 18; + ViewExpandResp view_expand_resp = 19; + ViewGetMinMaxResp view_get_min_max_resp = 20; + ViewOnUpdateResp view_on_update_resp = 21; + ViewRemoveOnUpdateResp view_remove_on_update_resp = 22; + ViewSetDepthResp view_set_depth_resp = 23; + ViewToColumnsStringResp view_to_columns_string_resp = 24; + ViewToCSVResp view_to_csv_resp = 25; + ViewToRowsStringResp view_to_rows_string_resp = 26; + ViewToNdjsonStringResp view_to_ndjson_string_resp = 36; + MakeTableResp make_table_resp = 27; + TableDeleteResp table_delete_resp = 28; + TableOnDeleteResp table_on_delete_resp = 29; + TableRemoveDeleteResp table_remove_delete_resp = 30; + TableRemoveResp table_remove_resp = 31; + TableReplaceResp table_replace_resp = 32; + TableUpdateResp table_update_resp = 33; + ViewOnDeleteResp view_on_delete_resp = 34; + ViewRemoveDeleteResp view_remove_delete_resp = 35; + MakeJoinTableResp make_join_table_resp = 38; + ServerError server_error = 50; + } +} + +//////////////////////////////////////////////////////////////////////////////// +// +// Virtual API + + +enum GroupRollupMode { + ROLLUP = 0; + FLAT = 1; + TOTAL = 2; +} + +// Informs the client of the feature set, e.g. what to expect in the +// `ViewConfig` message. +message GetFeaturesReq {} +message GetFeaturesResp { + bool group_by = 1; + bool split_by = 2; + bool expressions = 3; + bool on_update = 4; + bool sort = 5; + map filter_ops = 6; + map aggregates = 7; + repeated GroupRollupMode group_rollup_mode = 8; + + message ColumnTypeOptions { + repeated string options = 1; + } + + message AggregateOptions { + repeated AggregateArgs aggregates = 1; + } + + message AggregateArgs { + string name = 1; + repeated ColumnType args = 2; + } +} + +// `Client::get_hosted_tables` +message GetHostedTablesReq { + bool subscribe = 1; +} + +message GetHostedTablesResp { + repeated HostedTable table_infos = 1; +} + +message HostedTable { + string entity_id = 1; + optional string index = 2; + optional uint32 limit = 3; +} + +message RemoveHostedTablesUpdateReq { + uint32 id = 1; +} +message RemoveHostedTablesUpdateResp {} + +// `Table::size` +message TableSizeReq {} +message TableSizeResp { + uint32 size = 2; +} + +// `Table::schema` +message TableSchemaReq {} +message TableSchemaResp { + Schema schema = 1; +} + +// `Table::validate_expressions` +// TODO: This should be just `validate()` +message TableValidateExprReq { + map column_to_expr = 1; +} +message TableValidateExprResp { + map expression_schema = 1; + map errors = 2; + map expression_alias = 3; + message ExprValidationError { + string error_message = 1; + uint32 line = 2; + uint32 column = 3; + } +} + +// `Table::view` +message TableMakeViewReq { + string view_id = 1; + ViewConfig config = 2; +} +message TableMakeViewResp { + string view_id = 1; +} + +// `View::schema` +message ViewSchemaReq {} +message ViewSchemaResp { + map schema = 1; +} + +// `View::dimensions` +message ViewDimensionsReq {} +message ViewDimensionsResp { + uint32 num_table_rows = 1; + uint32 num_table_columns = 2; + uint32 num_view_rows = 3; + uint32 num_view_columns = 4; +} + +// `View::get_config` +message ViewGetConfigReq {} +message ViewGetConfigResp { + ViewConfig config = 1; +} + + +//////////////////////////////////////////////////////////////////////////////// + +// `Client::table`. +message MakeTableReq { + MakeTableData data = 1; + optional MakeTableOptions options = 2; + message MakeTableOptions { + oneof make_table_type { + string make_index_table = 1; + uint32 make_limit_table = 2; + }; + + // Back this Table's canonical data with the on-disk storage backend + // (memory-mapped file on native; OPFS on WASM) instead of memory. + // Orthogonal to `make_table_type`, so it is a standalone field. + optional bool page_to_disk = 3; + } +} +message MakeTableResp {} + +enum JoinType { + INNER = 0; + LEFT = 1; + OUTER = 2; +} + +// `Client::join` — create a read-only table from a JOIN of two tables. +message MakeJoinTableReq { + string left_table_id = 1; + string right_table_id = 2; + string on_column = 3; + JoinType join_type = 4; + string right_on_column = 5; +} +message MakeJoinTableResp {} + +// `Table::delete` +message TableDeleteReq { + bool is_immediate = 1; +} +message TableDeleteResp {} + +// `Table::on_delete` +message TableOnDeleteReq {} +message TableOnDeleteResp {} + +// `Table::make_port` +message TableMakePortReq {} +message TableMakePortResp { + uint32 port_id = 1; +} + +// `Table::remove_delete` +message TableRemoveDeleteReq { + uint32 id = 1; +} +message TableRemoveDeleteResp {} + +// `Table::update` +message TableUpdateReq { + MakeTableData data = 1; + uint32 port_id = 2; +} +message TableUpdateResp {} + +// `Table::replace` +message TableReplaceReq { + MakeTableData data = 1; +} +message TableReplaceResp {} + +// `Table::remove` +message TableRemoveReq { + MakeTableData data = 1; +} +message TableRemoveResp {} + +message ViewOnUpdateReq { + enum Mode { + ROW = 0; + } + optional Mode mode = 1; +} +message ViewOnUpdateResp { + optional bytes delta = 1; + uint32 port_id = 2; +} + +message ViewOnDeleteReq {} +message ViewOnDeleteResp {} + +message ViewRemoveDeleteReq { + uint32 id = 1; +} +message ViewRemoveDeleteResp {} + +message ViewToColumnsStringReq { + ViewPort viewport = 1; + optional bool id = 2; + optional bool index = 3; + optional bool formatted = 4; +} + +message ViewToColumnsStringResp { + string json_string = 1; +} + +message ViewToRowsStringReq { + ViewPort viewport = 1; + optional bool id = 2; + optional bool index = 3; + optional bool formatted = 4; +} + +message ViewToRowsStringResp { + string json_string = 1; +} + +message ViewToNdjsonStringReq { + ViewPort viewport = 1; + optional bool id = 2; + optional bool index = 3; + optional bool formatted = 4; +} + +message ViewToNdjsonStringResp { + string ndjson_string = 1; +} + +message ViewToArrowReq { + ViewPort viewport = 1; + optional string compression = 2; +} + +message ViewToArrowResp { + bytes arrow = 1; +} + +message ViewColumnPathsReq { + optional uint32 start_col = 1; + optional uint32 end_col = 2; +} + +// // TODO This is a better paths representations but its not compatible with +// // the legacy API. Let's do this when we can fix the API. + +// message ColumnPath { +// repeated string path = 1; +// } + +message ViewColumnPathsResp { + repeated string paths = 1; + // repeated ColumnPath paths = 1; +} + +message ViewDeleteReq {} +message ViewDeleteResp {} + +message ViewGetMinMaxReq { + string column_name = 1; +} + +message ViewGetMinMaxResp { + Scalar min = 1; + Scalar max = 2; +} + +message ViewExpressionSchemaReq {} +message ViewExpressionSchemaResp { + map schema = 1; +} + +message ViewToCSVReq { + ViewPort viewport = 1; +} + +message ViewToCSVResp { + string csv = 1; +} + +message ViewRemoveOnUpdateReq { + uint32 id = 1; +} +message ViewRemoveOnUpdateResp {} + +message ViewCollapseReq { + uint32 row_index = 1; +} + +message ViewCollapseResp { + uint32 num_changed = 1; +} + +message ViewExpandReq { + uint32 row_index = 1; +} + +message ViewExpandResp { + uint32 num_changed = 1; +} + +// `View::set_depth` +message ViewSetDepthReq { + uint32 depth = 1; +} +message ViewSetDepthResp {} + +message ServerSystemInfoReq {} +message ServerSystemInfoResp { + uint64 heap_size = 1; + uint64 used_size = 2; + uint32 cpu_time = 3; + uint32 cpu_time_epoch = 4; +} + + +message ViewConfig { + repeated string group_by = 1; + repeated string split_by = 2; + ColumnsUpdate columns = 3; + repeated Filter filter = 4; + repeated Sort sort = 5; + map expressions = 6; + map aggregates = 7; + FilterReducer filter_op = 8; + optional uint32 group_by_depth = 9; + optional GroupRollupMode group_rollup_mode = 10; + + message AggList { + repeated string aggregations = 1; + } + + message Sort { + string column = 1; + SortOp op = 2; + } + + message Filter { + string column = 1; + string op = 2; + repeated Scalar value = 3; + } + + enum FilterReducer { + AND = 0; + OR = 1; + } +} + +message ColumnsUpdate { + oneof opt_columns { + google.protobuf.NullValue default_columns = 1; + Columns columns = 2; + } + + message Columns { + repeated string columns = 1; + } +} diff --git a/rust/perspective-client/src/rust/client.rs b/rust/perspective-client/src/rust/client.rs new file mode 100644 index 0000000..b100160 --- /dev/null +++ b/rust/perspective-client/src/rust/client.rs @@ -0,0 +1,809 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 std::error::Error; +use std::ops::Deref; +use std::sync::Arc; + +use async_lock::{Mutex, RwLock}; +use futures::Future; +use futures::future::{BoxFuture, LocalBoxFuture, join_all}; +use prost::Message; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::proto::request::ClientReq; +use crate::proto::response::ClientResp; +use crate::proto::{ + ColumnType, GetFeaturesReq, GetFeaturesResp, GetHostedTablesReq, GetHostedTablesResp, + HostedTable, JoinType, MakeJoinTableReq, MakeTableReq, RemoveHostedTablesUpdateReq, Request, + Response, ServerError, ServerSystemInfoReq, +}; +use crate::table::{JoinOptions, Table, TableInitOptions, TableOptions}; +use crate::table_data::{TableData, UpdateData}; +use crate::table_ref::TableRef; +use crate::utils::*; +use crate::view::{OnUpdateData, ViewWindow}; +use crate::{OnUpdateMode, OnUpdateOptions, asyncfn, clone}; + +/// Metadata about the engine runtime (such as total heap utilization). +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +pub struct SystemInfo { + /// Total available bytes for allocation on the [`Server`]. + pub heap_size: T, + + /// Bytes allocated for use on the [`Server`]. + pub used_size: T, + + /// Wall-clock time spent processing requests on the [`Server`], in + /// milliseconds (estimated). This does not properly account for the + /// internal thread pool (which enables column-parallel processing of + /// individual requests). + pub cpu_time: u32, + + /// Milliseconds since internal CPU time accumulator was reset. + pub cpu_time_epoch: u32, + + /// Timestamp (POSIX) this request was made. This field may be omitted + /// for wasm due to `perspective-client` lacking a dependency on + /// `wasm_bindgen`. + pub timestamp: Option, + + /// Total available bytes for allocation on the [`Client`]. This is only + /// available if `trace-allocator` is enabled. + pub client_heap: Option, + + /// Bytes allocated for use on the [`Client`]. This is only + /// available if `trace-allocator` is enabled. + pub client_used: Option, +} + +impl SystemInfo { + /// Convert the numeric representation for `T` to something else, which is + /// useful for JavaScript where there is no `u64` native type. + pub fn cast(&self) -> SystemInfo + where + U: num_traits::AsPrimitive, + { + SystemInfo { + heap_size: self.heap_size.as_(), + used_size: self.used_size.as_(), + cpu_time: self.cpu_time, + cpu_time_epoch: self.cpu_time_epoch, + timestamp: self.timestamp.map(|x| x.as_()), + client_heap: self.client_heap.map(|x| x.as_()), + client_used: self.client_used.map(|x| x.as_()), + } + } +} + +/// Metadata about what features are supported by the `Server` to which this +/// [`Client`] connects. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Features(Arc); + +impl Features { + pub fn get_group_rollup_modes(&self) -> Vec { + self.group_rollup_mode + .iter() + .map(|x| { + crate::config::GroupRollupMode::from( + crate::proto::GroupRollupMode::try_from(*x).unwrap(), + ) + }) + .collect::>() + } +} + +impl Deref for Features { + type Target = GetFeaturesResp; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl GetFeaturesResp { + pub fn default_op(&self, col_type: ColumnType) -> Option<&str> { + self.filter_ops + .get(&(col_type as u32))? + .options + .first() + .map(|x| x.as_str()) + } +} + +type BoxFn = Box O + Send + Sync + 'static>; +type Box2Fn = Box O + Send + Sync + 'static>; + +type Subscriptions = Arc>>; +type OnErrorCallback = + Box2Fn, BoxFuture<'static, Result<(), ClientError>>>; + +type OnceCallback = Box ClientResult<()> + Send + Sync + 'static>; +type SendCallback = Arc< + dyn for<'a> Fn(&'a Request) -> BoxFuture<'a, Result<(), Box>> + + Send + + Sync + + 'static, +>; + +/// The client-side representation of a connection to a `Server`. +pub trait ClientHandler: Clone + Send + Sync + 'static { + fn send_request( + &self, + msg: Vec, + ) -> impl Future>> + Send; +} + +mod name_registry { + use std::collections::HashSet; + use std::sync::{Arc, LazyLock, Mutex}; + + use crate::ClientError; + use crate::view::ClientResult; + + static CLIENT_ID_GEN: LazyLock>> = LazyLock::new(Arc::default); + static REGISTERED_CLIENTS: LazyLock>>> = LazyLock::new(Arc::default); + + pub(crate) fn generate_name(name: Option<&str>) -> ClientResult { + if let Some(name) = name { + if let Some(name) = REGISTERED_CLIENTS + .lock() + .map_err(ClientError::from)? + .get(name) + { + Err(ClientError::DuplicateNameError(name.to_owned())) + } else { + Ok(name.to_owned()) + } + } else { + let mut guard = CLIENT_ID_GEN.lock()?; + *guard += 1; + Ok(format!("client-{guard}")) + } + } +} + +/// The type of the `reconnect` parameter passed to [`Client::handle_error`}, +/// and to the callback closure of [`Client::on_error`]. +/// +/// Calling this function from a [`Client::on_error`] closure should run the +/// (implementation specific) client reconnect logic, e.g. rebindign a +/// websocket. +#[derive(Clone)] +#[allow(clippy::type_complexity)] +pub struct ReconnectCallback( + Arc LocalBoxFuture<'static, Result<(), Box>> + Send + Sync>, +); + +impl Deref for ReconnectCallback { + type Target = dyn Fn() -> LocalBoxFuture<'static, Result<(), Box>> + Send + Sync; + + fn deref(&self) -> &Self::Target { + &*self.0 + } +} + +impl ReconnectCallback { + pub fn new( + f: impl Fn() -> LocalBoxFuture<'static, Result<(), Box>> + Send + Sync + 'static, + ) -> Self { + ReconnectCallback(Arc::new(f)) + } +} + +/// An instance of a [`Client`] is a connection to a single +/// `perspective_server::Server`, whether locally in-memory or remote over some +/// transport like a WebSocket. +#[derive(Clone)] +pub struct Client { + name: Arc, + features: Arc>>, + send: SendCallback, + id_gen: IDGen, + subscriptions_errors: Subscriptions, + subscriptions_once: Subscriptions, + subscriptions: Subscriptions>>>, +} + +impl PartialEq for Client { + fn eq(&self, other: &Self) -> bool { + self.name == other.name + } +} + +impl std::fmt::Debug for Client { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Client").finish() + } +} + +impl Client { + /// Create a new client instance with a closure that handles message + /// dispatch. See [`Client::new`] for details. + pub fn new_with_callback(name: Option<&str>, send_request: T) -> ClientResult + where + T: Fn(Vec) -> U + 'static + Sync + Send, + U: Future>> + Send + 'static, + { + let name = name_registry::generate_name(name)?; + let send_request = Arc::new(send_request); + let send: SendCallback = Arc::new(move |req| { + let mut bytes: Vec = Vec::new(); + req.encode(&mut bytes).unwrap(); + let send_request = send_request.clone(); + Box::pin(async move { send_request(bytes).await }) + }); + + Ok(Client { + name: Arc::new(name), + features: Arc::default(), + id_gen: IDGen::default(), + send, + subscriptions: Subscriptions::default(), + subscriptions_errors: Arc::default(), + subscriptions_once: Arc::default(), + }) + } + + /// Create a new [`Client`] instance with [`ClientHandler`]. + pub fn new(name: Option<&str>, client_handler: T) -> ClientResult + where + T: ClientHandler + 'static + Sync + Send, + { + Self::new_with_callback( + name, + asyncfn!(client_handler, async move |req| { + client_handler.send_request(req).await + }), + ) + } + + pub fn get_name(&self) -> &'_ str { + self.name.as_str() + } + + /// Handle a message from the external message queue. + /// [`Client::handle_response`] is part of the low-level message-handling + /// API necessary to implement new transports for a [`Client`] + /// connection to a local-or-remote `perspective_server::Server`, and + /// doesn't generally need to be called directly by "users" of a + /// [`Client`] once connected. + pub async fn handle_response<'a>(&'a self, msg: &'a [u8]) -> ClientResult { + let msg = Response::decode(msg)?; + tracing::debug!("RECV {}", msg); + let mut wr = self.subscriptions_once.write().await; + if let Some(handler) = (*wr).remove(&msg.msg_id) { + drop(wr); + handler(msg)?; + return Ok(true); + } else if let Some(handler) = self.subscriptions.try_read().unwrap().get(&msg.msg_id) { + drop(wr); + handler(msg).await?; + return Ok(true); + } + + if let Response { + client_resp: Some(ClientResp::ServerError(ServerError { message, .. })), + .. + } = &msg + { + tracing::error!("{}", message); + } else { + tracing::debug!("Received unsolicited server response: {}", msg); + } + + Ok(false) + } + + /// Handle an exception from the underlying transport. + pub async fn handle_error( + &self, + message: ClientError, + reconnect: Option, + ) -> ClientResult<()> + where + T: Fn() -> U + Clone + Send + Sync + 'static, + U: Future>, + { + let subs = self.subscriptions_errors.read().await; + let tasks = join_all(subs.values().map(|callback| { + callback( + message.clone(), + reconnect.clone().map(move |f| { + ReconnectCallback(Arc::new(move || { + clone!(f); + Box::pin(async move { Ok(f().await?) }) as LocalBoxFuture<'static, _> + })) + }), + ) + })); + + tasks.await.into_iter().collect::>()?; + self.close_and_error_subscriptions(&message).await + } + + /// TODO Synthesize an error to provide to the caller, since the + /// server did not respond and the other option is to just drop the call + /// which results in a non-descript error message. It would be nice to + /// have client-side failures be a native part of the Client API. + async fn close_and_error_subscriptions(&self, message: &ClientError) -> ClientResult<()> { + let synthetic_error = |msg_id| Response { + msg_id, + entity_id: "".to_string(), + client_resp: Some(ClientResp::ServerError(ServerError { + message: format!("{message}"), + status_code: 2, + })), + }; + + self.subscriptions.write().await.clear(); + let callbacks_once = self + .subscriptions_once + .write() + .await + .drain() + .collect::>(); + + callbacks_once + .into_iter() + .try_for_each(|(msg_id, f)| f(synthetic_error(msg_id))) + } + + pub async fn on_error(&self, on_error: T) -> ClientResult + where + T: Fn(ClientError, Option) -> U + Clone + Send + Sync + 'static, + U: Future + Send + 'static, + V: Into> + Sync + 'static, + { + let id = self.gen_id(); + let callback = asyncfn!(on_error, async move |x, y| on_error(x, y).await.into()); + self.subscriptions_errors + .write() + .await + .insert(id, Box::new(move |x, y| Box::pin(callback(x, y)))); + + Ok(id) + } + + /// Generate a message ID unique to this client. + pub(crate) fn gen_id(&self) -> u32 { + self.id_gen.next() + } + + pub(crate) async fn unsubscribe(&self, update_id: u32) -> ClientResult<()> { + let callback = self + .subscriptions + .write() + .await + .remove(&update_id) + .ok_or(ClientError::Unknown("remove_update".to_string()))?; + + drop(callback); + Ok(()) + } + + /// Register a callback which is expected to respond exactly once. + pub(crate) async fn subscribe_once( + &self, + msg: &Request, + on_update: Box ClientResult<()> + Send + Sync + 'static>, + ) -> ClientResult<()> { + self.subscriptions_once + .write() + .await + .insert(msg.msg_id, on_update); + + tracing::debug!("SEND {}", msg); + if let Err(e) = (self.send)(msg).await { + self.subscriptions_once.write().await.remove(&msg.msg_id); + Err(ClientError::Unknown(e.to_string())) + } else { + Ok(()) + } + } + + pub(crate) async fn subscribe(&self, msg: &Request, on_update: T) -> ClientResult<()> + where + T: Fn(Response) -> U + Send + Sync + 'static, + U: Future> + Send + 'static, + { + self.subscriptions + .write() + .await + .insert(msg.msg_id, Box::new(move |x| Box::pin(on_update(x)))); + + tracing::debug!("SEND {}", msg); + if let Err(e) = (self.send)(msg).await { + self.subscriptions.write().await.remove(&msg.msg_id); + Err(ClientError::Unknown(e.to_string())) + } else { + Ok(()) + } + } + + /// Send a `ClientReq` and await both the successful completion of the + /// `send`, _and_ the `ClientResp` which is returned. + pub(crate) async fn oneshot(&self, req: &Request) -> ClientResult { + let (sender, receiver) = futures::channel::oneshot::channel::(); + let on_update = Box::new(move |res: Response| { + sender.send(res.client_resp.unwrap()).map_err(|x| x.into()) + }); + + self.subscribe_once(req, on_update).await?; + receiver + .await + .map_err(|_| ClientError::Unknown(format!("Internal error for req {req}"))) + } + + pub(crate) async fn get_features(&self) -> ClientResult { + let mut guard = self.features.lock().await; + let features = if let Some(features) = &*guard { + features.clone() + } else { + let msg = Request { + msg_id: self.gen_id(), + entity_id: "".to_owned(), + client_req: Some(ClientReq::GetFeaturesReq(GetFeaturesReq {})), + }; + + let features = Features(Arc::new(match self.oneshot(&msg).await? { + ClientResp::GetFeaturesResp(features) => Ok(features), + resp => Err(resp), + }?)); + + *guard = Some(features.clone()); + features + }; + + Ok(features) + } + + /// Creates a new [`Table`] from either a _schema_ or _data_. + /// + /// The [`Client::table`] factory function can be initialized with either a + /// _schema_ (see [`Table::schema`]), or data in one of these formats: + /// + /// - Apache Arrow + /// - CSV + /// - JSON row-oriented + /// - JSON column-oriented + /// - NDJSON + /// + /// When instantiated with _data_, the schema is inferred from this data. + /// While this is convenient, inferrence is sometimes imperfect e.g. + /// when the input is empty, null or ambiguous. For these cases, + /// [`Client::table`] can first be instantiated with a explicit schema. + /// + /// When instantiated with a _schema_, the resulting [`Table`] is empty but + /// with known column names and column types. When subsqeuently + /// populated with [`Table::update`], these columns will be _coerced_ to + /// the schema's type. This behavior can be useful when + /// [`Client::table`]'s column type inferences doesn't work. + /// + /// The resulting [`Table`] is _virtual_, and invoking its methods + /// dispatches events to the `perspective_server::Server` this + /// [`Client`] connects to, where the data is stored and all calculation + /// occurs. + /// + /// # Arguments + /// + /// - `arg` - Either _schema_ or initialization _data_. + /// - `options` - Optional configuration which provides one of: + /// - `limit` - The max number of rows the resulting [`Table`] can + /// store. + /// - `index` - The column name to use as an _index_ column. If this + /// `Table` is being instantiated by _data_, this column name must be + /// present in the data. + /// - `name` - The name of the table. This will be generated if it is + /// not provided. + /// - `format` - The explicit format of the input data, can be one of + /// `"json"`, `"columns"`, `"csv"` or `"arrow"`. This overrides + /// language-specific type dispatch behavior, which allows stringified + /// and byte array alternative inputs. + /// + /// # Examples + /// + /// Load a CSV from a `String`: + /// + /// ```no_run + /// # use perspective_client::*; + /// # async fn run(client: Client) -> Result<(), Box> { + /// let opts = TableInitOptions::default(); + /// let data = TableData::Update(UpdateData::Csv("x,y\n1,2\n3,4".into())); + /// let table = client.table(data, opts).await?; + /// # Ok(()) } + /// ``` + pub async fn table(&self, input: TableData, options: TableInitOptions) -> ClientResult { + let entity_id = match options.name.clone() { + Some(x) => x.to_owned(), + None => randid(), + }; + + if let TableData::View(view) = &input { + let window = ViewWindow::default(); + let arrow = view.to_arrow(window).await?; + let mut table = self + .crate_table_inner(UpdateData::Arrow(arrow).into(), options.into(), entity_id) + .await?; + + let table_ = table.clone(); + let callback = asyncfn!(table_, update, async move |update: OnUpdateData| { + let update = UpdateData::Arrow(update.delta.expect("Malformed message").into()); + let options = crate::UpdateOptions::default(); + table_.update(update, options).await.unwrap_or_log(); + }); + + let options = OnUpdateOptions { + mode: Some(OnUpdateMode::Row), + }; + + let on_update_token = view.on_update(callback, options).await?; + table.view_update_token = Some(on_update_token); + Ok(table) + } else { + self.crate_table_inner(input, options.into(), entity_id) + .await + } + } + + async fn crate_table_inner( + &self, + input: TableData, + options: TableOptions, + entity_id: String, + ) -> ClientResult
    { + let msg = Request { + msg_id: self.gen_id(), + entity_id: entity_id.clone(), + client_req: Some(ClientReq::MakeTableReq(MakeTableReq { + data: Some(input.into()), + options: Some(options.clone().try_into()?), + })), + }; + + let client = self.clone(); + match self.oneshot(&msg).await? { + ClientResp::MakeTableResp(_) => Ok(Table::new(entity_id, client, options)), + resp => Err(resp.into()), + } + } + + /// Create a new read-only [`Table`] by performing a JOIN on two source + /// tables. The resulting table is reactive: when either source table is + /// updated, the join is automatically recomputed. + /// + /// # Arguments + /// + /// * `left` - The left source table (as a [`Table`] or name string). + /// * `right` - The right source table (as a [`Table`] or name string). + /// * `on` - The column name to join on. Must exist in both tables with the + /// same type. + /// * `options` - Join configuration (join type, table name). + pub async fn join( + &self, + left: TableRef, + right: TableRef, + on: &str, + options: JoinOptions, + ) -> ClientResult
    { + let entity_id = options.name.unwrap_or_else(randid); + let join_type: JoinType = options.join_type.unwrap_or_default(); + let right_on_column = options.right_on.unwrap_or_default(); + let msg = Request { + msg_id: self.gen_id(), + entity_id: entity_id.clone(), + client_req: Some(ClientReq::MakeJoinTableReq(MakeJoinTableReq { + left_table_id: left.table_name().to_owned(), + right_table_id: right.table_name().to_owned(), + on_column: on.to_owned(), + join_type: join_type.into(), + right_on_column, + })), + }; + + let client = self.clone(); + match self.oneshot(&msg).await? { + ClientResp::MakeJoinTableResp(_) => Ok(Table::new(entity_id, client, TableOptions { + index: Some(on.to_owned()), + limit: None, + page_to_disk: None, + })), + resp => Err(resp.into()), + } + } + + async fn get_table_infos(&self) -> ClientResult> { + let msg = Request { + msg_id: self.gen_id(), + entity_id: "".to_owned(), + client_req: Some(ClientReq::GetHostedTablesReq(GetHostedTablesReq { + subscribe: false, + })), + }; + + match self.oneshot(&msg).await? { + ClientResp::GetHostedTablesResp(GetHostedTablesResp { table_infos }) => Ok(table_infos), + resp => Err(resp.into()), + } + } + + /// Opens a [`Table`] that is hosted on the `perspective_server::Server` + /// that is connected to this [`Client`]. + /// + /// The `name` property of [`TableInitOptions`] is used to identify each + /// [`Table`]. [`Table`] `name`s can be looked up for each [`Client`] + /// via [`Client::get_hosted_table_names`]. + /// + /// # Examples + /// + /// ```no_run + /// # use perspective_client::Client; + /// # async fn run() -> Result<(), Box> { + /// # let client: Client = todo!(); + /// let table = client.open_table("table_one".to_owned()).await?; + /// # Ok(()) } + /// ``` + pub async fn open_table(&self, entity_id: String) -> ClientResult
    { + let infos = self.get_table_infos().await?; + + // TODO fix this - name is repeated 2x + if let Some(info) = infos.into_iter().find(|i| i.entity_id == entity_id) { + let options = TableOptions { + index: info.index, + limit: info.limit, + // `page_to_disk` is a server-side property not surfaced in table + // info; it does not affect client-side behavior. + page_to_disk: None, + }; + + let client = self.clone(); + Ok(Table::new(entity_id, client, options)) + } else { + Err(ClientError::Unknown(format!( + "Unknown table \"{}\"", + entity_id + ))) + } + } + + /// Retrieves the names of all tables that this client has access to. + /// + /// `name` is a string identifier unique to the [`Table`] (per [`Client`]), + /// which can be used in conjunction with [`Client::open_table`] to get + /// a [`Table`] instance without the use of [`Client::table`] + /// constructor directly (e.g., one created by another [`Client`]). + /// + /// # Examples + /// + /// ```no_run + /// # use perspective_client::Client; + /// # async fn run() -> Result<(), Box> { + /// # let client: Client = todo!(); + /// let tables = client.get_hosted_table_names().await?; + /// # Ok(()) } + /// ``` + pub async fn get_hosted_table_names(&self) -> ClientResult> { + let msg = Request { + msg_id: self.gen_id(), + entity_id: "".to_owned(), + client_req: Some(ClientReq::GetHostedTablesReq(GetHostedTablesReq { + subscribe: false, + })), + }; + + match self.oneshot(&msg).await? { + ClientResp::GetHostedTablesResp(GetHostedTablesResp { table_infos }) => { + Ok(table_infos.into_iter().map(|i| i.entity_id).collect()) + }, + resp => Err(resp.into()), + } + } + + /// Register a callback which is invoked whenever [`Client::table`] (on this + /// [`Client`]) or [`Table::delete`] (on a [`Table`] belinging to this + /// [`Client`]) are called. + pub async fn on_hosted_tables_update(&self, on_update: T) -> ClientResult + where + T: Fn() -> U + Send + Sync + 'static, + U: Future + Send + 'static, + { + let on_update = Arc::new(on_update); + let callback = asyncfn!(on_update, async move |resp: Response| { + match resp.client_resp { + Some(ClientResp::GetHostedTablesResp(_)) | None => { + on_update().await; + Ok(()) + }, + resp => Err(resp.into()), + } + }); + + let msg = Request { + msg_id: self.gen_id(), + entity_id: "".to_owned(), + client_req: Some(ClientReq::GetHostedTablesReq(GetHostedTablesReq { + subscribe: true, + })), + }; + + self.subscribe(&msg, callback).await?; + Ok(msg.msg_id) + } + + /// Remove a callback previously registered via + /// `Client::on_hosted_tables_update`. + pub async fn remove_hosted_tables_update(&self, update_id: u32) -> ClientResult<()> { + let msg = Request { + msg_id: self.gen_id(), + entity_id: "".to_owned(), + client_req: Some(ClientReq::RemoveHostedTablesUpdateReq( + RemoveHostedTablesUpdateReq { id: update_id }, + )), + }; + + self.unsubscribe(update_id).await?; + match self.oneshot(&msg).await? { + ClientResp::RemoveHostedTablesUpdateResp(_) => Ok(()), + resp => Err(resp.into()), + } + } + + /// Provides the [`SystemInfo`] struct, implementation-specific metadata + /// about the [`perspective_server::Server`] runtime such as Memory and + /// CPU usage. + pub async fn system_info(&self) -> ClientResult { + let msg = Request { + msg_id: self.gen_id(), + entity_id: "".to_string(), + client_req: Some(ClientReq::ServerSystemInfoReq(ServerSystemInfoReq {})), + }; + + match self.oneshot(&msg).await? { + ClientResp::ServerSystemInfoResp(resp) => { + #[cfg(not(target_family = "wasm"))] + let timestamp = Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64, + ); + + #[cfg(target_family = "wasm")] + let timestamp = None; + + #[cfg(feature = "talc-allocator")] + let (client_used, client_heap) = { + let (client_used, client_heap) = crate::utils::get_used(); + (Some(client_used as u64), Some(client_heap as u64)) + }; + + #[cfg(not(feature = "talc-allocator"))] + let (client_used, client_heap) = (None, None); + + let info = SystemInfo { + heap_size: resp.heap_size, + used_size: resp.used_size, + cpu_time: resp.cpu_time, + cpu_time_epoch: resp.cpu_time_epoch, + timestamp, + client_heap, + client_used, + }; + + Ok(info) + }, + resp => Err(resp.into()), + } + } +} diff --git a/rust/perspective-client/src/rust/config/aggregates.rs b/rust/perspective-client/src/rust/config/aggregates.rs new file mode 100644 index 0000000..e7f2e07 --- /dev/null +++ b/rust/perspective-client/src/rust/config/aggregates.rs @@ -0,0 +1,81 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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::fmt::Display; + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::proto::view_config; + +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize, TS)] +#[serde(untagged)] +pub enum Aggregate { + SingleAggregate(String), + MultiAggregate(String, Vec), +} + +impl From<&'static str> for Aggregate { + fn from(value: &'static str) -> Self { + if value.contains(" by ") { + let mut parts = value.split(" by "); + Aggregate::MultiAggregate( + parts.next().unwrap().into(), + parts.map(|x| x.into()).collect(), + ) + } else { + Aggregate::SingleAggregate(value.into()) + } + } +} + +impl Display for Aggregate { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> { + match self { + Self::SingleAggregate(x) => write!(fmt, "{x}")?, + Self::MultiAggregate(agg, x) => write!(fmt, "{} by {}", agg, x.join(", "))?, + }; + Ok(()) + } +} + +impl From for view_config::AggList { + fn from(value: Aggregate) -> Self { + view_config::AggList { + aggregations: match value { + Aggregate::SingleAggregate(x) => vec![x], + Aggregate::MultiAggregate(x, y) => { + vec![x, y.join(",")] + }, + }, + } + } +} + +impl From for Aggregate { + fn from(value: view_config::AggList) -> Self { + if value.aggregations.len() == 1 { + Aggregate::SingleAggregate(value.aggregations.first().unwrap().clone()) + } else { + Aggregate::MultiAggregate( + value.aggregations.first().unwrap().clone(), + value + .aggregations + .get(1) + .unwrap() + .split(",") + .map(|x| x.to_owned()) + .collect(), + ) + } + } +} diff --git a/rust/perspective-client/src/rust/config/column_type.rs b/rust/perspective-client/src/rust/config/column_type.rs new file mode 100644 index 0000000..8de1c42 --- /dev/null +++ b/rust/perspective-client/src/rust/config/column_type.rs @@ -0,0 +1,74 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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::fmt::Display; +use std::str::FromStr; + +use crate::ClientError; +use crate::proto::ColumnType; + +impl Display for ColumnType { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> { + write!(fmt, "{}", match self { + Self::String => "string", + Self::Integer => "integer", + Self::Float => "float", + Self::Boolean => "boolean", + Self::Date => "date", + Self::Datetime => "datetime", + }) + } +} + +impl FromStr for ColumnType { + type Err = ClientError; + + fn from_str(val: &str) -> Result { + val.try_into() + } +} + +impl TryFrom<&str> for ColumnType { + type Error = ClientError; + + fn try_from(val: &str) -> Result { + if val == "string" { + Ok(Self::String) + } else if val == "integer" { + Ok(Self::Integer) + } else if val == "float" { + Ok(Self::Float) + } else if val == "boolean" { + Ok(Self::Boolean) + } else if val == "date" { + Ok(Self::Date) + } else if val == "datetime" { + Ok(Self::Datetime) + } else { + Err(ClientError::Internal(format!("Unknown type {val}"))) + } + } +} + +impl ColumnType { + pub fn to_capitalized(&self) -> String { + match self { + ColumnType::String => "String", + ColumnType::Datetime => "Datetime", + ColumnType::Date => "Date", + ColumnType::Integer => "Integer", + ColumnType::Float => "Float", + ColumnType::Boolean => "Boolean", + } + .into() + } +} diff --git a/rust/perspective-client/src/rust/config/expressions.rs b/rust/perspective-client/src/rust/config/expressions.rs new file mode 100644 index 0000000..7ba2969 --- /dev/null +++ b/rust/perspective-client/src/rust/config/expressions.rs @@ -0,0 +1,685 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +//! Perspective supports _expression columns_, which are virtual columns +//! calculated as part of the [`crate::View`], optionally using values from its +//! underlying [`crate::Table`]'s columns. Such expression columns are defined +//! in Perspective's expression language, an extended version of +//! [ExprTK](https://github.com/ArashPartow/exprtk), which is itself quite similar +//! (in design and features) to expressions in Excel. +//! +//! ## UI +//! +//! Expression columns can be created in `` by clicking the +//! "New Column" button at the bottom of the column list, or via the API by +//! adding the expression to the `expressions` config key when calling +//! `viewer.restore()`. +//! +//! By default, such expression columns are not "used", and will appear above +//! the `Table`'s other deselected columns in the column list, with an +//! additional set of buttons for: +//! +//! - _Editing_ the column's expression. Doing so will update the definitions of +//! _all_ usage of the expression column. +//! - _Deleting_ the column. Clicking `Reset` (or calling the `reset()` method) +//! will not delete expressions unless the `Shift` key is held (or `true` +//! parameter supplied, respectively). This button only appears if the +//! expression column i unused. +//! +//! To use the column, just drag/select the column as you would a normal column, +//! e.g. as a "Filter", "Group By", etc. Expression columns will recalculate +//! whenever their dependent columns update. +//! +//! ## Perspective Extensions to ExprTK +//! +//! ExprTK has its own +//! [excellent documentation](http://www.partow.net/programming/exprtk/) which +//! covers the core langauge in depth, which is an excellent place to start in +//! learning the basics. In addition to these features, Perspective adds a few +//! of its own custom extensions and syntax. +//! +//! #### Static Typing +//! +//! In addition to `float` values which ExprTK supports natively, Perspective's +//! expression language also supports Perspective's other types `date`, +//! `datetime`, `integer`, `boolean`; as well as rudimentary type-checking, +//! which will report an error when the values/columns supplied as +//! arguments cannot be resolved to the expected type, e.g. `length(x)` expects +//! an argument `x` of type `string` and is not a valid expression for an `x` of +//! another type. Perspective supplies a set of _cast_ functions for converting +//! between types where possible e.g. `string(x)` to cast a variable `x` to a +//! `string`. +//! +//! #### Expression Column Name +//! +//! Expressions are named by their key in the `ViewConfig::expressions` +//! `HashMap`. This name will be used in the `` UI when +//! referring to the column, but will also be used in the API when specifying +//! e.g. `group_by` or `sort` fields. When creating a new column via +//! ``'s expression editor, new columns will get a default +//! name (which you may delete or change): +//! +//! ```html +//! // New Column 1 +//! ``` +//! +//! Without such a comment, an expression will show up in the +//! `` API and UI as itself (clipped to a reasonable length +//! for the latter). +//! +//! #### Referencing [`crate::Table`] Columns +//! +//! Columns from the [`crate::Table`] can be referenced in an expression with +//! _double quotes_. +//! +//! ```text +//! // Expected Sales ("Sales" * 10) + "Profit" +//! ``` +//! +//! #### String Literals +//! +//! In contrast to standard ExprTK, string literals are declared with _single +//! quotes_: +//! +//! ```text +//! // Profitable +//! if ("Profit" > 0) { +//! 'Stonks' +//! } else { +//! 'Not Stonks' +//! } +//! ``` +//! +//! #### Extended Library +//! +//! Perspective adds many of its own functions in addition to `ExprTK`'s +//! standard ones, including common functions for `datetime` and `string` types +//! such as `substring()`, `bucket()`, `day_of_week()`, etc. A full list of +//! available functions is available in the +//! [Expression Columns API](../obj/perspective-viewer-exprtk). +//! +//! ## Examples +//! +//! #### Casting +//! +//! Just `2`, as an `integer` (numeric literals currently default to `float` +//! unless cast). +//! +//! ```text +//! integer(2) +//! ``` +//! +//! #### Variables +//! +//! ```text +//! // My Column Name +//! var incrementedBy200 := "Sales" + 200; +//! var half := incrementedBy200 / 2; +//! half +//! ``` +//! +//! ```text +//! // Complex Expression +//! var upperCustomer := upper("Customer Name"); +//! var separator := concat(upperCustomer, ' | '); +//! var profitRatio := floor(percent_of("Profit", "Sales")); // Remove trailing decimal. +//! var combined := concat(separator, string(profitRatio)); +//! var percentDisplay := concat(combined, '%'); +//! percentDisplay +//! ``` +//! +//! #### Conditionals +//! +//! ```text +//! // Conditional +//! var priceAdjustmentDate := date(2016, 6, 18); +//! var finalPrice := "Sales" - "Discount"; +//! var additionalModifier := 0; +//! +//! if("Order Date" > priceAdjustmentDate) { +//! finalPrice -= 5; +//! additionalModifier -= 2; +//! } +//! else +//! finalPrice += 5; +//! +//! finalPrice + additionalModifier +//! ``` + +#![cfg_attr(not(feature = "omit_metadata"), doc = include_str!("../../../docs/expression_gen.md"))] + +use std::borrow::Cow; +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +#[derive(Deserialize, Clone, PartialEq, Debug)] +#[serde(untagged)] +pub enum ExpressionsDeserde { + Array(Vec), + Map(HashMap), +} + +#[derive(Deserialize, Serialize, Clone, PartialEq, Debug, Default, TS)] +#[serde(from = "ExpressionsDeserde")] +pub struct Expressions(pub HashMap); + +impl std::ops::Deref for Expressions { + type Target = HashMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::ops::DerefMut for Expressions { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +fn upgrade_legacy_format(expressions: &[String]) -> HashMap { + tracing::debug!("Legacy `expressions` format: {:?}", expressions); + expressions + .iter() + .map(|s| { + if let Some((name, expression)) = s.split_once('\n') { + if !expression.is_empty() && name.starts_with("//") { + (name.split_at(2).1.trim().to_owned(), expression.to_owned()) + } else { + (s.to_owned(), s.to_owned()) + } + } else { + (s.to_owned(), s.to_owned()) + } + }) + .collect::>() +} + +impl From for Expressions { + fn from(value: ExpressionsDeserde) -> Self { + match value { + ExpressionsDeserde::Array(arr) => Self(upgrade_legacy_format(&arr)), + ExpressionsDeserde::Map(map) => Self(map), + } + } +} + +#[derive(Clone, Debug, PartialEq, TS)] +pub struct Expression<'a> { + pub name: Cow<'a, str>, + pub expression: Cow<'a, str>, +} + +impl<'a> Expression<'a> { + /// If name is None, the expression is used as the name. + pub fn new(name: Option>, expression: Cow<'a, str>) -> Self { + Self { + name: name.unwrap_or_else(|| expression.clone()), + expression, + } + } +} + +impl<'a> FromIterator> for Expressions { + fn from_iter>>(iter: T) -> Self { + Self( + iter.into_iter() + .map(|x| (x.name.as_ref().to_owned(), x.expression.as_ref().to_owned())) + .collect(), + ) + } +} + +impl Expressions { + pub fn insert(&mut self, expr: &Expression) { + self.0.insert( + expr.name.as_ref().to_owned(), + expr.expression.as_ref().to_owned(), + ); + } +} + +#[doc(hidden)] +#[derive(Serialize, Clone, Copy, PartialEq)] +pub struct CompletionItemSuggestion { + pub label: &'static str, + pub insert_text: &'static str, + pub documentation: &'static str, +} + +#[doc(hidden)] +pub static COMPLETIONS: [CompletionItemSuggestion; 79] = [ + CompletionItemSuggestion { + label: "var", + insert_text: "var ${1:x := 1}", + documentation: "Declare a new local variable", + }, + CompletionItemSuggestion { + label: "abs", + insert_text: "abs(${1:x})", + documentation: "Absolute value of x", + }, + CompletionItemSuggestion { + label: "avg", + insert_text: "avg(${1:x})", + documentation: "Average of all inputs", + }, + CompletionItemSuggestion { + label: "bucket", + insert_text: "bucket(${1:x}, ${2:y})", + documentation: "Bucket x by y", + }, + CompletionItemSuggestion { + label: "ceil", + insert_text: "ceil(${1:x})", + documentation: "Smallest integer >= x", + }, + CompletionItemSuggestion { + label: "exp", + insert_text: "exp(${1:x})", + documentation: "Natural exponent of x (e ^ x)", + }, + CompletionItemSuggestion { + label: "floor", + insert_text: "floor(${1:x})", + documentation: "Largest integer <= x", + }, + CompletionItemSuggestion { + label: "frac", + insert_text: "frac(${1:x})", + documentation: "Fractional portion (after the decimal) of x", + }, + CompletionItemSuggestion { + label: "iclamp", + insert_text: "iclamp(${1:x})", + documentation: "Inverse clamp x within a range", + }, + CompletionItemSuggestion { + label: "inrange", + insert_text: "inrange(${1:x})", + documentation: "Returns whether x is within a range", + }, + CompletionItemSuggestion { + label: "log", + insert_text: "log(${1:x})", + documentation: "Natural log of x", + }, + CompletionItemSuggestion { + label: "log10", + insert_text: "log10(${1:x})", + documentation: "Base 10 log of x", + }, + CompletionItemSuggestion { + label: "log1p", + insert_text: "log1p(${1:x})", + documentation: "Natural log of 1 + x where x is very small", + }, + CompletionItemSuggestion { + label: "log2", + insert_text: "log2(${1:x})", + documentation: "Base 2 log of x", + }, + CompletionItemSuggestion { + label: "logn", + insert_text: "logn(${1:x}, ${2:N})", + documentation: "Base N log of x where N >= 0", + }, + CompletionItemSuggestion { + label: "max", + insert_text: "max(${1:x})", + documentation: "Maximum value of all inputs", + }, + CompletionItemSuggestion { + label: "min", + insert_text: "min(${1:x})", + documentation: "Minimum value of all inputs", + }, + CompletionItemSuggestion { + label: "mul", + insert_text: "mul(${1:x})", + documentation: "Product of all inputs", + }, + CompletionItemSuggestion { + label: "percent_of", + insert_text: "percent_of(${1:x})", + documentation: "Percent y of x", + }, + CompletionItemSuggestion { + label: "pow", + insert_text: "pow(${1:x}, ${2:y})", + documentation: "x to the power of y", + }, + CompletionItemSuggestion { + label: "root", + insert_text: "root(${1:x}, ${2:N})", + documentation: "N-th root of x where N >= 0", + }, + CompletionItemSuggestion { + label: "round", + insert_text: "round(${1:x})", + documentation: "Round x to the nearest integer", + }, + CompletionItemSuggestion { + label: "sgn", + insert_text: "sgn(${1:x})", + documentation: "Sign of x: -1, 1, or 0", + }, + CompletionItemSuggestion { + label: "sqrt", + insert_text: "sqrt(${1:x})", + documentation: "Square root of x", + }, + CompletionItemSuggestion { + label: "sum", + insert_text: "sum(${1:x})", + documentation: "Sum of all inputs", + }, + CompletionItemSuggestion { + label: "trunc", + insert_text: "trunc(${1:x})", + documentation: "Integer portion of x", + }, + CompletionItemSuggestion { + label: "acos", + insert_text: "acos(${1:x})", + documentation: "Arc cosine of x in radians", + }, + CompletionItemSuggestion { + label: "acosh", + insert_text: "acosh(${1:x})", + documentation: "Inverse hyperbolic cosine of x in radians", + }, + CompletionItemSuggestion { + label: "asin", + insert_text: "asin(${1:x})", + documentation: "Arc sine of x in radians", + }, + CompletionItemSuggestion { + label: "asinh", + insert_text: "asinh(${1:x})", + documentation: "Inverse hyperbolic sine of x in radians", + }, + CompletionItemSuggestion { + label: "atan", + insert_text: "atan(${1:x})", + documentation: "Arc tangent of x in radians", + }, + CompletionItemSuggestion { + label: "atanh", + insert_text: "atanh(${1:x})", + documentation: "Inverse hyperbolic tangent of x in radians", + }, + CompletionItemSuggestion { + label: "cos", + insert_text: "cos(${1:x})", + documentation: "Cosine of x", + }, + CompletionItemSuggestion { + label: "cosh", + insert_text: "cosh(${1:x})", + documentation: "Hyperbolic cosine of x", + }, + CompletionItemSuggestion { + label: "cot", + insert_text: "cot(${1:x})", + documentation: "Cotangent of x", + }, + CompletionItemSuggestion { + label: "sin", + insert_text: "sin(${1:x})", + documentation: "Sine of x", + }, + CompletionItemSuggestion { + label: "sinc", + insert_text: "sinc(${1:x})", + documentation: "Sine cardinal of x", + }, + CompletionItemSuggestion { + label: "sinh", + insert_text: "sinh(${1:x})", + documentation: "Hyperbolic sine of x", + }, + CompletionItemSuggestion { + label: "tan", + insert_text: "tan(${1:x})", + documentation: "Tangent of x", + }, + CompletionItemSuggestion { + label: "tanh", + insert_text: "tanh(${1:x})", + documentation: "Hyperbolic tangent of x", + }, + CompletionItemSuggestion { + label: "deg2rad", + insert_text: "deg2rad(${1:x})", + documentation: "Convert x from degrees to radians", + }, + CompletionItemSuggestion { + label: "deg2grad", + insert_text: "deg2grad(${1:x})", + documentation: "Convert x from degrees to gradians", + }, + CompletionItemSuggestion { + label: "rad2deg", + insert_text: "rad2deg(${1:x})", + documentation: "Convert x from radians to degrees", + }, + CompletionItemSuggestion { + label: "grad2deg", + insert_text: "grad2deg(${1:x})", + documentation: "Convert x from gradians to degrees", + }, + CompletionItemSuggestion { + label: "concat", + insert_text: "concat(${1:x}, ${2:y})", + documentation: "Concatenate string columns and string literals, such \ + as:\nconcat(\"State\" ', ', \"City\")", + }, + CompletionItemSuggestion { + label: "order", + insert_text: "order(${1:input column}, ${2:value}, ...)", + documentation: "Generates a sort order for a string column based on the input order of \ + the parameters, such as:\norder(\"State\", 'Texas', 'New York')", + }, + CompletionItemSuggestion { + label: "upper", + insert_text: "upper(${1:x})", + documentation: "Uppercase of x", + }, + CompletionItemSuggestion { + label: "lower", + insert_text: "lower(${1:x})", + documentation: "Lowercase of x", + }, + CompletionItemSuggestion { + label: "hour_of_day", + insert_text: "hour_of_day(${1:x})", + documentation: "Return a datetime's hour of the day as a string", + }, + CompletionItemSuggestion { + label: "month_of_year", + insert_text: "month_of_year(${1:x})", + documentation: "Return a datetime's month of the year as a string", + }, + CompletionItemSuggestion { + label: "day_of_week", + insert_text: "day_of_week(${1:x})", + documentation: "Return a datetime's day of week as a string", + }, + CompletionItemSuggestion { + label: "now", + insert_text: "now()", + documentation: "The current datetime in local time", + }, + CompletionItemSuggestion { + label: "today", + insert_text: "today()", + documentation: "The current date in local time", + }, + CompletionItemSuggestion { + label: "is_null", + insert_text: "is_null(${1:x})", + documentation: "Whether x is a null value", + }, + CompletionItemSuggestion { + label: "is_not_null", + insert_text: "is_not_null(${1:x})", + documentation: "Whether x is not a null value", + }, + CompletionItemSuggestion { + label: "coalesce", + insert_text: "coalesce(${1:x}, ${2:y})", + documentation: "Returns the first non-null argument.", + }, + CompletionItemSuggestion { + label: "contains", + insert_text: "contains(${1:x}, ${2:'substr'})", + documentation: "Whether the string column or value contains the literal substring.", + }, + CompletionItemSuggestion { + label: "not", + insert_text: "not(${1:x})", + documentation: "not x", + }, + CompletionItemSuggestion { + label: "true", + insert_text: "true", + documentation: "Boolean value true", + }, + CompletionItemSuggestion { + label: "false", + insert_text: "false", + documentation: "Boolean value false", + }, + CompletionItemSuggestion { + label: "if", + insert_text: "if (${1:condition}) {} else if (${2:condition}) {} else {}", + documentation: "An if/else conditional, which evaluates a condition such as:\n if \ + (\"Sales\" > 100) { true } else { false }", + }, + CompletionItemSuggestion { + label: "for", + insert_text: "for (${1:expression}) {}", + documentation: "A for loop, which repeatedly evaluates an incrementing expression such \ + as:\nvar x := 0; var y := 1; for (x < 10; x += 1) { y := x + y }", + }, + CompletionItemSuggestion { + label: "string", + insert_text: "string(${1:x})", + documentation: "Converts the given argument to a string", + }, + CompletionItemSuggestion { + label: "integer", + insert_text: "integer(${1:x})", + documentation: "Converts the given argument to a 32-bit integer. If the result \ + over/under-flows, null is returned", + }, + CompletionItemSuggestion { + label: "float", + insert_text: "float(${1:x})", + documentation: "Converts the argument to a float", + }, + CompletionItemSuggestion { + label: "date", + insert_text: "date(${1:year}, ${1:month}, ${1:day})", + documentation: "Given a year, month (1-12) and day, create a new date", + }, + CompletionItemSuggestion { + label: "datetime", + insert_text: "datetime(${1:timestamp})", + documentation: "Given a POSIX timestamp of milliseconds since epoch, create a new datetime", + }, + CompletionItemSuggestion { + label: "boolean", + insert_text: "boolean(${1:x})", + documentation: "Converts the given argument to a boolean", + }, + CompletionItemSuggestion { + label: "random", + insert_text: "random()", + documentation: "Returns a random float between 0 and 1, inclusive.", + }, + CompletionItemSuggestion { + label: "match", + insert_text: "match(${1:string}, ${2:pattern})", + documentation: "Returns True if any part of string matches pattern, and False otherwise.", + }, + CompletionItemSuggestion { + label: "match_all", + insert_text: "match_all(${1:string}, ${2:pattern})", + documentation: "Returns True if the whole string matches pattern, and False otherwise.", + }, + CompletionItemSuggestion { + label: "search", + insert_text: "search(${1:string}, ${2:pattern})", + documentation: "Returns the substring that matches the first capturing group in pattern, \ + or null if there are no capturing groups in the pattern or if there are \ + no matches.", + }, + CompletionItemSuggestion { + label: "indexof", + insert_text: "indexof(${1:string}, ${2:pattern}, ${3:output_vector})", + documentation: "Writes into index 0 and 1 of output_vector the start and end indices of \ + the substring that matches the first capturing group in \ + pattern.\n\nReturns true if there is a match and output was written, or \ + false if there are no capturing groups in the pattern, if there are no \ + matches, or if the indices are invalid.", + }, + CompletionItemSuggestion { + label: "substring", + insert_text: "substring(${1:string}, ${2:start_idx}, ${3:length})", + documentation: "Returns a substring of string from start_idx with the given length. If \ + length is not passed in, returns substring from start_idx to the end of \ + the string. Returns null if the string or any indices are invalid.", + }, + CompletionItemSuggestion { + label: "replace", + insert_text: "replace(${1:string}, ${2:pattern}, ${3:replacer})", + documentation: "Replaces the first match of pattern in string with replacer, or return \ + the original string if no replaces were made.", + }, + CompletionItemSuggestion { + label: "replace_all", + insert_text: "replace_all(${1:string}, ${2:pattern}, ${3:replacer})", + documentation: "Replaces all non-overlapping matches of pattern in string with replacer, \ + or return the original string if no replaces were made.", + }, + CompletionItemSuggestion { + label: "index", + insert_text: "index()", + documentation: "Looks up the index value of the current row", + }, + CompletionItemSuggestion { + label: "col", + insert_text: "col(${1:string})", + documentation: "Looks up a column value by name", + }, + CompletionItemSuggestion { + label: "vlookup", + insert_text: "vlookup(${1:string}, ${2:uint64})", + documentation: "Looks up a value in another column by index", + }, +]; + +#[test] +fn test_completions_insert_text_matches_label() { + for comp in COMPLETIONS { + let label = comp.label; + let insert_text = comp.insert_text; + assert!( + insert_text.starts_with(label), + "insert_text for label {label} does not start with {label}:\n {insert_text}" + ); + } +} diff --git a/rust/perspective-client/src/rust/config/filters.rs b/rust/perspective-client/src/rust/config/filters.rs new file mode 100644 index 0000000..c97403a --- /dev/null +++ b/rust/perspective-client/src/rust/config/filters.rs @@ -0,0 +1,202 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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::fmt::Display; + +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::proto; +use crate::proto::scalar; + +/// This type represents the ViewConfig serializable type, which must be JSON +/// safe. +#[derive(Clone, Default, Deserialize, Debug, PartialEq, Serialize, TS)] +#[serde(untagged)] +pub enum Scalar { + Float(f64), + String(String), + Bool(bool), + // DateTime(i64), + // Date(String), + // Int(i32), + #[default] + Null, +} + +impl From<&str> for Scalar { + fn from(value: &str) -> Self { + Self::String(value.into()) + } +} + +impl Display for Scalar { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + match self { + Self::Float(x) => write!(fmt, "{x}"), + Self::String(x) => write!(fmt, "{x}"), + Self::Bool(x) => write!(fmt, "{x}"), + Self::Null => write!(fmt, ""), + } + } +} + +#[derive(Clone, Deserialize, Debug, PartialEq, Serialize, TS)] +#[serde(untagged)] +pub enum FilterTerm { + Array(Vec), + Scalar(#[serde(default)] Scalar), +} + +impl<'a, T> From for FilterTerm +where + T: AsRef<[&'a str]>, +{ + fn from(value: T) -> Self { + Self::Array(value.as_ref().iter().map(|x| (*x).into()).collect()) + } +} + +impl Default for FilterTerm { + fn default() -> Self { + Self::Scalar(Scalar::Null) + } +} + +impl Display for FilterTerm { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + match self { + Self::Scalar(x) => { + write!(fmt, "{x}")?; + }, + Self::Array(xs) => write!( + fmt, + "{}", + Itertools::intersperse(xs.iter().map(|x| format!("{x}")), ",".to_owned()) + .collect::() + )?, + } + + Ok(()) + } +} + +impl FilterTerm { + pub fn is_null(&self) -> bool { + matches!(self, FilterTerm::Scalar(Scalar::Null)) + } +} + +#[derive(Clone, Deserialize, Debug, PartialEq, Serialize, TS)] +#[serde()] +pub struct Filter(String, String, #[serde(default)] FilterTerm); + +impl Filter { + pub fn new(column: &str, op: &str, term: T) -> Self + where + FilterTerm: From, + { + Filter(column.to_string(), op.to_string(), term.into()) + } + + pub fn column(&self) -> &str { + self.0.as_str() + } + + pub fn op(&self) -> &str { + self.1.as_str() + } + + pub fn term(&self) -> &FilterTerm { + &self.2 + } + + pub fn column_mut(&mut self) -> &mut String { + &mut self.0 + } + + pub fn op_mut(&mut self) -> &mut String { + &mut self.1 + } + + pub fn term_mut(&mut self) -> &mut FilterTerm { + &mut self.2 + } +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq, TS)] +pub enum FilterReducer { + #[default] + #[serde(rename = "and")] + And, + #[serde(rename = "or")] + Or, +} + +impl From for proto::Scalar { + fn from(value: Scalar) -> Self { + match value { + Scalar::Float(x) => proto::Scalar { + scalar: Some(scalar::Scalar::Float(x)), + }, + Scalar::String(x) => proto::Scalar { + scalar: Some(scalar::Scalar::String(x)), + }, + Scalar::Bool(x) => proto::Scalar { + scalar: Some(scalar::Scalar::Bool(x)), + }, + Scalar::Null => proto::Scalar { + scalar: Some(scalar::Scalar::Null(0)), + }, + } + } +} + +impl From for Scalar { + fn from(value: proto::Scalar) -> Self { + match value.scalar { + Some(scalar::Scalar::Bool(x)) => Scalar::Bool(x), + Some(scalar::Scalar::String(x)) => Scalar::String(x), + Some(scalar::Scalar::Float(x)) => Scalar::Float(x), + Some(scalar::Scalar::Null(_)) => Scalar::Null, + None => Scalar::Null, + } + } +} + +impl From for proto::view_config::Filter { + fn from(value: Filter) -> Self { + proto::view_config::Filter { + column: value.0, + op: value.1, + value: match value.2 { + FilterTerm::Scalar(x) => vec![x.into()], + FilterTerm::Array(x) => x.into_iter().map(|x| x.into()).collect(), + }, + } + } +} + +impl From for Filter { + fn from(value: proto::view_config::Filter) -> Self { + Filter( + value.column, + value.op, + if value.value.len() == 1 { + FilterTerm::Scalar(value.value.into_iter().next().unwrap().into()) + } else { + FilterTerm::Array(value.value.into_iter().map(|x| x.into()).collect()) + }, + ) + } +} diff --git a/rust/perspective-client/src/rust/config/mod.rs b/rust/perspective-client/src/rust/config/mod.rs new file mode 100644 index 0000000..6a84aad --- /dev/null +++ b/rust/perspective-client/src/rust/config/mod.rs @@ -0,0 +1,37 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +//! Options for [`Table::view`], and details about fields like `expressions`. +//! +//! [`ViewConfig`] for the top level argument to [`Table::view`]. +//! +//! [`expressions`] module for details about the ExprTK format. + +#[cfg(doc)] +use crate::Table; + +mod aggregates; +mod column_type; +pub mod expressions; +mod filters; +mod plugin; +mod sort; +mod view_config; + +pub use aggregates::*; +pub use expressions::*; +pub use filters::*; +pub use plugin::*; +pub use sort::*; +pub use view_config::*; + +pub use crate::proto::{ColumnType, SortOp}; diff --git a/rust/perspective-client/src/rust/config/plugin.rs b/rust/perspective-client/src/rust/config/plugin.rs new file mode 100644 index 0000000..a3a80f5 --- /dev/null +++ b/rust/perspective-client/src/rust/config/plugin.rs @@ -0,0 +1,69 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 std::fmt::Debug; + +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] +pub struct Symbol { + /// the name of the symbol + pub name: String, + /// unescaped HTML string + pub html: String, +} +impl std::fmt::Display for Symbol { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.name) + } +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] +pub struct SymbolAttributes { + /// a vec of SVGs to render + pub symbols: Vec, +} + +/// The default style configurations per type, as retrived by +/// plugin.plugin_attributes +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct DefaultStyleAttributes { + pub string: serde_json::Value, + pub datetime: serde_json::Value, + pub date: serde_json::Value, + pub integer: serde_json::Value, + pub float: serde_json::Value, + pub bool: serde_json::Value, +} + +/// The data needed to populate a column's settings. +/// +/// These are typically default values, a listing of possible values, or other +/// basic configuration settings for the plugin. This is the result of calling +/// plugin.plugin_attributes +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct PluginAttributes { + pub symbol: Option, + pub style: Option, + // color: ColorAttributes, + // axis: AxisAttributes, + //... +} + +/// The configuration which is created as the result of calling plugin.save +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] +pub struct PluginConfig { + /// Refers to the currently active columns. Maps name to configuration. + #[serde(default)] + pub columns: HashMap, +} diff --git a/rust/perspective-client/src/rust/config/sort.rs b/rust/perspective-client/src/rust/config/sort.rs new file mode 100644 index 0000000..0d75e97 --- /dev/null +++ b/rust/perspective-client/src/rust/config/sort.rs @@ -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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +use std::fmt::Display; + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::proto; + +#[derive(Clone, Deserialize, Debug, Eq, PartialEq, Serialize, TS)] +#[serde()] +pub struct Sort(pub String, pub SortDir); + +#[derive(Clone, Copy, Deserialize, Debug, Eq, PartialEq, Serialize, TS)] +#[serde()] +pub enum SortDir { + #[serde(rename = "none")] + None, + + #[serde(rename = "desc")] + Desc, + + #[serde(rename = "asc")] + Asc, + + #[serde(rename = "col desc")] + ColDesc, + + #[serde(rename = "col asc")] + ColAsc, + + #[serde(rename = "desc abs")] + DescAbs, + + #[serde(rename = "asc abs")] + AscAbs, + + #[serde(rename = "col desc abs")] + ColDescAbs, + + #[serde(rename = "col asc abs")] + ColAscAbs, +} + +impl Display for SortDir { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> { + write!(fmt, "{}", match self { + Self::None => "none", + Self::Desc => "desc", + Self::Asc => "asc", + Self::ColDesc => "col desc", + Self::ColAsc => "col asc", + Self::DescAbs => "desc abs", + Self::AscAbs => "asc abs", + Self::ColDescAbs => "col desc abs", + Self::ColAscAbs => "col asc abs", + }) + } +} + +impl SortDir { + /// Increment the `SortDir` in logical order, given an `abs()` modifier. + pub fn cycle(&self, split_by: bool, abs: bool) -> Self { + let order: &[Self] = match (split_by, abs) { + (false, false) => &[Self::None, Self::Asc, Self::Desc], + (false, true) => &[Self::None, Self::AscAbs, Self::DescAbs], + (true, false) => &[ + Self::None, + Self::Asc, + Self::Desc, + Self::ColAsc, + Self::ColDesc, + ], + (true, true) => &[ + Self::None, + Self::AscAbs, + Self::DescAbs, + Self::ColAscAbs, + Self::ColDescAbs, + ], + }; + + let index = order.iter().position(|x| x == self).unwrap_or(0); + order[(index + 1) % order.len()] + } +} + +impl From for proto::SortOp { + fn from(value: SortDir) -> Self { + match value { + SortDir::None => proto::SortOp::SortNone, + SortDir::Desc => proto::SortOp::SortDesc, + SortDir::Asc => proto::SortOp::SortAsc, + SortDir::ColDesc => proto::SortOp::SortColDesc, + SortDir::ColAsc => proto::SortOp::SortColAsc, + SortDir::DescAbs => proto::SortOp::SortDescAbs, + SortDir::AscAbs => proto::SortOp::SortAscAbs, + SortDir::ColDescAbs => proto::SortOp::SortColDescAbs, + SortDir::ColAscAbs => proto::SortOp::SortColAscAbs, + } + } +} + +impl From for SortDir { + fn from(value: proto::SortOp) -> Self { + match value { + proto::SortOp::SortNone => SortDir::None, + proto::SortOp::SortDesc => SortDir::Desc, + proto::SortOp::SortAsc => SortDir::Asc, + proto::SortOp::SortColAsc => SortDir::ColAsc, + proto::SortOp::SortColDesc => SortDir::ColDesc, + proto::SortOp::SortAscAbs => SortDir::AscAbs, + proto::SortOp::SortDescAbs => SortDir::DescAbs, + proto::SortOp::SortColAscAbs => SortDir::ColAscAbs, + proto::SortOp::SortColDescAbs => SortDir::ColDescAbs, + } + } +} + +impl From for proto::view_config::Sort { + fn from(value: Sort) -> Self { + proto::view_config::Sort { + column: value.0, + op: proto::SortOp::from(value.1).into(), + } + } +} + +impl From for Sort { + fn from(value: proto::view_config::Sort) -> Self { + Sort( + value.column, + proto::SortOp::try_from(value.op).unwrap().into(), + ) + } +} diff --git a/rust/perspective-client/src/rust/config/view_config.rs b/rust/perspective-client/src/rust/config/view_config.rs new file mode 100644 index 0000000..42f5cff --- /dev/null +++ b/rust/perspective-client/src/rust/config/view_config.rs @@ -0,0 +1,479 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 std::fmt::Display; + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use super::aggregates::*; +use super::expressions::*; +use super::filters::*; +use super::sort::*; +use crate::proto; +use crate::proto::columns_update; + +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq, TS)] +pub enum GroupRollupMode { + #[default] + #[serde(rename = "rollup")] + Rollup, + + #[serde(rename = "flat")] + Flat, + + #[serde(rename = "total")] + Total, +} + +impl Display for GroupRollupMode { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> { + write!(fmt, "{}", match self { + Self::Rollup => "Rollup", + Self::Flat => "Flat", + Self::Total => "Total", + }) + } +} + +impl From for GroupRollupMode { + fn from(value: proto::GroupRollupMode) -> Self { + match value { + proto::GroupRollupMode::Rollup => Self::Rollup, + proto::GroupRollupMode::Flat => Self::Flat, + proto::GroupRollupMode::Total => Self::Total, + } + } +} + +impl From for proto::GroupRollupMode { + fn from(value: GroupRollupMode) -> Self { + match value { + GroupRollupMode::Rollup => proto::GroupRollupMode::Rollup, + GroupRollupMode::Flat => proto::GroupRollupMode::Flat, + GroupRollupMode::Total => proto::GroupRollupMode::Total, + } + } +} + +#[derive(Clone, Debug, Deserialize, Default, PartialEq, Serialize, TS)] +#[serde(deny_unknown_fields)] +pub struct ViewConfig { + #[serde(default)] + pub group_by: Vec, + + #[serde(default)] + pub split_by: Vec, + + #[serde(default)] + pub sort: Vec, + + #[serde(default)] + pub filter: Vec, + + // #[serde(skip_serializing_if = "is_default_value")] + #[serde(default)] + pub group_rollup_mode: GroupRollupMode, + + #[serde(skip_serializing_if = "is_default_value")] + #[serde(default)] + pub filter_op: FilterReducer, + + #[serde(default)] + pub expressions: Expressions, + + #[serde(default)] + pub columns: Vec>, + + #[serde(default)] + pub aggregates: HashMap, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub group_by_depth: Option, +} + +fn is_default_value(value: &A) -> bool { + value == &A::default() +} + +#[derive(Clone, Debug, Deserialize, Default, PartialEq, Serialize, TS)] +#[serde(deny_unknown_fields)] +pub struct ViewConfigUpdate { + /// A group by _groups_ the dataset by the unique values of each column used + /// as a group by - a close analogue in SQL to the `GROUP BY` statement. + /// The underlying dataset is aggregated to show the values belonging to + /// each group, and a total row is calculated for each group, showing + /// the currently selected aggregated value (e.g. `sum`) of the column. + /// Group by are useful for hierarchies, categorizing data and + /// attributing values, i.e. showing the number of units sold based on + /// State and City. In Perspective, group by are represented as an array + /// of string column names to pivot, are applied in the order provided; + /// For example, a group by of `["State", "City", "Postal Code"]` shows + /// the values for each Postal Code, which are grouped by City, + /// which are in turn grouped by State. + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + #[ts(optional)] + pub group_by: Option>, + + /// A split by _splits_ the dataset by the unique values of each column used + /// as a split by. The underlying dataset is not aggregated, and a new + /// column is created for each unique value of the split by. Each newly + /// created column contains the parts of the dataset that correspond to + /// the column header, i.e. a `View` that has `["State"]` as its split + /// by will have a new column for each state. In Perspective, Split By + /// are represented as an array of string column names to pivot. + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + #[ts(optional)] + pub split_by: Option>, + + /// The `columns` property specifies which columns should be included in the + /// [`crate::View`]'s output. This allows users to show or hide a specific + /// subset of columns, as well as control the order in which columns + /// appear to the user. This is represented in Perspective as an array + /// of string column names. + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + #[ts(optional)] + pub columns: Option>>, + + /// The `filter` property specifies columns on which the query can be + /// filtered, returning rows that pass the specified filter condition. + /// This is analogous to the `WHERE` clause in SQL. There is no limit on + /// the number of columns where `filter` is applied, but the resulting + /// dataset is one that passes all the filter conditions, i.e. the + /// filters are joined with an `AND` condition. + /// + /// Perspective represents `filter` as an array of arrays, with the values + /// of each inner array being a string column name, a string filter + /// operator, and a filter operand in the type of the column. + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + #[ts(optional)] + pub filter: Option>, + + /// The `sort` property specifies columns on which the query should be + /// sorted, analogous to `ORDER BY` in SQL. A column can be sorted + /// regardless of its data type, and sorts can be applied in ascending + /// or descending order. Perspective represents `sort` as an array of + /// arrays, with the values of each inner array being a string column + /// name and a string sort direction. When `column-pivots` are applied, + /// the additional sort directions `"col asc"` and `"col desc"` will + /// determine the order of pivot columns groups. + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + #[ts(optional)] + pub sort: Option>, + + /// The `expressions` property specifies _new_ columns in Perspective that + /// are created using existing column values or arbitary scalar values + /// defined within the expression. In ``, + /// expressions are added using the "New Column" button in the side + /// panel. + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + #[ts(optional)] + pub expressions: Option, + + /// Aggregates perform a calculation over an entire column, and are + /// displayed when one or more [Group By](#group-by) are applied to the + /// `View`. Aggregates can be specified by the user, or Perspective will + /// use the following sensible default aggregates based on column type: + /// + /// - "sum" for `integer` and `float` columns + /// - "count" for all other columns + /// + /// Perspective provides a selection of aggregate functions that can be + /// applied to columns in the `View` constructor using a dictionary of + /// column name to aggregate function name. + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + #[ts(optional)] + pub aggregates: Option>, + + #[serde(skip_serializing)] + #[serde(default)] + #[ts(optional)] + pub group_by_depth: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + #[ts(optional)] + pub filter_op: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + #[ts(optional)] + pub group_rollup_mode: Option, +} + +impl From for proto::ViewConfig { + fn from(value: ViewConfigUpdate) -> Self { + proto::ViewConfig { + group_by: value.group_by.unwrap_or_default(), + split_by: value.split_by.unwrap_or_default(), + columns: value.columns.map(|x| proto::ColumnsUpdate { + opt_columns: Some(columns_update::OptColumns::Columns( + proto::columns_update::Columns { + columns: x.into_iter().flatten().collect(), + }, + )), + }), + filter: value + .filter + .unwrap_or_default() + .into_iter() + .map(|x| x.into()) + .collect(), + filter_op: value + .filter_op + .map(proto::view_config::FilterReducer::from) + .unwrap_or_default() as i32, + sort: value + .sort + .unwrap_or_default() + .into_iter() + .map(|x| x.into()) + .collect(), + expressions: value.expressions.unwrap_or_default().0, + aggregates: value + .aggregates + .unwrap_or_default() + .into_iter() + .map(|(x, y)| (x, y.into())) + .collect(), + group_by_depth: value.group_by_depth, + group_rollup_mode: value + .group_rollup_mode + .map(|x| proto::GroupRollupMode::from(x).into()), + } + } +} + +impl From for proto::view_config::FilterReducer { + fn from(value: FilterReducer) -> Self { + match value { + FilterReducer::And => proto::view_config::FilterReducer::And, + FilterReducer::Or => proto::view_config::FilterReducer::Or, + } + } +} + +impl From for FilterReducer { + fn from(value: proto::view_config::FilterReducer) -> Self { + match value { + proto::view_config::FilterReducer::And => FilterReducer::And, + proto::view_config::FilterReducer::Or => FilterReducer::Or, + } + } +} + +impl From for ViewConfigUpdate { + fn from(value: ViewConfig) -> Self { + ViewConfigUpdate { + group_by: Some(value.group_by), + split_by: Some(value.split_by), + columns: Some(value.columns), + filter: Some(value.filter), + filter_op: Some(value.filter_op), + sort: Some(value.sort), + expressions: Some(value.expressions), + aggregates: Some(value.aggregates), + group_by_depth: value.group_by_depth, + group_rollup_mode: Some(value.group_rollup_mode), + } + } +} + +impl From for ViewConfig { + fn from(value: proto::ViewConfig) -> Self { + ViewConfig { + group_by: value.group_by, + split_by: value.split_by, + columns: match value.columns.unwrap_or_default().opt_columns { + Some(columns_update::OptColumns::Columns(x)) => { + x.columns.into_iter().map(Some).collect() + }, + _ => { + vec![] + }, + }, + filter: value.filter.into_iter().map(|x| x.into()).collect(), + filter_op: proto::view_config::FilterReducer::try_from(value.filter_op) + .unwrap_or_default() + .into(), + sort: value.sort.into_iter().map(|x| x.into()).collect(), + expressions: Expressions(value.expressions), + aggregates: value + .aggregates + .into_iter() + .map(|(x, y)| (x, y.into())) + .collect(), + group_by_depth: value.group_by_depth, + group_rollup_mode: value + .group_rollup_mode + .map(proto::GroupRollupMode::try_from) + .and_then(|x| x.ok()) + .map(|x| x.into()) + .unwrap_or_default(), + } + } +} + +impl From for ViewConfig { + fn from(value: ViewConfigUpdate) -> Self { + ViewConfig { + group_by: value.group_by.unwrap_or_default(), + split_by: value.split_by.unwrap_or_default(), + columns: value.columns.unwrap_or_default(), + filter: value.filter.unwrap_or_default(), + filter_op: value.filter_op.unwrap_or_default(), + sort: value.sort.unwrap_or_default(), + expressions: value.expressions.unwrap_or_default(), + aggregates: value.aggregates.unwrap_or_default(), + group_by_depth: value.group_by_depth, + group_rollup_mode: value.group_rollup_mode.unwrap_or_default(), + } + } +} + +impl From for ViewConfigUpdate { + fn from(value: proto::ViewConfig) -> Self { + ViewConfigUpdate { + group_by: Some(value.group_by), + split_by: Some(value.split_by), + columns: match value.columns.unwrap_or_default().opt_columns { + Some(columns_update::OptColumns::Columns(x)) => { + Some(x.columns.into_iter().map(Some).collect()) + }, + _ => None, + }, + filter: Some(value.filter.into_iter().map(|x| x.into()).collect()), + filter_op: Some( + proto::view_config::FilterReducer::try_from(value.filter_op) + .unwrap_or_default() + .into(), + ), + sort: Some(value.sort.into_iter().map(|x| x.into()).collect()), + expressions: Some(Expressions(value.expressions)), + aggregates: Some( + value + .aggregates + .into_iter() + .map(|(x, y)| (x, y.into())) + .collect(), + ), + group_by_depth: value.group_by_depth, + group_rollup_mode: value + .group_rollup_mode + .and_then(|x| proto::GroupRollupMode::try_from(x).ok()) + .map(|x| x.into()), + } + } +} + +impl ViewConfig { + fn _apply(field: &mut T, update: Option) -> bool { + match update { + None => false, + Some(update) => { + *field = update; + true + }, + } + } + + pub fn reset(&mut self, reset_expressions: bool) { + let mut config = Self::default(); + if !reset_expressions { + config.expressions = self.expressions.clone(); + } + std::mem::swap(self, &mut config); + } + + /// Apply `ViewConfigUpdate` to a `ViewConfig`, ignoring any fields in + /// `update` which were unset. + pub fn apply_update(&mut self, mut update: ViewConfigUpdate) -> bool { + let mut changed = false; + if ((self.group_rollup_mode == GroupRollupMode::Total + && update.group_rollup_mode.is_none()) + || update.group_rollup_mode == Some(GroupRollupMode::Total)) + && update + .group_by + .as_ref() + .map(|x| !x.is_empty()) + .unwrap_or_default() + { + tracing::info!("`total` incompatible with `group_by`"); + changed = true; + update.group_rollup_mode = Some(GroupRollupMode::Rollup); + } + + if update.group_rollup_mode == Some(GroupRollupMode::Total) && !self.group_by.is_empty() { + tracing::info!("`group_by` incompatible with `total`"); + changed = true; + update.group_by = Some(vec![]); + } + + changed = Self::_apply(&mut self.group_by, update.group_by) || changed; + changed = Self::_apply(&mut self.split_by, update.split_by) || changed; + changed = Self::_apply(&mut self.columns, update.columns) || changed; + changed = Self::_apply(&mut self.filter, update.filter) || changed; + changed = Self::_apply(&mut self.sort, update.sort) || changed; + changed = Self::_apply(&mut self.aggregates, update.aggregates) || changed; + changed = Self::_apply(&mut self.expressions, update.expressions) || changed; + changed = Self::_apply(&mut self.group_rollup_mode, update.group_rollup_mode) || changed; + if self.group_rollup_mode == GroupRollupMode::Total && !self.group_by.is_empty() { + tracing::info!("`total` incompatible with `group_by`"); + changed = true; + self.group_by = vec![]; + } + + changed + } + + pub fn is_aggregated(&self) -> bool { + !self.group_by.is_empty() || self.group_rollup_mode == GroupRollupMode::Total + } + + pub fn is_column_expression_in_use(&self, name: &str) -> bool { + let name = name.to_owned(); + self.group_by.contains(&name) + || self.split_by.contains(&name) + || self.sort.iter().any(|x| x.0 == name) + || self.filter.iter().any(|x| x.column() == name) + || self.columns.contains(&Some(name)) + } + + /// `ViewConfig` carries additional metadata in the form of `None` columns + /// which are filtered befor ebeing passed to the engine, but whose position + /// is a placeholder for Viewer functionality. `is_equivalent` tests + /// equivalency from the perspective of the engine. + pub fn is_equivalent(&self, other: &Self) -> bool { + let _self = self.clone(); + let _self = ViewConfig { + columns: _self.columns.into_iter().filter(|x| x.is_some()).collect(), + .._self + }; + + let _other = other.clone(); + let _other = ViewConfig { + columns: _other.columns.into_iter().filter(|x| x.is_some()).collect(), + ..other.clone() + }; + + _self == _other + } +} diff --git a/rust/perspective-client/src/rust/lib.rs b/rust/perspective-client/src/rust/lib.rs new file mode 100644 index 0000000..faaf031 --- /dev/null +++ b/rust/perspective-client/src/rust/lib.rs @@ -0,0 +1,149 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +//! `perspective_client` is the client implementation of +//! [Perspective](https://perspective-dev.github.io), designed to be used from Rust +//! directly, and as a core to `perspective-js` and `perspective-python` crates +//! which wrap language-specific bindings for this module. +//! +//! # See also +//! +//! - [`perspective-rs`](https://docs.rs/perspective/latest/) for the Rust +//! Client and Server APIs. +//! - [`perspective-js`](https://docs.rs/perspective-js/latest/) for the +//! JavaScript API. +//! - [`perspective-python`](https://docs.rs/perspective-python/latest/) for the +//! Python API. +//! - [`perspective-server`](https://docs.rs/perspective-server/latest/) for +//! Data Binding details. +//! - [`perspective-viewer`](https://docs.rs/perspective-viewer/latest/) for the +//! WebAssembly `` Custom Element API. + +#![warn( + clippy::all, + clippy::panic_in_result_fn, + clippy::await_holding_refcell_ref +)] + +mod client; +mod session; +mod table; +mod table_data; +mod table_ref; +mod view; +pub mod virtual_server; + +pub mod config; + +#[rustfmt::skip] +#[allow(clippy::all)] +pub mod proto; + +pub mod utils; + +pub use crate::client::{Client, ClientHandler, Features, ReconnectCallback, SystemInfo}; +use crate::proto::HostedTable; +pub use crate::proto::JoinType; +pub use crate::session::{ProxySession, Session}; +pub use crate::table::{ + DeleteOptions, ExprValidationResult, JoinOptions, Table, TableInitOptions, TableReadFormat, + UpdateOptions, +}; +pub use crate::table_data::{TableData, UpdateData}; +pub use crate::table_ref::TableRef; +pub use crate::view::{ + ColumnWindow, OnUpdateData, OnUpdateMode, OnUpdateOptions, View, ViewWindow, +}; + +pub type ClientError = utils::ClientError; +pub type ExprValidationError = crate::proto::table_validate_expr_resp::ExprValidationError; + +#[doc(hidden)] +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, +/// because the signatures will not match exactly (unless every method is +/// made heavily generic). Instead, this macro complains when a method name +/// is missing. +#[doc(hidden)] +#[macro_export] +macro_rules! assert_table_api { + ($x:ty) => { + $crate::vendor::paste::paste! { + #[cfg(debug_assertions)] + fn [< _assert_table_api_ $x:lower >]() { + let _ = ( + &$x::clear, + &$x::columns, + &$x::delete, + &$x::get_index, + &$x::get_limit, + &$x::get_client, + &$x::make_port, + &$x::on_delete, + &$x::remove_delete, + &$x::replace, + &$x::schema, + &$x::size, + &$x::update, + &$x::validate_expressions, + &$x::view, + ); + } + } + }; +} + +/// Similar to [`assert_table_api`], but for [`View`]. See [`assert_table_api`]. +#[doc(hidden)] +#[macro_export] +macro_rules! assert_view_api { + ($x:ty) => { + $crate::vendor::paste::paste! { + #[cfg(debug_assertions)] + fn [< _assert_table_api_ $x:lower >]() { + let _ = ( + &$x::column_paths, + &$x::delete, + &$x::dimensions, + &$x::expression_schema, + &$x::get_config, + &$x::get_min_max, + &$x::num_rows, + // &$x::on_update, + &$x::remove_update, + &$x::on_delete, + &$x::remove_delete, + &$x::schema, + &$x::to_arrow, + &$x::to_columns_string, + &$x::to_json_string, + &$x::to_csv, + ); + } + } + }; +} diff --git a/rust/perspective-client/src/rust/session.rs b/rust/perspective-client/src/rust/session.rs new file mode 100644 index 0000000..cc37a2a --- /dev/null +++ b/rust/perspective-client/src/rust/session.rs @@ -0,0 +1,110 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 as StdError; +use std::sync::Arc; + +use futures::Future; +use prost::Message; + +use crate::proto::request::ClientReq; +use crate::proto::{Request, Response}; +use crate::{Client, ClientError, asyncfn}; +#[cfg(doc)] +use crate::{Table, View}; + +/// The server-side representation of a connection to a [`Client`]. +/// +/// For each [`Client`] that wants to connect to a `perspective_server::Server`, +/// a dedicated [`Session`] must be created. The [`Session`] handles routing +/// messages emitted by the `perspective_server::Server`ve_server::Server`, as +/// well as owning any resources the [`Client`] may request. +pub trait Session { + /// Handle an incoming request from the [`Client`]. Calling + /// [`Session::handle_request`] will result in the `send_response` parameter + /// which was used to construct this [`Session`] to fire one or more times. + /// + /// # Arguments + /// + /// - `request` An incoming request message, generated from a + /// [`Client::new`]'s `send_request` handler (which may-or-may-not be + /// local). + fn handle_request(&self, request: &[u8]) -> impl Future>; + + /// Close this [`Session`], cleaning up any callbacks (e.g. arguments + /// provided to [`Session::handle_request`]) and resources (e.g. views + /// returned by a call to [`Table::view`]). + /// + /// Dropping a [`Session`] outside of the context of [`Session::close`] + /// will cause a [`tracing`] error-level log to be emitted, but won't fail. + /// They will, however, leak. + fn close(self) -> impl Future; +} + +type ProxyCallbackError = Box; +type ProxyCallback = Arc Result<(), ProxyCallbackError> + Send + Sync>; + +/// A [`Session`] implementation which tunnels through another [`Client`]. +/// @private +#[derive(Clone)] +pub struct ProxySession { + parent: Client, + callback: ProxyCallback, +} + +impl ProxySession { + pub fn new( + client: Client, + send_response: impl Fn(&[u8]) -> Result<(), ProxyCallbackError> + Send + Sync + 'static, + ) -> Self { + ProxySession { + parent: client, + callback: Arc::new(send_response), + } + } +} + +fn encode(response: Response, callback: ProxyCallback) -> Result<(), ClientError> { + let mut enc = vec![]; + response.encode(&mut enc)?; + callback(&enc).map_err(|x| ClientError::Unknown(x.to_string()))?; + Ok(()) +} + +impl Session for ProxySession { + async fn handle_request(&self, request: &[u8]) -> Result<(), ClientError> { + let req = Request::decode(request)?; + let callback = self.callback.clone(); + match req.client_req.as_ref() { + Some(ClientReq::ViewOnUpdateReq(_)) => { + let on_update = + asyncfn!(callback, async move |response| encode(response, callback)); + self.parent.subscribe(&req, on_update).await? + }, + Some(_) => { + let on_update = move |response| encode(response, callback); + self.parent + .subscribe_once(&req, Box::new(on_update)) + .await? + }, + None => { + return Err(ClientError::Internal( + "ProxySession::handle_request: invalid request".to_string(), + )); + }, + }; + + Ok(()) + } + + async fn close(self) {} +} diff --git a/rust/perspective-client/src/rust/table.rs b/rust/perspective-client/src/rust/table.rs new file mode 100644 index 0000000..6ecc511 --- /dev/null +++ b/rust/perspective-client/src/rust/table.rs @@ -0,0 +1,620 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 std::fmt::Display; + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::assert_table_api; +use crate::client::{Client, Features}; +use crate::config::{Expressions, ViewConfigUpdate}; +use crate::proto::make_table_req::MakeTableOptions; +use crate::proto::make_table_req::make_table_options::MakeTableType; +use crate::proto::request::ClientReq; +use crate::proto::response::ClientResp; +use crate::proto::*; +use crate::table_data::UpdateData; +use crate::utils::*; +use crate::view::View; + +pub type Schema = HashMap; + +/// The format to interpret data preovided to [`Client::table`]. +/// +/// When serialized, these values are `"csv"`, `"json"`, `"columns"`, `"arrow"` +/// and `"ndjson"`. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)] +pub enum TableReadFormat { + #[serde(rename = "csv")] + Csv, + + #[serde(rename = "json")] + JsonString, + + #[serde(rename = "columns")] + ColumnsString, + + #[serde(rename = "arrow")] + Arrow, + + #[serde(rename = "ndjson")] + Ndjson, +} + +impl TableReadFormat { + pub fn parse(value: Option) -> Result, String> { + Ok(match value.as_deref() { + Some("csv") => Some(TableReadFormat::Csv), + Some("json") => Some(TableReadFormat::JsonString), + Some("columns") => Some(TableReadFormat::ColumnsString), + Some("arrow") => Some(TableReadFormat::Arrow), + Some("ndjson") => Some(TableReadFormat::Ndjson), + None => None, + Some(x) => return Err(format!("Unknown format \"{x}\"")), + }) + } +} + +/// Options which impact the behavior of [`Client::table`], as well as +/// subsequent calls to [`Table::update`]. +#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)] +pub struct TableInitOptions { + #[serde(default)] + #[ts(optional)] + pub name: Option, + + #[serde(default)] + #[ts(optional)] + pub format: Option, + + /// This [`Table`] should use the column named by the `index` parameter as + /// the `index`, which causes [`Table::update`] and [`Client::table`] input + /// to either insert or update existing rows based on `index` column + /// value equality. + #[serde(default)] + #[ts(optional)] + pub index: Option, + + /// This [`Table`] should be limited to `limit` rows, after which the + /// _earliest_ rows will be overwritten (where _earliest_ is defined as + /// relative to insertion order). + #[serde(default)] + #[ts(optional)] + pub limit: Option, + + /// Back this [`Table`]'s canonical data with the on-disk storage backend + /// instead of memory. On native targets this is a memory-mapped file; on + /// WASM it is OPFS (Worker only). Defaults to in-memory. + #[serde(default)] + #[ts(optional)] + pub page_to_disk: Option, +} + +impl TableInitOptions { + pub fn set_name(&mut self, name: D) { + self.name = Some(format!("{name}")) + } +} + +impl TryFrom for MakeTableOptions { + type Error = ClientError; + + fn try_from(value: TableOptions) -> Result { + let page_to_disk = value.page_to_disk; + Ok(MakeTableOptions { + page_to_disk, + make_table_type: match value { + TableOptions { + index: Some(_), + limit: Some(_), + .. + } => Err(ClientError::BadTableOptions)?, + TableOptions { + index: Some(index), .. + } => Some(MakeTableType::MakeIndexTable(index)), + TableOptions { + limit: Some(limit), .. + } => Some(MakeTableType::MakeLimitTable(limit)), + _ => None, + }, + }) + } +} + +#[derive(Clone, Debug)] +pub(crate) struct TableOptions { + pub index: Option, + pub limit: Option, + pub page_to_disk: Option, +} + +impl From for TableOptions { + fn from(value: TableInitOptions) -> Self { + TableOptions { + index: value.index, + limit: value.limit, + page_to_disk: value.page_to_disk, + } + } +} + +/// Options for [`Client::join`]. +#[derive(Clone, Debug, Default, Serialize, Deserialize, TS)] +pub struct JoinOptions { + #[serde(default)] + #[ts(optional)] + pub join_type: Option, + + #[serde(default)] + #[ts(optional)] + pub name: Option, + + #[serde(default)] + #[ts(optional)] + pub right_on: Option, +} + +/// Options for [`Table::delete`]. +#[derive(Clone, Debug, Default, Deserialize, TS)] +pub struct DeleteOptions { + pub lazy: bool, +} + +/// Options for [`Table::update`]. +#[derive(Clone, Debug, Default, Deserialize, Serialize, TS)] +pub struct UpdateOptions { + pub port_id: Option, + pub format: Option, +} + +/// Result of a call to [`Table::validate_expressions`], containing a schema +/// for valid expressions and error messages for invalid ones. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ExprValidationResult { + pub expression_schema: Schema, + pub errors: HashMap, + pub expression_alias: HashMap, +} + +/// [`Table`] is Perspective's columnar data frame, analogous to a Pandas/Polars +/// `DataFrame` or Apache Arrow, supporting append & in-place updates, removal +/// by index, and update notifications. +/// +/// A [`Table`] contains columns, each of which have a unique name, are strongly +/// and consistently typed, and contains rows of data conforming to the column's +/// type. Each column in a [`Table`] must have the same number of rows, though +/// not every row must contain data; null-values are used to indicate missing +/// values in the dataset. The schema of a [`Table`] is _immutable after +/// creation_, which means the column names and data types cannot be changed +/// after the [`Table`] has been created. Columns cannot be added or deleted +/// after creation either, but a [`View`] can be used to select an arbitrary set +/// of columns from the [`Table`]. +#[derive(Clone)] +pub struct Table { + name: String, + client: Client, + options: TableOptions, + + /// If this table is constructed from a View, the view's on_update callback + /// is wired into this table. So, we store the token to clean it up properly + /// on destruction. + pub(crate) view_update_token: Option, +} + +assert_table_api!(Table); + +impl PartialEq for Table { + fn eq(&self, other: &Self) -> bool { + self.name == other.name && self.client == other.client + } +} + +impl Table { + pub(crate) fn new(name: String, client: Client, options: TableOptions) -> Self { + Table { + name, + client, + options, + view_update_token: None, + } + } + + fn client_message(&self, req: ClientReq) -> Request { + Request { + msg_id: self.client.gen_id(), + entity_id: self.name.clone(), + client_req: Some(req), + } + } + + /// Get a copy of the [`Client`] this [`Table`] came from. + pub fn get_client(&self) -> Client { + self.client.clone() + } + + /// Get a metadata dictionary of the `perspective_server::Server`'s + /// features, which is (currently) implementation specific, but there is + /// only one implementation. + pub async fn get_features(&self) -> ClientResult { + self.client.get_features().await + } + + /// Returns the name of the index column for the table. + /// + /// # Examples + /// + /// ```no_run + /// # use perspective_client::{Client, TableData, TableInitOptions, UpdateData}; + /// # async fn run() -> Result<(), Box> { + /// # let client: Client = todo!(); + /// let options = TableInitOptions { + /// index: Some("x".to_string()), + /// ..TableInitOptions::default() + /// }; + /// let data = TableData::Update(UpdateData::Csv("x,y\n1,2\n3,4".into())); + /// let table = client.table(data, options).await?; + /// let index = table.get_index(); + /// # Ok(()) } + /// ``` + pub fn get_index(&self) -> Option { + self.options.index.as_ref().map(|index| index.to_owned()) + } + + /// Returns the user-specified row limit for this table. + pub fn get_limit(&self) -> Option { + self.options.limit.as_ref().map(|limit| *limit) + } + + /// Returns the user-specified name for this table, or the auto-generated + /// name if a name was not specified when the table was created. + pub fn get_name(&self) -> &str { + self.name.as_str() + } + + /// Removes all the rows in the [`Table`], but preserves everything else + /// including the schema, index, and any callbacks or registered + /// [`View`] instances. + /// + /// Calling [`Table::clear`], like [`Table::update`] and [`Table::remove`], + /// will trigger an update event to any registered listeners via + /// [`View::on_update`]. + pub async fn clear(&self) -> ClientResult<()> { + self.replace(UpdateData::JsonRows("[]".to_owned())).await + } + + /// Delete this [`Table`] and cleans up associated resources. + /// + /// [`Table`]s do not stop consuming resources or processing updates when + /// they are garbage collected in their host language - you must call + /// this method to reclaim these. + /// + /// # Arguments + /// + /// - `options` An options dictionary. + /// - `lazy` Whether to delete this [`Table`] _lazily_. When false (the + /// default), the delete will occur immediately, assuming it has no + /// [`View`] instances registered to it (which must be deleted first, + /// otherwise this method will throw an error). When true, the + /// [`Table`] will only be marked for deltion once its [`View`] + /// dependency count reaches 0. + /// + /// # Examples + /// + /// ```no_run + /// # use perspective_client::{Client, DeleteOptions, TableData, TableInitOptions, UpdateData}; + /// # async fn run() -> Result<(), Box> { + /// # let client: Client = todo!(); + /// let opts = TableInitOptions::default(); + /// let data = TableData::Update(UpdateData::Csv("x,y\n1,2\n3,4".into())); + /// let table = client.table(data, opts).await?; + /// + /// // ... + /// + /// table.delete(DeleteOptions::default()).await?; + /// # Ok(()) } + /// ``` + pub async fn delete(&self, options: DeleteOptions) -> ClientResult<()> { + let msg = self.client_message(ClientReq::TableDeleteReq(TableDeleteReq { + is_immediate: !options.lazy, + })); + + match self.client.oneshot(&msg).await? { + ClientResp::TableDeleteResp(_) => Ok(()), + resp => Err(resp.into()), + } + } + + /// Returns the column names of this [`Table`] in "natural" order (the + /// ordering implied by the input format). + /// + /// # Examples + /// + /// ```no_run + /// # use perspective_client::Table; + /// # async fn run() -> Result<(), Box> { + /// # let table: Table = todo!(); + /// let columns = table.columns().await?; + /// # Ok(()) } + /// ``` + pub async fn columns(&self) -> ClientResult> { + let msg = self.client_message(ClientReq::TableSchemaReq(TableSchemaReq {})); + match self.client.oneshot(&msg).await? { + ClientResp::TableSchemaResp(TableSchemaResp { schema }) => Ok(schema + .map(|x| x.schema.into_iter().map(|x| x.name.to_owned()).collect()) + .unwrap()), + resp => Err(resp.into()), + } + } + + /// Returns the number of rows in a [`Table`]. + pub async fn size(&self) -> ClientResult { + let msg = self.client_message(ClientReq::TableSizeReq(TableSizeReq {})); + match self.client.oneshot(&msg).await? { + ClientResp::TableSizeResp(TableSizeResp { size }) => Ok(size as usize), + resp => Err(resp.into()), + } + } + + /// Returns a table's [`Schema`], a mapping of column names to column types. + /// + /// The mapping of a [`Table`]'s column names to data types is referred to + /// as a [`Schema`]. Each column has a unique name and a data type, one + /// of: + /// + /// - `"boolean"` - A boolean type + /// - `"date"` - A timesonze-agnostic date type (month/day/year) + /// - `"datetime"` - A millisecond-precision datetime type in the UTC + /// timezone + /// - `"float"` - A 64 bit float + /// - `"integer"` - A signed 32 bit integer (the integer type supported by + /// JavaScript) + /// - `"string"` - A [`String`] data type (encoded internally as a + /// _dictionary_) + /// + /// Note that all [`Table`] columns are _nullable_, regardless of the data + /// type. + pub async fn schema(&self) -> ClientResult { + let msg = self.client_message(ClientReq::TableSchemaReq(TableSchemaReq {})); + match self.client.oneshot(&msg).await? { + ClientResp::TableSchemaResp(TableSchemaResp { schema }) => Ok(schema + .map(|x| { + x.schema + .into_iter() + .map(|x| (x.name, ColumnType::try_from(x.r#type).unwrap())) + .collect() + }) + .unwrap()), + resp => Err(resp.into()), + } + } + + /// Create a unique channel ID on this [`Table`], which allows + /// `View::on_update` callback calls to be associated with the + /// `Table::update` which caused them. + pub async fn make_port(&self) -> ClientResult { + let msg = self.client_message(ClientReq::TableMakePortReq(TableMakePortReq {})); + match self.client.oneshot(&msg).await? { + ClientResp::TableMakePortResp(TableMakePortResp { port_id }) => Ok(port_id as i32), + _ => Err(ClientError::Unknown("make_port".to_string())), + } + } + + /// Register a callback which is called exactly once, when this [`Table`] is + /// deleted with the [`Table::delete`] method. + /// + /// [`Table::on_delete`] resolves when the subscription message is sent, not + /// when the _delete_ event occurs. + pub async fn on_delete( + &self, + on_delete: Box, + ) -> ClientResult { + let callback = move |resp: Response| match resp.client_resp { + Some(ClientResp::TableOnDeleteResp(_)) => { + on_delete(); + Ok(()) + }, + resp => Err(resp.into()), + }; + + let msg = self.client_message(ClientReq::TableOnDeleteReq(TableOnDeleteReq {})); + self.client.subscribe_once(&msg, Box::new(callback)).await?; + Ok(msg.msg_id) + } + + /// Removes a listener with a given ID, as returned by a previous call to + /// [`Table::on_delete`]. + pub async fn remove_delete(&self, callback_id: u32) -> ClientResult<()> { + let msg = self.client_message(ClientReq::TableRemoveDeleteReq(TableRemoveDeleteReq { + id: callback_id, + })); + + match self.client.oneshot(&msg).await? { + ClientResp::TableRemoveDeleteResp(_) => Ok(()), + resp => Err(resp.into()), + } + } + + /// Removes rows from this [`Table`] with the `index` column values + /// supplied. + /// + /// # Arguments + /// + /// - `indices` - A list of `index` column values for rows that should be + /// removed. + /// + /// # Examples + /// + /// ```no_run + /// # use perspective_client::{Table, UpdateData}; + /// # async fn run() -> Result<(), Box> { + /// # let table: Table = todo!(); + /// table + /// .remove(UpdateData::Csv("index\n1\n2\n3".into())) + /// .await?; + /// # Ok(()) } + /// ``` + pub async fn remove(&self, input: UpdateData) -> ClientResult<()> { + let msg = self.client_message(ClientReq::TableRemoveReq(TableRemoveReq { + data: Some(input.into()), + })); + + match self.client.oneshot(&msg).await? { + ClientResp::TableRemoveResp(_) => Ok(()), + resp => Err(resp.into()), + } + } + + /// Replace all rows in this [`Table`] with the input data, coerced to this + /// [`Table`]'s existing [`Schema`], notifying any derived [`View`] and + /// [`View::on_update`] callbacks. + /// + /// Calling [`Table::replace`] is an easy way to replace _all_ the data in a + /// [`Table`] without losing any derived [`View`] instances or + /// [`View::on_update`] callbacks. [`Table::replace`] does _not_ infer + /// data types like [`Client::table`] does, rather it _coerces_ input + /// data to the `Schema` like [`Table::update`]. If you need a [`Table`] + /// with a different `Schema`, you must create a new one. + /// + /// # Examples + /// + /// ```no_run + /// # use perspective_client::{Table, UpdateData}; + /// # async fn run() -> Result<(), Box> { + /// # let table: Table = todo!(); + /// let data = UpdateData::Csv("x,y\n1,2".into()); + /// table.replace(data).await?; + /// # Ok(()) } + /// ``` + pub async fn replace(&self, input: UpdateData) -> ClientResult<()> { + let msg = self.client_message(ClientReq::TableReplaceReq(TableReplaceReq { + data: Some(input.into()), + })); + + match self.client.oneshot(&msg).await? { + ClientResp::TableReplaceResp(_) => Ok(()), + resp => Err(resp.into()), + } + } + + /// Updates the rows of this table and any derived [`View`] instances. + /// + /// Calling [`Table::update`] will trigger the [`View::on_update`] callbacks + /// register to derived [`View`], and the call itself will not resolve until + /// _all_ derived [`View`]'s are notified. + /// + /// When updating a [`Table`] with an `index`, [`Table::update`] supports + /// partial updates, by omitting columns from the update data. + /// + /// # Arguments + /// + /// - `input` - The input data for this [`Table`]. The schema of a [`Table`] + /// is immutable after creation, so this method cannot be called with a + /// schema. + /// - `options` - Options for this update step - see [`UpdateOptions`]. + /// + /// # Examples + /// + /// ```no_run + /// # use perspective_client::{Table, UpdateData, UpdateOptions}; + /// # async fn run() -> Result<(), Box> { + /// # let table: Table = todo!(); + /// let data = UpdateData::Csv("x,y\n1,2".into()); + /// let opts = UpdateOptions::default(); + /// table.update(data, opts).await?; + /// # Ok(()) } + /// ``` + pub async fn update(&self, input: UpdateData, options: UpdateOptions) -> ClientResult<()> { + let msg = self.client_message(ClientReq::TableUpdateReq(TableUpdateReq { + data: Some(input.into()), + port_id: options.port_id.unwrap_or(0), + })); + + match self.client.oneshot(&msg).await? { + ClientResp::TableUpdateResp(_) => Ok(()), + resp => Err(resp.into()), + } + } + + /// Validates the given expressions. + pub async fn validate_expressions( + &self, + expressions: Expressions, + ) -> ClientResult { + let msg = self.client_message(ClientReq::TableValidateExprReq(TableValidateExprReq { + column_to_expr: expressions.0, + })); + + match self.client.oneshot(&msg).await? { + ClientResp::TableValidateExprResp(result) => Ok(ExprValidationResult { + errors: result.errors, + expression_alias: result.expression_alias, + expression_schema: result + .expression_schema + .into_iter() + .map(|(x, y)| (x, ColumnType::try_from(y).unwrap())) + .collect(), + }), + resp => Err(resp.into()), + } + } + + /// Create a new [`View`] from this table with a specified + /// [`ViewConfigUpdate`]. + /// + /// See [`View`] struct. + /// + /// # Examples + /// + /// ```no_run + /// # use std::collections::HashMap; + /// # use perspective_client::Table; + /// # use perspective_client::config::*; + /// # async fn run() -> Result<(), Box> { + /// # let table: Table = todo!(); + /// let view = table + /// .view(Some(ViewConfigUpdate { + /// columns: Some(vec![Some("Sales".into())]), + /// aggregates: Some(HashMap::from_iter(vec![("Sales".into(), "sum".into())])), + /// group_by: Some(vec!["Region".into(), "Country".into()]), + /// filter: Some(vec![Filter::new("Category", "in", &[ + /// "Furniture", + /// "Technology", + /// ])]), + /// ..ViewConfigUpdate::default() + /// })) + /// .await?; + /// # Ok(()) } + /// ``` + pub async fn view(&self, config: Option) -> ClientResult { + let view_name = randid(); + let msg = Request { + msg_id: self.client.gen_id(), + entity_id: self.name.clone(), + client_req: ClientReq::TableMakeViewReq(TableMakeViewReq { + view_id: view_name.clone(), + config: config.map(|x| x.into()), + }) + .into(), + }; + + match self.client.oneshot(&msg).await? { + ClientResp::TableMakeViewResp(TableMakeViewResp { view_id }) + if view_id == view_name => + { + Ok(View::new(view_name, self.client.clone())) + }, + resp => Err(resp.into()), + } + } +} diff --git a/rust/perspective-client/src/rust/table_data.rs b/rust/perspective-client/src/rust/table_data.rs new file mode 100644 index 0000000..3262b7d --- /dev/null +++ b/rust/perspective-client/src/rust/table_data.rs @@ -0,0 +1,79 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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::bytes::Bytes; + +use crate::proto; +use crate::proto::*; +use crate::view::View; +#[cfg(doc)] +use crate::{Client, Table}; + +/// The possible formats of input data which [`Client::table`] may take as an +/// argument. +#[derive(Debug)] +pub enum TableData { + Schema(Vec<(String, ColumnType)>), + Update(UpdateData), + View(View), +} + +/// The possible formats of input data which [`Table::update`] may take as an +/// argument. +#[derive(Debug)] +pub enum UpdateData { + Csv(String), + Arrow(Bytes), + JsonRows(String), + JsonColumns(String), + Ndjson(String), +} + +impl From for TableData { + fn from(value: UpdateData) -> Self { + TableData::Update(value) + } +} + +impl From for proto::MakeTableData { + fn from(value: TableData) -> Self { + let data = match value { + TableData::Update(x) => return x.into(), + TableData::View(view) => make_table_data::Data::FromView(view.name), + TableData::Schema(x) => make_table_data::Data::FromSchema(proto::Schema { + schema: x + .into_iter() + .map(|(name, r#type)| schema::KeyTypePair { + name, + r#type: r#type as i32, + }) + .collect(), + }), + }; + + MakeTableData { data: Some(data) } + } +} + +impl From for proto::MakeTableData { + fn from(value: UpdateData) -> Self { + let data = match value { + UpdateData::Csv(x) => make_table_data::Data::FromCsv(x), + UpdateData::Arrow(x) => make_table_data::Data::FromArrow(x.into()), + UpdateData::JsonRows(x) => make_table_data::Data::FromRows(x), + UpdateData::JsonColumns(x) => make_table_data::Data::FromCols(x), + UpdateData::Ndjson(x) => make_table_data::Data::FromNdjson(x), + }; + + MakeTableData { data: Some(data) } + } +} diff --git a/rust/perspective-client/src/rust/table_ref.rs b/rust/perspective-client/src/rust/table_ref.rs new file mode 100644 index 0000000..bf0b169 --- /dev/null +++ b/rust/perspective-client/src/rust/table_ref.rs @@ -0,0 +1,53 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 crate::Table; + +/// A reference to a table, either by handle or by name. +#[derive(Clone)] +pub enum TableRef { + Table(Table), + Name(String), +} + +impl TableRef { + pub fn table_name(&self) -> &str { + match self { + TableRef::Table(table) => table.get_name(), + TableRef::Name(name) => name, + } + } +} + +impl From<&Table> for TableRef { + fn from(table: &Table) -> Self { + TableRef::Table(table.clone()) + } +} + +impl From
    for TableRef { + fn from(table: Table) -> Self { + TableRef::Table(table) + } +} + +impl From for TableRef { + fn from(name: String) -> Self { + TableRef::Name(name) + } +} + +impl From<&str> for TableRef { + fn from(name: &str) -> Self { + TableRef::Name(name.to_owned()) + } +} diff --git a/rust/perspective-client/src/rust/utils/clone.rs b/rust/perspective-client/src/rust/utils/clone.rs new file mode 100644 index 0000000..aaaf05d --- /dev/null +++ b/rust/perspective-client/src/rust/utils/clone.rs @@ -0,0 +1,141 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/// A helper to for the pattern `let x2 = x;` necessary to clone structs +/// destined for an `async` or `'static` closure stack. This is like `move || { +/// .. }` or `move async { .. }`, but for clone semantics. `clone!()` works +/// with symbols as well as properties and methods, using the last symbol name +/// in the method chain, or an alias via `x = ...` syntax. +/// ``` +#[doc(hidden)] +#[macro_export] +macro_rules! clone { + (impl @bind $i:tt { $($orig:tt)* } { }) => { + let $i = $($orig)*.clone(); + }; + + (impl @bind $i:tt { $($orig:tt)* } { @mut }) => { + let mut $i = $($orig)*.clone(); + }; + + (impl @bind $i:tt { $($orig:tt)* } { $binder:tt }) => { + let $binder = $($orig)*.clone(); + }; + + (impl @bind $i:tt { $($orig:tt)* } { @mut $binder:tt }) => { + let mut $binder = $($orig)*.clone(); + }; + + (impl @expand { $($orig:tt)* } { $($binder:tt)* } $i:tt) => { + $crate::clone!(impl @bind $i { $($orig)* $i } { $($binder)* }); + }; + + + (impl @expand { $($orig:tt)* } { $($binder:tt)* } mut $i:tt) => { + $crate::clone!(impl @bind $i { $($orig)* $i } { @mut $($binder)* }); + }; + + (impl @expand { $($orig:tt)* } { $($binder:tt)* } $i:tt ()) => { + $crate::clone!(impl @bind $i { $($orig)* $i () } { $($binder)* }); + }; + + (impl @expand { $($orig:tt)* } { $($binder:tt)* } $i:tt . 0) => { + $crate::clone!(impl @bind $i { $($orig)* $i . 0 } { $($binder)* }); + }; + + (impl @expand { $($orig:tt)* } { $($binder:tt)* } $i:tt . 1) => { + $crate::clone!(impl @bind $i { $($orig)* $i . 1 } { $($binder)* }); + }; + + (impl @expand { $($orig:tt)* } { $($binder:tt)* } $i:tt . 2) => { + $crate::clone!(impl @bind $i { $($orig)* $i . 2 } { $($binder)* }); + }; + + (impl @expand { $($orig:tt)* } { $($binder:tt)* } $i:tt . 3) => { + $crate::clone!(impl @bind $i { $($orig)* $i . 3 } { $($binder)* }); + }; + + (impl @expand { $($orig:tt)* } { $($binder:tt)* } mut $i:tt = $($tail:tt)+) => { + $crate::clone!(impl @expand { $($orig)* } { @mut $i } $($tail)+); + }; + + (impl @expand { $($orig:tt)* } { $($binder:tt)* } $i:tt = $($tail:tt)+) => { + $crate::clone!(impl @expand { $($orig)* } { $i } $($tail)+); + }; + + (impl @expand { $($orig:tt)* } { $($binder:tt)* } $i:tt $($tail:tt)+) => { + $crate::clone!(impl @expand { $($orig)* $i } { $($binder)* } $($tail)+) + }; + + (impl @context { $($orig:tt)* } $tail:tt) => { + $crate::clone!(impl @expand { } { } $($orig)* $tail) + }; + + (impl @context { $($orig:tt)* } , $($tail:tt)+) => { + $crate::clone!(impl @expand { } { } $($orig)*); + $crate::clone!(impl @context { } $($tail)+); + }; + + (impl @context { $($orig:tt)* } $i:tt $($tail:tt)+) => { + $crate::clone!(impl @context { $($orig)* $i } $($tail)+) + }; + + ($($tail:tt)+) => { + $crate::clone!(impl @context { } $($tail)+); + } +} + +#[doc(hidden)] +#[macro_export] +macro_rules! asyncfn { + // Munch function args + (impl @bindings { $($args:tt)* } { $($clones:tt)* } !{ } | $($tail:tt)+) => {{ + move |$($args)*| { + $($clones)* + async move { + $($tail)+ + } + } + }}; + + (impl @bindings { $($args:tt)* } { $($clones:tt)* } !{ } $i:tt $($tail:tt)+) => { + $crate::asyncfn!(impl @bindings { $($args)* $i } { $($clones)* } !{ } $($tail)+) + }; + + // Munch function start + (impl @context { $($clones:tt)* } !{ } async move || $($tail:tt)+) => {{ + move || { + $($clones)* + async move { + $($tail)+ + } + } + }}; + + (impl @context { $($clones:tt)* } !{ } async move | $($tail:tt)+) => { + $crate::asyncfn!(impl @bindings { } { $($clones)* } !{ } $($tail)+) + }; + + // Munch clone bindings + (impl @context { $($clones:tt)* } !{ $($stack:tt)* } , $($tail:tt)+) => {{ + $crate::asyncfn!(impl @context { $crate::clone!($($stack)*); $($clones)* } !{ } $($tail)+) + }}; + + (impl @context { $($clones:tt)* } !{ $($stack:tt)* } $i:tt $($tail:tt)+) => { + $crate::asyncfn!(impl @context { $($clones)* } !{ $($stack)* $i } $($tail)+) + }; + + // Root + ($($tail:tt)+) => { + $crate::asyncfn!(impl @context { } !{ } $($tail)+) + } +} diff --git a/rust/perspective-client/src/rust/utils/logging.rs b/rust/perspective-client/src/rust/utils/logging.rs new file mode 100644 index 0000000..b9cb02d --- /dev/null +++ b/rust/perspective-client/src/rust/utils/logging.rs @@ -0,0 +1,156 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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::Message; + +use crate::proto::make_table_data::Data; +use crate::proto::request::ClientReq; +use crate::proto::response::ClientResp; +use crate::proto::{ + MakeTableData, MakeTableReq, Request, Response, TableUpdateReq, ViewOnUpdateResp, + ViewToArrowResp, ViewToColumnsStringResp, ViewToCsvResp, ViewToNdjsonStringResp, + ViewToRowsStringResp, +}; + +fn replace(x: Data) -> Data { + match x { + Data::FromArrow(_) => Data::FromArrow("<< redacted >>".to_string().encode_to_vec()), + Data::FromRows(_) => Data::FromRows("<< redacted >>".to_string()), + Data::FromCols(_) => Data::FromCols("<< redacted >>".to_string()), + Data::FromCsv(_) => Data::FromCsv("<< redacted >>".to_string()), + x => x, + } +} + +/// `prost` generates `Debug` implementations that includes the `data` field, +/// which makes logs output unreadable. This `Display` implementation hides +/// fields that we don't want ot display in the logs. +impl std::fmt::Display for Request { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut msg = self.clone(); + msg = match msg { + Request { + client_req: + Some(ClientReq::MakeTableReq(MakeTableReq { + ref options, + data: + Some(MakeTableData { + data: Some(ref data), + }), + })), + .. + } => Request { + client_req: Some(ClientReq::MakeTableReq(MakeTableReq { + options: options.clone(), + data: Some(MakeTableData { + data: Some(replace(data.clone())), + }), + })), + ..msg.clone() + }, + Request { + client_req: + Some(ClientReq::TableUpdateReq(TableUpdateReq { + port_id, + data: + Some(MakeTableData { + data: Some(ref data), + }), + })), + .. + } => Request { + client_req: Some(ClientReq::TableUpdateReq(TableUpdateReq { + port_id, + data: Some(MakeTableData { + data: Some(replace(data.clone())), + }), + })), + ..msg.clone() + }, + x => x, + }; + + write!( + f, + "{}", + serde_json::to_string(&msg).unwrap_or("Can't deserialize".to_string()) + ) + } +} + +impl std::fmt::Display for Response { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut msg = self.clone(); + msg = match msg { + Response { + client_resp: Some(ClientResp::ViewToColumnsStringResp(_)), + .. + } => Response { + client_resp: Some(ClientResp::ViewToColumnsStringResp( + ViewToColumnsStringResp { + json_string: "<< redacted >>".to_owned(), + }, + )), + ..msg.clone() + }, + Response { + client_resp: Some(ClientResp::ViewToRowsStringResp(_)), + .. + } => Response { + client_resp: Some(ClientResp::ViewToRowsStringResp(ViewToRowsStringResp { + json_string: "<< redacted >>".to_owned(), + })), + ..msg.clone() + }, + Response { + client_resp: Some(ClientResp::ViewToNdjsonStringResp(_)), + .. + } => Response { + client_resp: Some(ClientResp::ViewToNdjsonStringResp(ViewToNdjsonStringResp { + ndjson_string: "<< redacted >>".to_owned(), + })), + ..msg.clone() + }, + Response { + client_resp: Some(ClientResp::ViewToArrowResp(_)), + .. + } => Response { + client_resp: Some(ClientResp::ViewToArrowResp(ViewToArrowResp { + arrow: vec![], + })), + ..msg.clone() + }, + Response { + client_resp: Some(ClientResp::ViewToCsvResp(_)), + .. + } => Response { + client_resp: Some(ClientResp::ViewToCsvResp(ViewToCsvResp { + csv: "<< redacted >>".to_owned(), + })), + ..msg.clone() + }, + Response { + client_resp: Some(ClientResp::ViewOnUpdateResp(ref x)), + .. + } => Response { + client_resp: Some(ClientResp::ViewOnUpdateResp(ViewOnUpdateResp { + delta: x.delta.as_ref().map(|_| vec![]), + port_id: x.port_id, + })), + ..msg.clone() + }, + x => x, + }; + + write!(f, "{}", serde_json::to_string(&msg).unwrap()) + } +} diff --git a/rust/perspective-client/src/rust/utils/mod.rs b/rust/perspective-client/src/rust/utils/mod.rs new file mode 100644 index 0000000..2483bd6 --- /dev/null +++ b/rust/perspective-client/src/rust/utils/mod.rs @@ -0,0 +1,200 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +//! Utility functions that are common to the `perspective` crates. + +mod clone; +mod logging; +mod rand_sequence; + +#[cfg(feature = "talc-allocator")] +mod talc_allocator; + +#[cfg(test)] +mod tests; + +use std::sync::Arc; +use std::time::SystemTimeError; + +use rand::prelude::*; +use rand::rngs::StdRng; +#[cfg(feature = "talc-allocator")] +pub(crate) use talc_allocator::get_used; +use thiserror::*; + +use crate::proto; +use crate::utils::rand_sequence::RandomSequence; + +#[derive(Clone, Error, Debug)] +pub enum ClientError { + #[error("View not found")] + ViewNotFound, + + #[error("Abort(): {0}")] + Internal(String), + + #[error("Transport error: {0}")] + TransportError(String), + + #[error("Client not yet initialized")] + NotInitialized, + + #[error("Unknown error: {0}")] + Unknown(String), + + #[error("Unwrapped option")] + Option, + + #[error("Bad string")] + Utf8(#[from] std::str::Utf8Error), + + #[error("Undecipherable server message {0:?}")] + DecodeError(#[from] prost::DecodeError), + + #[error("Response aborted")] + ResponseAborted, + + #[error("Unexpected response {0:?}")] + ResponseFailed(Box), + + #[error("Not yet implemented {0:?}")] + NotImplemented(&'static str), + + #[error("Can't use both `limit` and `index` arguments")] + BadTableOptions, + + #[error("External error: {0}")] + ExternalError(Arc>), + + #[error("Undecipherable proto message")] + ProtoError(#[from] prost::EncodeError), + + #[error("Duplicate name {0}")] + DuplicateNameError(String), + + #[error("{0}")] + TimeError(#[from] SystemTimeError), +} + +pub type ClientResult = Result; + +impl From> for ClientError { + fn from(value: Box) -> Self { + ClientError::ExternalError(Arc::new(value)) + } +} + +impl<'a, A> From>> for ClientError { + fn from(_: std::sync::PoisonError>) -> Self { + ClientError::Internal("Lock Error".to_owned()) + } +} + +impl From> for ClientError { + fn from(value: Option) -> Self { + match value { + Some(proto::response::ClientResp::ServerError(x)) => match x.status_code() { + proto::StatusCode::ServerError => ClientError::Internal(x.message), + proto::StatusCode::ViewNotFound => ClientError::ViewNotFound, + proto::StatusCode::TransportError => ClientError::TransportError(x.message), + }, + Some(x) => ClientError::ResponseFailed(Box::new(x)), + None => ClientError::ResponseAborted, + } + } +} + +impl From for ClientError { + fn from(value: proto::response::ClientResp) -> Self { + match value { + proto::response::ClientResp::ServerError(x) => match x.status_code() { + proto::StatusCode::ServerError => ClientError::Internal(x.message), + proto::StatusCode::ViewNotFound => ClientError::ViewNotFound, + proto::StatusCode::TransportError => ClientError::TransportError(x.message), + }, + x => ClientError::ResponseFailed(Box::new(x)), + } + } +} + +pub trait PerspectiveResultExt { + fn unwrap_or_log(&self); +} + +impl PerspectiveResultExt for Result +where + E: std::error::Error, +{ + fn unwrap_or_log(&self) { + if let Err(e) = self { + tracing::warn!("{}", e); + } + } +} + +/// Generate a sequence of IDs +#[derive(Clone)] +pub struct IDGen(Arc>); + +impl Default for IDGen { + fn default() -> Self { + Self(Arc::new(std::sync::Mutex::new(Self::new_seq()))) + } +} + +impl IDGen { + fn new_seq() -> RandomSequence { + let mut rng = rand::rngs::ThreadRng::default(); + let config = RandomSequence::new(rng.next_u32(), rng.next_u32()); + config.into_iter() + } + + pub fn next(&self) -> u32 { + let mut idgen = self.0.lock().unwrap(); + if let Some(x) = idgen.next() { + x + } else { + *idgen = Self::new_seq(); + idgen.next().unwrap() + } + } +} + +const SIZE: usize = 21; + +const CHARACTERS: [char; 52] = [ + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', + 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', + 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', +]; + +/// Generate a random identifier `String` +pub fn randid() -> String { + let mask = CHARACTERS.len().next_power_of_two() - 1; + let step: usize = 8 * SIZE / 5; + let mut id = String::with_capacity(SIZE); + loop { + let mut rng = rand::rngs::ThreadRng::default(); + let mut random = StdRng::from_rng(&mut rng); + let mut bytes: Vec = vec![0; step]; + random.fill(&mut bytes[..]); + for &byte in &bytes { + let byte = byte as usize & mask; + if CHARACTERS.len() > byte { + id.push(CHARACTERS[byte]); + if id.len() == SIZE { + return id; + } + } + } + } +} diff --git a/rust/perspective-client/src/rust/utils/rand_sequence.rs b/rust/perspective-client/src/rust/utils/rand_sequence.rs new file mode 100644 index 0000000..9aa9064 --- /dev/null +++ b/rust/perspective-client/src/rust/utils/rand_sequence.rs @@ -0,0 +1,87 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +//! This implementation liberally borrows from the excellent +//! [`rand-unique`](https://github.com/hoxxep/rand-unique) crate, MIT licensed. +//! Unlike `rand-unique`, this hacked up version only implements u32 (for wasm), +//! but is compatible with `rand>0.8` and ~20kb smaller (in wasm). + +const INIT_BASE: u32 = 0x682f0161; +const INIT_OFFSET: u32 = 0x46790905; +const PRIME: u32 = 4294967291; +const INTERMEDIATE_XOR: u32 = 0x5bf03635; + +#[derive(Debug, Clone)] +pub struct RandomSequence { + start_index: u32, + current_index: u32, + intermediate_offset: u32, + ended: bool, +} + +impl RandomSequence { + pub fn new(seed_base: u32, seed_offset: u32) -> Self { + let start_index = permute_qpr(permute_qpr(seed_base).wrapping_add(INIT_BASE)); + let intermediate_offset = permute_qpr(permute_qpr(seed_offset).wrapping_add(INIT_OFFSET)); + RandomSequence { + start_index, + current_index: 0, + intermediate_offset, + ended: false, + } + } +} + +impl Iterator for RandomSequence { + type Item = u32; + + #[inline] + fn next(&mut self) -> Option { + let index = self.start_index.wrapping_add(self.current_index); + let inner_residue = permute_qpr(index).wrapping_add(self.intermediate_offset); + let next = permute_qpr(inner_residue ^ INTERMEDIATE_XOR); + self.current_index = match self.current_index.checked_add(1) { + Some(v) => { + self.ended = false; + v + }, + None => { + if !self.ended { + self.ended = true; + self.current_index + } else { + return None; + } + }, + }; + + Some(next) + } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + (u32::MAX as usize - self.current_index as usize, None) + } +} + +fn permute_qpr(x: u32) -> u32 { + if x >= PRIME { + return x; + } + + let residue = ((x as u64 * x as u64) % PRIME as u64) as u32; + if x <= PRIME >> 1 { + residue + } else { + PRIME - residue + } +} diff --git a/rust/perspective-client/src/rust/utils/talc_allocator.rs b/rust/perspective-client/src/rust/utils/talc_allocator.rs new file mode 100644 index 0000000..3bcca32 --- /dev/null +++ b/rust/perspective-client/src/rust/utils/talc_allocator.rs @@ -0,0 +1,25 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#[global_allocator] +static TALC: talc::TalckWasm = unsafe { talc::TalckWasm::new_global() }; + +pub fn get_used() -> (usize, usize) { + return { + let counters = TALC.lock(); + ( + counters.get_counters().allocated_bytes, + (counters.get_counters().available_bytes + counters.get_counters().allocated_bytes) + as usize, + ) + }; +} diff --git a/rust/perspective-client/src/rust/utils/tests/clone.rs b/rust/perspective-client/src/rust/utils/tests/clone.rs new file mode 100644 index 0000000..3f37869 --- /dev/null +++ b/rust/perspective-client/src/rust/utils/tests/clone.rs @@ -0,0 +1,83 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 crate::*; + +#[derive(Clone)] +struct X { + x: i32, +} + +impl X { + const fn test(&self) -> i32 { + 1 + } +} + +#[derive(Clone)] +struct Y { + y: X, +} + +#[test] +fn test_one_ident() { + let x = 0; + clone!(x); + assert_eq!(x, 0); +} + +#[test] +fn test_two_idents() { + let x = 0; + let y = 1; + clone!(x, y); + assert_eq!(x, 0); + assert_eq!(y, 1); +} + +#[test] +fn test_struct_field() { + let x = X { x: 0 }; + clone!(x.x); + assert_eq!(x, 0); +} + +#[test] +fn test_struct_struct_field() { + let x = Y { y: X { x: 0 } }; + clone!(x.y.x); + assert_eq!(x, 0); +} + +#[test] +fn test_tuple() { + let x = (0, 1); + clone!(x.0); + assert_eq!(x, 0); +} + +#[test] +fn test_alias() { + let x = 0; + clone!(y = x); + assert_eq!(y, 0); +} + +#[test] +fn test_complex() { + let x = Y { y: X { x: 0 } }; + let w = (0, x.clone()); + clone!(z = x.y.x, x.y.test(), w.1.y.x); + assert_eq!(z, 0); + assert_eq!(x, 0); + assert_eq!(test, 1); +} diff --git a/rust/perspective-client/src/rust/utils/tests/mod.rs b/rust/perspective-client/src/rust/utils/tests/mod.rs new file mode 100644 index 0000000..b5da34e --- /dev/null +++ b/rust/perspective-client/src/rust/utils/tests/mod.rs @@ -0,0 +1,13 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +mod clone; diff --git a/rust/perspective-client/src/rust/view.rs b/rust/perspective-client/src/rust/view.rs new file mode 100644 index 0000000..e8248f1 --- /dev/null +++ b/rust/perspective-client/src/rust/view.rs @@ -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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +use std::collections::HashMap; +use std::ops::Deref; +use std::str::FromStr; +use std::sync::Arc; + +use futures::Future; +use prost::bytes::Bytes; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use self::view_on_update_req::Mode; +use crate::assert_view_api; +use crate::client::Client; +use crate::proto::request::ClientReq; +use crate::proto::response::ClientResp; +use crate::proto::*; +#[cfg(doc)] +use crate::table::Table; +pub use crate::utils::*; + +/// Options for [`View::on_update`]. +#[derive(Default, Debug, Deserialize, TS)] +pub struct OnUpdateOptions { + pub mode: Option, +} + +/// The update mode for [`View::on_update`]. +/// +/// `Row` mode calculates and provides the update batch new rows/columns as an +/// Apache Arrow to the callback provided to [`View::on_update`]. This allows +/// incremental updates if your callbakc can read this format, but should be +/// disabled otherwise. +#[derive(Default, Debug, Deserialize, TS)] +pub enum OnUpdateMode { + #[default] + #[serde(rename = "row")] + Row, +} + +impl FromStr for OnUpdateMode { + type Err = ClientError; + + fn from_str(s: &str) -> Result { + if s == "row" { + Ok(OnUpdateMode::Row) + } else { + Err(ClientError::Option) + } + } +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize, TS, PartialEq)] +pub struct ColumnWindow { + #[serde(skip_serializing_if = "Option::is_none")] + pub start_col: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub end_col: Option, +} + +/// Options for serializing a window of data from a [`View`]. +/// +/// Some fields of [`ViewWindow`] are only applicable to specific methods of +/// [`View`]. +#[derive(Clone, Debug, Default, Deserialize, Serialize, TS, PartialEq)] +pub struct ViewWindow { + #[ts(optional)] + #[serde(skip_serializing_if = "Option::is_none")] + pub start_row: Option, + + #[ts(optional)] + #[serde(skip_serializing_if = "Option::is_none")] + pub start_col: Option, + + #[ts(optional)] + #[serde(skip_serializing_if = "Option::is_none")] + pub end_row: Option, + + #[ts(optional)] + #[serde(skip_serializing_if = "Option::is_none")] + pub end_col: Option, + + #[ts(optional)] + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + + #[ts(optional)] + #[serde(skip_serializing_if = "Option::is_none")] + pub index: Option, + + /// Only impacts [`View::to_csv`] + #[ts(optional)] + #[serde(skip_serializing_if = "Option::is_none")] + pub formatted: Option, + + /// Only impacts [`View::to_arrow`] + #[ts(optional)] + #[serde(skip_serializing_if = "Option::is_none")] + pub compression: Option, + + /// When `true`, group-by columns use legacy `"colname (Group by N)"` + /// naming. When `false`, they use `__ROW_PATH_N__` naming consistent + /// with the SQL backend. Defaults to `true` for backwards compatibility. + #[ts(optional)] + #[serde(skip_serializing_if = "Option::is_none")] + pub emit_legacy_row_path_names: Option, +} + +impl From for ViewPort { + fn from(window: ViewWindow) -> Self { + ViewPort { + start_row: window.start_row.map(|x| x.floor() as u32), + start_col: window.start_col.map(|x| x.floor() as u32), + end_row: window.end_row.map(|x| x.ceil() as u32), + end_col: window.end_col.map(|x| x.ceil() as u32), + emit_legacy_row_path_names: window.emit_legacy_row_path_names, + } + } +} + +impl From for ViewWindow { + fn from(window: ViewPort) -> Self { + ViewWindow { + start_row: window.start_row.map(|x| x as f64), + start_col: window.start_col.map(|x| x as f64), + end_row: window.end_row.map(|x| x as f64), + end_col: window.end_col.map(|x| x as f64), + emit_legacy_row_path_names: window.emit_legacy_row_path_names, + ..ViewWindow::default() + } + } +} + +/// Rows updated and port ID corresponding to an update batch, provided to the +/// callback argument to [`View::on_update`] with the "rows" mode. +#[derive(TS)] +pub struct OnUpdateData(crate::proto::ViewOnUpdateResp); + +impl Deref for OnUpdateData { + type Target = crate::proto::ViewOnUpdateResp; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +/// The [`View`] struct is Perspective's query and serialization interface. It +/// represents a query on the `Table`'s dataset and is always created from an +/// existing `Table` instance via the [`Table::view`] method. +/// +/// [`View`]s are immutable with respect to the arguments provided to the +/// [`Table::view`] method; to change these parameters, you must create a new +/// [`View`] on the same [`Table`]. However, each [`View`] is _live_ with +/// respect to the [`Table`]'s data, and will (within a conflation window) +/// update with the latest state as its parent [`Table`] updates, including +/// incrementally recalculating all aggregates, pivots, filters, etc. [`View`] +/// query parameters are composable, in that each parameter works independently +/// _and_ in conjunction with each other, and there is no limit to the number of +/// pivots, filters, etc. which can be applied. +/// +/// To construct a [`View`], call the [`Table::view`] factory method. A +/// [`Table`] can have as many [`View`]s associated with it as you need - +/// Perspective conserves memory by relying on a single [`Table`] to power +/// multiple [`View`]s concurrently. +/// +/// # Examples +/// +/// ```no_run +/// # use perspective_client::{Client, TableData, TableInitOptions, UpdateData, ViewWindow}; +/// # async fn run() -> Result<(), Box> { +/// # let client: Client = todo!(); +/// let opts = TableInitOptions::default(); +/// let data = TableData::Update(UpdateData::Csv("x,y\n1,2\n3,4".into())); +/// let table = client.table(data, opts).await?; +/// +/// let view = table.view(None).await?; +/// let arrow = view.to_arrow(ViewWindow::default()).await?; +/// view.delete().await?; +/// # Ok(()) } +/// ``` +/// +/// ```no_run +/// # use std::collections::HashMap; +/// # use perspective_client::Table; +/// # use perspective_client::config::*; +/// # async fn run() -> Result<(), Box> { +/// # let table: Table = todo!(); +/// let view = table +/// .view(Some(ViewConfigUpdate { +/// columns: Some(vec![Some("Sales".into())]), +/// aggregates: Some(HashMap::from_iter(vec![("Sales".into(), "sum".into())])), +/// group_by: Some(vec!["Region".into(), "Country".into()]), +/// filter: Some(vec![Filter::new("Category", "in", &[ +/// "Furniture", +/// "Technology", +/// ])]), +/// ..ViewConfigUpdate::default() +/// })) +/// .await?; +/// # Ok(()) } +/// ``` +/// +/// Group By +/// +/// ```no_run +/// # use perspective_client::Table; +/// # use perspective_client::config::*; +/// # async fn run() -> Result<(), Box> { +/// # let table: Table = todo!(); +/// let view = table +/// .view(Some(ViewConfigUpdate { +/// group_by: Some(vec!["a".into(), "c".into()]), +/// ..ViewConfigUpdate::default() +/// })) +/// .await?; +/// # Ok(()) } +/// ``` +/// +/// Split By +/// +/// ```no_run +/// # use perspective_client::Table; +/// # use perspective_client::config::*; +/// # async fn run() -> Result<(), Box> { +/// # let table: Table = todo!(); +/// let view = table +/// .view(Some(ViewConfigUpdate { +/// split_by: Some(vec!["a".into(), "c".into()]), +/// ..ViewConfigUpdate::default() +/// })) +/// .await?; +/// # Ok(()) } +/// ``` +/// +/// In Javascript, a [`Table`] can be constructed on a [`Table::view`] instance, +/// which will return a new [`Table`] based on the [`Table::view`]'s dataset, +/// and all future updates that affect the [`Table::view`] will be forwarded to +/// the new [`Table`]. This is particularly useful for implementing a +/// [Client/Server Replicated](server.md#clientserver-replicated) design, by +/// serializing the `View` to an arrow and setting up an `on_update` callback. +/// +/// ```no_run +/// # use perspective_client::{Client, TableData, TableInitOptions, UpdateData, UpdateOptions}; +/// # async fn run() -> Result<(), Box> { +/// # let client: Client = todo!(); +/// let opts = TableInitOptions::default(); +/// let data = TableData::Update(UpdateData::Csv("x,y\n1,2\n3,4".into())); +/// let table = client.table(data, opts.clone()).await?; +/// let view = table.view(None).await?; +/// let table2 = client.table(TableData::View(view), opts).await?; +/// let more = UpdateData::Csv("x,y\n5,6".into()); +/// table.update(more, UpdateOptions::default()).await?; +/// # Ok(()) } +/// ``` +#[derive(Clone, Debug)] +pub struct View { + pub name: String, + client: Client, +} + +assert_view_api!(View); + +impl View { + pub fn new(name: String, client: Client) -> Self { + View { name, client } + } + + fn client_message(&self, req: ClientReq) -> Request { + crate::proto::Request { + msg_id: self.client.gen_id(), + entity_id: self.name.clone(), + client_req: Some(req), + } + } + + /// Returns an array of strings containing the column paths of the [`View`] + /// without any of the source columns. + /// + /// A column path shows the columns that a given cell belongs to after + /// pivots are applied. + pub async fn column_paths(&self, window: ColumnWindow) -> ClientResult> { + let msg = self.client_message(ClientReq::ViewColumnPathsReq(ViewColumnPathsReq { + start_col: window.start_col.map(|x| x as u32), + end_col: window.end_col.map(|x| x as u32), + })); + + match self.client.oneshot(&msg).await? { + ClientResp::ViewColumnPathsResp(ViewColumnPathsResp { paths }) => { + // Ok(paths.into_iter().map(|x| x.path).collect()) + Ok(paths) + }, + resp => Err(resp.into()), + } + } + + /// Returns this [`View`]'s _dimensions_, row and column count, as well as + /// those of the [`crate::Table`] from which it was derived. + /// + /// - `num_table_rows` - The number of rows in the underlying + /// [`crate::Table`]. + /// - `num_table_columns` - The number of columns in the underlying + /// [`crate::Table`] (including the `index` column if this + /// [`crate::Table`] was constructed with one). + /// - `num_view_rows` - The number of rows in this [`View`]. If this + /// [`View`] has a `group_by` clause, `num_view_rows` will also include + /// aggregated rows. + /// - `num_view_columns` - The number of columns in this [`View`]. If this + /// [`View`] has a `split_by` clause, `num_view_columns` will include all + /// _column paths_, e.g. the number of `columns` clause times the number + /// of `split_by` groups. + pub async fn dimensions(&self) -> ClientResult { + let msg = self.client_message(ClientReq::ViewDimensionsReq(ViewDimensionsReq {})); + match self.client.oneshot(&msg).await? { + ClientResp::ViewDimensionsResp(resp) => Ok(resp), + resp => Err(resp.into()), + } + } + + /// The expression schema of this [`View`], which contains only the + /// expressions created on this [`View`]. See [`View::schema`] for + /// details. + pub async fn expression_schema(&self) -> ClientResult> { + if self.client.get_features().await?.expressions { + let msg = self.client_message(ClientReq::ViewExpressionSchemaReq( + ViewExpressionSchemaReq {}, + )); + match self.client.oneshot(&msg).await? { + ClientResp::ViewExpressionSchemaResp(ViewExpressionSchemaResp { schema }) => { + Ok(schema + .into_iter() + .map(|(x, y)| (x, ColumnType::try_from(y).unwrap())) + .collect()) + }, + resp => Err(resp.into()), + } + } else { + Ok([].into_iter().collect()) + } + } + + /// A copy of the [`ViewConfig`] object passed to the [`Table::view`] method + /// which created this [`View`]. + pub async fn get_config(&self) -> ClientResult { + let msg = self.client_message(ClientReq::ViewGetConfigReq(ViewGetConfigReq {})); + match self.client.oneshot(&msg).await? { + ClientResp::ViewGetConfigResp(ViewGetConfigResp { + config: Some(config), + }) => Ok(config.into()), + resp => Err(resp.into()), + } + } + + /// The number of aggregated rows in this [`View`]. This is affected by the + /// "group_by" configuration parameter supplied to this view's contructor. + /// + /// # Returns + /// + /// The number of aggregated rows. + pub async fn num_rows(&self) -> ClientResult { + Ok(self.dimensions().await?.num_view_rows) + } + + /// The schema of this [`View`]. + /// + /// The [`View`] schema differs from the `schema` returned by + /// [`Table::schema`]; it may have different column names due to + /// `expressions` or `columns` configs, or it maye have _different + /// column types_ due to the application og `group_by` and `aggregates` + /// config. You can think of [`Table::schema`] as the _input_ schema and + /// [`View::schema`] as the _output_ schema of a Perspective pipeline. + pub async fn schema(&self) -> ClientResult> { + let msg = self.client_message(ClientReq::ViewSchemaReq(ViewSchemaReq {})); + match self.client.oneshot(&msg).await? { + ClientResp::ViewSchemaResp(ViewSchemaResp { schema }) => Ok(schema + .into_iter() + .map(|(x, y)| (x, ColumnType::try_from(y).unwrap())) + .collect()), + resp => Err(resp.into()), + } + } + + /// Serializes a [`View`] to the Apache Arrow data format. + pub async fn to_arrow(&self, window: ViewWindow) -> ClientResult { + let msg = self.client_message(ClientReq::ViewToArrowReq(ViewToArrowReq { + viewport: Some(window.clone().into()), + compression: window.compression, + })); + + match self.client.oneshot(&msg).await? { + ClientResp::ViewToArrowResp(ViewToArrowResp { arrow }) => Ok(arrow.into()), + resp => Err(resp.into()), + } + } + + /// Serializes this [`View`] to a string of JSON data. Useful if you want to + /// save additional round trip serialize/deserialize cycles. + pub async fn to_columns_string(&self, window: ViewWindow) -> ClientResult { + let msg = self.client_message(ClientReq::ViewToColumnsStringReq(ViewToColumnsStringReq { + viewport: Some(window.clone().into()), + id: window.id, + index: window.index, + formatted: window.formatted, + })); + + match self.client.oneshot(&msg).await? { + ClientResp::ViewToColumnsStringResp(ViewToColumnsStringResp { json_string }) => { + Ok(json_string) + }, + resp => Err(resp.into()), + } + } + + /// Render this `View` as a JSON string. + pub async fn to_json_string(&self, window: ViewWindow) -> ClientResult { + let viewport = ViewPort::from(window.clone()); + let msg = self.client_message(ClientReq::ViewToRowsStringReq(ViewToRowsStringReq { + viewport: Some(viewport), + id: window.id, + index: window.index, + formatted: window.formatted, + })); + + match self.client.oneshot(&msg).await? { + ClientResp::ViewToRowsStringResp(ViewToRowsStringResp { json_string }) => { + Ok(json_string) + }, + resp => Err(resp.into()), + } + } + + /// Renders this [`View`] as an [NDJSON](https://github.com/ndjson/ndjson-spec) + /// formatted [`String`]. + pub async fn to_ndjson(&self, window: ViewWindow) -> ClientResult { + let viewport = ViewPort::from(window.clone()); + let msg = self.client_message(ClientReq::ViewToNdjsonStringReq(ViewToNdjsonStringReq { + viewport: Some(viewport), + id: window.id, + index: window.index, + formatted: window.formatted, + })); + + match self.client.oneshot(&msg).await? { + ClientResp::ViewToNdjsonStringResp(ViewToNdjsonStringResp { ndjson_string }) => { + Ok(ndjson_string) + }, + resp => Err(resp.into()), + } + } + + /// Serializes this [`View`] to CSV data in a standard format. + pub async fn to_csv(&self, window: ViewWindow) -> ClientResult { + let msg = self.client_message(ClientReq::ViewToCsvReq(ViewToCsvReq { + viewport: Some(window.into()), + })); + + match self.client.oneshot(&msg).await? { + ClientResp::ViewToCsvResp(ViewToCsvResp { csv }) => Ok(csv), + resp => Err(resp.into()), + } + } + + /// Delete this [`View`] and clean up all resources associated with it. + /// [`View`] objects do not stop consuming resources or processing + /// updates when they are garbage collected - you must call this method + /// to reclaim these. + pub async fn delete(&self) -> ClientResult<()> { + let msg = self.client_message(ClientReq::ViewDeleteReq(ViewDeleteReq {})); + match self.client.oneshot(&msg).await? { + ClientResp::ViewDeleteResp(_) => Ok(()), + resp => Err(resp.into()), + } + } + + /// Calculates the [min, max] of the leaf nodes of a column `column_name`. + /// + /// # Returns + /// + /// A tuple of [min, max], whose types are column and aggregate dependent. + pub async fn get_min_max( + &self, + column_name: String, + ) -> ClientResult<(crate::config::Scalar, crate::config::Scalar)> { + let msg = self.client_message(ClientReq::ViewGetMinMaxReq(ViewGetMinMaxReq { + column_name, + })); + + match self.client.oneshot(&msg).await? { + ClientResp::ViewGetMinMaxResp(ViewGetMinMaxResp { min, max }) => { + let min = min.map(crate::config::Scalar::from).unwrap_or_default(); + let max = max.map(crate::config::Scalar::from).unwrap_or_default(); + Ok((min, max)) + }, + resp => Err(resp.into()), + } + } + + /// Register a callback with this [`View`]. Whenever the view's underlying + /// table emits an update, this callback will be invoked with an object + /// containing `port_id`, indicating which port the update fired on, and + /// optionally `delta`, which is the new data that was updated for each + /// cell or each row. + /// + /// # Arguments + /// + /// - `on_update` - A callback function invoked on update, which receives an + /// object with two keys: `port_id`, indicating which port the update was + /// triggered on, and `delta`, whose value is dependent on the mode + /// parameter. + /// - `options` - If this is provided as `OnUpdateOptions { mode: + /// Some(OnUpdateMode::Row) }`, then `delta` is an Arrow of the updated + /// rows. Otherwise `delta` will be [`Option::None`]. + pub async fn on_update(&self, on_update: T, options: OnUpdateOptions) -> ClientResult + where + T: Fn(OnUpdateData) -> U + Send + Sync + 'static, + U: Future + Send + 'static, + { + let on_update = Arc::new(on_update); + let callback = move |resp: Response| { + let on_update = on_update.clone(); + async move { + match resp.client_resp { + Some(ClientResp::ViewOnUpdateResp(resp)) => { + on_update(OnUpdateData(resp)).await; + Ok(()) + }, + resp => Err(resp.into()), + } + } + }; + + let msg = self.client_message(ClientReq::ViewOnUpdateReq(ViewOnUpdateReq { + mode: options.mode.map(|OnUpdateMode::Row| Mode::Row as i32), + })); + + self.client.subscribe(&msg, callback).await?; + Ok(msg.msg_id) + } + + /// Unregister a previously registered update callback with this [`View`]. + /// + /// # Arguments + /// + /// - `id` - A callback `id` as returned by a recipricol call to + /// [`View::on_update`]. + /// + /// # Examples + /// + /// ```no_run + /// # use perspective_client::{OnUpdateOptions, View}; + /// # async fn run() -> Result<(), Box> { + /// # let view: View = todo!(); + /// let callback = |_| async { print!("Updated!") }; + /// let cid = view.on_update(callback, OnUpdateOptions::default()).await?; + /// view.remove_update(cid).await?; + /// # Ok(()) } + /// ``` + pub async fn remove_update(&self, update_id: u32) -> ClientResult<()> { + let msg = self.client_message(ClientReq::ViewRemoveOnUpdateReq(ViewRemoveOnUpdateReq { + id: update_id, + })); + + self.client.unsubscribe(update_id).await?; + match self.client.oneshot(&msg).await? { + ClientResp::ViewRemoveOnUpdateResp(_) => Ok(()), + resp => Err(resp.into()), + } + } + + /// Register a callback with this [`View`]. Whenever the [`View`] is + /// deleted, this callback will be invoked. + pub async fn on_delete( + &self, + on_delete: Box, + ) -> ClientResult { + let callback = move |resp: Response| match resp.client_resp.unwrap() { + ClientResp::ViewOnDeleteResp(_) => { + on_delete(); + Ok(()) + }, + resp => Err(resp.into()), + }; + + let msg = self.client_message(ClientReq::ViewOnDeleteReq(ViewOnDeleteReq {})); + self.client.subscribe_once(&msg, Box::new(callback)).await?; + Ok(msg.msg_id) + } + + /// Unregister a previously registered [`View::on_delete`] callback. + pub async fn remove_delete(&self, callback_id: u32) -> ClientResult<()> { + let msg = self.client_message(ClientReq::ViewRemoveDeleteReq(ViewRemoveDeleteReq { + id: callback_id, + })); + + match self.client.oneshot(&msg).await? { + ClientResp::ViewRemoveDeleteResp(ViewRemoveDeleteResp {}) => Ok(()), + resp => Err(resp.into()), + } + } + + /// Collapses the `group_by` row at `row_index`. + pub async fn collapse(&self, row_index: u32) -> ClientResult { + let msg = self.client_message(ClientReq::ViewCollapseReq(ViewCollapseReq { row_index })); + match self.client.oneshot(&msg).await? { + ClientResp::ViewCollapseResp(ViewCollapseResp { num_changed }) => Ok(num_changed), + resp => Err(resp.into()), + } + } + + /// Expand the `group_by` row at `row_index`. + pub async fn expand(&self, row_index: u32) -> ClientResult { + let msg = self.client_message(ClientReq::ViewExpandReq(ViewExpandReq { row_index })); + match self.client.oneshot(&msg).await? { + ClientResp::ViewExpandResp(ViewExpandResp { num_changed }) => Ok(num_changed), + resp => Err(resp.into()), + } + } + + /// Set expansion `depth` of the `group_by` tree. + pub async fn set_depth(&self, depth: u32) -> ClientResult<()> { + let msg = self.client_message(ClientReq::ViewSetDepthReq(ViewSetDepthReq { depth })); + match self.client.oneshot(&msg).await? { + ClientResp::ViewSetDepthResp(_) => Ok(()), + resp => Err(resp.into()), + } + } +} 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 0000000..2e74c94 --- /dev/null +++ b/rust/perspective-client/src/rust/virtual_server/data.rs @@ -0,0 +1,1129 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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::sync::Arc; + +use arrow_array::builder::{ + BooleanBuilder, Float64Builder, Int32Builder, StringDictionaryBuilder, + TimestampMillisecondBuilder, +}; +use arrow_array::cast::AsArray; +use arrow_array::types::Int32Type; +use arrow_array::{ + Array, ArrayAccessor, ArrayRef, BooleanArray, Date32Array, Date64Array, Decimal128Array, + Float32Array, Float64Array, Int8Array, Int16Array, Int32Array, Int64Array, LargeStringArray, + RecordBatch, StringArray, Time32MillisecondArray, Time32SecondArray, Time64MicrosecondArray, + Time64NanosecondArray, TimestampMicrosecondArray, TimestampMillisecondArray, + TimestampNanosecondArray, TimestampSecondArray, UInt8Array, UInt16Array, UInt32Array, + UInt64Array, +}; +use arrow_ipc::reader::{FileReader, StreamReader}; +use arrow_ipc::writer::StreamWriter; +use arrow_schema::{DataType, Field, Schema, TimeUnit}; +use indexmap::IndexMap; +use serde::Serialize; + +use crate::config::{GroupRollupMode, Scalar, ViewConfig}; + +/// An Arrow column builder, used during the population phase of +/// [`VirtualDataSlice`]. +pub enum ColumnBuilder { + Boolean(BooleanBuilder), + String(StringDictionaryBuilder), + Float(Float64Builder), + Integer(Int32Builder), + Datetime(TimestampMillisecondBuilder), +} + +fn dict_data_type() -> DataType { + DataType::Dictionary(Box::new(DataType::Int32), Box::new(DataType::Utf8)) +} + +/// 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), + RowPath(Vec), +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum RowPathStyle { + /// Legacy: emit a single `__ROW_PATH__` sidecar (per-row nested + /// array in `render_to_rows`, array-of-arrays in + /// `render_to_columns_json`). `__ROW_PATH_N__` per-level columns + /// are filtered out. Matches the native engine's `to_json` / + /// `to_columns` shape. + Sidecar, + + /// Native: emit per-level `__ROW_PATH_0__`, `__ROW_PATH_1__`, … + /// columns directly. No `__ROW_PATH__` sidecar. Matches the native + /// engine's Arrow IPC, CSV, and NDJSON shapes. + PerLevel, +} + +/// Trait for types that can be written to a [`ColumnBuilder`] 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 builder. + /// + /// Returns an error if the column type does not match the value type. + fn write_to(self, col: &mut ColumnBuilder) -> Result<(), &'static str>; + + /// Creates a new empty column builder of the appropriate type for this + /// value. + fn new_builder() -> ColumnBuilder; + + /// Converts this value to a [`Scalar`] representation. + fn to_scalar(self) -> Scalar; +} + +impl SetVirtualDataColumn for Option { + fn write_to(self, col: &mut ColumnBuilder) -> Result<(), &'static str> { + if let ColumnBuilder::String(builder) = col { + match self { + Some(s) => builder.append_value(&s), + None => builder.append_null(), + } + Ok(()) + } else { + Err("Bad type") + } + } + + fn new_builder() -> ColumnBuilder { + ColumnBuilder::String(StringDictionaryBuilder::new()) + } + + fn to_scalar(self) -> Scalar { + if let Some(x) = self { + Scalar::String(x) + } else { + Scalar::Null + } + } +} + +impl SetVirtualDataColumn for Option { + fn write_to(self, col: &mut ColumnBuilder) -> Result<(), &'static str> { + if let ColumnBuilder::Float(builder) = col { + match self { + Some(v) => builder.append_value(v), + None => builder.append_null(), + } + Ok(()) + } else { + Err("Bad type") + } + } + + fn new_builder() -> ColumnBuilder { + ColumnBuilder::Float(Float64Builder::new()) + } + + fn to_scalar(self) -> Scalar { + if let Some(x) = self { + Scalar::Float(x) + } else { + Scalar::Null + } + } +} + +impl SetVirtualDataColumn for Option { + fn write_to(self, col: &mut ColumnBuilder) -> Result<(), &'static str> { + if let ColumnBuilder::Integer(builder) = col { + match self { + Some(v) => builder.append_value(v), + None => builder.append_null(), + } + Ok(()) + } else { + Err("Bad type") + } + } + + fn new_builder() -> ColumnBuilder { + ColumnBuilder::Integer(Int32Builder::new()) + } + + fn to_scalar(self) -> Scalar { + if let Some(x) = self { + Scalar::Float(x as f64) + } else { + Scalar::Null + } + } +} + +impl SetVirtualDataColumn for Option { + fn write_to(self, col: &mut ColumnBuilder) -> Result<(), &'static str> { + if let ColumnBuilder::Datetime(builder) = col { + match self { + Some(v) => builder.append_value(v), + None => builder.append_null(), + } + Ok(()) + } else { + Err("Bad type") + } + } + + fn new_builder() -> ColumnBuilder { + ColumnBuilder::Datetime(TimestampMillisecondBuilder::new()) + } + + fn to_scalar(self) -> Scalar { + if let Some(x) = self { + Scalar::Float(x as f64) + } else { + Scalar::Null + } + } +} + +impl SetVirtualDataColumn for Option { + fn write_to(self, col: &mut ColumnBuilder) -> Result<(), &'static str> { + if let ColumnBuilder::Boolean(builder) = col { + match self { + Some(v) => builder.append_value(v), + None => builder.append_null(), + } + Ok(()) + } else { + Err("Bad type") + } + } + + fn new_builder() -> ColumnBuilder { + ColumnBuilder::Boolean(BooleanBuilder::new()) + } + + fn to_scalar(self) -> Scalar { + if let Some(x) = self { + Scalar::Bool(x) + } else { + Scalar::Null + } + } +} + +/// A columnar data slice returned from a virtual server view query. +/// +/// This struct represents a rectangular slice of data from a view, stored +/// internally as Arrow builders during population and frozen into a +/// `RecordBatch` on first consumption. +#[derive(Debug)] +pub struct VirtualDataSlice { + config: ViewConfig, + builders: IndexMap, + row_path: Option>>, + frozen: Option, +} + +impl std::fmt::Debug for ColumnBuilder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ColumnBuilder::Boolean(_) => write!(f, "ColumnBuilder::Boolean(..)"), + ColumnBuilder::String(_) => write!(f, "ColumnBuilder::String(..)"), + ColumnBuilder::Float(_) => write!(f, "ColumnBuilder::Float(..)"), + ColumnBuilder::Integer(_) => write!(f, "ColumnBuilder::Integer(..)"), + ColumnBuilder::Datetime(_) => write!(f, "ColumnBuilder::Datetime(..)"), + } + } +} + +/// Extracts grouping ID values from an Arrow array as `i64`. +fn cast_to_int64(array: &ArrayRef) -> Result, Box> { + let num_rows = array.len(); + let mut result = Vec::with_capacity(num_rows); + match array.data_type() { + DataType::Int32 => { + let arr = array.as_any().downcast_ref::().unwrap(); + for i in 0..num_rows { + result.push(if arr.is_null(i) { + 0 + } else { + arr.value(i) as i64 + }); + } + }, + DataType::Int64 => { + let arr = array.as_any().downcast_ref::().unwrap(); + for i in 0..num_rows { + result.push(if arr.is_null(i) { 0 } else { arr.value(i) }); + } + }, + DataType::Float64 => { + let arr = array.as_any().downcast_ref::().unwrap(); + for i in 0..num_rows { + result.push(if arr.is_null(i) { + 0 + } else { + arr.value(i) as i64 + }); + } + }, + dt => return Err(format!("Cannot cast {} to Int64", dt).into()), + } + Ok(result) +} + +/// Extracts a single cell from an Arrow array as a [`Scalar`]. +fn extract_scalar(array: &ArrayRef, row_idx: usize) -> Scalar { + if array.is_null(row_idx) { + return Scalar::Null; + } + match array.data_type() { + DataType::Utf8 => { + let arr = array.as_any().downcast_ref::().unwrap(); + Scalar::String(arr.value(row_idx).to_string()) + }, + DataType::Dictionary(..) => { + let dict = array.as_dictionary::(); + let values = dict.downcast_dict::().unwrap(); + Scalar::String(values.value(row_idx).to_string()) + }, + DataType::Float64 => { + let arr = array.as_any().downcast_ref::().unwrap(); + Scalar::Float(arr.value(row_idx)) + }, + DataType::Int32 => { + let arr = array.as_any().downcast_ref::().unwrap(); + Scalar::Float(arr.value(row_idx) as f64) + }, + DataType::Int64 => { + let arr = array.as_any().downcast_ref::().unwrap(); + Scalar::Float(arr.value(row_idx) as f64) + }, + DataType::Boolean => { + let arr = array.as_any().downcast_ref::().unwrap(); + Scalar::Bool(arr.value(row_idx)) + }, + DataType::Timestamp(TimeUnit::Millisecond, _) => { + let arr = array + .as_any() + .downcast_ref::() + .unwrap(); + Scalar::Float(arr.value(row_idx) as f64) + }, + DataType::Date32 => { + let arr = array.as_any().downcast_ref::().unwrap(); + Scalar::Float(arr.value(row_idx) as f64 * 86_400_000.0) + }, + _ => { + let scalar_arr = array.slice(row_idx, 1); + Scalar::String(format!("{:?}", scalar_arr)) + }, + } +} + +/// Coerces an Arrow column to Perspective-compatible types, optionally +/// renaming. +/// Manually converts a timestamp array of any unit to milliseconds. +fn timestamp_to_millis(array: &ArrayRef, unit: &TimeUnit) -> ArrayRef { + let millis: TimestampMillisecondArray = match unit { + TimeUnit::Second => { + let arr = array + .as_any() + .downcast_ref::() + .unwrap(); + arr.iter().map(|v| v.map(|v| v * 1_000)).collect() + }, + TimeUnit::Microsecond => { + let arr = array + .as_any() + .downcast_ref::() + .unwrap(); + arr.iter().map(|v| v.map(|v| v / 1_000)).collect() + }, + TimeUnit::Nanosecond => { + let arr = array + .as_any() + .downcast_ref::() + .unwrap(); + arr.iter().map(|v| v.map(|v| v / 1_000_000)).collect() + }, + TimeUnit::Millisecond => { + return array.clone(); + }, + }; + Arc::new(millis) as ArrayRef +} + +fn coerce_column( + name: &str, + field: &Field, + array: &ArrayRef, +) -> Result<(Field, ArrayRef), Box> { + match field.data_type() { + DataType::Boolean | DataType::Float64 | DataType::Int32 | DataType::Date32 => Ok(( + Field::new(name, field.data_type().clone(), true), + array.clone(), + )), + DataType::Dictionary(..) => Ok((Field::new(name, dict_data_type(), true), array.clone())), + DataType::Utf8 => { + let arr = array.as_any().downcast_ref::().unwrap(); + let mut builder = StringDictionaryBuilder::::new(); + for i in 0..arr.len() { + if arr.is_null(i) { + builder.append_null(); + } else { + builder.append_value(arr.value(i)); + } + } + Ok(( + Field::new(name, dict_data_type(), true), + Arc::new(builder.finish()) as ArrayRef, + )) + }, + DataType::Timestamp(TimeUnit::Millisecond, _) => Ok(( + Field::new(name, DataType::Timestamp(TimeUnit::Millisecond, None), true), + array.clone(), + )), + DataType::Int8 => { + let arr = array.as_any().downcast_ref::().unwrap(); + let result: Int32Array = arr.iter().map(|v| v.map(|v| v as i32)).collect(); + Ok(( + Field::new(name, DataType::Int32, true), + Arc::new(result) as ArrayRef, + )) + }, + DataType::Int16 => { + let arr = array.as_any().downcast_ref::().unwrap(); + let result: Int32Array = arr.iter().map(|v| v.map(|v| v as i32)).collect(); + Ok(( + Field::new(name, DataType::Int32, true), + Arc::new(result) as ArrayRef, + )) + }, + DataType::UInt8 => { + let arr = array.as_any().downcast_ref::().unwrap(); + let result: Int32Array = arr.iter().map(|v| v.map(|v| v as i32)).collect(); + Ok(( + Field::new(name, DataType::Int32, true), + Arc::new(result) as ArrayRef, + )) + }, + DataType::UInt16 => { + let arr = array.as_any().downcast_ref::().unwrap(); + let result: Int32Array = arr.iter().map(|v| v.map(|v| v as i32)).collect(); + Ok(( + Field::new(name, DataType::Int32, true), + Arc::new(result) as ArrayRef, + )) + }, + DataType::UInt32 => { + let arr = array.as_any().downcast_ref::().unwrap(); + let result: Int64Array = arr.iter().map(|v| v.map(|v| v as i64)).collect(); + let result: Float64Array = result.iter().map(|v| v.map(|v| v as f64)).collect(); + Ok(( + Field::new(name, DataType::Float64, true), + Arc::new(result) as ArrayRef, + )) + }, + DataType::Int64 => { + let arr = array.as_any().downcast_ref::().unwrap(); + let result: Float64Array = arr.iter().map(|v| v.map(|v| v as f64)).collect(); + Ok(( + Field::new(name, DataType::Float64, true), + Arc::new(result) as ArrayRef, + )) + }, + DataType::UInt64 => { + let arr = array.as_any().downcast_ref::().unwrap(); + let result: Float64Array = arr.iter().map(|v| v.map(|v| v as f64)).collect(); + Ok(( + Field::new(name, DataType::Float64, true), + Arc::new(result) as ArrayRef, + )) + }, + DataType::Float32 => { + let arr = array.as_any().downcast_ref::().unwrap(); + let result: Float64Array = arr.iter().map(|v| v.map(|v| v as f64)).collect(); + Ok(( + Field::new(name, DataType::Float64, true), + Arc::new(result) as ArrayRef, + )) + }, + DataType::Decimal128(_, scale) => { + let scale = *scale; + let arr = array.as_any().downcast_ref::().unwrap(); + let divisor = 10_f64.powi(scale as i32); + let result: Float64Array = arr.iter().map(|v| v.map(|v| v as f64 / divisor)).collect(); + Ok(( + Field::new(name, DataType::Float64, true), + Arc::new(result) as ArrayRef, + )) + }, + DataType::Date64 => { + let arr = array.as_any().downcast_ref::().unwrap(); + let result: Date32Array = arr + .iter() + .map(|v| v.map(|v| (v / 86_400_000) as i32)) + .collect(); + Ok(( + Field::new(name, DataType::Date32, true), + Arc::new(result) as ArrayRef, + )) + }, + DataType::Timestamp(unit, _) => { + let casted = timestamp_to_millis(array, unit); + Ok(( + Field::new(name, DataType::Timestamp(TimeUnit::Millisecond, None), true), + casted, + )) + }, + DataType::Time32(TimeUnit::Second) => { + let arr = array.as_any().downcast_ref::().unwrap(); + let result: TimestampMillisecondArray = + arr.iter().map(|v| v.map(|v| v as i64 * 1_000)).collect(); + Ok(( + Field::new(name, DataType::Timestamp(TimeUnit::Millisecond, None), true), + Arc::new(result) as ArrayRef, + )) + }, + DataType::Time32(TimeUnit::Millisecond) => { + let arr = array + .as_any() + .downcast_ref::() + .unwrap(); + let result: TimestampMillisecondArray = + arr.iter().map(|v| v.map(|v| v as i64)).collect(); + Ok(( + Field::new(name, DataType::Timestamp(TimeUnit::Millisecond, None), true), + Arc::new(result) as ArrayRef, + )) + }, + DataType::Time64(TimeUnit::Microsecond) => { + let arr = array + .as_any() + .downcast_ref::() + .unwrap(); + let result: TimestampMillisecondArray = + arr.iter().map(|v| v.map(|v| v / 1_000)).collect(); + Ok(( + Field::new(name, DataType::Timestamp(TimeUnit::Millisecond, None), true), + Arc::new(result) as ArrayRef, + )) + }, + DataType::Time64(TimeUnit::Nanosecond) => { + let arr = array + .as_any() + .downcast_ref::() + .unwrap(); + let result: TimestampMillisecondArray = + arr.iter().map(|v| v.map(|v| v / 1_000_000)).collect(); + Ok(( + Field::new(name, DataType::Timestamp(TimeUnit::Millisecond, None), true), + Arc::new(result) as ArrayRef, + )) + }, + DataType::LargeUtf8 => { + let arr = array.as_any().downcast_ref::().unwrap(); + let mut builder = StringDictionaryBuilder::::new(); + for i in 0..arr.len() { + if arr.is_null(i) { + builder.append_null(); + } else { + builder.append_value(arr.value(i)); + } + } + Ok(( + Field::new(name, dict_data_type(), true), + Arc::new(builder.finish()) as ArrayRef, + )) + }, + dt => { + tracing::warn!( + "Coercing unknown Arrow type {} to Dictionary for column '{}'", + dt, + name + ); + let num_rows = array.len(); + let mut builder = StringDictionaryBuilder::::new(); + for i in 0..num_rows { + if array.is_null(i) { + builder.append_null(); + } else { + let scalar_arr = array.slice(i, 1); + builder.append_value(format!("{:?}", scalar_arr)); + } + } + Ok(( + Field::new(name, dict_data_type(), true), + Arc::new(builder.finish()) as ArrayRef, + )) + }, + } +} + +impl VirtualDataSlice { + pub fn new(config: ViewConfig) -> Self { + VirtualDataSlice { + config, + builders: IndexMap::default(), + row_path: None, + frozen: None, + } + } + + /// Loads data from Arrow IPC file format bytes, with automatic + /// post-processing based on the view configuration. + /// + /// When `group_by` is active, extracts `__GROUPING_ID__` and + /// `__ROW_PATH_N__` columns to build `self.row_path`, then removes + /// `__GROUPING_ID__` from the output `RecordBatch`. The + /// `__ROW_PATH_N__` columns are *kept* in the frozen batch so + /// downstream Arrow IPC consumers (`with_typed_arrays`, used by + /// viewer-charts to drive its categorical/numeric axis resolvers + /// and tree-hierarchy walkers) see them inline — matching the + /// native `perspective-server`'s `to_arrow` output when + /// `emit_legacy_row_path_names: false`. + /// + /// When `split_by` is active, renames data columns by replacing `_` + /// with `|` (the DuckDB PIVOT separator). + /// + /// Also coerces non-standard Arrow types (e.g. `Decimal128`, `Int64`) + /// to Perspective-compatible types. + pub fn from_arrow_ipc(&mut self, ipc: &[u8]) -> Result<(), Box> { + let cursor = std::io::Cursor::new(ipc); + let batches: Vec = if &ipc[0..6] == "ARROW1".as_bytes() { + FileReader::try_new(cursor, None)?.collect::, _>>()? + } else { + StreamReader::try_new(cursor, None)?.collect::, _>>()? + }; + + let batch = match batches.len() { + 0 => return Err("Arrow IPC stream contained no record batches".into()), + 1 => batches.into_iter().next().unwrap(), + _ => arrow_select::concat::concat_batches(&batches[0].schema(), &batches)?, + }; + + let has_group_by = !self.config.group_by.is_empty(); + let has_split_by = !self.config.split_by.is_empty(); + let is_total = self.config.group_rollup_mode == GroupRollupMode::Total; + + if !has_group_by && !has_split_by && !is_total { + self.frozen = Some(batch); + return Ok(()); + } + + let num_rows = batch.num_rows(); + let schema = batch.schema(); + + // Phase A: Extract row_path from __GROUPING_ID__ and __ROW_PATH_N__ + if has_group_by { + let group_by_len = self.config.group_by.len(); + let is_flat = self.config.group_rollup_mode == GroupRollupMode::Flat; + let grouping_ids = if is_flat { + None + } else { + let grouping_id_idx = schema + .index_of("__GROUPING_ID__") + .map_err(|_| "Missing __GROUPING_ID__ column")?; + Some(cast_to_int64(batch.column(grouping_id_idx))?) + }; + + let mut row_paths: Vec> = (0..num_rows).map(|_| Vec::new()).collect(); + for gidx in 0..group_by_len { + let col_name = format!("__ROW_PATH_{}__", gidx); + let col_idx = schema + .index_of(&col_name) + .map_err(|_| format!("Missing {} column", col_name))?; + + let col = batch.column(col_idx); + + // In flat mode, all rows are leaf rows + if is_flat { + // TODO I may be dumb but I'm not exactly sure what Clippy + // wants here. This could be an `enumerate` but how is this + // better? + #[allow(clippy::needless_range_loop)] + for row_idx in 0..num_rows { + row_paths[row_idx].push(extract_scalar(col, row_idx)); + } + } else { + let gids = grouping_ids.as_ref().unwrap(); + let max_grouping_id = 2_i64.pow(group_by_len as u32 - gidx as u32) - 1; + for row_idx in 0..num_rows { + if gids[row_idx] < max_grouping_id { + row_paths[row_idx].push(extract_scalar(col, row_idx)); + } + } + } + } + + self.row_path = Some(row_paths); + } + + // Phase B: Rebuild RecordBatch without metadata columns, with + // column renames and type coercion. + let mut new_fields = Vec::new(); + let mut new_arrays: Vec = Vec::new(); + for (col_idx, field) in schema.fields().iter().enumerate() { + let name = field.name(); + // `__GROUPING_ID__` is an internal SQL-rollup discriminator + // (used in Phase A above to decide which row-path levels + // belong to each row). No JS consumer reads it, so it's + // dropped from the frozen batch. + // + // `__ROW_PATH_N__` columns are kept. Phase A copied their + // values into `self.row_path` for the JSON sidecar paths + // (`render_to_columns_json`, `render_to_rows`), but + // viewer-charts' `with_typed_arrays` callback needs the + // per-level columns inline in the Arrow stream — its + // categorical-axis resolver, numeric-position lookup, and + // tree hierarchy walker all do `columns.get(\`__ROW_PATH_${n}__\`)`. + // Keeping the columns here lets `render_to_arrow_ipc` + // serialize them naturally, matching native + // `perspective-server`'s `to_arrow` output. + if name == "__GROUPING_ID__" { + continue; + } + + let new_name = if has_split_by && !name.starts_with("__") { + name.replace('_', "|") + } else { + name.clone() + }; + + let (coerced_field, coerced_array) = + coerce_column(&new_name, field, batch.column(col_idx))?; + new_fields.push(coerced_field); + new_arrays.push(coerced_array); + } + + let new_schema = Arc::new(Schema::new(new_fields)); + self.frozen = if new_arrays.is_empty() { + Some(RecordBatch::new_empty(new_schema)) + } else { + Some(RecordBatch::try_new(new_schema, new_arrays)?) + }; + Ok(()) + } + + /// Freezes the builders into a `RecordBatch`. Idempotent — subsequent + /// calls return the cached batch. + pub(crate) fn freeze(&mut self) -> &RecordBatch { + if self.frozen.is_none() { + let mut fields = Vec::new(); + let mut arrays: Vec = Vec::new(); + + for (name, builder) in &mut self.builders { + let (field, array): (Field, ArrayRef) = match builder { + ColumnBuilder::Boolean(b) => ( + Field::new(name, DataType::Boolean, true), + Arc::new(b.finish()), + ), + ColumnBuilder::String(b) => ( + Field::new(name, dict_data_type(), true), + Arc::new(b.finish()), + ), + ColumnBuilder::Float(b) => ( + Field::new(name, DataType::Float64, true), + Arc::new(b.finish()), + ), + ColumnBuilder::Integer(b) => ( + Field::new(name, DataType::Int32, true), + Arc::new(b.finish()), + ), + ColumnBuilder::Datetime(b) => ( + Field::new(name, DataType::Timestamp(TimeUnit::Millisecond, None), true), + Arc::new(b.finish()), + ), + }; + fields.push(field); + arrays.push(array); + } + + let schema = Arc::new(Schema::new(fields)); + self.frozen = Some( + RecordBatch::try_new(schema, arrays) + .expect("RecordBatch construction should not fail for well-formed builders"), + ); + } + self.frozen.as_ref().unwrap() + } + + /// Serializes the data to Arrow IPC streaming format. + pub(crate) fn render_to_arrow_ipc(&mut self) -> Result, Box> { + let batch = self.freeze().clone(); + let schema = batch.schema(); + let mut buf = Vec::new(); + { + let mut writer = StreamWriter::try_new(&mut buf, &schema)?; + writer.write(&batch)?; + writer.finish()?; + } + Ok(buf) + } + + /// Converts the columnar data to a row-oriented representation for JSON + /// serialization. + /// + /// `style` selects between the legacy `__ROW_PATH__` sidecar + /// (`Sidecar`, used by `to_json`) and the native per-level + /// `__ROW_PATH_N__` columns (`PerLevel`, used by `to_csv` / + /// `to_ndjson`). See [`RowPathStyle`] for the deprecation plan. + pub(crate) fn render_to_rows( + &mut self, + style: RowPathStyle, + ) -> Vec> { + let batch = self.freeze().clone(); + let num_rows = batch.num_rows(); + let schema = batch.schema(); + + (0..num_rows) + .map(|row_idx| { + let mut row = IndexMap::new(); + if style == RowPathStyle::Sidecar + && let Some(ref rp) = self.row_path + && row_idx < rp.len() + { + row.insert( + "__ROW_PATH__".to_string(), + VirtualDataCell::RowPath(rp[row_idx].clone()), + ); + } + + for (col_idx, field) in schema.fields().iter().enumerate() { + if style == RowPathStyle::Sidecar && field.name().starts_with("__ROW_PATH_") { + continue; + } + + let col = batch.column(col_idx); + let cell = if col.is_null(row_idx) { + match field.data_type() { + DataType::Boolean => VirtualDataCell::Boolean(None), + DataType::Utf8 | DataType::Dictionary(..) => { + VirtualDataCell::String(None) + }, + DataType::Float64 => VirtualDataCell::Float(None), + DataType::Int32 => VirtualDataCell::Integer(None), + DataType::Timestamp(TimeUnit::Millisecond, _) => { + VirtualDataCell::Datetime(None) + }, + _ => continue, + } + } else { + match field.data_type() { + DataType::Boolean => { + let arr = col.as_any().downcast_ref::().unwrap(); + VirtualDataCell::Boolean(Some(arr.value(row_idx))) + }, + DataType::Utf8 => { + let arr = col.as_any().downcast_ref::().unwrap(); + VirtualDataCell::String(Some(arr.value(row_idx).to_string())) + }, + DataType::Dictionary(..) => { + let dict = col.as_dictionary::(); + let values = dict.downcast_dict::().unwrap(); + VirtualDataCell::String(Some(values.value(row_idx).to_string())) + }, + DataType::Float64 => { + let arr = col.as_any().downcast_ref::().unwrap(); + VirtualDataCell::Float(Some(arr.value(row_idx))) + }, + DataType::Int32 => { + let arr = col.as_any().downcast_ref::().unwrap(); + VirtualDataCell::Integer(Some(arr.value(row_idx))) + }, + DataType::Int64 => { + // TODO ???? + let arr = col.as_any().downcast_ref::().unwrap(); + VirtualDataCell::Float(Some(arr.value(row_idx) as f64)) + }, + DataType::Time64(TimeUnit::Microsecond) => { + let arr = col + .as_any() + .downcast_ref::() + .unwrap(); + VirtualDataCell::Float(Some(arr.value(row_idx) as f64)) + }, + DataType::Timestamp(TimeUnit::Microsecond, _) => { + let arr = col + .as_any() + .downcast_ref::() + .unwrap(); + VirtualDataCell::Datetime(Some(arr.value(row_idx) * 1000)) + }, + DataType::Timestamp(TimeUnit::Millisecond, _) => { + let arr = col + .as_any() + .downcast_ref::() + .unwrap(); + VirtualDataCell::Datetime(Some(arr.value(row_idx))) + }, + DataType::Date32 => { + let arr = col.as_any().downcast_ref::().unwrap(); + VirtualDataCell::Datetime(Some( + arr.value(row_idx) as i64 * 86_400_000, + )) + }, + x => { + tracing::error!("Unknown Arrow IPC type {}", x); + continue; + }, + } + }; + row.insert(field.name().clone(), cell); + } + + row + }) + .collect() + } + + /// Serializes the data to a column-oriented JSON string. + /// + /// `style` selects between the legacy `__ROW_PATH__` sidecar + /// (`Sidecar`, used by `to_columns`) and the native per-level + /// `__ROW_PATH_N__` columns (`PerLevel`, currently unused — reserved + /// for the future deprecation of `__ROW_PATH__`). See + /// [`RowPathStyle`] for context. + pub fn render_to_columns_json( + &mut self, + style: RowPathStyle, + ) -> Result> { + let batch = self.freeze().clone(); + let schema = batch.schema(); + let mut map = serde_json::Map::new(); + + if style == RowPathStyle::Sidecar + && let Some(ref rp) = self.row_path + { + map.insert("__ROW_PATH__".to_string(), serde_json::to_value(rp)?); + } + + for (col_idx, field) in schema.fields().iter().enumerate() { + if style == RowPathStyle::Sidecar && field.name().starts_with("__ROW_PATH_") { + continue; + } + + let col = batch.column(col_idx); + let num_rows = col.len(); + let values: serde_json::Value = match field.data_type() { + DataType::Boolean => { + let arr = col.as_any().downcast_ref::().unwrap(); + serde_json::to_value( + (0..num_rows) + .map(|i| { + if arr.is_null(i) { + None + } else { + Some(arr.value(i)) + } + }) + .collect::>(), + )? + }, + DataType::Utf8 => { + let arr = col.as_any().downcast_ref::().unwrap(); + serde_json::to_value( + (0..num_rows) + .map(|i| { + if arr.is_null(i) { + None + } else { + Some(arr.value(i)) + } + }) + .collect::>(), + )? + }, + DataType::Dictionary(..) => { + let dict = col.as_dictionary::(); + let values = dict.downcast_dict::().unwrap(); + serde_json::to_value( + (0..num_rows) + .map(|i| { + if col.is_null(i) { + None + } else { + Some(values.value(i)) + } + }) + .collect::>(), + )? + }, + DataType::Float64 => { + let arr = col.as_any().downcast_ref::().unwrap(); + serde_json::to_value( + (0..num_rows) + .map(|i| { + if arr.is_null(i) { + None + } else { + Some(arr.value(i)) + } + }) + .collect::>(), + )? + }, + DataType::Int32 => { + let arr = col.as_any().downcast_ref::().unwrap(); + serde_json::to_value( + (0..num_rows) + .map(|i| { + if arr.is_null(i) { + None + } else { + Some(arr.value(i)) + } + }) + .collect::>(), + )? + }, + DataType::Int64 => { + let arr = col.as_any().downcast_ref::().unwrap(); + serde_json::to_value( + (0..num_rows) + .map(|i| { + if arr.is_null(i) { + None + } else { + Some(arr.value(i) as f64) + } + }) + .collect::>(), + )? + }, + DataType::Timestamp(TimeUnit::Millisecond, _) => { + let arr = col + .as_any() + .downcast_ref::() + .unwrap(); + serde_json::to_value( + (0..num_rows) + .map(|i| { + if arr.is_null(i) { + None + } else { + Some(arr.value(i)) + } + }) + .collect::>(), + )? + }, + DataType::Time64(TimeUnit::Microsecond) => { + let arr = col + .as_any() + .downcast_ref::() + .unwrap(); + serde_json::to_value( + (0..num_rows) + .map(|i| { + if arr.is_null(i) { + None + } else { + Some(arr.value(i) as f64) + } + }) + .collect::>(), + )? + }, + DataType::Date32 => { + let arr = col.as_any().downcast_ref::().unwrap(); + serde_json::to_value( + (0..num_rows) + .map(|i| { + if arr.is_null(i) { + None + } else { + Some(arr.value(i) as i64 * 86_400_000) + } + }) + .collect::>(), + )? + }, + x => { + tracing::error!("Unknown Arrow IPC type {}", x); + continue; + }, + }; + map.insert(field.name().clone(), values); + } + + Ok(serde_json::to_string(&map)?) + } + + /// 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, + grouping_id: Option, + index: usize, + value: T, + ) -> Result<(), Box> { + if name == "__GROUPING_ID__" { + return Ok(()); + } + + if name.starts_with("__ROW_PATH_") { + let group_by_index: u32 = name[11..name.len() - 2].parse()?; + let max_grouping_id = + 2_i32.pow((self.config.group_by.len() as u32) - group_by_index) - 1; + + if grouping_id.map(|x| x as i32).unwrap_or(i32::MAX) < max_grouping_id { + let col = self.row_path.get_or_insert_with(Vec::new); + 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_name = if !self.config.split_by.is_empty() && !name.starts_with("__") { + name.replace('_', "|") + } else { + name.to_owned() + }; + + if !self.builders.contains_key(&col_name) { + self.builders.insert(col_name.clone(), T::new_builder()); + } + + let col = self + .builders + .get_mut(&col_name) + .ok_or_else(|| format!("Column '{}' not found after insertion", col_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 0000000..cbec0ee --- /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("View not found '{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 0000000..03399dd --- /dev/null +++ b/rust/perspective-client/src/rust/virtual_server/features.rs @@ -0,0 +1,120 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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, Serialize}; + +use crate::config::GroupRollupMode; +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(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct Features<'a> { + /// Whether group-by aggregation is supported. + #[serde(default)] + pub group_by: bool, + + /// Which `group_by_rollup_mode` options are supported + #[serde(default)] + pub group_rollup_mode: Vec, + + /// 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(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[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, + group_rollup_mode: value + .group_rollup_mode + .iter() + .map(|x| crate::proto::GroupRollupMode::from(*x) as i32) + .collect(), + 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/generic_sql_model.rs b/rust/perspective-client/src/rust/virtual_server/generic_sql_model.rs new file mode 100644 index 0000000..4590fa6 --- /dev/null +++ b/rust/perspective-client/src/rust/virtual_server/generic_sql_model.rs @@ -0,0 +1,341 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +//! SQL query builder for virtual server operations. +//! +//! This module provides a stateless SQL query generator that produces +//! generic SQL strings for perspective virtual server operations. + +// TODO(texodus): Missing these features +// +// - 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. + +mod table_make_view; + +#[cfg(test)] +mod tests; + +use std::fmt; + +use indexmap::IndexMap; +use serde::Deserialize; + +use crate::config::{FilterTerm, GroupRollupMode, Scalar, Sort, SortDir, ViewConfig}; +use crate::proto::{ColumnType, ViewPort}; +use crate::virtual_server::generic_sql_model::table_make_view::ViewQueryContext; + +/// Error type for SQL generation operations. +#[derive(Debug, Clone)] +pub enum GenericSQLError { + /// A required column was not found in the schema. + ColumnNotFound(String), + /// An invalid configuration was provided. + InvalidConfig(String), + /// An unsupported operation was requested. + UnsupportedOperation(String), +} + +impl fmt::Display for GenericSQLError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ColumnNotFound(col) => write!(f, "Column not found: {}", col), + Self::InvalidConfig(msg) => write!(f, "Invalid configuration: {}", msg), + Self::UnsupportedOperation(msg) => write!(f, "Unsupported operation: {}", msg), + } + } +} + +impl std::error::Error for GenericSQLError {} + +/// Result type alias for SQL operations. +pub type GenericSQLResult = Result; + +#[derive(Clone, Debug, Deserialize, Default)] +pub struct GenericSQLVirtualServerModelArgs { + create_entity: Option, + grouping_fn: Option, +} + +/// A stateless SQL query builder virtual server operations. +/// +/// This struct generates SQL query strings without executing them, allowing +/// the caller to execute the queries against a SQL connection. +#[derive(Debug, Default, Clone)] +pub struct GenericSQLVirtualServerModel(GenericSQLVirtualServerModelArgs); + +impl GenericSQLVirtualServerModel { + /// Creates a new `GenericSQLVirtualServerModel` instance. + pub fn new(args: GenericSQLVirtualServerModelArgs) -> Self { + Self(args) + } + + /// Returns the SQL query to list all hosted tables. + /// + /// # Returns + /// SQL: `SHOW ALL TABLES` + pub fn get_hosted_tables(&self) -> GenericSQLResult { + Ok("SHOW ALL TABLES".to_string()) + } + + /// Returns the SQL query to describe a table's schema. + /// + /// # Arguments + /// * `table_id` - The identifier of the table to describe. + /// + /// # Returns + /// SQL: `DESCRIBE {table_id}` + pub fn table_schema(&self, table_id: &str) -> GenericSQLResult { + Ok(format!("DESCRIBE {}", table_id)) + } + + /// Returns the SQL query to get the row count of a table. + /// + /// # Arguments + /// * `table_id` - The identifier of the table. + /// + /// # Returns + /// SQL: `SELECT COUNT(*) FROM {table_id}` + pub fn table_size(&self, table_id: &str) -> GenericSQLResult { + Ok(format!("SELECT COUNT(*) FROM {}", table_id)) + } + + /// Returns the SQL query to get the column count of a view. + /// + /// # Arguments + /// * `view_id` - The identifier of the view. + /// + /// # Returns + /// SQL: `SELECT COUNT(*) FROM (DESCRIBE {view_id})` + pub fn view_column_size(&self, view_id: &str) -> GenericSQLResult { + Ok(format!("SELECT COUNT(*) FROM (DESCRIBE {})", view_id)) + } + + /// Returns the SQL query to validate an expression against a table. + /// + /// # Arguments + /// * `table_id` - The identifier of the table. + /// * `expression` - The SQL expression to validate. + /// + /// # Returns + /// SQL: `DESCRIBE (SELECT {expression} FROM {table_id})` + pub fn table_validate_expression( + &self, + table_id: &str, + expression: &str, + ) -> GenericSQLResult { + Ok(format!( + "DESCRIBE (SELECT {} FROM {})", + expression, table_id + )) + } + + /// Returns the SQL query to delete a view. + /// + /// # Arguments + /// * `view_id` - The identifier of the view to delete. + /// + /// # Returns + /// SQL: `DROP TABLE IF EXISTS {view_id}` + pub fn view_delete(&self, view_id: &str) -> GenericSQLResult { + Ok(format!("DROP TABLE IF EXISTS {}", view_id)) + } + + /// Returns the SQL query to create a view from a table with the given + /// configuration. + /// + /// # Arguments + /// * `table_id` - The identifier of the source table. + /// * `view_id` - The identifier for the new view. + /// * `config` - The view configuration specifying columns, group_by, + /// split_by, etc. + /// + /// # Returns + /// SQL: `CREATE TABLE {view_id} AS (...)` + pub fn table_make_view( + &self, + table_id: &str, + view_id: &str, + config: &ViewConfig, + ) -> GenericSQLResult { + let ctx = ViewQueryContext::new(self, table_id, config); + let query = ctx.build_query(); + let template = self.0.create_entity.as_deref().unwrap_or("TABLE"); + Ok(format!("CREATE {} {} AS ({})", template, view_id, query)) + } + + /// Returns the SQL query to fetch data from a view with the given viewport. + /// + /// # Arguments + /// * `view_id` - The identifier of the view. + /// * `config` - The view configuration. + /// * `viewport` - The viewport specifying row/column ranges. + /// * `schema` - The schema of the view (column names to types). + /// + /// # Returns + /// SQL: `SELECT ... FROM {view_id} LIMIT ... OFFSET ...` + pub fn view_get_data( + &self, + view_id: &str, + config: &ViewConfig, + viewport: &ViewPort, + schema: &IndexMap, + ) -> GenericSQLResult { + let group_by = &config.group_by; + let sort = &config.sort; + let start_col = viewport.start_col.unwrap_or(0) as usize; + let end_col = viewport.end_col.map(|x| x as usize); + let start_row = viewport.start_row.unwrap_or(0); + let end_row = viewport.end_row; + let limit_clause = if let Some(end) = end_row { + format!("LIMIT {} OFFSET {}", end - start_row, start_row) + } else { + String::new() + }; + + let mut data_columns: Vec<&String> = schema + .keys() + .filter(|col_name| !col_name.starts_with("__")) + .collect(); + + let col_sort_dir = sort.iter().find_map(|Sort(_, dir)| match dir { + SortDir::ColAsc | SortDir::ColAscAbs => Some(true), + SortDir::ColDesc | SortDir::ColDescAbs => Some(false), + _ => None, + }); + + if let Some(ascending) = col_sort_dir { + if ascending { + data_columns.sort(); + } else { + data_columns.sort_by(|a, b| b.cmp(a)); + } + } + + let data_columns: Vec<&String> = data_columns + .into_iter() + .skip(start_col) + .take(end_col.map(|e| e - start_col).unwrap_or(usize::MAX)) + .collect(); + + let mut group_by_cols: Vec = Vec::new(); + if !group_by.is_empty() { + if config.group_rollup_mode != GroupRollupMode::Flat { + group_by_cols.push("\"__GROUPING_ID__\"".to_string()); + } + for idx in 0..group_by.len() { + group_by_cols.push(format!("\"__ROW_PATH_{}__\"", idx)); + } + } + + let all_columns: Vec = group_by_cols + .into_iter() + .chain(data_columns.iter().map(|col| format!("\"{}\"", col))) + .collect(); + + Ok(format!( + "SELECT {} FROM {} {}", + all_columns.join(", "), + view_id, + limit_clause + ) + .trim() + .to_string()) + } + + /// Returns the SQL query to describe a view's schema. + /// + /// # Arguments + /// * `view_id` - The identifier of the view. + /// + /// # Returns + /// SQL: `DESCRIBE {view_id}` + pub fn view_schema(&self, view_id: &str) -> GenericSQLResult { + Ok(format!("DESCRIBE {}", view_id)) + } + + /// Returns the SQL query to get the row count of a view. + /// + /// # Arguments + /// * `view_id` - The identifier of the view. + /// + /// # Returns + /// SQL: `SELECT COUNT(*) FROM {view_id}` + pub fn view_size(&self, view_id: &str) -> GenericSQLResult { + Ok(format!("SELECT COUNT(*) FROM {}", view_id)) + } + + /// Returns the SQL query to get the min and max values of a column. + /// + /// # Arguments + /// * `view_id` - The identifier of the view. + /// * `column_name` - The name of the column. + /// * `config` - The view configuration. + /// + /// # Returns + /// SQL: `SELECT MIN("column_name"), MAX("column_name") FROM {view_id}` + /// When the view uses ROLLUP grouping (non-flat mode with group_by), + /// a `WHERE __GROUPING_ID__ = 0` clause is added to exclude non-leaf rows. + pub fn view_get_min_max( + &self, + view_id: &str, + column_name: &str, + config: &ViewConfig, + ) -> GenericSQLResult { + let has_grouping_id = + !config.group_by.is_empty() && config.group_rollup_mode != GroupRollupMode::Flat; + let where_clause = if has_grouping_id { + " WHERE __GROUPING_ID__ = 0" + } else { + "" + }; + + Ok(format!( + "SELECT MIN(\"{}\"), MAX(\"{}\") FROM {}{}", + column_name, column_name, view_id, where_clause + )) + } + + fn filter_term_to_sql(term: &FilterTerm) -> Option { + match term { + FilterTerm::Scalar(scalar) => Self::scalar_to_sql(scalar), + FilterTerm::Array(scalars) => { + let values: Vec = scalars.iter().filter_map(Self::scalar_to_sql).collect(); + if values.is_empty() { + None + } else { + Some(format!("({})", values.join(", "))) + } + }, + } + } + + fn scalar_to_sql(scalar: &Scalar) -> Option { + match scalar { + Scalar::Null => None, + Scalar::Bool(b) => Some(if *b { "TRUE" } else { "FALSE" }.to_string()), + Scalar::Float(f) => Some(f.to_string()), + Scalar::String(s) => Some(format!("'{}'", s.replace('\'', "''"))), + } + } +} diff --git a/rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs b/rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs new file mode 100644 index 0000000..7e7149e --- /dev/null +++ b/rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs @@ -0,0 +1,649 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 crate::config::{Aggregate, GroupRollupMode, Sort, SortDir, ViewConfig}; + +fn aggregate_to_string(agg: &Aggregate) -> String { + match agg { + Aggregate::SingleAggregate(name) => name.clone(), + Aggregate::MultiAggregate(name, _args) => name.clone(), + } +} + +fn sort_dir_to_string(dir: &SortDir) -> &'static str { + match dir { + SortDir::None => "", + SortDir::Asc | SortDir::ColAsc | SortDir::AscAbs | SortDir::ColAscAbs => "ASC", + SortDir::Desc | SortDir::ColDesc | SortDir::DescAbs | SortDir::ColDescAbs => "DESC", + } +} + +fn is_col_sort(dir: &SortDir) -> bool { + matches!( + dir, + SortDir::ColAsc | SortDir::ColDesc | SortDir::ColAscAbs | SortDir::ColDescAbs + ) +} + +enum QueryOrientation { + /// Default + Flat, + + /// `group_by` set + Grouped, + + /// `split_by` set + Pivoted, + + /// `group_by` and `split_by` set + GroupedAndPivoted, + + /// `total` set + Total, + + /// `total` and `split_by` + TotalPivoted, +} + +/// Precomputed context for building a SQL view query from a [`ViewConfig`]. +/// +/// Holds the resolved column names, grouping function, and row-path aliases +/// needed to emit the correct `SELECT`, `GROUP BY`, `PIVOT`, `ORDER BY`, and +/// `WINDOW` clauses for every combination of `group_by` / `split_by`. +pub(crate) struct ViewQueryContext<'a> { + table: &'a str, + config: &'a ViewConfig, + group_col_names: Vec, + grouping_fn: &'a str, + row_path_aliases: Vec, +} + +impl<'a> ViewQueryContext<'a> { + /// Creates a new query context by resolving expressions, the grouping + /// function, and row-path aliases from the given model and config. + pub(crate) fn new( + model: &'a super::GenericSQLVirtualServerModel, + table: &'a str, + config: &'a ViewConfig, + ) -> Self { + let expressions = &config.expressions.0; + let col_name_resolve = |col: &str| -> String { + expressions + .get(col) + .cloned() + .unwrap_or_else(|| format!("\"{}\"", col)) + }; + + let grouping_fn = model.0.grouping_fn.as_deref().unwrap_or("GROUPING_ID"); + let group_col_names: Vec = config + .group_by + .iter() + .map(|c| col_name_resolve(c)) + .collect(); + + let row_path_aliases: Vec = (0..config.group_by.len()) + .map(|i| format!("__ROW_PATH_{}__", i)) + .collect(); + + Self { + table, + config, + group_col_names, + grouping_fn, + row_path_aliases, + } + } + + /// Builds the inner `SELECT` query (without the outer `CREATE TABLE` + /// wrapper) for the four `group_by` x `split_by` combinations, appending + /// `WINDOW` and `ORDER BY` clauses as needed. + pub(crate) fn build_query(&self) -> String { + let where_sql = self.where_sql(); + let order_by = self.order_by_clauses(); + let windows = self.window_clauses(); + let mut query = match self.query_orientation() { + QueryOrientation::Flat => { + let select = self.select_clauses().join(", "); + format!("SELECT {} FROM {}{}", select, self.table, where_sql) + }, + QueryOrientation::Grouped => { + let mut clauses = self.select_clauses(); + clauses.extend(self.row_path_select_clauses()); + if self.is_flat_mode() { + format!( + "SELECT {} FROM {}{} GROUP BY {}", + clauses.join(", "), + self.table, + where_sql, + self.group_col_names.join(", ") + ) + } else { + clauses.push(self.grouping_id_clause()); + format!( + "SELECT {} FROM {}{} GROUP BY ROLLUP({})", + clauses.join(", "), + self.table, + where_sql, + self.group_col_names.join(", ") + ) + } + }, + QueryOrientation::Pivoted => { + let select = self.select_clauses(); + let pivot_using: Vec = self + .config + .columns + .iter() + .flatten() + .map(|col| { + let escaped = col.replace('"', "\"\"").replace('_', "-"); + format!("first(\"{}\") as \"{}\"", escaped, escaped) + }) + .collect(); + + let split_cols: String = self + .config + .split_by + .iter() + .map(|c| format!("\"{}\"", c)) + .collect::>() + .join(", "); + + let row_num_order = self.pivot_row_num_order(); + format!( + "SELECT * EXCLUDE (__ROW_NUM__) FROM (PIVOT (SELECT {}, {}, ROW_NUMBER() OVER \ + (ORDER BY {}) as __ROW_NUM__ FROM {}{}) ON {} USING {} GROUP BY __ROW_NUM__)", + select.join(", "), + split_cols, + row_num_order, + self.table, + where_sql, + self.pivot_on_expr(), + pivot_using.join(", "), + ) + }, + QueryOrientation::GroupedAndPivoted => { + let groups_joined = self.group_col_names.join(", "); + let split_cols_joined = self.pivot_on_expr(); + let mut inner_clauses = self.select_clauses(); + inner_clauses.extend(self.row_path_select_clauses()); + if !self.is_flat_mode() { + inner_clauses.push(self.grouping_id_clause()); + } + for sb_col in &self.config.split_by { + inner_clauses.push(self.col_name(sb_col)); + } + + for (sidx, Sort(sort_col, sort_dir)) in self.config.sort.iter().enumerate() { + if *sort_dir != SortDir::None && !is_col_sort(sort_dir) { + let agg = self.get_aggregate(sort_col); + if self.is_flat_mode() { + inner_clauses.push(format!( + "sum({}({})) OVER (PARTITION BY {}) AS __SORT_{}__", + agg, + self.col_name(sort_col), + groups_joined, + sidx, + )); + } else { + inner_clauses.push(format!( + "sum({}({})) OVER (PARTITION BY {}({}), {}) AS __SORT_{}__", + agg, + self.col_name(sort_col), + self.grouping_fn, + groups_joined, + groups_joined, + sidx, + )); + } + } + } + + let inner_query = if self.is_flat_mode() { + format!( + "SELECT {} FROM {}{} GROUP BY {}, {}", + inner_clauses.join(", "), + self.table, + where_sql, + groups_joined, + split_cols_joined, + ) + } else { + format!( + "SELECT {} FROM {}{} GROUP BY ROLLUP({}), {}", + inner_clauses.join(", "), + self.table, + where_sql, + groups_joined, + split_cols_joined, + ) + }; + + let pivot_using: String = self + .config + .columns + .iter() + .flatten() + .map(|col| { + let escaped = col.replace('"', "\"\"").replace('_', "-"); + format!("first(\"{}\") as \"{}\"", escaped, escaped) + }) + .collect::>() + .join(", "); + let mut row_id_cols = self.row_path_aliases.clone(); + if !self.is_flat_mode() { + row_id_cols.push("__GROUPING_ID__".to_string()); + } + for (sidx, Sort(_, sort_dir)) in self.config.sort.iter().enumerate() { + if *sort_dir != SortDir::None && !is_col_sort(sort_dir) { + row_id_cols.push(format!("__SORT_{}__", sidx)); + } + } + + format!( + "SELECT * FROM (PIVOT ({}) ON {} USING {} GROUP BY {})", + inner_query, + self.pivot_on_expr(), + pivot_using, + row_id_cols.join(", ") + ) + }, + QueryOrientation::Total => { + let select = self.select_clauses().join(", "); + format!("SELECT {} FROM {}{}", select, self.table, where_sql) + }, + QueryOrientation::TotalPivoted => { + let raw_cols: Vec = self + .config + .columns + .iter() + .flatten() + .map(|col| self.col_name(col)) + .collect(); + + let split_cols: String = self + .config + .split_by + .iter() + .map(|c| format!("\"{}\"", c)) + .collect::>() + .join(", "); + + let pivot_using: Vec = self + .config + .columns + .iter() + .flatten() + .map(|col| { + let agg = self.get_aggregate(col); + let escaped = col.replace('"', "\"\"").replace('_', "-"); + format!("{}(\"{}\") as \"{}\"", agg, escaped, escaped) + }) + .collect(); + + format!( + "SELECT * FROM (PIVOT (SELECT {}, {} FROM {}{}) ON {} USING {})", + raw_cols.join(", "), + split_cols, + self.table, + where_sql, + self.pivot_on_expr(), + pivot_using.join(", "), + ) + }, + }; + + if !windows.is_empty() { + query = format!("{} WINDOW {}", query, windows.join(", ")); + } + + if !order_by.is_empty() { + query = format!("{} ORDER BY {}", query, order_by.join(", ")); + } else if self.is_flat_mode() && !self.config.group_by.is_empty() { + let default_order: Vec = self + .row_path_aliases + .iter() + .map(|alias| format!("{} ASC", alias)) + .collect(); + query = format!("{} ORDER BY {}", query, default_order.join(", ")); + } else if self.config.group_by.is_empty() + && self.config.group_rollup_mode != GroupRollupMode::Total + { + let default_order = if self.config.split_by.is_empty() { + "rowid" + } else { + "__ROW_NUM__" + }; + + query = format!("{} ORDER BY {}", query, default_order); + } + + query + } + + fn is_flat_mode(&self) -> bool { + self.config.group_rollup_mode == GroupRollupMode::Flat + } + + fn needs_aggregation(&self) -> bool { + !self.config.group_by.is_empty() || self.config.group_rollup_mode == GroupRollupMode::Total + } + + fn query_orientation(&self) -> QueryOrientation { + if self.config.group_rollup_mode == GroupRollupMode::Total { + return if self.config.split_by.is_empty() { + QueryOrientation::Total + } else { + QueryOrientation::TotalPivoted + }; + } + + match ( + self.config.group_by.is_empty(), + self.config.split_by.is_empty(), + ) { + (true, true) => QueryOrientation::Flat, + (false, true) => QueryOrientation::Grouped, + (true, false) => QueryOrientation::Pivoted, + (false, false) => QueryOrientation::GroupedAndPivoted, + } + } + + fn col_name(&self, col: &str) -> String { + self.config + .expressions + .0 + .get(col) + .cloned() + .unwrap_or_else(|| format!("\"{}\"", col)) + } + + fn get_aggregate(&self, col: &str) -> String { + self.config + .aggregates + .get(col) + .map(aggregate_to_string) + .unwrap_or_else(|| "any_value".to_string()) + } + + fn select_clauses(&self) -> Vec { + let mut clauses = Vec::new(); + if self.needs_aggregation() { + for col in self.config.columns.iter().flatten() { + let agg = self.get_aggregate(col); + let escaped = col.replace('"', "\"\"").replace("_", "-"); + clauses.push(format!( + "{}({}) as \"{}\"", + agg, + self.col_name(col), + escaped + )); + } + } else if !self.config.columns.is_empty() { + for col in self.config.columns.iter().flatten() { + let escaped = col.replace('"', "\"\"").replace("_", "-"); + clauses.push(format!("{} as \"{}\"", self.col_name(col), escaped)); + } + } + + clauses + } + + fn where_sql(&self) -> String { + let clauses: Vec = self + .config + .filter + .iter() + .filter_map(|flt| { + super::GenericSQLVirtualServerModel::filter_term_to_sql(flt.term()).map( + |term_lit| format!("{} {} {}", self.col_name(flt.column()), flt.op(), term_lit), + ) + }) + .collect(); + + if clauses.is_empty() { + String::new() + } else { + format!(" WHERE {}", clauses.join(" AND ")) + } + } + + /// Builds the `ORDER BY` expression for the `ROW_NUMBER()` window + /// function used inside `PIVOT` queries. Uses sort config if available, + /// otherwise falls back to `rowid`. + fn pivot_row_num_order(&self) -> String { + let sort_exprs: Vec = self + .config + .sort + .iter() + .filter(|Sort(_, dir)| *dir != SortDir::None && !is_col_sort(dir)) + .map(|Sort(col, dir)| format!("{} {}", self.col_name(col), sort_dir_to_string(dir))) + .collect(); + + if sort_exprs.is_empty() { + "rowid".to_string() + } else { + sort_exprs.join(", ") + } + } + + fn pivot_on_expr(&self) -> String { + self.config + .split_by + .iter() + .map(|c| format!("\"{}\"", c)) + .collect::>() + .join(", ") + } + + fn grouping_id_clause(&self) -> String { + format!( + "{}({}) AS __GROUPING_ID__", + self.grouping_fn, + self.group_col_names.join(", ") + ) + } + + fn row_path_select_clauses(&self) -> Vec { + self.config + .group_by + .iter() + .enumerate() + .map(|(i, col)| format!("{} as __ROW_PATH_{}__", self.col_name(col), i)) + .collect() + } + + fn order_by_clauses(&self) -> Vec { + let mut clauses = Vec::new(); + if !self.config.group_by.is_empty() && self.is_flat_mode() { + let has_row_sort = self + .config + .sort + .iter() + .any(|Sort(_, dir)| *dir != SortDir::None && !is_col_sort(dir)); + if self.config.group_by.len() > 1 && has_row_sort { + // Hierarchical flat sort — mirrors rollup logic but without GROUPING_ID + for gidx in 0..self.config.group_by.len() { + let is_leaf = gidx >= self.config.group_by.len() - 1; + for (sidx, Sort(sort_col, sort_dir)) in self.config.sort.iter().enumerate() { + if *sort_dir == SortDir::None || is_col_sort(sort_dir) { + continue; + } + + let dir = sort_dir_to_string(sort_dir); + if !self.config.split_by.is_empty() { + if is_leaf { + clauses.push(format!("__SORT_{}__ {}", sidx, dir)); + } else { + clauses.push(format!( + "first(__SORT_{}__) OVER __WINDOW_{}__ {}", + sidx, gidx, dir + )); + } + } else { + let agg = self.get_aggregate(sort_col); + if is_leaf { + clauses.push(format!( + "{}({}) {}", + agg, + self.col_name(sort_col), + dir + )); + } else { + clauses.push(format!( + "first({}({})) OVER __WINDOW_{}__ {}", + agg, + self.col_name(sort_col), + gidx, + dir + )); + } + } + } + + clauses.push(format!("{} ASC", self.row_path_aliases[gidx])); + } + } else { + // Single group level — simple sort, no window needed + for (sidx, Sort(sort_col, sort_dir)) in self.config.sort.iter().enumerate() { + if *sort_dir != SortDir::None && !is_col_sort(sort_dir) { + let dir = sort_dir_to_string(sort_dir); + if !self.config.split_by.is_empty() { + clauses.push(format!("__SORT_{}__ {}", sidx, dir)); + } else { + let agg = self.get_aggregate(sort_col); + clauses.push(format!("{}({}) {}", agg, self.col_name(sort_col), dir)); + } + } + } + } + } else if !self.config.group_by.is_empty() { + for gidx in 0..self.config.group_by.len() { + if !self.config.split_by.is_empty() { + let shift = self.config.group_by.len() - 1 - gidx; + if shift > 0 { + clauses.push(format!("(__GROUPING_ID__ >> {}) DESC", shift)); + } else { + clauses.push("__GROUPING_ID__ DESC".to_string()); + } + } else { + let groups_up_to = self.config.group_by[..=gidx] + .iter() + .map(|c| self.col_name(c)) + .collect::>() + .join(", "); + clauses.push(format!("{}({}) DESC", self.grouping_fn, groups_up_to)); + } + + let is_leaf = gidx >= self.config.group_by.len() - 1; + for (sidx, Sort(sort_col, sort_dir)) in self.config.sort.iter().enumerate() { + if *sort_dir == SortDir::None || is_col_sort(sort_dir) { + continue; + } + + let dir = sort_dir_to_string(sort_dir); + if !self.config.split_by.is_empty() { + if is_leaf { + clauses.push(format!("__SORT_{}__ {}", sidx, dir)); + } else { + clauses.push(format!( + "first(__SORT_{}__) OVER __WINDOW_{}__ {}", + sidx, gidx, dir + )); + } + } else { + let agg = self.get_aggregate(sort_col); + if is_leaf { + clauses.push(format!("{}({}) {}", agg, self.col_name(sort_col), dir)); + } else { + clauses.push(format!( + "first({}({})) OVER __WINDOW_{}__ {}", + agg, + self.col_name(sort_col), + gidx, + dir + )); + } + } + } + + clauses.push(format!("{} ASC", self.row_path_aliases[gidx])); + } + } else if self.config.split_by.is_empty() { + for Sort(sort_col, sort_dir) in &self.config.sort { + if *sort_dir != SortDir::None && !is_col_sort(sort_dir) { + let dir = sort_dir_to_string(sort_dir); + clauses.push(format!("{} {}", self.col_name(sort_col), dir)); + } + } + } + + clauses + } + + fn window_clauses(&self) -> Vec { + if self.config.sort.is_empty() || self.config.group_by.len() <= 1 { + return Vec::new(); + } + + let mut clauses = Vec::new(); + for gidx in 0..(self.config.group_by.len() - 1) { + let partition = self.row_path_aliases[..=gidx].join(", "); + if self.is_flat_mode() { + // Flat mode: partition by row path only (no GROUPING_ID) + if !self.config.split_by.is_empty() { + let order = self.row_path_aliases.join(", "); + clauses.push(format!( + "__WINDOW_{}__ AS (PARTITION BY {} ORDER BY {})", + gidx, partition, order, + )); + } else { + clauses.push(format!( + "__WINDOW_{}__ AS (PARTITION BY {} ORDER BY {})", + gidx, + partition, + self.group_col_names.join(", ") + )); + } + } else if !self.config.split_by.is_empty() { + let shift = self.config.group_by.len() - 1 - gidx; + let grouping_expr = if shift > 0 { + format!("(__GROUPING_ID__ >> {})", shift) + } else { + "__GROUPING_ID__".to_string() + }; + + let order = self.row_path_aliases.join(", "); + clauses.push(format!( + "__WINDOW_{}__ AS (PARTITION BY {}, {} ORDER BY {})", + gidx, grouping_expr, partition, order, + )); + } else { + let sub_groups = self.config.group_by[..=gidx] + .iter() + .map(|c| self.col_name(c)) + .collect::>() + .join(", "); + clauses.push(format!( + "__WINDOW_{}__ AS (PARTITION BY {}({}), {} ORDER BY {})", + gidx, + self.grouping_fn, + sub_groups, + partition, + self.group_col_names.join(", ") + )); + } + } + + clauses + } +} diff --git a/rust/perspective-client/src/rust/virtual_server/generic_sql_model/tests.rs b/rust/perspective-client/src/rust/virtual_server/generic_sql_model/tests.rs new file mode 100644 index 0000000..fa4a7ee --- /dev/null +++ b/rust/perspective-client/src/rust/virtual_server/generic_sql_model/tests.rs @@ -0,0 +1,620 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 super::*; +use crate::config::{Aggregate, GroupRollupMode}; + +#[test] +fn test_get_hosted_tables() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + assert_eq!(builder.get_hosted_tables().unwrap(), "SHOW ALL TABLES"); +} + +#[test] +fn test_table_schema() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + assert_eq!( + builder.table_schema("my_table").unwrap(), + "DESCRIBE my_table" + ); +} + +#[test] +fn test_table_size() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + assert_eq!( + builder.table_size("my_table").unwrap(), + "SELECT COUNT(*) FROM my_table" + ); +} + +#[test] +fn test_view_delete() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + assert_eq!( + builder.view_delete("my_view").unwrap(), + "DROP TABLE IF EXISTS my_view" + ); +} + +#[test] +fn test_table_make_view_simple() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("col1".to_string()), Some("col2".to_string())]; + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!(sql.starts_with("CREATE TABLE dest_view AS")); + assert!(sql.contains("\"col1\"")); + assert!(sql.contains("\"col2\"")); +} + +#[test] +fn test_table_make_view_with_group_by() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("value".to_string())]; + config.group_by = vec!["category".to_string()]; + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!(sql.contains("GROUP BY ROLLUP")); + assert!(sql.contains("__ROW_PATH_0__")); + assert!(sql.contains("__GROUPING_ID__")); +} + +#[test] +fn test_table_make_view_with_group_by_and_split_by() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("value".to_string())]; + config.group_by = vec!["category".to_string()]; + config.split_by = vec!["quarter".to_string()]; + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!(sql.contains("GROUP BY ROLLUP"), "expected ROLLUP: {}", sql); + assert!(sql.contains("PIVOT"), "expected PIVOT: {}", sql); + assert!( + sql.contains("__ROW_PATH_0__"), + "expected __ROW_PATH_0__: {}", + sql + ); + + assert!( + sql.contains("__GROUPING_ID__"), + "expected __GROUPING_ID__: {}", + sql + ); +} + +#[test] +fn test_table_make_view_with_sort_group_by_and_split_by() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("value".to_string())]; + config.group_by = vec!["category".to_string()]; + config.split_by = vec!["quarter".to_string()]; + config.sort = vec![Sort("value".to_string(), SortDir::Asc)]; + config.aggregates = HashMap::from([( + "value".to_string(), + Aggregate::SingleAggregate("sum".to_string()), + )]); + + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!(sql.contains("__SORT_0__"), "expected __SORT_0__: {}", sql); + assert!( + sql.contains("__GROUPING_ID__, __SORT_0__"), + "expected __SORT_0__ in GROUP BY: {}", + sql + ); + + assert!( + sql.contains("__SORT_0__ ASC"), + "expected __SORT_0__ ASC in ORDER BY: {}", + sql + ); + + assert!( + !sql.contains("sum(\"value\") ASC"), + "should not have raw aggregate in ORDER BY: {}", + sql + ); +} + +#[test] +fn test_table_make_view_with_sort_multi_group_by_and_split_by() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("value".to_string())]; + config.group_by = vec!["region".to_string(), "category".to_string()]; + config.split_by = vec!["quarter".to_string()]; + config.sort = vec![Sort("value".to_string(), SortDir::Asc)]; + config.aggregates = HashMap::from([( + "value".to_string(), + Aggregate::SingleAggregate("sum".to_string()), + )]); + + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!( + sql.contains("PARTITION BY (__GROUPING_ID__ >> 1)"), + "expected shifted __GROUPING_ID__ in WINDOW: {}", + sql + ); + + assert!( + sql.contains("first(__SORT_0__) OVER __WINDOW_0__"), + "expected first(__SORT_0__) OVER __WINDOW_0__: {}", + sql + ); + + assert!( + !sql.contains("GROUPING_ID(\"region\")"), + "should not have GROUPING_ID function in WINDOW: {}", + sql + ); +} + +#[test] +fn test_table_make_view_with_sort_and_group_by_no_split_by() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("value".to_string())]; + config.group_by = vec!["category".to_string()]; + config.sort = vec![Sort("value".to_string(), SortDir::Asc)]; + config.aggregates = HashMap::from([( + "value".to_string(), + Aggregate::SingleAggregate("sum".to_string()), + )]); + + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!( + sql.contains("sum(\"value\") ASC"), + "expected raw aggregate in ORDER BY: {}", + sql + ); + + assert!( + !sql.contains("__SORT_0__"), + "should not have __SORT_0__ without split_by: {}", + sql + ); +} + +#[test] +fn test_table_make_view_col_sort_excludes_row_order_by() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("value".to_string())]; + config.group_by = vec!["category".to_string()]; + config.split_by = vec!["quarter".to_string()]; + config.sort = vec![Sort("value".to_string(), SortDir::ColAsc)]; + config.aggregates = HashMap::from([( + "value".to_string(), + Aggregate::SingleAggregate("sum".to_string()), + )]); + + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!( + !sql.contains("__SORT_0__"), + "col sort should not produce __SORT_0__: {}", + sql + ); + + assert!( + !sql.contains("sum(\"value\") ASC"), + "col sort should not produce row ORDER BY: {}", + sql + ); + + assert!(sql.contains("PIVOT"), "should still have PIVOT: {}", sql); +} + +#[test] +fn test_table_make_view_mixed_row_and_col_sort() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("value".to_string()), Some("qty".to_string())]; + config.group_by = vec!["category".to_string()]; + config.split_by = vec!["quarter".to_string()]; + config.sort = vec![ + Sort("value".to_string(), SortDir::ColDesc), + Sort("qty".to_string(), SortDir::Asc), + ]; + + config.aggregates = HashMap::from([ + ( + "value".to_string(), + Aggregate::SingleAggregate("sum".to_string()), + ), + ( + "qty".to_string(), + Aggregate::SingleAggregate("sum".to_string()), + ), + ]); + + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!( + !sql.contains("__SORT_0__"), + "col sort (idx 0) should not produce __SORT_0__: {}", + sql + ); + + assert!( + sql.contains("__SORT_1__"), + "row sort (idx 1) should produce __SORT_1__: {}", + sql + ); +} + +#[test] +fn test_table_make_view_pivoted_with_sort() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("value".to_string())]; + config.split_by = vec!["quarter".to_string()]; + config.sort = vec![Sort("value".to_string(), SortDir::Desc)]; + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!(sql.contains("PIVOT"), "expected PIVOT: {}", sql); + assert!( + sql.contains("ROW_NUMBER() OVER (ORDER BY \"value\" DESC)"), + "expected sort in ROW_NUMBER window: {}", + sql + ); + assert!( + sql.ends_with("ORDER BY __ROW_NUM__)"), + "should end with ORDER BY __ROW_NUM__: {}", + sql + ); +} + +#[test] +fn test_view_get_data_col_sort_ascending() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.sort = vec![Sort("value".to_string(), SortDir::ColAsc)]; + let viewport = ViewPort { + start_row: Some(0), + end_row: Some(100), + start_col: Some(0), + end_col: None, + ..ViewPort::default() + }; + + let mut schema = IndexMap::new(); + schema.insert("C_value".to_string(), ColumnType::Float); + schema.insert("A_value".to_string(), ColumnType::Float); + schema.insert("B_value".to_string(), ColumnType::Float); + let sql = builder + .view_get_data("my_view", &config, &viewport, &schema) + .unwrap(); + + let a_pos = sql.find("\"A_value\"").unwrap(); + let b_pos = sql.find("\"B_value\"").unwrap(); + let c_pos = sql.find("\"C_value\"").unwrap(); + assert!( + a_pos < b_pos && b_pos < c_pos, + "col asc should order columns A < B < C: {}", + sql + ); +} + +#[test] +fn test_view_get_data_col_sort_descending() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.sort = vec![Sort("value".to_string(), SortDir::ColDesc)]; + let viewport = ViewPort { + start_row: Some(0), + end_row: Some(100), + start_col: Some(0), + end_col: None, + ..ViewPort::default() + }; + + let mut schema = IndexMap::new(); + schema.insert("A_value".to_string(), ColumnType::Float); + schema.insert("C_value".to_string(), ColumnType::Float); + schema.insert("B_value".to_string(), ColumnType::Float); + let sql = builder + .view_get_data("my_view", &config, &viewport, &schema) + .unwrap(); + + let a_pos = sql.find("\"A_value\"").unwrap(); + let b_pos = sql.find("\"B_value\"").unwrap(); + let c_pos = sql.find("\"C_value\"").unwrap(); + assert!( + c_pos < b_pos && b_pos < a_pos, + "col desc should order columns C > B > A: {}", + sql + ); +} + +#[test] +fn test_view_get_data() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let config = ViewConfig::default(); + let viewport = ViewPort { + start_row: Some(0), + end_row: Some(100), + start_col: Some(0), + end_col: Some(5), + ..ViewPort::default() + }; + + let mut schema = IndexMap::new(); + schema.insert("col1".to_string(), ColumnType::String); + schema.insert("col2".to_string(), ColumnType::Integer); + let sql = builder + .view_get_data("my_view", &config, &viewport, &schema) + .unwrap(); + + assert!(sql.contains("SELECT")); + assert!(sql.contains("FROM my_view")); + assert!(sql.contains("LIMIT 100 OFFSET 0")); +} + +#[test] +fn test_table_make_view_flat_group_by() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("value".to_string())]; + config.group_by = vec!["category".to_string()]; + config.group_rollup_mode = GroupRollupMode::Flat; + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!( + sql.contains("GROUP BY \"category\""), + "expected plain GROUP BY: {}", + sql + ); + assert!( + !sql.contains("ROLLUP"), + "should not contain ROLLUP: {}", + sql + ); + assert!( + !sql.contains("__GROUPING_ID__"), + "should not contain __GROUPING_ID__: {}", + sql + ); + assert!( + sql.contains("__ROW_PATH_0__"), + "should contain __ROW_PATH_0__: {}", + sql + ); +} + +#[test] +fn test_table_make_view_flat_group_by_with_split_by() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("value".to_string())]; + config.group_by = vec!["category".to_string()]; + config.split_by = vec!["quarter".to_string()]; + config.group_rollup_mode = GroupRollupMode::Flat; + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!(sql.contains("PIVOT"), "expected PIVOT: {}", sql); + assert!( + !sql.contains("ROLLUP"), + "should not contain ROLLUP: {}", + sql + ); + assert!( + !sql.contains("__GROUPING_ID__"), + "should not contain __GROUPING_ID__: {}", + sql + ); + assert!( + sql.contains("__ROW_PATH_0__"), + "should contain __ROW_PATH_0__: {}", + sql + ); +} + +#[test] +fn test_table_make_view_flat_group_by_with_sort() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("value".to_string())]; + config.group_by = vec!["category".to_string()]; + config.sort = vec![Sort("value".to_string(), SortDir::Asc)]; + config.aggregates = HashMap::from([( + "value".to_string(), + Aggregate::SingleAggregate("sum".to_string()), + )]); + config.group_rollup_mode = GroupRollupMode::Flat; + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!( + sql.contains("sum(\"value\") ASC"), + "expected direct aggregate in ORDER BY: {}", + sql + ); + assert!( + !sql.contains("ROLLUP"), + "should not contain ROLLUP: {}", + sql + ); + assert!( + !sql.contains("__WINDOW_"), + "should not contain WINDOW clauses: {}", + sql + ); +} + +#[test] +fn test_table_make_view_flat_group_by_with_split_by_and_sort() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("value".to_string())]; + config.group_by = vec!["category".to_string()]; + config.split_by = vec!["quarter".to_string()]; + config.sort = vec![Sort("value".to_string(), SortDir::Desc)]; + config.aggregates = HashMap::from([( + "value".to_string(), + Aggregate::SingleAggregate("sum".to_string()), + )]); + config.group_rollup_mode = GroupRollupMode::Flat; + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!(sql.contains("PIVOT"), "expected PIVOT: {}", sql); + assert!( + !sql.contains("ROLLUP"), + "should not contain ROLLUP: {}", + sql + ); + assert!( + !sql.contains("__GROUPING_ID__"), + "should not contain __GROUPING_ID__: {}", + sql + ); + assert!( + sql.contains("__SORT_0__"), + "expected __SORT_0__ for flat+pivoted+sort: {}", + sql + ); + assert!( + sql.contains("__SORT_0__ DESC"), + "expected __SORT_0__ DESC in ORDER BY: {}", + sql + ); + assert!( + !sql.contains("sum(\"value\") DESC"), + "should not have raw aggregate in ORDER BY: {}", + sql + ); +} + +#[test] +fn test_view_get_data_flat_no_grouping_id() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.group_by = vec!["category".to_string()]; + config.group_rollup_mode = GroupRollupMode::Flat; + let viewport = ViewPort { + start_row: Some(0), + end_row: Some(100), + start_col: Some(0), + end_col: None, + ..ViewPort::default() + }; + + let mut schema = IndexMap::new(); + schema.insert("value".to_string(), ColumnType::Float); + let sql = builder + .view_get_data("my_view", &config, &viewport, &schema) + .unwrap(); + + assert!( + !sql.contains("__GROUPING_ID__"), + "flat mode should not select __GROUPING_ID__: {}", + sql + ); + assert!( + sql.contains("__ROW_PATH_0__"), + "flat mode should still select __ROW_PATH_0__: {}", + sql + ); +} + +#[test] +fn test_table_make_view_total() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("value".to_string())]; + config.group_rollup_mode = GroupRollupMode::Total; + config.aggregates = HashMap::from([( + "value".to_string(), + Aggregate::SingleAggregate("sum".to_string()), + )]); + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!( + sql.contains("sum(\"value\")"), + "expected aggregate function: {}", + sql + ); + assert!( + !sql.contains("GROUP BY"), + "should not contain GROUP BY: {}", + sql + ); + assert!( + !sql.contains("ORDER BY"), + "should not contain ORDER BY: {}", + sql + ); +} + +#[test] +fn test_table_make_view_total_with_split_by() { + let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default()); + let mut config = ViewConfig::default(); + config.columns = vec![Some("value".to_string())]; + config.split_by = vec!["quarter".to_string()]; + config.group_rollup_mode = GroupRollupMode::Total; + config.aggregates = HashMap::from([( + "value".to_string(), + Aggregate::SingleAggregate("sum".to_string()), + )]); + let sql = builder + .table_make_view("source_table", "dest_view", &config) + .unwrap(); + + assert!(sql.contains("PIVOT"), "expected PIVOT: {}", sql); + assert!( + !sql.contains("GROUP BY"), + "should not contain GROUP BY: {}", + sql + ); + assert!( + !sql.contains("ROW_NUMBER"), + "should not contain ROW_NUMBER: {}", + sql + ); +} 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 0000000..6b7f013 --- /dev/null +++ b/rust/perspective-client/src/rust/virtual_server/handler.rs @@ -0,0 +1,173 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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(feature = "sendable")] +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(feature = "sendable"))] +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. + #[cfg(not(feature = "sendable"))] + type Error: std::error::Error + Send + Sync + 'static; + + #[cfg(feature = "sendable")] + type Error: std::error::Error + '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, + schema: &IndexMap, + 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) }) + } + + /// Returns the min and max values of a column in a view. + /// + /// Default implementation panics with "not implemented". + fn view_get_min_max( + &self, + _view_id: &str, + _column_name: &str, + _config: &crate::config::ViewConfig, + ) -> VirtualServerFuture<'_, Result<(crate::config::Scalar, crate::config::Scalar), Self::Error>> + { + Box::pin(async { unimplemented!("view_get_min_max not implemented") }) + } + + // 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 0000000..c35bd51 --- /dev/null +++ b/rust/perspective-client/src/rust/virtual_server/mod.rs @@ -0,0 +1,33 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 generic_sql_model; +mod handler; +mod server; + +pub use data::{RowPathStyle, SetVirtualDataColumn, VirtualDataCell, VirtualDataSlice}; +pub use error::{ResultExt, VirtualServerError}; +pub use features::{AggSpec, Features}; +pub use generic_sql_model::{ + GenericSQLError, GenericSQLResult, GenericSQLVirtualServerModel, + GenericSQLVirtualServerModelArgs, +}; +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 0000000..e1c557f --- /dev/null +++ b/rust/perspective-client/src/rust/virtual_server/server.rs @@ -0,0 +1,451 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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::data::RowPathStyle; +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::{ + ColumnType, GetFeaturesResp, GetHostedTablesResp, MakeTableResp, Request, Response, + ServerError, TableMakePortResp, TableMakeViewResp, TableOnDeleteResp, TableRemoveDeleteResp, + TableSchemaResp, TableSizeResp, TableValidateExprResp, ViewColumnPathsResp, ViewDeleteResp, + ViewDimensionsResp, ViewExpressionSchemaResp, ViewGetConfigResp, ViewGetMinMaxResp, + ViewOnDeleteResp, ViewOnUpdateResp, ViewRemoveDeleteResp, ViewRemoveOnUpdateResp, + ViewSchemaResp, ViewToArrowResp, ViewToColumnsStringResp, ViewToCsvResp, + ViewToNdjsonStringResp, 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, + view_schemas: 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(), + view_schemas: 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> { + let msg = Request::decode(bytes).map_err(VirtualServerError::DecodeError)?; + tracing::debug!( + "Handling request: entity_id={}, req={:?}", + msg.entity_id, + msg.client_req + ); + + match self.internal_handle_request(msg.clone()).await { + Ok(resp) => Ok(resp), + Err(err) => { + tracing::error!("{}", err); + Ok(respond!(msg, ServerError { + message: err.to_string(), + status_code: 0 + })) + }, + } + } + + async fn get_cached_view_schema( + &mut self, + entity_id: &str, + to_psp_format: bool, + ) -> Result, VirtualServerError> { + if !self.view_schemas.contains_key(entity_id) { + self.view_schemas.insert( + entity_id.to_string(), + self.handler + .view_schema(entity_id, self.view_configs.get(entity_id).unwrap()) + .await?, + ); + } + + if to_psp_format { + Ok(self + .view_schemas + .get(entity_id) + .unwrap() + .iter() + .map(|(k, v)| { + ( + k.split("_").collect::>().last().unwrap().to_string(), + *v, + ) + }) + .collect()) + } else { + Ok(self.view_schemas.get(entity_id).cloned().unwrap()) + } + } + + async fn internal_handle_request( + &mut self, + msg: Request, + ) -> Result> { + use crate::proto::request::ClientReq::*; + 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: Some(crate::proto::Schema { + schema: self + .handler + .table_schema(msg.entity_id.as_str()) + .await? + .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 + .get_cached_view_schema(&msg.entity_id, true) + .await? + .into_iter() + .map(|(x, y)| (x.to_string(), 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() + }) + }, + ViewToArrowReq(view_to_arrow_req) => { + let viewport = view_to_arrow_req.viewport.unwrap(); + let schema = self.get_cached_view_schema(&msg.entity_id, false).await?; + let config = self.view_configs.get(&msg.entity_id).unwrap(); + let mut cols = self + .handler + .view_get_data(msg.entity_id.as_str(), config, &schema, &viewport) + .await?; + + let arrow = cols + .render_to_arrow_ipc() + .map_err(|e| VirtualServerError::Other(e.to_string()))?; + + respond!(msg, ViewToArrowResp { arrow }) + }, + ViewToCsvReq(view_to_csv_req) => { + let viewport = view_to_csv_req.viewport.unwrap(); + let schema = self.get_cached_view_schema(&msg.entity_id, false).await?; + let config = self.view_configs.get(&msg.entity_id).unwrap(); + let mut cols = self + .handler + .view_get_data(msg.entity_id.as_str(), config, &schema, &viewport) + .await?; + + let rows = cols.render_to_rows(RowPathStyle::PerLevel); + let mut csv = String::new(); + if let Some(first_row) = rows.first() { + let headers: Vec<&str> = first_row.keys().map(|k| k.as_str()).collect(); + csv.push_str(&headers.join(",")); + csv.push('\n'); + } + + for row in &rows { + let values: Vec = row + .values() + .map(|cell| serde_json::to_string(cell).unwrap_or_default()) + .collect(); + csv.push_str(&values.join(",")); + csv.push('\n'); + } + + respond!(msg, ViewToCsvResp { csv }) + }, + ViewToNdjsonStringReq(view_to_ndjson_req) => { + let viewport = view_to_ndjson_req.viewport.unwrap(); + let schema = self.get_cached_view_schema(&msg.entity_id, false).await?; + let config = self.view_configs.get(&msg.entity_id).unwrap(); + let mut cols = self + .handler + .view_get_data(msg.entity_id.as_str(), config, &schema, &viewport) + .await?; + + let rows = cols.render_to_rows(RowPathStyle::PerLevel); + let ndjson_string = rows + .iter() + .map(serde_json::to_string) + .collect::, _>>() + .map_err(|e| VirtualServerError::InvalidJSON(std::sync::Arc::new(e)))? + .join("\n"); + + respond!(msg, ViewToNdjsonStringResp { ndjson_string }) + }, + ViewToRowsStringReq(view_to_rows_string_req) => { + let viewport = view_to_rows_string_req.viewport.unwrap(); + let schema = self.get_cached_view_schema(&msg.entity_id, false).await?; + let config = self.view_configs.get(&msg.entity_id).unwrap(); + let mut cols = self + .handler + .view_get_data(msg.entity_id.as_str(), config, &schema, &viewport) + .await?; + + let rows = cols.render_to_rows(RowPathStyle::Sidecar); + 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 schema = self.get_cached_view_schema(&msg.entity_id, false).await?; + let config = self.view_configs.get(&msg.entity_id).unwrap(); + let mut cols = self + .handler + .view_get_data(msg.entity_id.as_str(), config, &schema, &viewport) + .await?; + + let json_string = cols + .render_to_columns_json(RowPathStyle::Sidecar) + .map_err(|e| VirtualServerError::Other(e.to_string()))?; + + 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 {}) + }, + ViewGetMinMaxReq(req) => { + let config = self.view_configs.get(&msg.entity_id).unwrap(); + let (min, max) = self + .handler + .view_get_min_max(&msg.entity_id, &req.column_name, config) + .await?; + respond!(msg, ViewGetMinMaxResp { + min: Some(min.into()), + max: Some(max.into()), + }) + }, + + // 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-js/Cargo.toml b/rust/perspective-js/Cargo.toml new file mode 100644 index 0000000..734111e --- /dev/null +++ b/rust/perspective-js/Cargo.toml @@ -0,0 +1,110 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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-js" +version = "4.5.2" +authors = ["Andrew Stein "] +edition = "2024" +description = "A data visualization and analytics component, especially well-suited for large and/or streaming datasets." +repository = "https://github.com/perspective-dev/perspective" +license = "Apache-2.0" +homepage = "https://perspective-dev.github.io" +keywords = ["experimental"] +include = ["src/**/*", "Cargo.toml", "./package.json", "docs/**/*"] + +[package.metadata.docs.rs] +rustc-args = ["--cfg", "web_sys_unstable_apis"] +rustdoc-args = ["--html-in-header", "docs/index.html"] + +[lib] +crate-type = ["cdylib", "rlib"] +path = "src/rust/lib.rs" + +[features] +default = [] + +# Shoould the WASM initialization methods be exported? This should _not_ be +# enabled if you want to import `perspective-js` as a Rust dependency, in which +# case you'll initialize the WebAssembly yourself. +export-init = [] + +# Should the code by built from the Perspective source tree, or the packaged +# copy? This should only be enabled for development on the Perspective source +# tree. +external-cpp = [ + "perspective-client/generate-proto", + "perspective-client/protobuf-src", +] + +# Should `talc` be used as a global allocator? Allows enhanced runtime metrics +# via `Client::system_info`. Mutually exclusive to `trace-allocator` +talc-allocator = ["perspective-client/talc-allocator"] + +# Should global allocation be traced? Allows enhanced runtime metrics via +# `Client::system_info`. Mutually exclusive to `talc-allocator`, which does +# not requires a `wasm_bindgen` call to get available heap size. +trace-allocator = [] + +[build-dependencies] +serde_json = { version = "1.0.107", features = ["raw_value"] } +anyhow = "1.0.66" + +[dev-dependencies] +wasm-bindgen-test = "0.3.13" + +[dependencies] +perspective-client = { version = "4.5.2", features = ["sendable"] } +arrow-array = { version = "57.3.0", default-features = false } +arrow-ipc = { version = "57.3.0", default-features = false } +arrow-schema = { version = "57.3.0", default-features = false } +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.12.1" +js-sys = "0.3.85" +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0.107", features = ["raw_value"] } +serde-wasm-bindgen = "0.6.0" +thiserror = "2.0.17" +tracing = { version = ">=0.1.36" } +tracing-subscriber = "0.3.15" +wasm-bindgen-derive = "0.3.0" +wasm-bindgen-futures = "0.4.41" + +[dependencies.prost] +version = "0.12.3" +default-features = false +features = ["prost-derive", "std"] + +[dependencies.ts-rs] +version = "11.0.1" +features = ["serde-json-impl", "no-serde-warnings", "import-esm"] + +[dependencies.wasm-bindgen] +version = "=0.2.108" +features = ["enable-interning"] + +[dependencies.web-sys] +version = "0.3.85" +features = [ + "console", + "Document", + "HtmlElement", + "Navigator", + "Performance", + "PerformanceMark", + "Window", +] diff --git a/rust/perspective-js/README.md b/rust/perspective-js/README.md new file mode 100644 index 0000000..06b022c --- /dev/null +++ b/rust/perspective-js/README.md @@ -0,0 +1,3 @@ +# `perspective` + +This crate provides Rust bindings to the JavaScript Perspective API. diff --git a/rust/perspective-js/build.mjs b/rust/perspective-js/build.mjs new file mode 100644 index 0000000..256285d --- /dev/null +++ b/rust/perspective-js/build.mjs @@ -0,0 +1,159 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { execSync } from "child_process"; +import { build } from "@perspective-dev/esbuild-plugin/build.js"; +import { PerspectiveEsbuildPlugin } from "@perspective-dev/esbuild-plugin"; +import { NodeModulesExternal } from "@perspective-dev/esbuild-plugin/external.js"; + +import "zx/globals"; +import { compress } from "pro_self_extracting_wasm"; + +const IS_DEBUG = + !!process.env.PSP_DEBUG || process.argv.indexOf("--debug") >= 0; + +const BUILD = [ + { + entryPoints: ["src/ts/perspective-server.worker.ts"], + format: "esm", + target: "es2022", + // plugins: [PerspectiveEsbuildPlugin()], + // loader: { ".wasm": "binary" }, + outfile: "dist/cdn/perspective-server.worker.js", + }, + // WASM assets inlined into a single monolithic `.js` file. No special + // loades required, this version of Perspective should be the easiest + // to use but also the least performant at load time. + // { + // 'Import via ` + + + diff --git a/rust/perspective-js/package.json b/rust/perspective-js/package.json new file mode 100644 index 0000000..364fe82 --- /dev/null +++ b/rust/perspective-js/package.json @@ -0,0 +1,96 @@ +{ + "name": "@perspective-dev/client", + "version": "4.5.2", + "description": "Client for Perspective, a high-performance WASM engine with reactive Tables, Views, and joins.", + "keywords": [ + "perspective", + "data", + "analytics", + "streaming", + "wasm", + "arrow", + "duckdb", + "clickhouse" + ], + "homepage": "https://perspective-dev.github.io", + "repository": { + "type": "git", + "url": "https://github.com/perspective-dev/perspective" + }, + "type": "module", + "license": "Apache-2.0", + "sideEffects": false, + "unpkg": "dist/cdn/perspective.js", + "jsdelivr": "dist/cdn/perspective.js", + "exports": { + ".": { + "node": { + "types": "./dist/esm/perspective.node.d.ts", + "import": "./dist/esm/perspective.node.js", + "default": "./dist/esm/perspective.node.js" + }, + "types": "./dist/esm/perspective.browser.d.ts", + "import": "./dist/esm/perspective.js", + "default": "./dist/esm/perspective.js" + }, + "./node": { + "types": "./dist/esm/perspective.node.d.ts", + "import": "./dist/esm/perspective.node.js", + "default": "./dist/esm/perspective.node.js" + }, + "./inline": { + "types": "./dist/esm/perspective.browser.d.ts", + "import": "./dist/esm/perspective.inline.js", + "default": "./dist/esm/perspective.inline.js" + }, + "./virtual_servers/*": { + "types": "./dist/esm/virtual_servers/*.d.ts", + "import": "./dist/esm/virtual_servers/*.js", + "default": "./dist/esm/virtual_servers/*.js" + }, + "./dist/*": "./dist/*", + "./src/*": "./src/*", + "./test/*": "./test/*", + "./package.json": "./package.json", + "./tsconfig.json": "./tsconfig.json" + }, + "files": [ + "dist/**/*", + "src/**/*", + "tsconfig.json" + ], + "scripts": { + "build": "node ./build.mjs", + "clean": "node ./clean.mjs", + "docs": "node ./docs.mjs" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@perspective-dev/server": "workspace:", + "pro_self_extracting_wasm": "catalog:", + "stoppable": "catalog:", + "ws": "catalog:" + }, + "devDependencies": { + "@perspective-dev/esbuild-plugin": "workspace:", + "@perspective-dev/metadata": "workspace:", + "@perspective-dev/test": "workspace:", + "@clickhouse/client-web": "catalog:", + "@duckdb/duckdb-wasm": "catalog:", + "@playwright/experimental-ct-react": "catalog:", + "@playwright/test": "catalog:", + "@types/node": "catalog:", + "@types/ws": "catalog:", + "@types/stoppable": "catalog:", + "apache-arrow": "catalog:", + "superstore-arrow": "catalog:", + "lodash": "catalog:", + "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 new file mode 100644 index 0000000..ac24837 --- /dev/null +++ b/rust/perspective-js/src/rust/client.rs @@ -0,0 +1,589 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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::future::Future; +use std::sync::Arc; + +use derivative::Derivative; +use futures::channel::oneshot; +use js_sys::{Function, Uint8Array}; +#[cfg(doc)] +use perspective_client::SystemInfo; +use perspective_client::{ + ClientError, ReconnectCallback, Session, TableData, TableInitOptions, TableRef, asyncfn, +}; +use wasm_bindgen::prelude::*; +use wasm_bindgen_derive::TryFromJsValue; +use wasm_bindgen_futures::{JsFuture, future_to_promise}; + +pub use crate::table::*; +use crate::utils::{ApiError, ApiResult, JsValueSerdeExt, LocalPollLoop}; +use crate::{TableDataExt, apierror}; + +#[wasm_bindgen] +extern "C" { + #[derive(Clone)] + #[wasm_bindgen(typescript_type = "TableInitOptions")] + pub type JsTableInitOptions; + + #[derive(Clone)] + #[wasm_bindgen(typescript_type = "JoinOptions")] + pub type JsJoinOptions; +} + +async fn js_to_table_ref(val: &JsValue) -> ApiResult { + if let Some(name) = val.as_string() { + Ok(TableRef::from(name)) + } else { + let get_name = js_sys::Reflect::get(val, &wasm_bindgen::intern("get_name").into()) + .map_err(|_| apierror!(TableRefError))? + .dyn_into::() + .map_err(|_| apierror!(TableRefError))?; + + let promise = get_name + .call0(val) + .map_err(|_| apierror!(TableRefError))? + .dyn_into::() + .map_err(|_| apierror!(TableRefError))?; + + let name = wasm_bindgen_futures::JsFuture::from(promise) + .await + .map_err(|_| apierror!(TableRefError))? + .as_string() + .ok_or_else(|| apierror!(TableRefError))?; + + Ok(TableRef::from(name)) + } +} + +#[wasm_bindgen] +#[derive(Clone)] +pub struct ProxySession(perspective_client::ProxySession); + +#[wasm_bindgen] +impl ProxySession { + #[wasm_bindgen(constructor)] + pub fn new(client: &Client, on_response: &Function) -> Self { + let poll_loop = LocalPollLoop::new({ + let on_response = on_response.clone(); + move |msg: Vec| { + let msg = Uint8Array::from(&msg[..]); + on_response.call1(&JsValue::UNDEFINED, &JsValue::from(msg))?; + Ok(JsValue::null()) + } + }); + // NB: This swallows any errors raised by the inner callback + let on_response = Box::new(move |msg: &[u8]| { + wasm_bindgen_futures::spawn_local(poll_loop.poll(msg.to_vec())); + Ok(()) + }); + Self(perspective_client::ProxySession::new( + client.client.clone(), + on_response, + )) + } + + #[wasm_bindgen] + pub async fn handle_request(&self, value: JsValue) -> ApiResult<()> { + let uint8array = Uint8Array::new(&value); + let slice = uint8array.to_vec(); + self.0.handle_request(&slice).await?; + Ok(()) + } + + pub async fn close(self) { + self.0.close().await; + } +} + +/// An instance of a [`Client`] is a connection to a single +/// `perspective_server::Server`, whether locally in-memory or remote over some +/// transport like a WebSocket. +/// +/// The browser and node.js libraries both support the `websocket(url)` +/// constructor, which connects to a remote `perspective_server::Server` +/// instance over a WebSocket transport. +/// +/// In the browser, the `worker()` constructor creates a new Web Worker +/// `perspective_server::Server` and returns a [`Client`] connected to it. +/// +/// In node.js, a pre-instantied [`Client`] connected synhronously to a global +/// singleton `perspective_server::Server` is the default module export. +/// +/// # JavaScript Examples +/// +/// Create a Web Worker `perspective_server::Server` in the browser and return a +/// [`Client`] instance connected for it: +/// +/// ```javascript +/// import perspective from "@perspective-dev/client"; +/// const client = await perspective.worker(); +/// ``` +/// +/// Create a WebSocket connection to a remote `perspective_server::Server`: +/// +/// ```javascript +/// import perspective from "@perspective-dev/client"; +/// const client = await perspective.websocket("ws://locahost:8080/ws"); +/// ``` +/// +/// Access the synchronous client in node.js: +/// +/// ```javascript +/// import { default as client } from "@perspective-dev/client"; +/// ``` +#[wasm_bindgen] +#[derive(TryFromJsValue, Clone)] +pub struct Client { + pub(crate) client: perspective_client::Client, + pub(crate) close: Option, +} + +impl From for Client { + fn from(client: perspective_client::Client) -> Self { + Client { + client, + close: None, + } + } +} + +impl PartialEq for Client { + fn eq(&self, other: &Self) -> bool { + self.client.get_name() == other.client.get_name() + } +} + +/// A wrapper around [`js_sys::Function`] to ease async integration for the +/// `reconnect` argument of [`Client::on_error`] callback. +#[derive(Derivative)] +#[derivative(Clone(bound = ""))] +struct JsReconnect(Arc js_sys::Promise>); + +// This type is not thread safe, but the JavaScript environment does not allow +// threading. +unsafe impl Send for JsReconnect {} +unsafe impl Sync for JsReconnect {} + +impl JsReconnect { + fn run(&self, args: I) -> js_sys::Promise { + self.0(args) + } + + fn run_all( + &self, + args: I, + ) -> impl Future>> + + Send + + Sync + + 'static + + use { + let (sender, receiver) = oneshot::channel::>>(); + let p = self.0(args); + let _ = future_to_promise(async move { + let result = JsFuture::from(p) + .await + .map(|_| ()) + .map_err(|x| format!("{x:?}").into()); + + sender.send(result).unwrap(); + Ok(JsValue::UNDEFINED) + }); + + async move { receiver.await.unwrap() } + } +} + +impl From for JsReconnect +where + F: Fn(I) -> js_sys::Promise + 'static, +{ + fn from(value: F) -> Self { + JsReconnect(Arc::new(value)) + } +} + +impl Client { + pub fn get_client(&self) -> &'_ perspective_client::Client { + &self.client + } +} + +#[wasm_bindgen] +impl Client { + #[wasm_bindgen(constructor)] + pub fn new(send_request: Function, close: Option) -> ApiResult { + let send_request = JsReconnect::from(move |mut v: Vec| { + let buff2 = unsafe { js_sys::Uint8Array::view_mut_raw(v.as_mut_ptr(), v.len()) }; + send_request + .call1(&JsValue::UNDEFINED, &buff2) + .unwrap() + .unchecked_into::() + }); + + let client = perspective_client::Client::new_with_callback(None, move |msg| { + send_request.run_all(msg) + })?; + + Ok(Client { close, client }) + } + + #[wasm_bindgen] + pub fn new_proxy_session(&self, on_response: &Function) -> ProxySession { + ProxySession::new(self, on_response) + } + + #[wasm_bindgen] + pub async fn handle_response(&self, value: &JsValue) -> ApiResult<()> { + let uint8array = Uint8Array::new(value); + let slice = uint8array.to_vec(); + self.client.handle_response(&slice).await?; + Ok(()) + } + + #[wasm_bindgen] + pub async fn handle_error(&self, error: String, reconnect: Option) -> ApiResult<()> { + self.client + .handle_error( + ClientError::Unknown(error), + reconnect.map(|reconnect| { + let reconnect = + JsReconnect::from(move |()| match reconnect.call0(&JsValue::UNDEFINED) { + Ok(x) => x.unchecked_into::(), + Err(e) => { + // This error may occur when _invoking_ the function + tracing::warn!("{:?}", e); + js_sys::Promise::reject(&format!("C {e:?}").into()) + }, + }); + + asyncfn!(reconnect, async move || { + if let Err(e) = JsFuture::from(reconnect.run(())).await { + if let Some(e) = e.dyn_ref::() { + Err(ClientError::Unknown(e.to_string().as_string().unwrap())) + } else { + Err(ClientError::Unknown(e.as_string().unwrap())) + } + } else { + Ok(()) + } + }) + }), + ) + .await?; + + Ok(()) + } + + #[wasm_bindgen] + pub async fn on_error(&self, callback: Function) -> ApiResult { + let callback = JsReconnect::from( + move |(message, reconnect): (ClientError, Option)| { + let cl: Closure js_sys::Promise> = Closure::new(move || { + let reconnect = reconnect.clone(); + future_to_promise(async move { + if let Some(f) = reconnect { + (*f)().await.map_err(|e| JsValue::from(format!("A {e}")))?; + } + + Ok(JsValue::UNDEFINED) + }) + }); + + if let Err(e) = callback.call2( + &JsValue::UNDEFINED, + &JsValue::from(apierror!(ClientError(message))), + &cl.into_js_value(), + ) { + tracing::warn!("{:?}", e); + } + + js_sys::Promise::resolve(&JsValue::UNDEFINED) + }, + ); + + let poll_loop = LocalPollLoop::new_async(move |x| JsFuture::from(callback.run(x))); + let id = self + .client + .on_error(asyncfn!(poll_loop, async move |message, reconnect| { + poll_loop.poll((message, reconnect)).await; + Ok(()) + })) + .await?; + + Ok(id) + } + + /// Creates a new [`Table`] from either a _schema_ or _data_. + /// + /// The [`Client::table`] factory function can be initialized with either a + /// _schema_ (see [`Table::schema`]), or data in one of these formats: + /// + /// - Apache Arrow + /// - CSV + /// - JSON row-oriented + /// - JSON column-oriented + /// - NDJSON + /// + /// When instantiated with _data_, the schema is inferred from this data. + /// While this is convenient, inferrence is sometimes imperfect e.g. + /// when the input is empty, null or ambiguous. For these cases, + /// [`Client::table`] can first be instantiated with a explicit schema. + /// + /// When instantiated with a _schema_, the resulting [`Table`] is empty but + /// with known column names and column types. When subsqeuently + /// populated with [`Table::update`], these columns will be _coerced_ to + /// the schema's type. This behavior can be useful when + /// [`Client::table`]'s column type inferences doesn't work. + /// + /// The resulting [`Table`] is _virtual_, and invoking its methods + /// dispatches events to the `perspective_server::Server` this + /// [`Client`] connects to, where the data is stored and all calculation + /// occurs. + /// + /// # Arguments + /// + /// - `arg` - Either _schema_ or initialization _data_. + /// - `options` - Optional configuration which provides one of: + /// - `limit` - The max number of rows the resulting [`Table`] can + /// store. + /// - `index` - The column name to use as an _index_ column. If this + /// `Table` is being instantiated by _data_, this column name must be + /// present in the data. + /// - `name` - The name of the table. This will be generated if it is + /// not provided. + /// - `format` - The explicit format of the input data, can be one of + /// `"json"`, `"columns"`, `"csv"` or `"arrow"`. This overrides + /// language-specific type dispatch behavior, which allows stringified + /// and byte array alternative inputs. + /// + /// # JavaScript Examples + /// + /// Load a CSV from a `string`: + /// + /// ```javascript + /// const table = await client.table("x,y\n1,2\n3,4"); + /// ``` + /// + /// Load an Arrow from an `ArrayBuffer`: + /// + /// ```javascript + /// import * as fs from "node:fs/promises"; + /// const table2 = await client.table(await fs.readFile("superstore.arrow")); + /// ``` + /// + /// Load a CSV from a `UInt8Array` (the default for this type is Arrow) + /// using a format override: + /// + /// ```javascript + /// const enc = new TextEncoder(); + /// const table = await client.table(enc.encode("x,y\n1,2\n3,4"), { + /// format: "csv", + /// }); + /// ``` + /// + /// Create a table with an `index`: + /// + /// ```javascript + /// const table = await client.table(data, { index: "Row ID" }); + /// ``` + #[wasm_bindgen] + pub async fn table( + &self, + value: &JsTableInitData, + options: Option, + ) -> ApiResult
    { + let options = options + .into_serde_ext::>()? + .unwrap_or_default(); + + let args = TableData::from_js_value(value, options.format)?; + Ok(Table(self.client.table(args, options).await?)) + } + + /// Creates a new read-only [`Table`] by performing an INNER JOIN on two + /// source tables. The resulting table is reactive: when either source + /// table is updated, the join is automatically recomputed. + /// + /// # Arguments + /// + /// - `left` - The left source table (a [`Table`] instance or a table name + /// string). + /// - `right` - The right source table (a [`Table`] instance or a table name + /// string). + /// - `on` - The column name to join on. Must exist in both tables with the + /// same type. + /// - `options` - Optional join configuration: `{ join_type?: "inner" | + /// "left" | "outer", name?: string }`. + /// + /// # JavaScript Examples + /// + /// ```javascript + /// const joined = await client.join(orders_table, products_table, "Product ID", { join_type: "left" }); + /// const joined = await client.join("orders", "products", "Product ID", { join_type: "left" }); + /// ``` + #[wasm_bindgen] + pub async fn join( + &self, + left: JsValue, + right: JsValue, + on: &str, + options: Option, + ) -> ApiResult
    { + let options = options + .into_serde_ext::>()? + .unwrap_or_default(); + + let left_ref = js_to_table_ref(&left).await?; + let right_ref = js_to_table_ref(&right).await?; + Ok(Table( + self.client.join(left_ref, right_ref, on, options).await?, + )) + } + + /// Terminates this [`Client`], cleaning up any [`crate::View`] handles the + /// [`Client`] has open as well as its callbacks. + #[wasm_bindgen] + pub fn terminate(&self) -> ApiResult { + if let Some(f) = self.close.clone() { + Ok(f.call0(&JsValue::UNDEFINED)?) + } else { + Err(ApiError::new("Client type cannot be terminated")) + } + } + + /// Opens a [`Table`] that is hosted on the `perspective_server::Server` + /// that is connected to this [`Client`]. + /// + /// The `name` property of [`TableInitOptions`] is used to identify each + /// [`Table`]. [`Table`] `name`s can be looked up for each [`Client`] + /// via [`Client::get_hosted_table_names`]. + /// + /// # JavaScript Examples + /// + /// Get a virtual [`Table`] named "table_one" from this [`Client`] + /// + /// ```javascript + /// const tables = await client.open_table("table_one"); + /// ``` + #[wasm_bindgen] + pub async fn open_table(&self, entity_id: String) -> ApiResult
    { + Ok(Table(self.client.open_table(entity_id).await?)) + } + + /// Unsafely gets a [`View`] by raw ID, useful for JavaScript multi-threaded + /// (via Web Worker) context where a standard `View` cannot otherwise be + /// shared because its wrapper is not serializable. + /// + /// # Safety + /// + /// This method is unsafe because the lifetime of a [`View`] is bound to + /// the [`Client`] which created it. + /// + /// The caller must guarantee that `entity_id` corresponds to a live + /// [`crate::View`] on the connected server (obtained from another + /// [`Client`]'s [`crate::View::get_name`] and forwarded across the + /// serialization boundary). + /// + /// # JavaScript Examples + /// + /// ```javascript + /// const view = client.__unsafe_open_view(name_from_main_thread); + /// const cols = await view.to_columns(); + /// ``` + #[wasm_bindgen] + pub fn __unsafe_open_view(&self, entity_id: String) -> crate::view::View { + crate::view::View(perspective_client::View::new( + entity_id, + self.client.clone(), + )) + } + + /// Retrieves the names of all tables that this client has access to. + /// + /// `name` is a string identifier unique to the [`Table`] (per [`Client`]), + /// which can be used in conjunction with [`Client::open_table`] to get + /// a [`Table`] instance without the use of [`Client::table`] + /// constructor directly (e.g., one created by another [`Client`]). + /// + /// # JavaScript Examples + /// + /// ```javascript + /// const tables = await client.get_hosted_table_names(); + /// ``` + #[wasm_bindgen] + 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 + /// [`Client`]) or [`Table::delete`] (on a [`Table`] belinging to this + /// [`Client`]) are called. + #[wasm_bindgen] + pub async fn on_hosted_tables_update(&self, on_update_js: Function) -> ApiResult { + let poll_loop = LocalPollLoop::new(move |_| on_update_js.call0(&JsValue::UNDEFINED)); + let on_update = Box::new(move || poll_loop.poll(())); + let id = self.client.on_hosted_tables_update(on_update).await?; + Ok(id) + } + + /// Remove a callback previously registered via + /// `Client::on_hosted_tables_update`. + #[wasm_bindgen] + pub async fn remove_hosted_tables_update(&self, update_id: u32) -> ApiResult<()> { + self.client.remove_hosted_tables_update(update_id).await?; + Ok(()) + } + + /// Provides the [`SystemInfo`] struct, implementation-specific metadata + /// about the [`perspective_server::Server`] runtime such as Memory and + /// CPU usage. + /// + /// For WebAssembly servers, this method includes the WebAssembly heap size. + /// + /// # JavaScript Examples + /// + /// ```javascript + /// const info = await client.system_info(); + /// ``` + #[wasm_bindgen] + pub async fn system_info(&self) -> ApiResult { + let mut info = self.client.system_info().await?; + + #[cfg(feature = "trace-allocator")] + if let perspective_client::SystemInfo { + client_used: None, + client_heap: None, + .. + } = &info + { + let (client_used, client_heap) = crate::utils::get_used(); + info.client_used = Some(client_used as u64); + info.client_heap = Some(client_heap as u64); + }; + + let timestamp = web_sys::window() + .and_then(|x| x.performance()) + .map(|x| x.now() as u64); + + info.timestamp = timestamp; + let record = JsValue::from_serde_ext(&info.cast::())?; + Ok(record.unchecked_into()) + } +} + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "SystemInfo")] + pub type JsSystemInfo; +} diff --git a/rust/perspective-js/src/rust/generic_sql_model.rs b/rust/perspective-js/src/rust/generic_sql_model.rs new file mode 100644 index 0000000..b0d486a --- /dev/null +++ b/rust/perspective-js/src/rust/generic_sql_model.rs @@ -0,0 +1,205 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +//! WASM bindings for the DuckDB SQL query builder. + +use std::str::FromStr; + +use indexmap::IndexMap; +use js_sys::Object; +use perspective_client::config::ViewConfig; +use perspective_client::proto::{ColumnType, ViewPort}; +use perspective_client::virtual_server; +use wasm_bindgen::prelude::*; + +use crate::utils::*; + +/// JavaScript-facing DuckDB SQL query builder. +/// +/// This struct wraps the Rust `DuckDBSqlBuilder` and exposes it to JavaScript +/// via wasm_bindgen. +#[wasm_bindgen] +pub struct GenericSQLVirtualServerModel { + inner: virtual_server::GenericSQLVirtualServerModel, +} + +#[wasm_bindgen] +extern "C" { + pub type JsGenericSQLVirtualServerModelArgs; +} + +#[wasm_bindgen] +impl GenericSQLVirtualServerModel { + /// Creates a new `JsDuckDBSqlBuilder` instance. + #[wasm_bindgen(constructor)] + pub fn new(args: Option) -> Result { + Ok(Self { + inner: virtual_server::GenericSQLVirtualServerModel::new( + args.map(|x| x.into_serde_ext()) + .transpose()? + .unwrap_or_default(), + ), + }) + } + + /// Returns the SQL query to list all hosted tables. + #[wasm_bindgen(js_name = "getHostedTables")] + pub fn get_hosted_tables(&self) -> Result { + self.inner + .get_hosted_tables() + .map_err(|e| JsValue::from_str(&e.to_string())) + } + + /// Returns the SQL query to describe a table's schema. + #[wasm_bindgen(js_name = "tableSchema")] + pub fn table_schema(&self, table_id: &str) -> Result { + self.inner + .table_schema(table_id) + .map_err(|e| JsValue::from_str(&e.to_string())) + } + + /// Returns the SQL query to get the row count of a table. + #[wasm_bindgen(js_name = "tableSize")] + pub fn table_size(&self, table_id: &str) -> Result { + self.inner + .table_size(table_id) + .map_err(|e| JsValue::from_str(&e.to_string())) + } + + /// Returns the SQL query to get the column count of a view. + #[wasm_bindgen(js_name = "viewColumnSize")] + pub fn view_column_size(&self, view_id: &str) -> Result { + self.inner + .view_column_size(view_id) + .map_err(|e| JsValue::from_str(&e.to_string())) + } + + /// Returns the SQL query to validate an expression against a table. + #[wasm_bindgen(js_name = "tableValidateExpression")] + pub fn table_validate_expression( + &self, + table_id: &str, + expression: &str, + ) -> Result { + self.inner + .table_validate_expression(table_id, expression) + .map_err(|e| JsValue::from_str(&e.to_string())) + } + + /// Returns the SQL query to delete a view. + #[wasm_bindgen(js_name = "viewDelete")] + pub fn view_delete(&self, view_id: &str) -> Result { + self.inner + .view_delete(view_id) + .map_err(|e| JsValue::from_str(&e.to_string())) + } + + /// Returns the SQL query to create a view from a table with the given + /// configuration. + #[wasm_bindgen(js_name = "tableMakeView")] + pub fn table_make_view( + &self, + table_id: &str, + view_id: &str, + config: JsValue, + ) -> Result { + let config: ViewConfig = serde_wasm_bindgen::from_value(config) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + + self.inner + .table_make_view(table_id, view_id, &config) + .map_err(|e| JsValue::from_str(&e.to_string())) + } + + /// Returns the SQL query to fetch data from a view with the given viewport. + #[wasm_bindgen(js_name = "viewGetData")] + pub fn view_get_data( + &self, + view_id: &str, + config: JsValue, + viewport: JsValue, + schema: JsValue, + ) -> Result { + let config: ViewConfig = serde_wasm_bindgen::from_value(config) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + + let viewport: ViewPort = serde_wasm_bindgen::from_value(viewport) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + + let schema = self.parse_schema(schema)?; + + self.inner + .view_get_data(view_id, &config, &viewport, &schema) + .map_err(|e| JsValue::from_str(&e.to_string())) + } + + /// Returns the SQL query to describe a view's schema. + #[wasm_bindgen(js_name = "viewSchema")] + pub fn view_schema(&self, view_id: &str) -> Result { + self.inner + .view_schema(view_id) + .map_err(|e| JsValue::from_str(&e.to_string())) + } + + /// Returns the SQL query to get the row count of a view. + #[wasm_bindgen(js_name = "viewSize")] + pub fn view_size(&self, view_id: &str) -> Result { + self.inner + .view_size(view_id) + .map_err(|e| JsValue::from_str(&e.to_string())) + } + + /// Returns the SQL query to get the min and max values of a column. + #[wasm_bindgen(js_name = "viewGetMinMax")] + pub fn view_get_min_max( + &self, + view_id: &str, + column_name: &str, + config: JsValue, + ) -> Result { + let config: ViewConfig = serde_wasm_bindgen::from_value(config) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + + self.inner + .view_get_min_max(view_id, column_name, &config) + .map_err(|e| JsValue::from_str(&e.to_string())) + } +} + +impl GenericSQLVirtualServerModel { + fn parse_schema(&self, schema: JsValue) -> Result, JsValue> { + let obj = schema.dyn_ref::().ok_or_else(|| { + JsValue::from_str("Schema must be an object mapping column names to types") + })?; + + let mut result = IndexMap::new(); + let entries = Object::entries(obj); + for i in 0..entries.length() { + let entry = entries.get(i); + let entry_array = entry + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("Invalid schema entry"))?; + let key = entry_array + .get(0) + .as_string() + .ok_or_else(|| JsValue::from_str("Column name must be a string"))?; + let value = entry_array + .get(1) + .as_string() + .ok_or_else(|| JsValue::from_str("Column type must be a string"))?; + let column_type = ColumnType::from_str(&value) + .map_err(|_| JsValue::from_str(&format!("Unknown column type: {}", value)))?; + result.insert(key, column_type); + } + Ok(result) + } +} diff --git a/rust/perspective-js/src/rust/lib.rs b/rust/perspective-js/src/rust/lib.rs new file mode 100644 index 0000000..7099c35 --- /dev/null +++ b/rust/perspective-js/src/rust/lib.rs @@ -0,0 +1,88 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +//!
    +//! The examples in this module are in JavaScript. See perspective docs for the Rust API. +//!
    + +#![warn( + clippy::all, + clippy::panic_in_result_fn, + clippy::await_holding_refcell_ref, + rustdoc::broken_intra_doc_links, + unstable_features +)] +#![allow(non_snake_case)] + +extern crate alloc; + +mod client; +mod generic_sql_model; +mod table; +mod table_data; +mod typed_array; +pub mod utils; +mod view; +mod virtual_server; + +#[cfg(feature = "export-init")] +use wasm_bindgen::prelude::*; + +pub use crate::client::Client; +pub use crate::generic_sql_model::*; +pub use crate::table::*; +pub use crate::table_data::*; +pub use crate::typed_array::*; +pub use crate::virtual_server::*; + +#[cfg(feature = "export-init")] +#[wasm_bindgen(typescript_custom_section)] +const TS_APPEND_CONTENT: &'static str = r#" +export type * from "../../src/ts/ts-rs/ViewWindow.d.ts"; +export type * from "../../src/ts/ts-rs/ColumnType.d.ts"; +export type * from "../../src/ts/ts-rs/ColumnWindow.d.ts"; +export type * from "../../src/ts/ts-rs/TableInitOptions.d.ts"; +export type * from "../../src/ts/ts-rs/ViewConfigUpdate.d.ts"; +export type * from "../../src/ts/ts-rs/ViewOnUpdateResp.d.ts"; +export type * from "../../src/ts/ts-rs/OnUpdateOptions.d.ts"; +export type * from "../../src/ts/ts-rs/UpdateOptions.d.ts"; +export type * from "../../src/ts/ts-rs/DeleteOptions.d.ts"; +export type * from "../../src/ts/ts-rs/Scalar.d.ts"; +export type * from "../../src/ts/ts-rs/SystemInfo.d.ts"; +export type * from "../../src/ts/ts-rs/SortDir.d.ts"; +export type * from "../../src/ts/ts-rs/Filter.d.ts"; +export type * from "../../src/ts/ts-rs/ViewConfig.d.ts"; +export type * from "../../src/ts/ts-rs/JoinOptions.ts"; +export type * from "../../src/ts/ts-rs/JoinType.ts"; +export type * from "../../src/ts/ts-rs/TypedArrayWindow.ts"; + +import type {ColumnWindow} from "../../src/ts/ts-rs/ColumnWindow.d.ts"; +import type {ColumnType} from "../../src/ts/ts-rs/ColumnType.d.ts"; +import type {ViewWindow} from "../../src/ts/ts-rs/ViewWindow.d.ts"; +import type {TypedArrayWindow} from "../../src/ts/ts-rs/TypedArrayWindow.ts"; +import type {TableInitOptions} from "../../src/ts/ts-rs/TableInitOptions.d.ts"; +import type {JoinOptions} from "../../src/ts/ts-rs/JoinOptions.ts"; +import type {JoinType} from "../../src/ts/ts-rs/JoinType.ts"; +import type {ViewConfigUpdate} from "../../src/ts/ts-rs/ViewConfigUpdate.d.ts"; +import type * as on_update_args from "../../src/ts/ts-rs/ViewOnUpdateResp.d.ts"; +import type {OnUpdateOptions} from "../../src/ts/ts-rs/OnUpdateOptions.d.ts"; +import type {UpdateOptions} from "../../src/ts/ts-rs/UpdateOptions.d.ts"; +import type {DeleteOptions} from "../../src/ts/ts-rs/DeleteOptions.d.ts"; +import type {SystemInfo} from "../../src/ts/ts-rs/SystemInfo.d.ts"; +"#; + +#[cfg(feature = "export-init")] +#[wasm_bindgen] +pub fn init() { + // console_error_panic_hook::set_once(); + utils::set_global_logging(); +} diff --git a/rust/perspective-js/src/rust/table.rs b/rust/perspective-js/src/rust/table.rs new file mode 100644 index 0000000..2066579 --- /dev/null +++ b/rust/perspective-js/src/rust/table.rs @@ -0,0 +1,365 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 js_sys::Function; +use perspective_client::config::*; +use perspective_client::{DeleteOptions, UpdateData, UpdateOptions, assert_table_api}; +use wasm_bindgen::prelude::*; +use wasm_bindgen_derive::TryFromJsValue; +use wasm_bindgen_futures::spawn_local; + +use crate::Client; +use crate::table_data::UpdateDataExt; +use crate::utils::{ApiFuture, ApiResult, JsValueSerdeExt, LocalPollLoop}; +pub use crate::view::*; + +#[derive(TryFromJsValue, Clone, PartialEq)] +#[wasm_bindgen] +pub struct Table(pub(crate) perspective_client::Table); + +assert_table_api!(Table); + +impl From for Table { + fn from(value: perspective_client::Table) -> Self { + Table(value) + } +} + +/// [`Table`] is Perspective's columnar data frame, analogous to a Pandas/Polars +/// `DataFrame` or Apache Arrow, supporting append & in-place updates, removal +/// by index, and update notifications. +/// +/// A [`Table`] contains columns, each of which have a unique name, are strongly +/// and consistently typed, and contains rows of data conforming to the column's +/// type. Each column in a [`Table`] must have the same number of rows, though +/// not every row must contain data; null-values are used to indicate missing +/// values in the dataset. The schema of a [`Table`] is _immutable after +/// creation_, which means the column names and data types cannot be changed +/// after the [`Table`] has been created. Columns cannot be added or deleted +/// after creation either, but a [`View`] can be used to select an arbitrary set +/// of columns from the [`Table`]. +impl Table { + pub fn get_table(&self) -> &'_ perspective_client::Table { + &self.0 + } +} + +#[wasm_bindgen] +extern "C" { + // TODO Fix me + #[wasm_bindgen(typescript_type = "\ + string | ArrayBuffer | Record | Record[] | \ + Record")] + pub type JsTableInitData; + + #[wasm_bindgen(typescript_type = "ViewConfigUpdate")] + pub type JsViewConfig; + + #[wasm_bindgen(typescript_type = "UpdateOptions")] + pub type JsUpdateOptions; + + #[wasm_bindgen(typescript_type = "DeleteOptions")] + pub type JsDeleteOptions; +} + +#[wasm_bindgen] +impl Table { + /// Returns the name of the index column for the table. + /// + /// # JavaScript Examples + /// + /// ```javascript + /// const table = await client.table("x,y\n1,2\n3,4", { index: "x" }); + /// const index = table.get_index(); // "x" + /// ``` + #[wasm_bindgen] + pub async fn get_index(&self) -> Option { + self.0.get_index() + } + + /// Get a copy of the [`Client`] this [`Table`] came from. + #[wasm_bindgen] + pub async fn get_client(&self) -> Client { + Client { + close: None, + client: self.0.get_client(), + } + } + + /// Returns the user-specified name for this table, or the auto-generated + /// name if a name was not specified when the table was created. + #[wasm_bindgen] + pub async fn get_name(&self) -> String { + self.0.get_name().to_owned() + } + + /// Returns the user-specified row limit for this table. + #[wasm_bindgen] + pub async fn get_limit(&self) -> Option { + self.0.get_limit() + } + + /// Removes all the rows in the [`Table`], but preserves everything else + /// including the schema, index, and any callbacks or registered + /// [`View`] instances. + /// + /// Calling [`Table::clear`], like [`Table::update`] and [`Table::remove`], + /// will trigger an update event to any registered listeners via + /// [`View::on_update`]. + #[wasm_bindgen] + pub async fn clear(&self) -> ApiResult<()> { + self.0.clear().await?; + Ok(()) + } + + /// Delete this [`Table`] and cleans up associated resources. + /// + /// [`Table`]s do not stop consuming resources or processing updates when + /// they are garbage collected in their host language - you must call + /// this method to reclaim these. + /// + /// # Arguments + /// + /// - `options` An options dictionary. + /// - `lazy` Whether to delete this [`Table`] _lazily_. When false (the + /// default), the delete will occur immediately, assuming it has no + /// [`View`] instances registered to it (which must be deleted first, + /// otherwise this method will throw an error). When true, the + /// [`Table`] will only be marked for deltion once its [`View`] + /// dependency count reaches 0. + /// + /// # JavaScript Examples + /// + /// ```javascript + /// const table = await client.table("x,y\n1,2\n3,4"); + /// + /// // ... + /// + /// await table.delete({ lazy: true }); + /// ``` + #[wasm_bindgen] + pub async fn delete(self, options: Option) -> ApiResult<()> { + let options = options + .into_serde_ext::>()? + .unwrap_or_default(); + + self.0.delete(options).await?; + Ok(()) + } + + /// Returns the number of rows in a [`Table`]. + #[wasm_bindgen] + pub async fn size(&self) -> ApiResult { + Ok(self.0.size().await? as f64) + } + + /// Returns a table's [`Schema`], a mapping of column names to column types. + /// + /// The mapping of a [`Table`]'s column names to data types is referred to + /// as a [`Schema`]. Each column has a unique name and a data type, one + /// of: + /// + /// - `"boolean"` - A boolean type + /// - `"date"` - A timesonze-agnostic date type (month/day/year) + /// - `"datetime"` - A millisecond-precision datetime type in the UTC + /// timezone + /// - `"float"` - A 64 bit float + /// - `"integer"` - A signed 32 bit integer (the integer type supported by + /// JavaScript) + /// - `"string"` - A [`String`] data type (encoded internally as a + /// _dictionary_) + /// + /// Note that all [`Table`] columns are _nullable_, regardless of the data + /// type. + #[wasm_bindgen(unchecked_return_type = "Record")] + pub async fn schema(&self) -> ApiResult { + let schema = self.0.schema().await?; + Ok(JsValue::from_serde_ext(&schema)?) + } + + /// Returns the column names of this [`Table`] in "natural" order (the + /// ordering implied by the input format). + /// + /// # JavaScript Examples + /// + /// ```javascript + /// const columns = await table.columns(); + /// ``` + #[wasm_bindgen] + pub async fn columns(&self) -> ApiResult { + let columns = self.0.columns().await?; + Ok(JsValue::from_serde_ext(&columns)?) + } + + /// Create a unique channel ID on this [`Table`], which allows + /// `View::on_update` callback calls to be associated with the + /// `Table::update` which caused them. + #[wasm_bindgen] + pub async fn make_port(&self) -> ApiResult { + Ok(self.0.make_port().await?) + } + + /// Register a callback which is called exactly once, when this [`Table`] is + /// deleted with the [`Table::delete`] method. + /// + /// [`Table::on_delete`] resolves when the subscription message is sent, not + /// when the _delete_ event occurs. + #[wasm_bindgen] + pub fn on_delete(&self, on_delete: Function) -> ApiFuture { + let table = self.clone(); + ApiFuture::new(async move { + let emit = LocalPollLoop::new(move |()| on_delete.call0(&JsValue::UNDEFINED)); + let on_delete = Box::new(move || spawn_local(emit.poll(()))); + Ok(table.0.on_delete(on_delete).await?) + }) + } + + /// Removes a listener with a given ID, as returned by a previous call to + /// [`Table::on_delete`]. + #[wasm_bindgen] + pub fn remove_delete(&self, callback_id: u32) -> ApiFuture<()> { + let client = self.0.clone(); + ApiFuture::new(async move { + client.remove_delete(callback_id).await?; + Ok(()) + }) + } + + /// Removes rows from this [`Table`] with the `index` column values + /// supplied. + /// + /// # Arguments + /// + /// - `indices` - A list of `index` column values for rows that should be + /// removed. + /// + /// # JavaScript Examples + /// + /// ```javascript + /// await table.remove([1, 2, 3]); + /// ``` + #[wasm_bindgen] + pub async fn remove(&self, value: &JsValue, options: Option) -> ApiResult<()> { + let options = options + .into_serde_ext::>()? + .unwrap_or_default(); + + let input = UpdateData::from_js_value(value, options.format)?; + self.0.remove(input).await?; + Ok(()) + } + + /// Replace all rows in this [`Table`] with the input data, coerced to this + /// [`Table`]'s existing [`perspective_client::Schema`], notifying any + /// derived [`View`] and [`View::on_update`] callbacks. + /// + /// Calling [`Table::replace`] is an easy way to replace _all_ the data in a + /// [`Table`] without losing any derived [`View`] instances or + /// [`View::on_update`] callbacks. [`Table::replace`] does _not_ infer + /// data types like [`Client::table`] does, rather it _coerces_ input + /// data to the `Schema` like [`Table::update`]. If you need a [`Table`] + /// with a different `Schema`, you must create a new one. + /// + /// # JavaScript Examples + /// + /// ```javascript + /// await table.replace("x,y\n1,2"); + /// ``` + #[wasm_bindgen] + pub async fn replace( + &self, + input: &JsValue, + options: Option, + ) -> ApiResult<()> { + let options = options + .into_serde_ext::>()? + .unwrap_or_default(); + + let input = UpdateData::from_js_value(input, options.format)?; + self.0.replace(input).await?; + Ok(()) + } + + /// Updates the rows of this table and any derived [`View`] instances. + /// + /// Calling [`Table::update`] will trigger the [`View::on_update`] callbacks + /// register to derived [`View`], and the call itself will not resolve until + /// _all_ derived [`View`]'s are notified. + /// + /// When updating a [`Table`] with an `index`, [`Table::update`] supports + /// partial updates, by omitting columns from the update data. + /// + /// # Arguments + /// + /// - `input` - The input data for this [`Table`]. The schema of a [`Table`] + /// is immutable after creation, so this method cannot be called with a + /// schema. + /// - `options` - Options for this update step - see [`UpdateOptions`]. + /// + /// # JavaScript Examples + /// + /// ```javascript + /// await table.update("x,y\n1,2"); + /// ``` + #[wasm_bindgen] + pub fn update( + &self, + input: JsTableInitData, + options: Option, + ) -> ApiFuture<()> { + let table = self.clone(); + ApiFuture::new(async move { + let options = options + .into_serde_ext::>()? + .unwrap_or_default(); + + let input = UpdateData::from_js_value(&input, options.format)?; + Ok(table.0.update(input, options).await?) + }) + } + + /// Create a new [`View`] from this table with a specified + /// [`ViewConfigUpdate`]. + /// + /// See [`View`] struct. + /// + /// # JavaScript Examples + /// + /// ```javascript + /// const view = await table.view({ + /// columns: ["Sales"], + /// aggregates: { Sales: "sum" }, + /// group_by: ["Region", "Country"], + /// filter: [["Category", "in", ["Furniture", "Technology"]]], + /// }); + /// ``` + #[wasm_bindgen] + pub async fn view(&self, config: Option) -> ApiResult { + let config = config + .map(|config| js_sys::JSON::stringify(&config)) + .transpose()? + .and_then(|x| x.as_string()) + .map(|x| serde_json::from_str(x.as_str())) + .transpose()?; + + let view = self.0.view(config).await?; + Ok(View(view)) + } + + /// Validates the given expressions. + #[wasm_bindgen] + pub async fn validate_expressions(&self, exprs: &JsValue) -> ApiResult { + let exprs = JsValue::into_serde_ext::(exprs.clone())?; + let columns = self.0.validate_expressions(exprs).await?; + Ok(JsValue::from_serde_ext(&columns)?) + } +} diff --git a/rust/perspective-js/src/rust/table_data.rs b/rust/perspective-js/src/rust/table_data.rs new file mode 100644 index 0000000..7987ff9 --- /dev/null +++ b/rust/perspective-js/src/rust/table_data.rs @@ -0,0 +1,164 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 extend::ext; +use js_sys::{Array, ArrayBuffer, Function, JSON, Object, Reflect, Uint8Array}; +use perspective_client::config::ColumnType; +use perspective_client::{TableData, TableReadFormat, UpdateData}; +use wasm_bindgen::convert::TryFromJsValue; +use wasm_bindgen::intern; +use wasm_bindgen::prelude::*; + +use crate::apierror; +use crate::utils::{ApiError, ApiResult, JsValueSerdeExt}; +pub use crate::view::*; + +#[ext] +impl Vec<(String, ColumnType)> { + fn from_js_value(value: &JsValue) -> ApiResult> { + Ok(Object::keys(value.unchecked_ref()) + .iter() + .map(|x| -> Result<_, JsValue> { + let key = x.as_string().expect("Not string??"); + let val = Reflect::get(value, &x)? + .as_string() + .expect("Y no string?") + .into_serde_ext()?; + + Ok((key, val)) + }) + .collect::, _>>()?) + } +} + +#[ext] +pub(crate) impl TableData { + fn from_js_value(value: &JsValue, format: Option) -> ApiResult { + if let Some(result) = UpdateData::from_js_value_partial(value, format)? { + Ok(result.into()) + } else if value.is_instance_of::() + && Reflect::has(value, &intern("__get_model").into())? + { + let val = Reflect::get(value, &intern("__get_model").into())? + .dyn_into::()? + .call0(value)?; + + let view = View::try_from_js_value(val)?; + Ok(TableData::View(view.0)) + } else if value.is_instance_of::() { + let all_strings = || { + Object::values(value.unchecked_ref()) + .to_vec() + .iter() + .all(|x| x.is_string()) + }; + + let all_arrays = || { + Object::values(value.unchecked_ref()) + .to_vec() + .iter() + .all(|x| x.is_instance_of::()) + }; + + if all_strings() { + Ok(TableData::Schema(Vec::from_js_value(value)?)) + } else if all_arrays() { + let json = JSON::stringify(value)? + .as_string() + .expect("STRINGIFY_ARRAY??"); + Ok(UpdateData::JsonColumns(json).into()) + } else { + Err(apierror!(TableError(value.clone()))) + } + } else { + Err(apierror!(TableError(value.clone()))) + } + } +} + +#[ext] +pub(crate) impl UpdateData { + fn from_js_value_partial( + value: &JsValue, + format: Option, + ) -> ApiResult> { + if value.is_undefined() { + Err(apierror!(TableError(value.clone()))) + } else if value.is_string() { + match format { + None | Some(TableReadFormat::Csv) => { + Ok(Some(UpdateData::Csv(value.as_string().expect("Csv????")))) + }, + Some(TableReadFormat::JsonString) => Ok(Some(UpdateData::JsonRows( + value.as_string().expect("JSON???"), + ))), + Some(TableReadFormat::ColumnsString) => Ok(Some(UpdateData::JsonColumns( + value.as_string().expect("ColumnString???"), + ))), + Some(TableReadFormat::Arrow) => Ok(Some(UpdateData::Arrow( + value.as_string().expect("Arrow???").into_bytes().into(), + ))), + Some(TableReadFormat::Ndjson) => Ok(Some(UpdateData::Ndjson( + value.as_string().expect("Ndjson???"), + ))), + } + } else if value.is_instance_of::() { + let uint8array = Uint8Array::new(value); + let slice = uint8array.to_vec(); + match format { + Some(TableReadFormat::Csv) => Ok(Some(UpdateData::Csv(String::from_utf8(slice)?))), + Some(TableReadFormat::JsonString) => { + Ok(Some(UpdateData::JsonRows(String::from_utf8(slice)?))) + }, + Some(TableReadFormat::ColumnsString) => { + Ok(Some(UpdateData::JsonColumns(String::from_utf8(slice)?))) + }, + Some(TableReadFormat::Ndjson) => { + Ok(Some(UpdateData::Ndjson(String::from_utf8(slice)?))) + }, + None | Some(TableReadFormat::Arrow) => Ok(Some(UpdateData::Arrow(slice.into()))), + } + } else if let Some(uint8array) = value.dyn_ref::() { + let slice = uint8array.to_vec(); + match format { + Some(TableReadFormat::Csv) => Ok(Some(UpdateData::Csv(String::from_utf8(slice)?))), + Some(TableReadFormat::JsonString) => { + Ok(Some(UpdateData::JsonRows(String::from_utf8(slice)?))) + }, + Some(TableReadFormat::ColumnsString) => { + Ok(Some(UpdateData::JsonColumns(String::from_utf8(slice)?))) + }, + Some(TableReadFormat::Ndjson) => { + Ok(Some(UpdateData::Ndjson(String::from_utf8(slice)?))) + }, + None | Some(TableReadFormat::Arrow) => Ok(Some(UpdateData::Arrow(slice.into()))), + } + } else if value.is_instance_of::() { + let rows = JSON::stringify(value)?.as_string().expect("STRINGIFY??"); + Ok(Some(UpdateData::JsonRows(rows))) + } else { + Ok(None) + } + } + + fn from_js_value(value: &JsValue, format: Option) -> ApiResult { + match TableData::from_js_value(value, format)? { + TableData::Schema(_) => Err(ApiError::new( + "Method cannot be called with `Schema` argument", + )), + TableData::Update(x) => Ok(x), + TableData::View(_) => Err(ApiError::new( + "Method cannot be called with `Schema` argument", + )), + } + } +} diff --git a/rust/perspective-js/src/rust/typed_array.rs b/rust/perspective-js/src/rust/typed_array.rs new file mode 100644 index 0000000..ad195d7 --- /dev/null +++ b/rust/perspective-js/src/rust/typed_array.rs @@ -0,0 +1,243 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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::io::Cursor; + +use arrow_array::Array as _; +use arrow_array::cast::AsArray; +use arrow_array::types::*; +use arrow_ipc::reader::StreamReader; +use arrow_schema::{DataType, TimeUnit}; +use js_sys::{Array, Function, JsString, Uint8Array}; +use perspective_client::ViewWindow; +use ts_rs::TS; +use wasm_bindgen::JsCast; +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +unsafe extern "C" { + #[wasm_bindgen(typescript_type = "TypedArrayWindow")] + #[derive(Clone)] + pub type JsTypedArrayWindow; +} + +/// Options for `with_typed_arrays`, extending `ViewWindow` with +/// typed-array-specific options. +#[derive(Default, serde::Deserialize, TS)] +pub struct TypedArrayWindow { + #[serde(flatten)] + pub view_window: ViewWindow, + + /// When `true`, Float64/Date32/Timestamp columns are output as + /// `Float32Array` instead of `Float64Array`. + #[serde(default)] + pub float32: bool, +} + +impl From for ViewWindow { + fn from(w: TypedArrayWindow) -> Self { + w.view_window + } +} + +/// Decode an Arrow IPC batch and call `callback` once with all columns. +/// +/// Callback signature: +/// ```js +/// callback(names: string[], values: TypedArray[], validities: (Uint8Array|null)[], dictionaries: (string[]|null)[]) => void | Promise +/// ``` +/// +/// If the callback returns a `Promise`, it is awaited before the Arrow +/// batch (and therefore the zero-copy typed-array views into it) is +/// dropped. A synchronous callback returning `undefined` is supported +/// with no promise-handling overhead. +pub(crate) async fn decode_and_call( + arrow: &[u8], + float32: bool, + callback: &Function, +) -> Result<(), JsValue> { + let cursor = Cursor::new(arrow); + let reader = StreamReader::try_new(cursor, None) + .map_err(|e| JsValue::from_str(&format!("Arrow decode error: {e}")))?; + + let batch = reader + .into_iter() + .next() + .ok_or_else(|| JsValue::from_str("Arrow IPC contained no record batches"))? + .map_err(|e| JsValue::from_str(&format!("Arrow batch error: {e}")))?; + + let schema = batch.schema(); + let num_cols = batch.num_columns(); + + let js_names = Array::new_with_length(num_cols as u32); + let js_values = Array::new_with_length(num_cols as u32); + let js_validities = Array::new_with_length(num_cols as u32); + let js_dicts = Array::new_with_length(num_cols as u32); + + // Storage for allocated conversion buffers. These MUST outlive the + // callback because `js_sys::*Array::view()` creates zero-copy views + // into their heap memory. Using `Box<[T]>` (rather than `Vec`) + // yields a stable pointer that won't move when the outer Vec grows. + let mut f32_storage: Vec> = Vec::new(); + let mut f64_storage: Vec> = Vec::new(); + + for col_idx in 0..num_cols { + let field = schema.field(col_idx); + let col = batch.column(col_idx); + let validity = col.nulls().map(|nulls| nulls.validity()); + + js_names.set(col_idx as u32, JsString::from(field.name().as_str()).into()); + + match col.data_type() { + DataType::UInt32 => { + let vals = col.as_primitive::().values(); + let arr = unsafe { js_sys::Uint32Array::view(vals.as_ref()) }; + js_values.set(col_idx as u32, arr.into()); + js_dicts.set(col_idx as u32, JsValue::NULL); + }, + DataType::Int32 => { + let vals = col.as_primitive::().values(); + let arr = unsafe { js_sys::Int32Array::view(vals.as_ref()) }; + js_values.set(col_idx as u32, arr.into()); + js_dicts.set(col_idx as u32, JsValue::NULL); + }, + DataType::Float32 => { + let vals = col.as_primitive::().values(); + let arr = unsafe { js_sys::Float32Array::view(vals.as_ref()) }; + js_values.set(col_idx as u32, arr.into()); + js_dicts.set(col_idx as u32, JsValue::NULL); + }, + DataType::Float64 => { + if float32 { + let vals = col.as_primitive::().values(); + f32_storage.push(vals.iter().map(|&v| v as f32).collect()); + } else { + let vals = col.as_primitive::().values(); + let arr = unsafe { js_sys::Float64Array::view(vals.as_ref()) }; + js_values.set(col_idx as u32, arr.into()); + } + js_dicts.set(col_idx as u32, JsValue::NULL); + }, + DataType::Date32 => { + // Datetime values are always emitted as Float64 — narrowing + // epoch-ms to f32 collapses ~256 ms of resolution at modern + // timestamps, so the `float32` flag is intentionally ignored + // for date/timestamp columns. + let typed = col.as_primitive::(); + f64_storage.push( + typed + .values() + .iter() + .map(|&v| v as f64 * 86_400_000.0) + .collect(), + ); + js_dicts.set(col_idx as u32, JsValue::NULL); + }, + DataType::Timestamp(TimeUnit::Millisecond, _) => { + let typed = col.as_primitive::(); + f64_storage.push(typed.values().iter().map(|&v| v as f64).collect()); + js_dicts.set(col_idx as u32, JsValue::NULL); + }, + DataType::Int64 => { + let typed = col.as_primitive::(); + if float32 { + f32_storage.push(typed.values().iter().map(|&v| v as f32).collect()); + } else { + f64_storage.push(typed.values().iter().map(|&v| v as f64).collect()); + } + js_dicts.set(col_idx as u32, JsValue::NULL); + }, + DataType::Dictionary(..) => { + let dict = col.as_dictionary::(); + let keys = dict.keys(); + let arr = unsafe { js_sys::Int32Array::view(keys.values().as_ref()) }; + js_values.set(col_idx as u32, arr.into()); + + let values = dict.values().as_string::(); + let js_dict = Array::new_with_length(values.len() as u32); + for i in 0..values.len() { + js_dict.set(i as u32, JsValue::from_str(values.value(i))); + } + js_dicts.set(col_idx as u32, js_dict.into()); + }, + dt => { + return Err(JsValue::from_str(&format!( + "Unsupported column type for typed array: {dt}" + ))); + }, + } + + // SAFETY: Validity bitmap is owned by `batch` which outlives the + // callback — safe to view zero-copy. + let js_validity = validity.map(|v| unsafe { Uint8Array::view(v) }); + js_validities.set( + col_idx as u32, + js_validity + .as_ref() + .map(JsValue::from) + .unwrap_or(JsValue::NULL), + ); + } + + // Second pass: fill in value views for columns backed by f32_storage / + // f64_storage. The Box<[T]> buffers are heap-allocated and stable; their + // data pointers remain valid even as the outer Vec grows. + let mut f32_idx = 0; + let mut f64_idx = 0; + for col_idx in 0..num_cols { + let col = batch.column(col_idx); + let uses_f32_storage = matches!( + (col.data_type(), float32), + (DataType::Float64, true) | (DataType::Int64, true), + ); + let uses_f64_storage = matches!( + (col.data_type(), float32), + (DataType::Date32, _) + | (DataType::Timestamp(TimeUnit::Millisecond, _), _) + | (DataType::Int64, false), + ); + + if uses_f32_storage { + let arr = unsafe { js_sys::Float32Array::view(&f32_storage[f32_idx]) }; + js_values.set(col_idx as u32, arr.into()); + f32_idx += 1; + } else if uses_f64_storage { + let arr = unsafe { js_sys::Float64Array::view(&f64_storage[f64_idx]) }; + js_values.set(col_idx as u32, arr.into()); + f64_idx += 1; + } + } + + let ret = callback.call4( + &JsValue::UNDEFINED, + &js_names.into(), + &js_values.into(), + &js_validities.into(), + &js_dicts.into(), + )?; + + // If the callback returned a Promise, await it before releasing the + // batch — zero-copy TypedArray views into `batch`/`f32_storage`/ + // `f64_storage` must remain valid for the full lifetime of the + // awaited work. + if ret.is_instance_of::() { + let promise: js_sys::Promise = ret.unchecked_into(); + wasm_bindgen_futures::JsFuture::from(promise).await?; + } + + // Keep storage alive until after the callback (and its awaited + // promise, if any) returns. + drop(f32_storage); + drop(f64_storage); + + Ok(()) +} diff --git a/rust/perspective-js/src/rust/utils/browser.rs b/rust/perspective-js/src/rust/utils/browser.rs new file mode 100644 index 0000000..971a362 --- /dev/null +++ b/rust/perspective-js/src/rust/utils/browser.rs @@ -0,0 +1,35 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/// Convenient short-name functions for common browser globals. These types are +/// not `send` and cannot be made static. +pub mod global { + pub fn window() -> web_sys::Window { + web_sys::window().unwrap() + } + + pub fn document() -> web_sys::Document { + window().document().unwrap() + } + + pub fn performance() -> web_sys::Performance { + window().performance().unwrap() + } + + pub fn body() -> web_sys::HtmlElement { + document().body().unwrap() + } + + pub fn navigator() -> web_sys::Navigator { + window().navigator() + } +} diff --git a/rust/perspective-js/src/rust/utils/console_logger.rs b/rust/perspective-js/src/rust/utils/console_logger.rs new file mode 100644 index 0000000..9811417 --- /dev/null +++ b/rust/perspective-js/src/rust/utils/console_logger.rs @@ -0,0 +1,237 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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::fmt::{Debug, Write}; +use std::sync::OnceLock; + +use tracing::Subscriber; +use tracing::field::{Field, Visit}; +use tracing_subscriber::Layer; +use tracing_subscriber::layer::Context; +use tracing_subscriber::registry::LookupSpan; +use wasm_bindgen::intern; +use wasm_bindgen::prelude::*; + +use crate::utils::*; + +/// A struct to implement the `Visit` visitor pattern trait to process +/// `tracing::Event`s. +#[derive(Default)] +struct LogLineBuffer { + value: String, + is_tail: bool, +} + +impl Visit for LogLineBuffer { + fn record_debug(&mut self, field: &Field, value: &dyn Debug) { + if field.name() == "message" { + if !self.value.is_empty() { + self.value = format!("{:?}\n{}", value, self.value) + } else { + self.value = format!("{value:?}") + } + } else { + if self.is_tail { + writeln!(self.value).unwrap(); + } else { + write!(self.value, " ").unwrap(); + self.is_tail = true; + } + + write!(self.value, "{} = {:?};", field.name(), value).unwrap(); + } + } +} + +#[extend::ext] +impl tracing::Level { + /// Convert a `tracing::Level` to an equivalent 4-arg call to the + /// browser console via the `web_sys::console` module. + fn web_logger_4(&self) -> fn(&JsValue, &JsValue, &JsValue, &JsValue) { + match *self { + tracing::Level::TRACE => web_sys::console::trace_4, + tracing::Level::DEBUG => web_sys::console::debug_4, + tracing::Level::INFO => web_sys::console::info_4, + tracing::Level::WARN => web_sys::console::warn_4, + tracing::Level::ERROR => web_sys::console::error_4, + } + } + + fn web_logger_1(&self) -> fn(&JsValue) { + match *self { + tracing::Level::TRACE => web_sys::console::trace_1, + tracing::Level::DEBUG => web_sys::console::debug_1, + tracing::Level::INFO => web_sys::console::info_1, + tracing::Level::WARN => web_sys::console::warn_1, + tracing::Level::ERROR => web_sys::console::error_1, + } + } + + /// Return a pretty color theme for a `tracing::Level`. + fn web_log_color(&self) -> &'static str { + match *self { + tracing::Level::TRACE => "background: #005F73; color: #000", + tracing::Level::DEBUG => "background: #0A9396; color: #000", + tracing::Level::INFO => "background: #E9D8A6; color: #000", + tracing::Level::WARN => "background: #EE9B00; color: #000", + tracing::Level::ERROR => "background: #AE2012; color: #000", + } + } +} + +static IS_CHROME: OnceLock = OnceLock::new(); + +fn detect_chrome() -> bool { + if let Some(window) = web_sys::window() { + window.get("chrome").is_some() + } else { + true + } +} + +#[extend::ext] +impl<'a> tracing::Metadata<'a> { + /// Log a message in the style of `WasmLogger`. + fn console_log(&self, msg: &str) { + let level = self.level(); + let origin = self + .module_path() + .and_then(|file| self.line().map(|ln| format!("{}:{}", &file[11..], ln))) + .unwrap_or_default(); + + if *IS_CHROME.get_or_init(detect_chrome) { + level.web_logger_4()( + &format!("%c {level} %c {origin}%c {msg} ").into(), + &level.web_log_color().into(), + &intern("color: gray; font-style: italic").into(), + &intern("color: inherit").into(), + ); + } else { + level.web_logger_1()(&format!("{origin} {msg}").into()); + } + } +} + +/// A custom logger modelled afer the `tracing_wasm` crate. +struct WasmLogger { + max_level: tracing::Level, +} + +impl Default for WasmLogger { + fn default() -> Self { + Self { + max_level: tracing::Level::WARN, + } + } +} + +impl LookupSpan<'a>> Layer for WasmLogger { + fn on_event( + &self, + event: &tracing::Event<'_>, + _ctx: tracing_subscriber::layer::Context<'_, S>, + ) { + let mut recorder = LogLineBuffer::default(); + event.record(&mut recorder); + event.metadata().console_log(&recorder.value); + } + + fn enabled(&self, metadata: &tracing::Metadata<'_>, _: Context<'_, S>) -> bool { + let level = metadata.level(); + level <= &self.max_level + } + + fn on_new_span( + &self, + attrs: &tracing::span::Attributes<'_>, + id: &tracing::Id, + ctx: Context<'_, S>, + ) { + let mut new_debug_record = LogLineBuffer::default(); + attrs.record(&mut new_debug_record); + if let Some(span_ref) = ctx.span(id) { + span_ref + .extensions_mut() + .insert::(new_debug_record); + } + + global::performance() + .mark(&format!("t{:x}", id.into_u64())) + .unwrap(); + } + + fn on_record(&self, id: &tracing::Id, values: &tracing::span::Record<'_>, ctx: Context<'_, S>) { + if let Some(span_ref) = ctx.span(id) + && let Some(debug_record) = span_ref.extensions_mut().get_mut::() + { + values.record(debug_record); + } + } + + fn on_close(&self, id: tracing::Id, ctx: Context<'_, S>) { + if let Some(span_ref) = ctx.span(&id) { + let perf = global::performance(); + let meta = span_ref.metadata(); + let mark = format!("t{:x}", id.into_u64()); + let start = perf + .get_entries_by_name_with_entry_type(&mark, "mark") + .at(-1) + .unchecked_into::() + .start_time(); + + meta.console_log(&format!("{:.0}ms", perf.now() - start)); + let msg = format!( + "\"{}\" {} {}", + meta.name(), + meta.module_path().unwrap_or_default(), + span_ref + .extensions() + .get::() + .map(|x| &x.value[..]) + .unwrap_or_default(), + ); + + perf.measure_with_start_mark(&msg, &mark).unwrap(); + } + } +} + +/// Configure `WasmLogger` as a global default for tracing. +/// +/// This operation will conflict with any other library which sets a global +/// default `tracing::Subscriber`, so it should not be called when `perspective` +/// is used as a library from a larger app; in this case the app itself should +/// configure `tracing` explicitly. +pub fn set_global_logging() { + static INIT_LOGGING: OnceLock<()> = OnceLock::new(); + INIT_LOGGING.get_or_init(|| { + if !*IS_CHROME.get_or_init(detect_chrome) { + web_sys::console::warn_1( + &"Unknown browser detected. Some features may not work, and performance may be \ + degraded." + .into(), + ); + } + + use tracing_subscriber::layer::SubscriberExt; + let filter = tracing_subscriber::filter::filter_fn(|meta| { + meta.module_path() + .as_ref() + .map(|x| x.starts_with("perspective")) + .unwrap_or_default() + }); + + let layer = WasmLogger::default().with_filter(filter); + let subscriber = tracing_subscriber::Registry::default().with(layer); + tracing::subscriber::set_global_default(subscriber).unwrap(); + }); +} diff --git a/rust/perspective-js/src/rust/utils/errors.rs b/rust/perspective-js/src/rust/utils/errors.rs new file mode 100644 index 0000000..fa0e452 --- /dev/null +++ b/rust/perspective-js/src/rust/utils/errors.rs @@ -0,0 +1,274 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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::fmt::Display; +use std::rc::Rc; +use std::string::FromUtf8Error; + +use perspective_client::{ClientError, ExprValidationResult}; +use thiserror::*; +use wasm_bindgen::intern; +use wasm_bindgen::prelude::*; + +#[macro_export] +macro_rules! apierror { + ($msg:expr) => {{ + use $crate::utils::errors::ApiErrorType::*; + let js_err_type = $msg; + let err = js_sys::Error::new(js_err_type.to_string().as_str()); + let js_err = $crate::utils::errors::ApiError( + js_err_type, + $crate::utils::errors::JsBackTrace(std::rc::Rc::new(err.clone())), + ); + js_err + }}; +} + +fn format_js_error(value: &JsValue) -> String { + if let Some(err) = value.dyn_ref::() { + let msg = err.message().as_string().unwrap(); + if let Ok(x) = js_sys::Reflect::get(value, &intern("stack").into()) { + format!("{}\n{}", msg, x.as_string().unwrap()) + } else { + msg + } + } else { + value + .as_string() + .unwrap_or_else(|| format!("{value:?}")) + .to_string() + } +} + +fn format_valid_exprs(recs: &ExprValidationResult) -> String { + recs.errors + .iter() + .map(|x| format!("\"{}\": {}", x.0, x.1.error_message)) + .collect::>() + .join(", ") +} + +/// A bespoke error class for chaining a litany of error types with the `?` +/// operator. +#[derive(Clone, Debug, Error)] +pub enum ApiErrorType { + #[error("{}", format_js_error(.0))] + JsError(JsValue), + + #[error("{}", format_js_error(.0))] + JsRawError(js_sys::Error), + + #[error("Failed to construct table from {0:?}")] + TableError(JsValue), + + #[error("{}", format_js_error(.0))] + ViewerPluginError(JsValue), + + #[error("{0}")] + ExternalError(Rc>), + + #[error("{0}")] + UnknownError(String), + + #[error("{0}")] + ClientError(#[from] ClientError), + + #[error("Cancelled")] + CancelledError(#[from] futures::channel::oneshot::Canceled), + + #[error("{0}")] + SerdeJsonError(Rc), + + #[error("{0}")] + ProstError(#[from] prost::DecodeError), + + #[error("Unknown column \"{1}\" in field `{0}`")] + InvalidViewerConfigError(&'static str, String), + + #[error("Invalid `expressions` {}", format_valid_exprs(.0))] + InvalidViewerConfigExpressionsError(Rc), + + #[error("Expected a Table or string table name")] + TableRefError, + + #[error("No `Table` attached")] + NoTableError, + + #[error(transparent)] + SerdeWasmBindgenError(Rc), + + #[error(transparent)] + Utf8Error(#[from] FromUtf8Error), + + #[error(transparent)] + StdIoError(Rc), + + #[error(transparent)] + ChronoParseError(#[from] chrono::ParseError), +} + +#[derive(Clone, Debug, Error)] +pub struct ApiError(pub ApiErrorType, pub JsBackTrace); + +impl ApiError { + pub fn new(val: T) -> Self { + apierror!(UnknownError(format!("{val}"))) + } + + /// The error category + pub fn kind(&self) -> &'static str { + match self.0 { + ApiErrorType::JsError(..) => "[JsError]", + ApiErrorType::TableError(_) => "[TableError]", + ApiErrorType::ExternalError(_) => "[ExternalError]", + ApiErrorType::UnknownError(..) => "[UnknownError]", + ApiErrorType::ClientError(_) => "[ClientError]", + ApiErrorType::CancelledError(_) => "[CancelledError]", + ApiErrorType::SerdeJsonError(_) => "[SerdeJsonError]", + ApiErrorType::ProstError(_) => "[ProstError]", + ApiErrorType::InvalidViewerConfigError(..) => "[InvalidViewerConfigError]", + ApiErrorType::InvalidViewerConfigExpressionsError(_) => "[InvalidViewerConfigError]", + ApiErrorType::TableRefError => "[TableRefError]", + ApiErrorType::NoTableError => "[NoTableError]", + ApiErrorType::SerdeWasmBindgenError(_) => "[SerdeWasmBindgenError]", + ApiErrorType::Utf8Error(_) => "[FromUtf8Error]", + ApiErrorType::StdIoError(_) => "[StdIoError]", + ApiErrorType::ChronoParseError(_) => "[ChronoParseError]", + ApiErrorType::ViewerPluginError(_) => "[ViewerPluginError]", + ApiErrorType::JsRawError(_) => "[JsRawError]", + } + } + + /// The raw internal enum + pub fn inner(&self) -> &'_ ApiErrorType { + &self.0 + } + + /// The `Display` for this error + pub fn message(&self) -> String { + self.0.to_string() + } + + /// This error's stacktrace from when it was constructed. + pub fn stacktrace(&self) -> String { + js_sys::Reflect::get(&self.1.0, &intern("stack").into()) + .unwrap() + .as_string() + .unwrap() + .to_string() + } +} + +// This type is not thread safe, but the JavaScript environment does not allow +// threading. +unsafe impl Send for ApiError {} +unsafe impl Sync for ApiError {} + +impl std::fmt::Display for ApiError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl> From for ApiError { + fn from(value: T) -> Self { + let value: ApiErrorType = value.into(); + let err = js_sys::Error::new(value.to_string().as_str()); + ApiError(value, JsBackTrace(Rc::new(err.clone()))) + } +} + +impl From for JsValue { + fn from(err: ApiError) -> Self { + err.1.0.unchecked_ref::().clone() + } +} + +impl From for ApiError { + fn from(value: serde_wasm_bindgen::Error) -> Self { + ApiErrorType::SerdeWasmBindgenError(Rc::new(value)).into() + } +} + +impl From for ApiError { + fn from(value: std::io::Error) -> Self { + ApiErrorType::StdIoError(Rc::new(value)).into() + } +} + +impl From> for ApiError { + fn from(value: Box) -> Self { + ApiErrorType::ExternalError(Rc::new(value)).into() + } +} + +impl From for ApiError { + fn from(value: serde_json::Error) -> Self { + ApiErrorType::SerdeJsonError(Rc::new(value)).into() + } +} + +impl From for ApiError { + fn from(err: JsValue) -> Self { + if err.is_instance_of::() { + ApiError( + ApiErrorType::JsRawError(err.clone().unchecked_into()), + JsBackTrace(Rc::new(err.unchecked_into())), + ) + } else { + apierror!(JsError(err)) + } + } +} + +impl From for ApiError { + fn from(value: String) -> Self { + apierror!(UnknownError(value.to_owned())) + } +} + +impl From<&str> for ApiError { + fn from(value: &str) -> Self { + apierror!(UnknownError(value.to_owned())) + } +} + +/// `ToApiError` handles complex cases that can't be into-d +pub trait ToApiError { + fn into_apierror(self) -> ApiResult; +} + +impl ToApiError for Option { + fn into_apierror(self) -> ApiResult { + self.ok_or_else(|| intern("Unwrap on None").into()) + } +} + +impl ToApiError for Result<(), ApiResult> { + fn into_apierror(self) -> ApiResult { + self.map_or_else(|x| x, |()| Ok(JsValue::UNDEFINED)) + } +} + +/// A common Rust error handling idiom (see e.g. `anyhow::Result`) +pub type ApiResult = Result; + +// Backtrace + +#[derive(Clone, Debug)] +pub struct JsBackTrace(pub Rc); + +impl std::fmt::Display for JsBackTrace { + fn fmt(&self, _: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Ok(()) + } +} diff --git a/rust/perspective-js/src/rust/utils/futures.rs b/rust/perspective-js/src/rust/utils/futures.rs new file mode 100644 index 0000000..50fc600 --- /dev/null +++ b/rust/perspective-js/src/rust/utils/futures.rs @@ -0,0 +1,179 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 perspective_client::ClientError; +// TODO This is risky to rely on, but it is currently impossible to implement +// this trait locally due to the orphan instance restriction. Using this trait +// removes alow of boilerplate required by `async` when casting to `Promise`. +use wasm_bindgen::__rt::IntoJsResult; +use wasm_bindgen::convert::{FromWasmAbi, IntoWasmAbi}; +use wasm_bindgen::describe::WasmDescribe; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::{JsFuture, future_to_promise}; + +use super::errors::*; + +/// A newtype wrapper for a `Future` trait object which supports being +/// marshalled to a `JsPromise`. +/// +/// This avoids implementing an API which requires type casting to +/// and from `JsValue` and the associated loss of type safety. +#[must_use] +pub struct ApiFuture(Pin>>>) +where + Result: IntoJsResult + 'static; + +impl ApiFuture +where + Result: IntoJsResult + 'static, +{ + /// Constructor for `ApiFuture`. Note that, like a regular `Future`, the + /// `ApiFuture` created does _not_ execute without being further cast to a + /// `Promise`, either explicitly or implcitly (when exposed via + /// `wasm_bindgen`). + pub fn new> + 'static>(x: U) -> Self { + Self(Box::pin(x)) + } + + pub fn new_throttled> + 'static>(x: U) -> ApiFuture<()> { + ApiFuture::<()>(Box::pin( + async move { x.await.ignore_view_delete().map(|_| ()) }, + )) + } +} + +impl ApiFuture +where + Result: IntoJsResult + 'static, +{ + /// Construct an `ApiFuture` and execute it immediately. The `Promise` + /// handle created internally is dropped, but since JavaScript `Promise` + /// executes on construction, the async invocation persists. + pub fn spawn> + 'static>(x: U) { + drop(js_sys::Promise::from(Self::new(x))) + } + + pub fn spawn_throttled> + 'static>(x: U) { + drop(js_sys::Promise::from(Self::new_throttled(x))) + } +} + +impl Default for ApiFuture +where + Result: IntoJsResult + 'static, + T: Default, +{ + fn default() -> Self { + Self::new(async { Ok(Default::default()) }) + } +} + +impl From> for JsValue +where + Result: IntoJsResult + 'static, +{ + fn from(fut: ApiFuture) -> Self { + js_sys::Promise::from(fut).unchecked_into() + } +} + +impl From> for js_sys::Promise +where + Result: IntoJsResult + 'static, +{ + fn from(fut: ApiFuture) -> Self { + future_to_promise(async move { Ok(fut.0.await?).into_js_result() }) + } +} + +impl WasmDescribe for ApiFuture +where + Result: IntoJsResult + 'static, +{ + fn describe() { + ::describe() + } +} + +impl IntoWasmAbi for ApiFuture +where + Result: IntoJsResult + 'static, +{ + type Abi = ::Abi; + + #[inline] + fn into_abi(self) -> Self::Abi { + js_sys::Promise::from(self).into_abi() + } +} + +impl FromWasmAbi for ApiFuture +where + Result: IntoJsResult + 'static, + T: From + Into, +{ + type Abi = ::Abi; + + #[inline] + unsafe fn from_abi(js: Self::Abi) -> Self { + Self::new(async move { + let promise = unsafe { js_sys::Promise::from_abi(js) }; + Ok(JsFuture::from(promise).await?.into()) + }) + } +} + +impl Future for ApiFuture +where + Result: IntoJsResult + 'static, +{ + type Output = ApiResult; + + fn poll( + self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll { + let mut fut = unsafe { self.map_unchecked_mut(|s| &mut s.0) }; + fut.as_mut().poll(cx) + } +} + +#[extend::ext] +pub impl Result { + /// Wraps an error `JsValue` return from a caught JavaScript exception, + /// checking for the explicit error type indicating that a + /// `JsPerspectiveView` call has been cancelled due to it already being + /// deleted. This is a normal mechanic of the `JsPerspectiveView` to + /// cancel a `View` call that is no longer need be the viewer, e.g. when + /// the user updates the UI before the previous update has finished + /// drawing. Without using exceptions for this, we'd need to wrap every + /// such `JsPerspectiveView` call individually. + /// + /// When `"View method cancelled"` message is received, this call should + /// silently be replaced with `Ok`. The message itself is returned in this + /// case (instead of whatever the `async` returns), which is helpful for + /// detecting this condition when debugging. + fn ignore_view_delete(self) -> Result, ApiError> { + self.map(|x| Some(x)).or_else(|err| match err.inner() { + ApiErrorType::ClientError(ClientError::ViewNotFound) => Ok(None), + ApiErrorType::JsRawError(..) | ApiErrorType::JsError(..) + if format!("{err}").contains("View not found") => + { + Ok(None) + }, + _ => Err(err), + }) + } +} diff --git a/rust/perspective-js/src/rust/utils/json.rs b/rust/perspective-js/src/rust/utils/json.rs new file mode 100644 index 0000000..fcc7cd1 --- /dev/null +++ b/rust/perspective-js/src/rust/utils/json.rs @@ -0,0 +1,252 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +//! A JSON generating macro which mirrors the format's classic syntax in Rust +//! via an incremental TT munching macro. This code was stolen from the +//! `serde-json` crate (licensed under Apache 2.0), and modified to create +//! `js_sys::*` types directly instead of serde types. Thanks `serde-json`! +//! +//! License: https://github.com/serde-rs/json/blob/master/LICENSE-APACHE +//! Repo: https://github.com/serde-rs/json + +#[macro_export] +#[doc(hidden)] +macro_rules! json { + // Hide distracting implementation details from the generated rustdoc. + ($($json:tt)+) => { + $crate::json_internal!($($json)+) + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! json_internal { + + // TT muncher for parsing the inside of an array [...]. Produces a vec![...] + // of the elements. + // + // Must be invoked as: json_internal!(@array [] $($tt)*) + + // Done with trailing comma. + (@array [$($elems:expr,)*]) => { + vec![$($elems,)*] + }; + + // Done without trailing comma. + (@array [$($elems:expr),*]) => { + vec![$($elems),*] + }; + + // Next element is `null`. + (@array [$($elems:expr,)*] null $($rest:tt)*) => { + $crate::json_internal!(@array [$($elems,)* $crate::json_internal!(null)] $($rest)*) + }; + + // Next element is `true`. + (@array [$($elems:expr,)*] true $($rest:tt)*) => { + $crate::json_internal!(@array [$($elems,)* $crate::json_internal!(true)] $($rest)*) + }; + + // Next element is `false`. + (@array [$($elems:expr,)*] false $($rest:tt)*) => { + $crate::json_internal!(@array [$($elems,)* $crate::json_internal!(false)] $($rest)*) + }; + + // Next element is an array. + (@array [$($elems:expr,)*] [$($array:tt)*] $($rest:tt)*) => { + $crate::json_internal!(@array [$($elems,)* $crate::json_internal!([$($array)*])] $($rest)*) + }; + + // Next element is a map. + (@array [$($elems:expr,)*] {$($map:tt)*} $($rest:tt)*) => { + $crate::json_internal!(@array [$($elems,)* $crate::json_internal!({$($map)*})] $($rest)*) + }; + + // Next element is an expression followed by comma. + (@array [$($elems:expr,)*] $next:expr, $($rest:tt)*) => { + $crate::json_internal!(@array [$($elems,)* $crate::json_internal!($next),] $($rest)*) + }; + + // Last element is an expression with no trailing comma. + (@array [$($elems:expr,)*] $last:expr) => { + $crate::json_internal!(@array [$($elems,)* $crate::json_internal!($last)]) + }; + + // Comma after the most recent element. + (@array [$($elems:expr),*] , $($rest:tt)*) => { + $crate::json_internal!(@array [$($elems,)*] $($rest)*) + }; + + // Unexpected token after most recent element. + (@array [$($elems:expr),*] $unexpected:tt $($rest:tt)*) => { + $crate::json_unexpected!($unexpected) + }; + + ////////////////////////////////////////////////////////////////////////// + // TT muncher for parsing the inside of an object {...}. Each entry is + // inserted into the given map variable. + // + // Must be invoked as: json_internal!(@object $map () ($($tt)*) ($($tt)*)) + // + // We require two copies of the input tokens so that we can match on one + // copy and trigger errors on the other copy. + ////////////////////////////////////////////////////////////////////////// + + // Done. + (@object $object:ident () () ()) => {}; + + // Insert the current entry followed by trailing comma. + (@object $object:ident [$($key:tt)+] ($value:expr) , $($rest:tt)*) => { + let k = JsValue::from(wasm_bindgen::intern($($key)+)); + js_sys::Reflect::set(&$object, &k, &$value).unwrap(); + $crate::json_internal!(@object $object () ($($rest)*) ($($rest)*)); + }; + + // Current entry followed by unexpected token. + (@object $object:ident [$($key:tt)+] ($value:expr) $unexpected:tt $($rest:tt)*) => { + $crate::json_unexpected!($unexpected); + }; + + // Insert the last entry without trailing comma. + (@object $object:ident [$($key:tt)+] ($value:expr)) => { + let k = JsValue::from(wasm_bindgen::intern($($key)+)); + js_sys::Reflect::set(&$object, &k, &$value).unwrap(); + }; + + // Next value is `null`. + (@object $object:ident ($($key:tt)+) (: null $($rest:tt)*) $copy:tt) => { + $crate::json_internal!(@object $object [$($key)+] ($crate::json_internal!(null)) $($rest)*); + }; + + // Next value is `true`. + (@object $object:ident ($($key:tt)+) (: true $($rest:tt)*) $copy:tt) => { + $crate::json_internal!(@object $object [$($key)+] ($crate::json_internal!(true)) $($rest)*); + }; + + // Next value is `false`. + (@object $object:ident ($($key:tt)+) (: false $($rest:tt)*) $copy:tt) => { + $crate::json_internal!(@object $object [$($key)+] ($crate::json_internal!(false)) $($rest)*); + }; + + // Next value is an array. + (@object $object:ident ($($key:tt)+) (: [$($array:tt)*] $($rest:tt)*) $copy:tt) => { + $crate::json_internal!(@object $object [$($key)+] ($crate::json_internal!([$($array)*])) $($rest)*); + }; + + // Next value is a map. + (@object $object:ident ($($key:tt)+) (: {$($map:tt)*} $($rest:tt)*) $copy:tt) => { + $crate::json_internal!(@object $object [$($key)+] (JsValue::from($crate::json_internal!({$($map)*}))) $($rest)*); + }; + + // Next value is an expression followed by comma. + (@object $object:ident ($($key:tt)+) (: $value:expr , $($rest:tt)*) $copy:tt) => { + $crate::json_internal!(@object $object [$($key)+] ($crate::json_internal!($value)) , $($rest)*); + }; + + // Last value is an expression with no trailing comma. + (@object $object:ident ($($key:tt)+) (: $value:expr) $copy:tt) => { + $crate::json_internal!(@object $object [$($key)+] ($crate::json_internal!($value))); + }; + + // Missing value for last entry. Trigger a reasonable error message. + (@object $object:ident ($($key:tt)+) (:) $copy:tt) => { + // "unexpected end of macro invocation" + $crate::json_internal!(); + }; + + // Missing colon and value for last entry. Trigger a reasonable error + // message. + (@object $object:ident ($($key:tt)+) () $copy:tt) => { + // "unexpected end of macro invocation" + $crate::json_internal!(); + }; + + // Misplaced colon. Trigger a reasonable error message. + (@object $object:ident () (: $($rest:tt)*) ($colon:tt $($copy:tt)*)) => { + // Takes no arguments so "no rules expected the token `:`". + $crate::json_unexpected!($colon); + }; + + // Found a comma inside a key. Trigger a reasonable error message. + (@object $object:ident ($($key:tt)*) (, $($rest:tt)*) ($comma:tt $($copy:tt)*)) => { + // Takes no arguments so "no rules expected the token `,`". + $crate::json_unexpected!($comma); + }; + + // Key is fully parenthesized. This avoids clippy double_parens false + // positives because the parenthesization may be necessary here. + (@object $object:ident () (($key:expr) : $($rest:tt)*) $copy:tt) => { + $crate::json_internal!(@object $object ($key) (: $($rest)*) (: $($rest)*)); + }; + + // Refuse to absorb colon token into key expression. + (@object $object:ident ($($key:tt)*) (: $($unexpected:tt)+) $copy:tt) => { + $crate::json_expect_expr_comma!($($unexpected)+); + }; + + // Munch a token into the current key. + (@object $object:ident ($($key:tt)*) ($tt:tt $($rest:tt)*) $copy:tt) => { + $crate::json_internal!(@object $object ($($key)* $tt) ($($rest)*) ($($rest)*)); + }; + + // The main implementation. + // + // Must be invoked as: json_internal!($($json)+) + + (null) => { + $crate::Value::Null + }; + + (true) => { + ::wasm_bindgen::JsValue::from(true) + }; + + (false) => { + ::wasm_bindgen::JsValue::from(false) + }; + + ([]) => { + js_sys::Array::new() + }; + + ([ $($tt:tt)+ ]) => { + $crate::json_internal!(@array [] $($tt)+).iter().collect::() + }; + + ({}) => { + js_sys::Object::new() + }; + + ({ $($tt:tt)+ }) => {{ + let object = js_sys::Object::new(); + $crate::json_internal!(@object object () ($($tt)+) ($($tt)+)); + object + }}; + + // Any Serialize type: numbers, strings, struct literals, variables etc. + // Must be below every other rule. + ($other:expr) => { + ::wasm_bindgen::JsValue::from($other) + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! json_unexpected { + () => {}; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! json_expect_expr_comma { + ($e:expr, $($tt:tt)*) => {}; +} diff --git a/rust/perspective-js/src/rust/utils/local_poll_loop.rs b/rust/perspective-js/src/rust/utils/local_poll_loop.rs new file mode 100644 index 0000000..e059606 --- /dev/null +++ b/rust/perspective-js/src/rust/utils/local_poll_loop.rs @@ -0,0 +1,63 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 futures::channel::mpsc::{UnboundedSender, unbounded}; +use futures::{Future, SinkExt, StreamExt}; +use wasm_bindgen::JsValue; +use wasm_bindgen_futures::spawn_local; + +/// A useful abstraction for connecting `!Sync + !Send` callbacks (like +/// `js_sys::Function`) to `Send + Sync` contexts (like the client loop). +#[derive(Clone)] +pub struct LocalPollLoop(UnboundedSender); + +impl LocalPollLoop { + /// Create a new loop which accepts a `R: Send + Sync` intermediate state + /// argument and calls the `!Send + !Sync` callback. + pub fn new Result + 'static>(send: F) -> Self { + let (emit, mut receive) = unbounded::(); + spawn_local(async move { + while let Some(resp) = receive.next().await { + let resp = send(resp); + if let Err(err) = resp { + web_sys::console::error_2(&"Failed to serialize".into(), &err); + } + } + }); + + Self(emit) + } + + /// Create a new loop which accepts a `R: Send + Sync` intermediate state + /// argument and calls the `!Send + !Sync` callback. + pub fn new_async FUT + 'static, FUT: Future>>( + send: F, + ) -> Self { + let (emit, mut receive) = unbounded::(); + spawn_local(async move { + while let Some(resp) = receive.next().await { + let resp = send(resp).await; + if let Err(err) = resp { + web_sys::console::error_2(&"Failed to serialize".into(), &err); + } + } + }); + + Self(emit) + } + + /// Send a new `R` to the poll loop. + pub fn poll(&self, msg: R) -> impl Future + Send + Sync + 'static + use { + let mut emit = self.0.clone(); + async move { emit.send(msg).await.unwrap() } + } +} diff --git a/rust/perspective-js/src/rust/utils/mod.rs b/rust/perspective-js/src/rust/utils/mod.rs new file mode 100644 index 0000000..6464e52 --- /dev/null +++ b/rust/perspective-js/src/rust/utils/mod.rs @@ -0,0 +1,32 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#![doc(hidden)] + +mod browser; +mod console_logger; +pub mod errors; +mod futures; +mod json; +mod local_poll_loop; +mod serde; +#[cfg(feature = "trace-allocator")] +mod trace_allocator; + +pub use browser::*; +pub use console_logger::*; +pub use errors::*; +pub use futures::*; +pub use local_poll_loop::*; +pub use serde::*; +#[cfg(feature = "trace-allocator")] +pub(crate) use trace_allocator::get_used; diff --git a/rust/perspective-js/src/rust/utils/serde.rs b/rust/perspective-js/src/rust/utils/serde.rs new file mode 100644 index 0000000..5428cfa --- /dev/null +++ b/rust/perspective-js/src/rust/utils/serde.rs @@ -0,0 +1,46 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 wasm_bindgen::JsValue; + +pub trait JsValueSerdeExt { + fn from_serde_ext(t: &T) -> Result + where + T: serde::ser::Serialize + ?Sized; + + fn into_serde_ext(self) -> Result + where + T: for<'a> serde::de::Deserialize<'a>; +} + +impl JsValueSerdeExt for U +where + U: Into, +{ + fn from_serde_ext(t: &T) -> Result + where + T: serde::ser::Serialize + ?Sized, + { + t.serialize( + &serde_wasm_bindgen::Serializer::new() + .serialize_maps_as_objects(true) + .serialize_missing_as_null(true), + ) + } + + fn into_serde_ext(self) -> Result + where + T: for<'a> serde::de::Deserialize<'a>, + { + serde_wasm_bindgen::from_value(self.into()) + } +} diff --git a/rust/perspective-js/src/rust/utils/trace_allocator.rs b/rust/perspective-js/src/rust/utils/trace_allocator.rs new file mode 100644 index 0000000..b4b3a72 --- /dev/null +++ b/rust/perspective-js/src/rust/utils/trace_allocator.rs @@ -0,0 +1,98 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +//! A wrapper around the default allocator which counts allocations, useful for +//! instrumenting the `Client` as an alternative to switching to `talc`. +//! +//! Lifted from https://stackoverflow.com/questions/78246635/how-do-i-log-wasm-heap-memory-usage-from-rust +//! or as its popular to refer to these days, "AI". + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::sync::atomic::{AtomicIsize, Ordering}; + +use wasm_bindgen::JsCast; +use wasm_bindgen::prelude::wasm_bindgen; + +#[wasm_bindgen] +extern "C" { + type Memory; + #[wasm_bindgen(method, getter)] + fn buffer(this: &Memory) -> MaybeSharedArrayBuffer; + + type MaybeSharedArrayBuffer; + #[wasm_bindgen(method, getter = byteLength)] + fn byte_length(this: &MaybeSharedArrayBuffer) -> f64; +} + +struct CountingAllocator { + inner: A, + allocated_now: AtomicIsize, +} + +impl CountingAllocator { + const fn new(inner: A) -> Self { + Self { + inner, + allocated_now: AtomicIsize::new(0), + } + } + + fn allocated_now(&self) -> usize { + self.allocated_now + .load(Ordering::Relaxed) + .try_into() + .unwrap_or(0) + } +} + +unsafe impl GlobalAlloc for CountingAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + self.allocated_now + .fetch_add(layout.size() as isize, Ordering::Relaxed); + unsafe { self.inner.alloc(layout) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + self.allocated_now + .fetch_sub(layout.size() as isize, Ordering::Relaxed); + unsafe { self.inner.dealloc(ptr, layout) }; + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + self.allocated_now + .fetch_add(layout.size() as isize, Ordering::Relaxed); + unsafe { self.inner.alloc_zeroed(layout) } + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + self.allocated_now.fetch_add( + new_size as isize - layout.size() as isize, + Ordering::Relaxed, + ); + unsafe { self.inner.realloc(ptr, layout, new_size) } + } +} + +#[global_allocator] +static ALLOCATOR: CountingAllocator = CountingAllocator::new(System); + +pub(crate) fn get_used() -> (usize, usize) { + let client_heap = wasm_bindgen::memory() + .unchecked_into::() + .buffer() + .byte_length() as u64; + + (ALLOCATOR.allocated_now(), client_heap as usize) +} + +#[cfg(all(feature = "talc-allocator", feature = "trace-allocator"))] +compile_error!("Cannot enable both `talc-allocator` and `trace-allocator` features"); diff --git a/rust/perspective-js/src/rust/view.rs b/rust/perspective-js/src/rust/view.rs new file mode 100644 index 0000000..9ba8a70 --- /dev/null +++ b/rust/perspective-js/src/rust/view.rs @@ -0,0 +1,402 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 js_sys::{Array, ArrayBuffer, Function, Object}; +use perspective_client::{ + ColumnWindow, OnUpdateData, OnUpdateOptions, ViewWindow, assert_view_api, +}; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::spawn_local; + +#[cfg(doc)] +use crate::table::Table; +use crate::utils::{ApiFuture, ApiResult, JsValueSerdeExt, LocalPollLoop}; + +#[wasm_bindgen] +unsafe extern "C" { + #[wasm_bindgen(typescript_type = "ViewWindow")] + #[derive(Clone)] + pub type JsViewWindow; + + #[wasm_bindgen(typescript_type = "ColumnWindow")] + #[derive(Clone)] + pub type JsColumnWindow; + + #[wasm_bindgen(method, setter, js_name = "formatted")] + pub fn set_formatted(this: &JsViewWindow, x: bool); + + #[wasm_bindgen(typescript_type = "OnUpdateOptions")] + pub type JsOnUpdateOptions; + +} + +impl From for JsViewWindow { + fn from(value: ViewWindow) -> Self { + JsViewWindow::from_serde_ext(&value) + .unwrap() + .unchecked_into() + } +} + +fn scalar_to_jsvalue(scalar: &perspective_client::config::Scalar) -> JsValue { + match scalar { + perspective_client::config::Scalar::Float(x) => JsValue::from_f64(*x), + perspective_client::config::Scalar::String(x) => JsValue::from_str(x), + perspective_client::config::Scalar::Bool(x) => JsValue::from_bool(*x), + perspective_client::config::Scalar::Null => JsValue::NULL, + } +} + +/// The [`View`] struct is Perspective's query and serialization interface. It +/// represents a query on the `Table`'s dataset and is always created from an +/// existing `Table` instance via the [`Table::view`] method. +/// +/// [`View`]s are immutable with respect to the arguments provided to the +/// [`Table::view`] method; to change these parameters, you must create a new +/// [`View`] on the same [`Table`]. However, each [`View`] is _live_ with +/// respect to the [`Table`]'s data, and will (within a conflation window) +/// update with the latest state as its parent [`Table`] updates, including +/// incrementally recalculating all aggregates, pivots, filters, etc. [`View`] +/// query parameters are composable, in that each parameter works independently +/// _and_ in conjunction with each other, and there is no limit to the number of +/// pivots, filters, etc. which can be applied. +#[wasm_bindgen] +#[derive(Clone)] +pub struct View(pub(crate) perspective_client::View); + +assert_view_api!(View); + +impl From for View { + fn from(value: perspective_client::View) -> Self { + View(value) + } +} + +#[wasm_bindgen] +impl View { + #[doc(hidden)] + pub fn __get_model(&self) -> View { + self.clone() + } + + #[wasm_bindgen] + #[doc(hidden)] + pub fn __unsafe_get_name(&self) -> String { + self.0.name.clone() + } + + /// Returns an array of strings containing the column paths of the [`View`] + /// without any of the source columns. + /// + /// A column path shows the columns that a given cell belongs to after + /// pivots are applied. + #[wasm_bindgen] + pub async fn column_paths(&self, window: Option) -> ApiResult { + let window = window.into_serde_ext::>()?; + let columns = self.0.column_paths(window.unwrap_or_default()).await?; + Ok(JsValue::from_serde_ext(&columns)?) + } + + /// Delete this [`View`] and clean up all resources associated with it. + /// [`View`] objects do not stop consuming resources or processing + /// updates when they are garbage collected - you must call this method + /// to reclaim these. + #[wasm_bindgen] + pub async fn delete(self) -> ApiResult<()> { + self.0.delete().await?; + Ok(()) + } + + /// Returns this [`View`]'s _dimensions_, row and column count, as well as + /// those of the [`crate::Table`] from which it was derived. + /// + /// - `num_table_rows` - The number of rows in the underlying + /// [`crate::Table`]. + /// - `num_table_columns` - The number of columns in the underlying + /// [`crate::Table`] (including the `index` column if this + /// [`crate::Table`] was constructed with one). + /// - `num_view_rows` - The number of rows in this [`View`]. If this + /// [`View`] has a `group_by` clause, `num_view_rows` will also include + /// aggregated rows. + /// - `num_view_columns` - The number of columns in this [`View`]. If this + /// [`View`] has a `split_by` clause, `num_view_columns` will include all + /// _column paths_, e.g. the number of `columns` clause times the number + /// of `split_by` groups. + #[wasm_bindgen] + pub async fn dimensions(&self) -> ApiResult { + let dimensions = self.0.dimensions().await?; + Ok(JsValue::from_serde_ext(&dimensions)?) + } + + /// The expression schema of this [`View`], which contains only the + /// expressions created on this [`View`]. See [`View::schema`] for + /// details. + #[wasm_bindgen] + pub async fn expression_schema(&self) -> ApiResult { + let schema = self.0.expression_schema().await?; + Ok(JsValue::from_serde_ext(&schema)?) + } + + /// A copy of the config object passed to the [`Table::view`] method which + /// created this [`View`]. + #[wasm_bindgen] + pub async fn get_config(&self) -> ApiResult { + let config = self.0.get_config().await?; + Ok(JsValue::from_serde_ext(&config)?) + } + + /// Calculates the [min, max] of the leaf nodes of a column `column_name`. + /// + /// # Returns + /// + /// A tuple of [min, max], whose types are column and aggregate dependent. + #[wasm_bindgen] + pub async fn get_min_max(&self, name: String) -> ApiResult { + let result = self.0.get_min_max(name).await?; + let arr = Array::new(); + arr.push(&scalar_to_jsvalue(&result.0)); + arr.push(&scalar_to_jsvalue(&result.1)); + Ok(arr) + } + + /// The number of aggregated rows in this [`View`]. This is affected by the + /// "group_by" configuration parameter supplied to this view's contructor. + /// + /// # Returns + /// + /// The number of aggregated rows. + #[wasm_bindgen] + pub async fn num_rows(&self) -> ApiResult { + let size = self.0.num_rows().await?; + Ok(size as i32) + } + + /// The schema of this [`View`]. + /// + /// The [`View`] schema differs from the `schema` returned by + /// [`Table::schema`]; it may have different column names due to + /// `expressions` or `columns` configs, or it maye have _different + /// column types_ due to the application og `group_by` and `aggregates` + /// config. You can think of [`Table::schema`] as the _input_ schema and + /// [`View::schema`] as the _output_ schema of a Perspective pipeline. + #[wasm_bindgen] + pub async fn schema(&self) -> ApiResult { + let schema = self.0.schema().await?; + Ok(JsValue::from_serde_ext(&schema)?) + } + + /// Serializes a [`View`] to the Apache Arrow data format. + #[wasm_bindgen] + pub async fn to_arrow(&self, window: Option) -> ApiResult { + let window = window.into_serde_ext::>()?; + let arrow = self.0.to_arrow(window.unwrap_or_default()).await?; + Ok(js_sys::Uint8Array::from(&arrow[..]) + .buffer() + .unchecked_into()) + } + + /// Serializes this [`View`] to a string of JSON data. Useful if you want to + /// save additional round trip serialize/deserialize cycles. + #[wasm_bindgen] + pub async fn to_columns_string(&self, window: Option) -> ApiResult { + let window = window.into_serde_ext::>()?; + let json = self.0.to_columns_string(window.unwrap_or_default()).await?; + Ok(json) + } + + /// Serializes this [`View`] to JavaScript objects in a column-oriented + /// format. + #[wasm_bindgen] + pub async fn to_columns(&self, window: Option) -> ApiResult { + let json = self.to_columns_string(window).await?; + Ok(js_sys::JSON::parse(&json)?.unchecked_into()) + } + + /// Render this `View` as a JSON string. + #[wasm_bindgen] + pub async fn to_json_string(&self, window: Option) -> ApiResult { + let window = window.into_serde_ext::>()?; + let json = self.0.to_json_string(window.unwrap_or_default()).await?; + Ok(json) + } + + /// Serializes this [`View`] to JavaScript objects in a row-oriented + /// format. + #[wasm_bindgen] + pub async fn to_json(&self, window: Option) -> ApiResult { + let json = self.to_json_string(window).await?; + Ok(js_sys::JSON::parse(&json)?.unchecked_into()) + } + + /// Renders this [`View`] as an [NDJSON](https://github.com/ndjson/ndjson-spec) + /// formatted [`String`]. + #[wasm_bindgen] + pub async fn to_ndjson(&self, window: Option) -> ApiResult { + let window = window.into_serde_ext::>()?; + let ndjson = self.0.to_ndjson(window.unwrap_or_default()).await?; + Ok(ndjson) + } + + /// Serializes this [`View`] to CSV data in a standard format. + #[wasm_bindgen] + pub async fn to_csv(&self, window: Option) -> ApiResult { + let window = window.into_serde_ext::>()?; + Ok(self.0.to_csv(window.unwrap_or_default()).await?) + } + + /// Fetches columns from the [`View`] in Arrow format, decodes them, and + /// passes typed array views to `callback`. All arrays are only valid for + /// the duration of the callback — if `callback` returns a `Promise`, it + /// is awaited before the backing Arrow buffer is released, so async + /// callbacks may use the views for the full duration of the awaited + /// work (e.g. across an `await requestAnimationFrame`-backed promise). + /// + /// # Arguments + /// + /// - `window` - Optional [`TypedArrayWindow`] controlling row/column + /// windowing and output options (e.g., `float32` mode). + /// - `callback` - A JS function called with `(names: string[], values: + /// TypedArray[], validities: (Uint8Array|null)[], dictionaries: + /// (string[]|null)[]) => void | Promise`. + #[wasm_bindgen] + pub async fn with_typed_arrays( + &self, + window: Option, + callback: Function, + ) -> ApiResult<()> { + let opts: crate::typed_array::TypedArrayWindow = window + .into_serde_ext::>()? + .unwrap_or_default(); + + let float32 = opts.float32; + let mut view_window: ViewWindow = opts.into(); + view_window.emit_legacy_row_path_names = Some(false); + let arrow = self.0.to_arrow(view_window).await?; + crate::typed_array::decode_and_call(&arrow, float32, &callback).await?; + Ok(()) + } + + /// Register a callback with this [`View`]. Whenever the view's underlying + /// table emits an update, this callback will be invoked with an object + /// containing `port_id`, indicating which port the update fired on, and + /// optionally `delta`, which is the new data that was updated for each + /// cell or each row. + /// + /// # Arguments + /// + /// - `on_update` - A callback function invoked on update, which receives an + /// object with two keys: `port_id`, indicating which port the update was + /// triggered on, and `delta`, whose value is dependent on the mode + /// parameter. + /// - `options` - If this is provided as `OnUpdateOptions { mode: + /// Some(OnUpdateMode::Row) }`, then `delta` is an Arrow of the updated + /// rows. Otherwise `delta` will be [`Option::None`]. + /// + /// # JavaScript Examples + /// + /// ```javascript + /// // Attach an `on_update` callback + /// view.on_update((updated) => console.log(updated.port_id)); + /// ``` + /// + /// ```javascript + /// // `on_update` with row deltas + /// view.on_update((updated) => console.log(updated.delta), { mode: "row" }); + /// ``` + #[wasm_bindgen] + pub fn on_update( + &self, + on_update_js: Function, + options: Option, + ) -> ApiFuture { + let poll_loop = LocalPollLoop::new(move |args: OnUpdateData| { + let js_obj = JsValue::from_serde_ext(&*args)?; + on_update_js.call1(&JsValue::UNDEFINED, &js_obj) + }); + + let on_update = Box::new(move |msg| poll_loop.poll(msg)); + let view = self.0.clone(); + ApiFuture::new(async move { + let on_update_opts = options + .into_serde_ext::>()? + .unwrap_or_default(); + + let id = view.on_update(on_update, on_update_opts).await?; + Ok(id) + }) + } + + /// Unregister a previously registered update callback with this [`View`]. + /// + /// # Arguments + /// + /// - `id` - A callback `id` as returned by a recipricol call to + /// [`View::on_update`]. + #[wasm_bindgen] + pub async fn remove_update(&self, callback_id: u32) -> ApiResult<()> { + Ok(self.0.remove_update(callback_id).await?) + } + + /// Register a callback with this [`View`]. Whenever the [`View`] is + /// deleted, this callback will be invoked. + #[wasm_bindgen] + pub fn on_delete(&self, on_delete: Function) -> ApiFuture { + let view = self.clone(); + ApiFuture::new(async move { + let emit = LocalPollLoop::new(move |()| on_delete.call0(&JsValue::UNDEFINED)); + let on_delete = Box::new(move || spawn_local(emit.poll(()))); + Ok(view.0.on_delete(on_delete).await?) + }) + } + + /// The number of aggregated columns in this [`View`]. This is affected by + /// the "split_by" configuration parameter supplied to this view's + /// contructor. + /// + /// # Returns + /// + /// The number of aggregated columns. + #[wasm_bindgen] + pub async fn num_columns(&self) -> ApiResult { + // TODO: This is broken because of how split by creates a + // cartesian product of columns * unique values. + Ok(self.0.dimensions().await?.num_view_columns) + } + + /// Unregister a previously registered [`View::on_delete`] callback. + #[wasm_bindgen] + pub fn remove_delete(&self, callback_id: u32) -> ApiFuture<()> { + let client = self.0.clone(); + ApiFuture::new(async move { + client.remove_delete(callback_id).await?; + Ok(()) + }) + } + + /// Collapses the `group_by` row at `row_index`. + #[wasm_bindgen] + pub async fn collapse(&self, row_index: u32) -> ApiResult { + Ok(self.0.collapse(row_index).await?) + } + + /// Expand the `group_by` row at `row_index`. + #[wasm_bindgen] + pub async fn expand(&self, row_index: u32) -> ApiResult { + Ok(self.0.expand(row_index).await?) + } + + /// Set expansion `depth` of the `group_by` tree. + #[wasm_bindgen] + pub async fn set_depth(&self, depth: u32) -> ApiResult<()> { + Ok(self.0.set_depth(depth).await?) + } +} diff --git a/rust/perspective-js/src/rust/virtual_server.rs b/rust/perspective-js/src/rust/virtual_server.rs new file mode 100644 index 0000000..d3f6a74 --- /dev/null +++ b/rust/perspective-js/src/rust/virtual_server.rs @@ -0,0 +1,817 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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::cell::UnsafeCell; +use std::future::Future; +use std::pin::Pin; +use std::rc::Rc; +use std::str::FromStr; +use std::sync::{Arc, Mutex}; + +use indexmap::IndexMap; +use js_sys::{Array, Date, Object, Reflect, Uint8Array}; +use perspective_client::proto::{ColumnType, HostedTable}; +use perspective_client::virtual_server; +use perspective_client::virtual_server::{Features, ResultExt, VirtualServerHandler}; +use serde::Serialize; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::JsFuture; + +use crate::JsViewConfig; +use crate::utils::{ApiError, ApiFuture, *}; + +type HandlerFuture = Pin>>; + +#[derive(Debug)] +pub struct JsError(JsValue); + +impl std::fmt::Display for JsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self.0) + } +} + +impl std::error::Error for JsError {} + +impl From for JsError { + fn from(value: JsValue) -> Self { + JsError(value) + } +} + +impl From for JsValue { + fn from(error: JsError) -> Self { + error.0 + } +} + +impl From for JsError { + fn from(error: serde_wasm_bindgen::Error) -> Self { + JsError(error.into()) + } +} + +fn jsvalue_to_scalar(val: &JsValue) -> perspective_client::config::Scalar { + if val.is_null() || val.is_undefined() { + perspective_client::config::Scalar::Null + } else if let Some(b) = val.as_bool() { + perspective_client::config::Scalar::Bool(b) + } else if let Some(n) = val.as_f64() { + perspective_client::config::Scalar::Float(n) + } else if let Some(s) = val.as_string() { + perspective_client::config::Scalar::String(s) + } else { + perspective_client::config::Scalar::Null + } +} + +pub struct JsServerHandler(Object); + +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(JsError) + } else { + Ok(result) + } + } +} + +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_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); + + 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)); + 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> { + let handler = self.0.clone(); + let table_id = table_id.to_string(); + let view_id = view_id.to_string(); + 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(&JsValue::from_serde_ext(&config)?); + let _ = this.call_method_js_async("tableMakeView", &args).await?; + Ok(view_id.to_string()) + }) + } + + 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 = JsValue::from_serde_ext(config).ok(); + + 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( + 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")))?; + + 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(); + 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( + if has_view_size { + "viewSize" + } else { + "tableSize" + }, + &args, + ) + .await?; + + result + .as_f64() + .map(|x| x as u32) + .ok_or_else(|| JsError(JsValue::from_str("viewSize must return a number"))) + }) + } + + 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 = JsValue::from_serde_ext(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(); + 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_min_max( + &self, + view_id: &str, + column_name: &str, + config: &perspective_client::config::ViewConfig, + ) -> HandlerFuture< + Result< + ( + perspective_client::config::Scalar, + perspective_client::config::Scalar, + ), + Self::Error, + >, + > { + let has_method = Reflect::get(&self.0, &JsValue::from_str("viewGetMinMax")) + .map(|val| !val.is_undefined()) + .unwrap_or(false); + + if !has_method { + return Box::pin(async { + Err(JsError(JsValue::from_str("viewGetMinMax not implemented"))) + }); + } + + let handler = self.0.clone(); + let view_id = view_id.to_string(); + let column_name = column_name.to_string(); + let config_js = JsValue::from_serde_ext(config).unwrap(); + Box::pin(async move { + let this = JsServerHandler(handler); + let args = Array::new(); + args.push(&JsValue::from_str(&view_id)); + args.push(&JsValue::from_str(&column_name)); + args.push(&config_js); + let result = this.call_method_js_async("viewGetMinMax", &args).await?; + let obj = result.dyn_ref::().unwrap(); + let min_val = Reflect::get(obj, &JsValue::from_str(wasm_bindgen::intern("min")))?; + let max_val = Reflect::get(obj, &JsValue::from_str(wasm_bindgen::intern("max")))?; + Ok((jsvalue_to_scalar(&min_val), jsvalue_to_scalar(&max_val))) + }) + } + + fn view_get_data( + &self, + view_id: &str, + config: &perspective_client::config::ViewConfig, + schema: &IndexMap, + 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 = JsValue::from_serde_ext(config).unwrap(); + let window_value = JsValue::from_serde_ext(&window).unwrap(); + let schema_value = JsValue::from_serde_ext(&schema).unwrap(); + + Box::pin(async move { + let this = JsServerHandler(handler); + let data = VirtualDataSlice::new(config_value.clone().unchecked_into()); + + { + let args = Array::new(); + args.push(&JsValue::from_str(&view_id)); + args.push(&config_value); + args.push(&schema_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 VirtualDataSlice(_obj, arc) = data; + let slice = std::mem::take(&mut *arc.lock().unwrap()).unwrap(); + Ok(slice) + }) + } +} + +#[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, +} + +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, + } + } +} + +#[wasm_bindgen(js_name = "VirtualDataSlice")] +#[derive(Clone)] +pub struct VirtualDataSlice(Object, Arc>>); + +#[wasm_bindgen] +impl VirtualDataSlice { + #[wasm_bindgen(constructor)] + pub fn new(config: JsViewConfig) -> Self { + VirtualDataSlice( + Object::new(), + Arc::new(Mutex::new(Some(virtual_server::VirtualDataSlice::new( + config.into_serde_ext().unwrap(), + )))), + ) + } + + #[wasm_bindgen(js_name = "fromArrowIpc")] + pub fn from_arrow_ipc(&self, ipc: Uint8Array) -> Result<(), JsValue> { + self.1 + .lock() + .unwrap() + .as_mut() + .unwrap() + .from_arrow_ipc(&ipc.to_vec()) + .map_err(|e| JsValue::from_str(&e.to_string())) + } + + #[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() + .as_mut() + .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() + .as_mut() + .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() + .as_mut() + .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() + .as_mut() + .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() + .as_mut() + .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() + .as_mut() + .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() + .as_mut() + .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() + .as_mut() + .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() + .as_mut() + .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() + .as_mut() + .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() + .as_mut() + .unwrap() + .set_col(name, group_by_index, index as usize, Some(n as i64)) + .unwrap(); + } else { + tracing::error!("Unhandled datetime value"); + } + + Ok(()) + } +} + +#[wasm_bindgen] +pub struct VirtualServer(Rc>>); + +#[wasm_bindgen] +impl VirtualServer { + #[wasm_bindgen(constructor)] + pub fn new(handler: Object) -> Result { + Ok(VirtualServer(Rc::new(UnsafeCell::new( + virtual_server::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/ts/perspective-server.worker.ts b/rust/perspective-js/src/ts/perspective-server.worker.ts new file mode 100644 index 0000000..2aebe2d --- /dev/null +++ b/rust/perspective-js/src/ts/perspective-server.worker.ts @@ -0,0 +1,64 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { + PerspectiveSession, + PerspectiveServer, + PerspectivePollThread, +} from "./wasm/engine.ts"; +import { compile_perspective } from "./wasm/emscripten_api.ts"; + +let GLOBAL_SERVER: PerspectiveServer; +let POLL_THREAD: PerspectivePollThread; + +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(), + }); + + 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 { + 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", 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 new file mode 100644 index 0000000..6b4f935 --- /dev/null +++ b/rust/perspective-js/src/ts/perspective.browser.ts @@ -0,0 +1,242 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +export type * from "../../dist/wasm/perspective-js.d.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"; +import { load_wasm_stage_0 } from "./wasm/decompress.ts"; + +export { + GenericSQLVirtualServerModel, + VirtualDataSlice, + VirtualServer, +} from "../../dist/wasm/perspective-js.js"; + +import { + GenericSQLVirtualServerModel, + VirtualDataSlice, + VirtualServer, +} from "../../dist/wasm/perspective-js.js"; + +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 + | Promise + | ArrayBuffer + | Response + | WebAssembly.Module, + disable_stage_0: boolean = false, +) { + if (wasm instanceof Uint8Array) { + GLOBAL_SERVER_WASM = Promise.resolve(wasm.buffer); + } else if (wasm instanceof Response) { + GLOBAL_SERVER_WASM = Promise.resolve(wasm); + } else if (wasm instanceof Promise) { + GLOBAL_SERVER_WASM = wasm; + } else { + GLOBAL_SERVER_WASM = Promise.resolve(wasm); + } + + if (!disable_stage_0) { + GLOBAL_SERVER_WASM = GLOBAL_SERVER_WASM.then((x) => + load_wasm_stage_0(x).then((x) => x.buffer as ArrayBuffer), + ); + } +} + +let GLOBAL_CLIENT_WASM: Promise; +let GLOBAL_CLIENT_MODULE: Promise | undefined; + +async function compile_module(wasm: any): Promise { + if (wasm instanceof WebAssembly.Module) { + return wasm; + } + + if (typeof Response !== "undefined" && wasm instanceof Response) { + return WebAssembly.compileStreaming(wasm); + } + + return WebAssembly.compile(wasm); +} + +async function compilerize( + wasm: PerspectiveWasm, + disable_stage_0: boolean = false, +) { + const wasm_buff = disable_stage_0 ? wasm : await load_wasm_stage_0(wasm); + // Compile to a `WebAssembly.Module` once so it can be both instantiated + // locally and forwarded to other workers via `getCompiledClientWasm()`. + // `WebAssembly.Module` is structured-cloneable across workers, so the + // recipient can instantiate without re-fetching or re-compiling. + const compiled = await compile_module(wasm_buff); + GLOBAL_CLIENT_MODULE = Promise.resolve(compiled); + await wasm_module.default({ module_or_path: compiled }); + await wasm_module.init(); + return wasm_module; +} + +export type PerspectiveWasm = + | ArrayBuffer + | Response + | typeof psp + | Promise; + +export function init_client(wasm: PerspectiveWasm, disable_stage_0 = false) { + if (wasm instanceof Uint8Array) { + GLOBAL_CLIENT_WASM = compilerize( + wasm.buffer as ArrayBuffer, + disable_stage_0, + ); + } else if (wasm instanceof ArrayBuffer) { + GLOBAL_CLIENT_WASM = compilerize(wasm, disable_stage_0); + } else if (wasm instanceof Response) { + GLOBAL_CLIENT_WASM = compilerize(wasm, disable_stage_0); + } else if (wasm instanceof Promise) { + GLOBAL_CLIENT_WASM = compilerize(wasm, disable_stage_0); + } else if (wasm instanceof Object) { + GLOBAL_CLIENT_WASM = Promise.resolve(wasm as typeof psp); + } +} + +function get_client() { + const viewer_class: any = customElements.get("perspective-viewer"); + if (viewer_class) { + GLOBAL_CLIENT_WASM = Promise.resolve(viewer_class.__wasm_module__); + if ( + GLOBAL_CLIENT_MODULE === undefined && + viewer_class.__wasm_client_module__ + ) { + GLOBAL_CLIENT_MODULE = Promise.resolve( + viewer_class.__wasm_client_module__, + ); + } + } else if (GLOBAL_CLIENT_WASM === undefined) { + throw new Error("Missing perspective-client.wasm"); + } + + return GLOBAL_CLIENT_WASM; +} + +/** + * Returns the compiled `WebAssembly.Module` for the perspective-js client + * runtime. The module is structured-cloneable, so it can be sent via + * `postMessage` to a Worker which can instantiate its own `Client` without + * re-fetching or re-compiling the wasm binary. + * + * Requires that the client wasm has been initialized — typically by a prior + * call to `init_client(...)`, or implicitly by mounting a `` + * element. Throws otherwise. + * + * # Examples + * + * ```javascript + * const mod = await perspective.getCompiledClientWasm(); + * worker.postMessage({ kind: "init", clientWasm: mod }, [port]); + * ``` + */ +export async function getCompiledClientWasm(): Promise { + if (GLOBAL_CLIENT_MODULE !== undefined) { + return GLOBAL_CLIENT_MODULE; + } + + const viewer_class: any = customElements.get("perspective-viewer"); + if (viewer_class?.__wasm_client_module__) { + GLOBAL_CLIENT_MODULE = Promise.resolve( + viewer_class.__wasm_client_module__, + ); + return GLOBAL_CLIENT_MODULE; + } + + throw new Error( + "perspective-js client wasm has not been compiled yet — call " + + "`init_client(...)` or `perspective.worker()` before " + + "`getCompiledClientWasm()`.", + ); +} + +function get_server() { + if (GLOBAL_SERVER_WASM === undefined) { + throw new Error("Missing perspective-server.wasm"); + } + + return GLOBAL_SERVER_WASM.then((x) => + x instanceof WebAssembly.Module ? x : x.slice(0), + ); +} + +let GLOBAL_WORKER: undefined | (() => Promise) = undefined; + +// `WorkerPlugin` resolves this import to a stub that exports +// `getPerspectiveWorkerURL(): Promise`. The URL is either a +// Blob URL (inline mode — production builds) or a real file path +// resolved against `import.meta.url` (file mode — debug builds). +// Constructing the `Worker` lives here in the consumer rather than +// inside the plugin so the same module text can also be loaded +// in-process via dynamic `import(url)` when a future caller wants +// it; the plugin no longer owns Worker lifecycle. +// +// `initialize()` constructs `new Worker(blobUrl)` and falls back to +// running the worker source on the main thread via `new Function(...)` +// when Worker construction is unavailable (e.g. `file://` origins where +// module-Worker support is gated). The shim it returns is +// MessagePort-shaped so downstream code can treat it like a real +// Worker. +// @ts-ignore — resolved at build time by `@perspective-dev/esbuild-plugin/worker` +import { initialize as initializePerspectiveWorker } from "../../src/ts/perspective-server.worker.js"; + +async function get_worker(): Promise { + if (GLOBAL_WORKER === undefined) { + return await initializePerspectiveWorker({ + type: "module", + name: "perspective-server", + }); + } + + return GLOBAL_WORKER(); +} + +export async function websocket(url: string | URL) { + return await api.websocket(get_client(), url); +} + +export async function worker( + worker?: Promise, +) { + if (typeof worker === "undefined") { + worker = get_worker(); + } + + return await api.worker(get_client(), get_server(), worker); +} + +export default { + websocket, + worker, + init_client, + init_server, + createMessageHandler, + getCompiledClientWasm, + GenericSQLVirtualServerModel, + VirtualDataSlice, + VirtualServer, +}; diff --git a/rust/perspective-js/src/ts/perspective.cdn.ts b/rust/perspective-js/src/ts/perspective.cdn.ts new file mode 100644 index 0000000..b23d211 --- /dev/null +++ b/rust/perspective-js/src/ts/perspective.cdn.ts @@ -0,0 +1,22 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 from "./perspective.browser.ts"; +export * from "./perspective.browser.ts"; + +const url = new URL( + "../../../server/dist/wasm/perspective-server.wasm", + import.meta.url, +); + +perspective.init_server(fetch(url)); +export default perspective; diff --git a/rust/perspective-js/src/ts/perspective.inline.ts b/rust/perspective-js/src/ts/perspective.inline.ts new file mode 100644 index 0000000..8f59ba0 --- /dev/null +++ b/rust/perspective-js/src/ts/perspective.inline.ts @@ -0,0 +1,27 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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, { PerspectiveWasm } from "./perspective.browser.ts"; +export * from "./perspective.browser.ts"; + +// @ts-ignore; +import server_wasm from "@perspective-dev/server/dist/wasm/perspective-server.wasm"; + +// @ts-ignore; +import client_wasm from "../../dist/wasm/perspective-js.wasm"; + +await perspective.init_server(server_wasm); +await perspective.init_client(client_wasm as any as PerspectiveWasm); + +console.warn("Perspective wasn been initialized in inline mode"); + +export default perspective; diff --git a/rust/perspective-js/src/ts/perspective.node.ts b/rust/perspective-js/src/ts/perspective.node.ts new file mode 100644 index 0000000..b1a36e5 --- /dev/null +++ b/rust/perspective-js/src/ts/perspective.node.ts @@ -0,0 +1,524 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +export type * from "../../dist/wasm/perspective-js.d.ts"; +export type * from "./virtual_server.ts"; + +import WebSocket, { WebSocketServer as HttpWebSocketServer } from "ws"; +import stoppable from "stoppable"; +import { promises as fs } from "node:fs"; +import { + openSync, + readSync, + writeSync, + closeSync, + mkdirSync, + unlinkSync, + rmSync, +} from "node:fs"; +import os from "node:os"; +import http from "node:http"; +import path from "node:path"; +import { webcrypto } from "node:crypto"; +import type * as net from "node:net"; +import * as url from "node:url"; +import { createRequire } from "node:module"; + +import * as perspective_client from "../../dist/wasm/perspective-js.js"; +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"; + +export { + GenericSQLVirtualServerModel, + VirtualDataSlice, + VirtualServer, +} from "../../dist/wasm/perspective-js.js"; + +import { + GenericSQLVirtualServerModel, + VirtualDataSlice, + VirtualServer, +} from "../../dist/wasm/perspective-js.js"; + +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); + +const { resolve } = createRequire(import.meta.url); + +if (!globalThis.crypto) { + globalThis.crypto = webcrypto as Crypto; +} + +const uncompressed_client_wasm = await fs + .readFile(path.join(__dirname, "../../dist/wasm/perspective-js.wasm")) + .then((buffer) => load_wasm_stage_0(buffer.buffer as ArrayBuffer)); + +await perspective_client.default({ module_or_path: uncompressed_client_wasm }); + +function make_node_disk_bridge({ + heap, + toAddr, + readCString, +}: { + heap: () => Uint8Array; + toAddr: (p: number | bigint) => number; + readCString: (p: number | bigint) => string; +}) { + const root = path.join(os.tmpdir(), `perspective-${process.pid}`); + const resolve_path = (name: string) => { + const p = path.join(root, name); + if (!p.startsWith(root)) { + throw new Error(`refusing disk path outside root: ${name}`); + } + return p; + }; + + let cleaned = false; + const cleanup = () => { + if (cleaned) return; + cleaned = true; + try { + rmSync(root, { recursive: true, force: true }); + } catch (e) { + /* best effort */ + } + }; + process.on("exit", cleanup); + process.on("SIGINT", () => { + cleanup(); + process.exit(130); + }); + + return { + // node:fs is synchronous — nothing to pre-open between safepoint phases. + async ensureOpen(_name: string) {}, + store( + namePtr: number | bigint, + dataPtr: number | bigint, + len: number, + ): number { + try { + const p = resolve_path(readCString(namePtr)); + mkdirSync(path.dirname(p), { recursive: true }); + const fd = openSync(p, "w"); + try { + if (len > 0) { + const addr = toAddr(dataPtr); + const view = heap().subarray(addr, addr + len); + let off = 0; + while (off < len) { + off += writeSync(fd, view, off, len - off, off); + } + } + } finally { + closeSync(fd); + } + return len; + } catch (e) { + console.error("node disk store failed", e); + return -1; + } + }, + load( + namePtr: number | bigint, + dataPtr: number | bigint, + len: number, + ): number { + try { + if (len <= 0) return 0; + let fd: number; + try { + fd = openSync(resolve_path(readCString(namePtr)), "r"); + } catch (e) { + return 0; // never-flushed file reads as zeros + } + try { + const addr = toAddr(dataPtr); + const view = heap().subarray(addr, addr + len); + let off = 0; + let n: number; + while ( + off < len && + (n = readSync(fd, view, off, len - off, off)) > 0 + ) { + off += n; + } + return off; + } finally { + closeSync(fd); + } + } catch (e) { + return 0; + } + }, + remove(namePtr: number | bigint) { + try { + unlinkSync(resolve_path(readCString(namePtr))); + } catch (e) { + /* already gone */ + } + }, + }; +} + +const SYNC_MODULE = await fs + .readFile( + resolve("@perspective-dev/server/dist/wasm/perspective-server.wasm"), + ) + .then((buffer) => load_wasm_stage_0(buffer.buffer as ArrayBuffer)) + .then((buffer) => + compile_perspective(buffer.buffer as ArrayBuffer, { + make_disk_bridge: make_node_disk_bridge, + }), + ); + +let SYNC_CLIENT: perspective_client.Client; + +const SYNC_SERVER = new engine.PerspectiveServer(SYNC_MODULE); + +// const SYNC_SERVER = new engine.PerspectiveServer(SYNC_MODULE, { +// on_poll_request: () => SYNC_POLL_HANDLE.on_poll_request(), +// }); + +// const SYNC_POLL_HANDLE: engine.PerspectivePollThread = +// new engine.PerspectivePollThread(SYNC_SERVER); + +const SYNC_SESSION = SYNC_SERVER.make_session( + async (resp) => await SYNC_CLIENT.handle_response(resp), +); + +SYNC_CLIENT = new perspective_client.Client(async (req: Uint8Array) => { + await SYNC_SESSION.handle_request(req); +}); + +export class PerspectiveServer extends engine.PerspectiveServer { + constructor(options?: engine.PerspectiveServerOptions) { + super(SYNC_MODULE, options); + } +} + +export const make_session = async ( + send_response: (buffer: Uint8Array) => Promise, +) => SYNC_SERVER.make_session(send_response); + +// Helper function to create client emitter/receiver pairs +export function make_client( + send_request: (buffer: Uint8Array) => Promise, + close?: Function, +) { + return new perspective_client.Client(send_request, close); +} + +const CONTENT_TYPES: Record = { + ".js": "text/javascript", + ".mjs": "text/javascript", + ".css": "text/css; charset=utf-8", + ".json": "application/json", + ".arrow": "arraybuffer", + ".feather": "arraybuffer", + ".wasm": "application/wasm", + ".svg": "image/svg+xml", +}; + +/** + * Host a Perspective server that hosts data, code files, etc. + * Strip version numbers from the URL so we can handle CDN-like requests + * of the form @[^~]major.minor.patch when testing local versions of + * Perspective against Voila. + */ +export async function cwd_static_file_handler( + request: http.IncomingMessage, + response: http.ServerResponse, + assets = ["./"], + { debug = false } = {}, +) { + let url = + request.url + ?.split(/[\?\#]/)[0] + .replace(/@[\^~]?\d+.[\d\*]*.[\d\*]*/, "") || "/"; + + if (url === "/") { + url = "/index.html"; + } + + let extname = path.extname(url); + let contentType = CONTENT_TYPES[extname] || "text/html"; + try { + for (const root of assets) { + let filePath = root + url; + try { + let content = await fs.readFile(filePath); + if (typeof content !== "undefined") { + if (debug) { + console.log(`200 ${url}`); + } + + response.writeHead(200, { + "Content-Type": contentType, + "Access-Control-Allow-Origin": "*", + }); + + if (extname === ".arrow" || extname === ".feather") { + response.end(content, "utf8"); + } else { + response.end(content, "utf8"); + } + + return; + } + } catch (e) {} + } + + if (debug) { + console.error(`404 ${url}`); + } + + response.writeHead(404); + response.end("", "utf-8"); + } catch (error) { + if (debug) { + console.error(`500 ${url} ${error}`); + } + + response.writeHead(500); + response.end("", "utf-8"); + } +} + +function buffer_to_arraybuffer( + buffer: string | Buffer | ArrayBuffer | Buffer[] | Uint8Array, +): Uint8Array { + if (typeof buffer === "string") { + throw new Error("Unknown websocket message: " + buffer); + } else if (buffer instanceof ArrayBuffer) { + return new Uint8Array(buffer); + } else if (buffer instanceof Array) { + throw new Error("Unknown websocket message: " + buffer); + } else { + return new Uint8Array( + buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.length, + ), + ); + } +} + +/** + * A simple Node `http`-based WebSocket adapter that exposes a + * `PerspectiveServer` over the wire. + * + * @remarks + * + * **Security.** `WebSocketServer` is a reference integration with no + * authentication, authorization, origin enforcement, or rate limiting, and + * is not safe to expose to untrusted networks — see + * [`SECURITY.md`](https://github.com/perspective-dev/perspective/blob/master/SECURITY.md) + * for the full threat model. + */ +export class WebSocketServer { + _server: http.Server | any; // stoppable has no type ... + _wss: HttpWebSocketServer; + constructor({ port = 8080, assets = ["./"], server = undefined } = {}) { + const perspective_server = + typeof server === "undefined" ? SYNC_SERVER : server; + + port = typeof port === "undefined" ? 8080 : port; + this._server = stoppable( + http.createServer((x, y) => cwd_static_file_handler(x, y, assets)), + ); + + this._wss = new HttpWebSocketServer({ + noServer: true, + perMessageDeflate: true, + }); + + this._wss.on("connection", (ws) => { + console.log("... Connecting websocket"); + const session = perspective_server.make_session( + async (proto: Uint8Array) => { + ws.send(buffer_to_arraybuffer(proto)); + }, + ); + + ws.on("message", (proto) => { + session.handle_request(buffer_to_arraybuffer(proto)); + }); + + ws.on("close", () => { + session.close(); + }); + }); + + this._server.on( + "upgrade", + ( + request: http.IncomingMessage, + socket: net.Socket, + head: Buffer, + ) => { + console.log("200 Websocket upgrade"); + this._wss.handleUpgrade( + request, + socket as net.Socket, + head, + (sock) => this._wss.emit("connection", sock, request), + ); + }, + ); + + this._server.listen(port, () => { + console.log(`Listening on ${this._server.address().port}`); + }); + } + + async close() { + await new Promise((x) => this._server.stop(x)); + } +} + +export function get_hosted_table_names() { + return SYNC_CLIENT.get_hosted_table_names(); +} + +export function on_hosted_tables_update(cb: () => void) { + return SYNC_CLIENT.on_hosted_tables_update(cb); +} + +export function remove_hosted_tables_update(id: number) { + return SYNC_CLIENT.remove_hosted_tables_update(id); +} + +export function system_info() { + return SYNC_CLIENT.system_info(); +} + +export function on_error(callback: Function) { + return SYNC_CLIENT.on_error(callback); +} + +/** + * Create a read-only table from a JOIN of two source tables. + * @param left - The left source table (a Table instance or a table name string). + * @param right - The right source table (a Table instance or a table name string). + * @param on + * @param options - Optional join configuration: { join_type?: "inner"|"left"|"outer", name?: string } + * @returns + */ +export function join( + left: perspective_client.Table | string, + right: perspective_client.Table | string, + on: string, + options?: perspective_client.JoinOptions, +) { + return SYNC_CLIENT.join(left as any, right as any, on, options); +} + +/** + * Create a table from the global Perspective instance. + * @param init_data + * @param options + * @returns + */ +export function table( + init_data: + | string + | ArrayBuffer + | Record + | Record[], + options?: perspective_client.TableInitOptions, +) { + return SYNC_CLIENT.table(init_data, options); +} + +/** + * Create a new client connected via WebSocket to a server implemnting the + * Perspective Protocol. + * @param module + * @param url + * @returns + */ +export async function websocket( + url: string, +): Promise { + return await psp_websocket.websocket( + WebSocket as unknown as typeof window.WebSocket, + perspective_client.Client, + url, + ); +} + +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); +} + +/** + * The initialized WASM module. Use this when you need to pass the module + * to components that require it, such as `DuckDBHandler`. + */ +export { perspective_client as wasmModule }; + +export default { + table, + join, + websocket, + worker, + get_hosted_table_names, + on_hosted_tables_update, + remove_hosted_tables_update, + on_error, + system_info, + WebSocketServer, + GenericSQLVirtualServerModel, + VirtualDataSlice, + VirtualServer, +}; 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 0000000..0b91659 --- /dev/null +++ b/rust/perspective-js/src/ts/virtual_server.ts @@ -0,0 +1,121 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { 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. + * + * 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 + */ + +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). + */ +export interface VirtualServerHandler { + getHostedTables(): string[] | Promise; + tableSchema( + tableId: string, + ): Record | Promise>; + tableSize(tableId: string): number | Promise; + tableMakeView( + tableId: string, + viewId: string, + config: ViewConfig, + ): void | Promise; + viewDelete(viewId: string): void | Promise; + viewGetData( + viewId: string, + config: ViewConfig, + schema: Record, + viewport: ViewWindow, + dataSlice: perspective.VirtualDataSlice, + ): void | Promise; + viewSchema?( + viewId: string, + config?: ViewConfig, + ): Record | Promise>; + viewSize?(viewId: string): number | Promise; + tableValidateExpression?( + tableId: string, + expression: string, + ): ColumnType | Promise; + viewGetMinMax?( + viewId: string, + columnName: string, + config: ViewConfig, + ): { min: any; max: any } | Promise<{ min: any; max: any }>; + getFeatures?(): ServerFeatures | Promise; + makeTable?( + tableId: string, + data: string | Uint8Array, + ): void | Promise; +} + +export function createMessageHandler( + mod: typeof perspective, + handler: VirtualServerHandler, +) { + let virtualServer: perspective.VirtualServer; + async function postMessage(port: MessagePort, msg: MessageEvent) { + if (msg.data.cmd === "init") { + try { + virtualServer = new mod.VirtualServer(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/rust/perspective-js/src/ts/virtual_servers/clickhouse.ts b/rust/perspective-js/src/ts/virtual_servers/clickhouse.ts new file mode 100644 index 0000000..fb9c472 --- /dev/null +++ b/rust/perspective-js/src/ts/virtual_servers/clickhouse.ts @@ -0,0 +1,352 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * An implementation of a Perspective Virtual Server for DuckDB. + * + * This import is optional, and so must be imported manually from either + * `@perspective-dev/client/dist/esm/virtual_servers/duckdb.js` or + * `@perspective-dev/client/src/ts/virtual_servers/duckdb.ts`, it is not + * exported from the package root `@perspective-dev/client` + * + * @module + */ + +import type * as perspective 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 clickhouse from "@clickhouse/client-web"; + +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", + ">=", + "<=", + ">", + "<", +]; + +function duckdbTypeToPsp(name: string): ColumnType { + if (name.startsWith("Nullable")) { + name = name.match(/Nullable\((.+?)\)/)![1]; + } + + if (name.startsWith("Array")) { + return "string"; + } + + if (name === "Int64" || name === "UInt64" || name === "Float64") { + return "float"; + } + + if (name === "String") { + return "string"; + } + + if (name === "DateTime") { + return "datetime"; + } + + if (name === "Date") { + return "date"; + } + + throw new Error(`Unknown type '${name}'`); +} + +function convertDecimalToNumber(value: any, dtypeString: string) { + if (!(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 scaleMatch = dtypeString.match(/Decimal\[\d+e(\d+)\]/); + if (scaleMatch) { + const scale = parseInt(scaleMatch[1]); + return Number(bigIntValue) / Math.pow(10, scale); + } else { + return Number(bigIntValue); + } +} + +class Lock { + lockPromise: Promise; + constructor() { + this.lockPromise = Promise.resolve(); + } + + acquire() { + let releaseLock: (value: void) => void; + const newLockPromise: Promise = new Promise((resolve) => { + releaseLock = resolve; + }); + + const acquirePromise = this.lockPromise.then(() => releaseLock); + this.lockPromise = newLockPromise; + return acquirePromise; + } +} + +const LOCK = new Lock(); + +async function runQuery( + db: clickhouse.ClickHouseClient, + query: string, + options: { columns?: true; execute?: boolean }, +): Promise<{ + rows: any[]; + columns: string[]; + dtypes: string[]; +}>; + +async function runQuery( + db: clickhouse.ClickHouseClient, + query: string, + options?: { columns?: false; execute?: boolean }, +): Promise; + +async function runQuery( + db: clickhouse.ClickHouseClient, + query: string, + options: { columns?: boolean; execute?: boolean } = {}, +) { + query = query.replace(/\s+/g, " ").trim(); + const release = await LOCK.acquire(); + try { + const result = await db.query({ query }); + if (!options.execute) { + const { data, meta } = + (await result.json()) as clickhouse.ResponseJSON; + + if (options.columns) { + return { + rows: data, + columns: meta!.map((f) => f.name), + dtypes: meta!.map((f) => f.type), + }; + } + + return data; + } + } catch (error) { + console.error("Query error:", error); + console.error("Query:", query); + throw error; + } finally { + release(); + } +} + +/** + * An implementation of Perspective's Virtual Server for `@duckdb/duckdb-wasm`. + */ +export class ClickhouseHandler implements perspective.VirtualServerHandler { + private db: clickhouse.ClickHouseClient; + private sqlBuilder: perspective.GenericSQLVirtualServerModel; + constructor(db: clickhouse.ClickHouseClient, mod?: typeof perspective) { + if (!mod) { + if (customElements) { + const viewer_class: any = + customElements.get("perspective-viewer"); + if (viewer_class) { + mod = viewer_class.__wasm_module__; + } else { + throw new Error("Missing perspective-client.wasm"); + } + } else { + } + } + + this.db = db; + this.sqlBuilder = new mod!.GenericSQLVirtualServerModel({ + create_entity: "VIEW", + grouping_fn: "GROUPING", + }); + } + + getFeatures() { + return { + group_by: true, + split_by: false, + sort: true, + expressions: true, + group_rollup_mode: ["rollup", "flat", "total"], + 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, + }, + }; + } + + async getHostedTables() { + const query = "SHOW TABLES"; + const results = await runQuery(this.db, query); + return results.map((row) => { + return `${row.name}`; + }); + } + + async tableSchema(tableId: string, config?: ViewConfig) { + const query = this.sqlBuilder.tableSchema(tableId); + const results = await runQuery(this.db, query); + const schema = {} as Record; + for (const result of results) { + const colName = result.name; + if (!colName.startsWith("__")) { + schema[colName] = duckdbTypeToPsp(result.type) as ColumnType; + } + } + + return schema; + } + + async viewColumnSize(viewId: string, config: ViewConfig) { + const query = `SELECT COUNT() FROM system.columns WHERE table = '${viewId}'`; + const results = await runQuery(this.db, query); + const gs = config.group_by?.length || 0; + const count = Number(results[0]["COUNT()"]); + console.log(count); + return ( + count - + (gs === 0 ? 0 : gs + (config.split_by?.length === 0 ? 1 : 0)) + ); + } + + async tableSize(tableId: string) { + const query = this.sqlBuilder.tableSize(tableId); + const results = await runQuery(this.db, query); + return Number(results[0]["COUNT()"]); + } + + async tableMakeView(tableId: string, viewId: string, config: ViewConfig) { + const query = this.sqlBuilder.tableMakeView(tableId, viewId, config); + await runQuery(this.db, query, { execute: true }); + } + + async tableValidateExpression(tableId: string, expression: string) { + const query = this.sqlBuilder.tableValidateExpression( + tableId, + expression, + ); + const results = await runQuery(this.db, query); + return duckdbTypeToPsp(results[0]["type"]) as ColumnType; + } + + async viewDelete(viewId: string) { + const query = this.sqlBuilder.viewDelete(viewId); + await runQuery(this.db, query, { execute: true }); + } + + async viewGetData( + viewId: string, + config: ViewConfig, + schema: Record, + viewport: ViewWindow, + dataSlice: perspective.VirtualDataSlice, + ) { + const query = this.sqlBuilder.viewGetData( + viewId, + config, + viewport, + schema, + ); + + const { rows, columns, dtypes } = await runQuery(this.db, query, { + columns: true, + }); + + for (let cidx = 0; cidx < columns.length; cidx++) { + const col = columns[cidx]; + const dtype = duckdbTypeToPsp(dtypes[cidx]) as ColumnType; + + const isDecimal = dtypes[cidx].startsWith("Decimal"); + for (let ridx = 0; ridx < rows.length; ridx++) { + const row = rows[ridx]; + const grouping_id = row["__GROUPING_ID__"]; + let value = row[col]; + if (isDecimal) { + value = convertDecimalToNumber(value, dtypes[cidx]); + } + + if (typeof value === "bigint") { + value = Number(value); + } + + if (dtype === "datetime" && typeof value === "string") { + value = +new Date(value); + } + + if (dtype === "string" && typeof value !== "string") { + value = `${value}`; + } + + dataSlice.setCol(dtype, col, ridx, value, grouping_id); + } + } + } +} diff --git a/rust/perspective-js/src/ts/virtual_servers/duckdb.ts b/rust/perspective-js/src/ts/virtual_servers/duckdb.ts new file mode 100644 index 0000000..a8a762a --- /dev/null +++ b/rust/perspective-js/src/ts/virtual_servers/duckdb.ts @@ -0,0 +1,312 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * An implementation of a Perspective Virtual Server for DuckDB. + * + * This import is optional, and so must be imported manually from either + * `@perspective-dev/client/dist/esm/virtual_servers/duckdb.js` or + * `@perspective-dev/client/src/ts/virtual_servers/duckdb.ts`, it is not + * exported from the package root `@perspective-dev/client` + * + * @module + */ + +import type * as perspective 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", + "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", + ">=", + "<=", + ">", + "<", +]; + +function duckdbTypeToPsp(name: string): ColumnType { + name = name.toLowerCase(); + if (name === "varchar" || name == "utf8") { + return "string"; + } + + if ( + name === "double" || + name === "bigint" || + name === "hugeint" || + name === "float64" || + name.startsWith("decimal") + ) { + return "float"; + } + + if (name.startsWith("int")) { + return "integer"; + } + + if (name.startsWith("date")) { + return "date"; + } + + if (name.startsWith("bool")) { + return "boolean"; + } + + if (name.startsWith("timestamp")) { + return "datetime"; + } + + if (name.startsWith("json")) { + return "string"; + } + + if (name.startsWith("struct")) { + return "string"; + } + + if (name.startsWith("time")) { + return "float"; + } + + console.warn(`Unknown type '${name}'`); + return "string"; +} + +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: false }, +): Promise; + +async function runQuery( + db: duckdb.AsyncDuckDBConnection, + query: string, + options: { columns?: boolean } = {}, +) { + query = query.replace(/\s+/g, " ").trim(); + try { + const result = await db.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; + } +} + +/** + * An implementation of Perspective's Virtual Server for `@duckdb/duckdb-wasm`. + */ +export class DuckDBHandler implements perspective.VirtualServerHandler { + private db: duckdb.AsyncDuckDBConnection; + private sqlBuilder: perspective.GenericSQLVirtualServerModel; + constructor(db: duckdb.AsyncDuckDBConnection, mod?: typeof perspective) { + if (!mod) { + if (customElements) { + const viewer_class: any = + customElements.get("perspective-viewer"); + if (viewer_class) { + mod = viewer_class.__wasm_module__; + } else { + throw new Error("Missing perspective-client.wasm"); + } + } else { + } + } + + this.db = db; + this.sqlBuilder = new mod!.GenericSQLVirtualServerModel(); + } + + getFeatures() { + return { + group_by: true, + split_by: true, + sort: true, + expressions: true, + group_rollup_mode: ["rollup", "flat", "total"], + 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, + }, + }; + } + + async getHostedTables() { + const query = this.sqlBuilder.getHostedTables(); + const results = await runQuery(this.db, query); + return results.map((row) => { + const json = row.toJSON(); + return `${json.database || "memory"}.${json.name}`; + }); + } + + async tableSchema(tableId: string, config?: ViewConfig) { + const query = this.sqlBuilder.tableSchema(tableId); + 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("__")) { + schema[colName] = duckdbTypeToPsp( + res.column_type, + ) as ColumnType; + } + } + + return schema; + } + + async viewColumnSize(viewId: string, config: ViewConfig) { + const query = this.sqlBuilder.viewColumnSize(viewId); + const results = await runQuery(this.db, query); + const count = Number(Object.values(results[0].toJSON())[0]); + const gs = config.group_by?.length || 0; + const is_flat = config.group_rollup_mode === "flat"; + return count - (gs === 0 ? 0 : is_flat ? gs : gs + 1); + } + + async tableSize(tableId: string) { + const query = this.sqlBuilder.tableSize(tableId); + const results = await runQuery(this.db, query); + return Number(results[0].toJSON()["count_star()"]); + } + + async tableMakeView(tableId: string, viewId: string, config: ViewConfig) { + const query = this.sqlBuilder.tableMakeView(tableId, viewId, config); + await runQuery(this.db, query); + } + + async tableValidateExpression(tableId: string, expression: string) { + const query = this.sqlBuilder.tableValidateExpression( + tableId, + expression, + ); + const results = await runQuery(this.db, query); + return duckdbTypeToPsp( + results[0].toJSON()["column_type"], + ) as ColumnType; + } + + async viewDelete(viewId: string) { + const query = this.sqlBuilder.viewDelete(viewId); + await runQuery(this.db, query); + } + + async viewGetMinMax( + viewId: string, + columnName: string, + config: ViewConfig, + ) { + const query = this.sqlBuilder.viewGetMinMax(viewId, columnName, config); + const results = await runQuery(this.db, query); + const row = results[0].toJSON(); + let [min, max] = Object.values(row); + if (typeof min === "bigint") min = Number(min); + if (typeof max === "bigint") max = Number(max); + return { min: min ?? null, max: max ?? null }; + } + + async viewGetData( + viewId: string, + config: ViewConfig, + schema: Record, + viewport: ViewWindow, + dataSlice: perspective.VirtualDataSlice, + ) { + const query = this.sqlBuilder.viewGetData( + viewId, + config, + viewport, + schema, + ); + + const ipc = await this.db.useUnsafe((bindings, conn) => + bindings.runQuery(conn, query), + ); + + dataSlice.fromArrowIpc(ipc); + } +} diff --git a/rust/perspective-js/src/ts/wasm/browser.ts b/rust/perspective-js/src/ts/wasm/browser.ts new file mode 100644 index 0000000..b2d2295 --- /dev/null +++ b/rust/perspective-js/src/ts/wasm/browser.ts @@ -0,0 +1,131 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type * as psp from "../../../dist/wasm/perspective-js.d.ts"; + +import * as psp_websocket from "../websocket.ts"; + +function invert_promise(): [ + (t: T) => void, + Promise, + (e: string) => void, +] { + let sender, reject; + let receiver: Promise = new Promise((x, y) => { + sender = x; + reject = y; + }); + + return [ + sender as unknown as (t: T) => void, + receiver, + reject as unknown as (e: string) => void, + ]; +} + +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); + sender(null); + }); + + ws.onmessage = function listener(resp) { + ws.onmessage = function () {}; + sender(null); + }; + + ws.onmessageerror = console.error; + ws.postMessage( + { cmd: "init", args: [wasm] }, + { + transfer: + wasm === undefined || wasm instanceof WebAssembly.Module + ? [] + : [wasm], + }, + ); + + await receiver; +} + +/** + * Create a new client connected exclusively to a new Web Worker instance of + * the Perspective engine. + * @param module + * @returns + */ +export async function worker( + module: Promise, + server_wasm: Promise | undefined, + perspective_wasm_worker: Promise< + SharedWorker | ServiceWorker | Worker | MessagePort + >, +) { + let [wasm, webworker]: [ + WebAssembly.Module | undefined, + SharedWorker | ServiceWorker | Worker | MessagePort, + ] = await Promise.all([server_wasm, perspective_wasm_worker]); + + const { Client } = await module; + let port: MessagePort; + if ( + typeof SharedWorker !== "undefined" && + webworker instanceof SharedWorker + ) { + port = webworker.port; + } else { + // Assume `MessagePort` + port = webworker as MessagePort; + } + + const client = new Client( + async (proto: Uint8Array) => { + const f = proto.slice().buffer; + port.postMessage(f, { transfer: [f] }); + }, + async () => { + console.debug("Closing WebWorker"); + port.close(); + if (webworker instanceof Worker) { + webworker.terminate(); + } + }, + ); + + await _init(port, wasm); + port.addEventListener("message", (json: MessageEvent) => { + client.handle_response(json.data); + }); + + return client; +} + +/** + * Create a new client connected via WebSocket to a server implemnting the + * Perspective Protocol. + * @param module + * @param url + * @returns + */ +export async function websocket( + module: Promise, + url: string | URL, +) { + const { Client } = await module; + return await psp_websocket.websocket(WebSocket, Client, url); +} + +export default { websocket, worker }; diff --git a/rust/perspective-js/src/ts/wasm/decompress.ts b/rust/perspective-js/src/ts/wasm/decompress.ts new file mode 100644 index 0000000..9778906 --- /dev/null +++ b/rust/perspective-js/src/ts/wasm/decompress.ts @@ -0,0 +1,64 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// @ts-ignore +import { extract } from "pro_self_extracting_wasm"; + +if (typeof WebAssembly === "undefined") { + throw new Error("WebAssembly not supported."); +} + +interface Module extends WebAssembly.Exports { + size(): number; + offset(): number; + memory: WebAssembly.Memory; +} + +// Perform a silly dance to deal with the different ways webpack and esbuild +// load binary, as this may either be an `ArrayBuffer` or `URL` depending +// on whether `inline` option was specified to `perspective-esbuild-plugin`. +async function compile( + buffer: ArrayBuffer | Response | WebAssembly.Module | WebAssembly.Exports, +): Promise { + if (buffer instanceof Response) { + return (await WebAssembly.instantiateStreaming(buffer)).instance + .exports as Module; + } else if (buffer instanceof WebAssembly.Module) { + return (await WebAssembly.instantiate(buffer)).exports as Module; + } else if (buffer instanceof WebAssembly.Instance) { + return buffer.exports as Module; + } else if (buffer instanceof ArrayBuffer) { + return (await WebAssembly.instantiate(buffer as BufferSource)).instance + .exports as Module; + } else { + return buffer as Module; + } +} + +export async function load_wasm_stage_0( + wasm: + | ArrayBuffer + | Response + | WebAssembly.Module + | (() => Promise), +): Promise { + if (wasm instanceof Function) { + wasm = await wasm(); + } + + try { + return await extract(wasm as ArrayBuffer); + } catch (e) { + console.warn("Stage 0 wasm loading failed, skipping"); + return new Uint8Array(wasm as ArrayBuffer); + } +} diff --git a/rust/perspective-js/src/ts/wasm/emscripten_api.ts b/rust/perspective-js/src/ts/wasm/emscripten_api.ts new file mode 100644 index 0000000..d9e43fb --- /dev/null +++ b/rust/perspective-js/src/ts/wasm/emscripten_api.ts @@ -0,0 +1,88 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 perspective_server from "./perspective-server.poly.ts"; +export type * from "../../../dist/wasm/perspective-js.js"; +import type * as perspective_server_t from "@perspective-dev/server/dist/wasm/perspective-server.js"; + +export type PspPtr = BigInt | number; +export type EmscriptenServer = bigint | number; + +export interface DiskBridgeHelpers { + heap: () => Uint8Array; + toAddr: (p: number | bigint) => number; + readCString: (p: number | bigint) => string; +} + +export interface CompileOptions { + make_disk_bridge?: (helpers: DiskBridgeHelpers) => { + store( + namePtr: number | bigint, + dataPtr: number | bigint, + len: number, + ): number; + load( + namePtr: number | bigint, + dataPtr: number | bigint, + len: number, + ): number; + remove(namePtr: number | bigint): void; + ensureOpen(name: string): Promise; + }; +} + +export async function compile_perspective( + wasmBinary: ArrayBuffer, + opts?: CompileOptions, +): Promise { + const module = await perspective_server.default({ + locateFile(x: any) { + return x; + }, + make_disk_bridge: opts?.make_disk_bridge, + instantiateWasm: async ( + imports: any, + receive: (_: WebAssembly.Instance) => void, + ) => { + imports["env"] = { + ...imports["env"], + psp_stack_trace() { + const str = Error().stack || ""; + const textEncoder = new TextEncoder(); + const bytes = textEncoder.encode(str); + const ptr = module._psp_alloc( + module._psp_is_memory64() + ? (BigInt(bytes.byteLength + 1) as any as number) + : bytes.byteLength + 1, + ); + + module.HEAPU8.set(bytes, Number(ptr)); + module.HEAPU8[Number(ptr) + bytes.byteLength] = 0; + return ptr; + }, + psp_heap_size() { + if (module._psp_is_memory64()) { + return BigInt(module.HEAPU8.buffer.byteLength); + } else { + return module.HEAPU8.buffer.byteLength; + } + }, + }; + + const webasm = await WebAssembly.instantiate(wasmBinary, imports); + receive(webasm.instance); + return webasm.instance.exports; + }, + }); + + return module; +} diff --git a/rust/perspective-js/src/ts/wasm/engine.ts b/rust/perspective-js/src/ts/wasm/engine.ts new file mode 100644 index 0000000..a176434 --- /dev/null +++ b/rust/perspective-js/src/ts/wasm/engine.ts @@ -0,0 +1,316 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type { MainModule } from "@perspective-dev/server/dist/wasm/perspective-server.js"; +import type { EmscriptenServer, PspPtr } from "./emscripten_api.ts"; + +export type ApiResponse = { + client_id: number; + data: Uint8Array; +}; + +export interface PerspectiveServerOptions { + on_poll_request?: (x: PerspectiveServer) => Promise; +} + +/** + * A server-wide FIFO async mutex. + */ +class AsyncLock { + private tail: Promise = Promise.resolve(); + run(fn: () => Promise | T): Promise { + const result = this.tail.then(fn, fn); + this.tail = result.then( + () => undefined, + () => undefined, + ); + + return result; + } +} + +async function residency_safepoint( + mod: MainModule, + lock: AsyncLock, + server: EmscriptenServer, +) { + const safepoint = (mod as any).psp_residency_safepoint; + if (safepoint) { + await lock.run(() => safepoint(server)); + } +} + +export class PerspectivePollThread { + private poll_handle?: Promise; + private server: PerspectiveServer; + constructor(server: PerspectiveServer) { + this.server = server; + } + + private set_poll_handle() { + this.poll_handle = new Promise((resolve, reject) => + setTimeout(() => + this.server + .poll() + .then(resolve) + .catch(reject) + .finally(() => { + this.poll_handle = undefined; + }), + ), + ); + + return this.poll_handle; + } + + async on_poll_request() { + if (!this.poll_handle) { + await this.set_poll_handle(); + } else { + await this.poll_handle.then(() => { + if (!this.poll_handle) { + return this.set_poll_handle(); + } + }); + } + } +} + +export class PerspectiveServer { + clients: Map Promise>; + server: EmscriptenServer; + module: MainModule; + on_poll_request?: (x: PerspectiveServer) => Promise; + lock: AsyncLock; + + constructor(module: MainModule, options?: PerspectiveServerOptions) { + this.clients = new Map(); + this.module = module; + this.on_poll_request = options?.on_poll_request; + this.lock = new AsyncLock(); + this.server = module._psp_new_server( + !!options?.on_poll_request ? 1 : 0, + ) as EmscriptenServer; + } + + /** + * Helper function to create server emitter/receiver pairs + */ + make_session( + callback: (buffer: Uint8Array) => Promise, + ): PerspectiveSession { + const client_id = this.module._psp_new_session(this.server as any); + this.clients.set(client_id, callback); + return new PerspectiveSession( + this.module, + this.server, + client_id, + this.clients, + this.lock, + this.on_poll_request && (() => this.on_poll_request!(this)), + ); + } + + async poll() { + const polled = await this.lock.run(() => + this.module._psp_poll(this.server as any), + ); + + await decode_api_responses( + this.module, + polled, + async (msg: ApiResponse) => { + await this.clients.get(msg.client_id)!(msg.data); + }, + ); + + await residency_safepoint(this.module, this.lock, this.server); + } + + delete() { + this.module._psp_delete_server(this.server as any); + } +} + +export class PerspectiveSession { + constructor( + private mod: MainModule, + private server: EmscriptenServer, + private client_id: number, + private client_map: Map Promise>, + private lock: AsyncLock, + private on_poll_request?: () => Promise, + ) {} + + async handle_request(view: Uint8Array) { + const ptr = await convert_typed_array_to_pointer( + this.mod, + view, + async (viewPtr) => { + const len = this.mod._psp_is_memory64() + ? (BigInt(view.byteLength) as any as number) + : (view.byteLength as any); + + return this.lock.run(() => + this.mod._psp_handle_request( + this.server as any, + this.client_id, + viewPtr as any, + len, + ), + ); + }, + ); + + await decode_api_responses(this.mod, ptr, async (msg: ApiResponse) => { + await this.client_map.get(msg.client_id)!(msg.data); + }); + + if (this.on_poll_request) { + await this.on_poll_request(); + } else { + await this.poll(); + } + + await residency_safepoint(this.mod, this.lock, this.server); + } + + private async poll() { + const polled = await this.lock.run(() => + this.mod._psp_poll(this.server as any), + ); + await decode_api_responses( + this.mod, + polled, + async (msg: ApiResponse) => { + if (msg.client_id === 0) { + await this.client_map.get(this.client_id)!(msg.data); + } else { + await this.client_map.get(msg.client_id)!(msg.data); + } + }, + ); + } + + close() { + this.mod._psp_close_session(this.server as any, this.client_id); + } +} + +async function convert_typed_array_to_pointer( + core: MainModule, + array: Uint8Array, + callback: (_: PspPtr) => Promise, +): Promise { + const ptr = core._psp_alloc( + core._psp_is_memory64() + ? (BigInt(array.byteLength) as any as number) + : (array.byteLength as any), + ); + + core.HEAPU8.set(array, Number(ptr) >>> 0); + const msg = await callback(Number(ptr) >>> 0); + core._psp_free(Number(ptr) >>> 0); + return msg; +} + +/** + * Convert a pointer to WASM memory into an `ApiResponse[]`, via a custom + * encoding. + * + * @param core The emscripten API + * @param ptr A pointer to a fixed-sized struct representing a set of + * `proto::Resp` payloads, encoded as a length-prefixed array of + * (char* data, u32_t len, u32_t client_id) tuples: + * + * ```text + * N data length client_id data length client_id + * +-------------------------------------------------------------+ + * | 2 | 0xabc | 9 | 0 | 0xdef | 12 | 0 | + * +-------------------------------------------------------------+ + * | | + * | +-------------+ | +----------------+ + * +--| "Test Data" | +--| "Hello, World" | + * +-------------+ +----------------+ + * ``` + * + * @param callback A callback to which is passed the responses. THe responses + * must be fully processed or copied before the callback returns, as it + * references memory on the wasm stack. + */ +async function decode_api_responses( + core: MainModule, + ptr: PspPtr, + callback: (_: ApiResponse) => Promise, +) { + const is_64 = core._psp_is_memory64(); + const response = new DataView( + core.HEAPU8.buffer, + Number(ptr) >>> 0, + is_64 ? 12 : 8, + ); + + const num_msgs = response.getUint32(0, true); + const msgs_ptr = is_64 + ? response.getBigInt64(4, true) + : response.getUint32(4, true); + + const messages = new DataView( + core.HEAPU8.buffer, + Number(msgs_ptr), + num_msgs * (is_64 ? 16 : 12), + ); + + try { + for (let i = 0; i < num_msgs; i++) { + const [data_ptr, data_len, client_id] = is_64 + ? [ + messages.getBigInt64(i * 16, true), + messages.getInt32(i * 16 + 8, true), + messages.getInt32(i * 16 + 12, true), + ] + : [ + messages.getUint32(i * 12, true), + messages.getUint32(i * 12 + 4, true), + messages.getInt32(i * 12 + 8, true), + ]; + + const data = new Uint8Array( + core.HEAPU8.buffer, + Number(data_ptr), + data_len, + ); + + const resp = { client_id, data }; + await callback(resp); + } + } finally { + for (let i = 0; i < num_msgs; i++) { + const data_ptr = is_64 + ? messages.getBigInt64(i * 16, true) + : messages.getInt32(i * 12, true); + + core._psp_free(data_ptr as any); + } + + core._psp_free( + is_64 + ? (BigInt(messages.byteOffset) as any as number) + : (messages.byteOffset as any), + ); + core._psp_free( + is_64 + ? (BigInt(response.byteOffset) as any as number) + : (response.byteOffset as any), + ); + } +} diff --git a/rust/perspective-js/src/ts/wasm/perspective-server.poly.ts b/rust/perspective-js/src/ts/wasm/perspective-server.poly.ts new file mode 100644 index 0000000..b3787a0 --- /dev/null +++ b/rust/perspective-js/src/ts/wasm/perspective-server.poly.ts @@ -0,0 +1,414 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type * as perspective_server from "@perspective-dev/server/dist/wasm/perspective-server.js"; + +var out = console.log.bind(console); +var err = console.error.bind(console); + +var UTF8Decoder = new TextDecoder("utf8"); +var printCharBuffers: Array | null> = [null, [], []]; + +function UTF8ArrayToString( + heapOrArray: Uint8Array | Array, + idx = 0, + maxBytesToRead = NaN, +) { + var endIdx = idx + maxBytesToRead; + var endPtr = idx; + while (heapOrArray[endPtr] && !(endPtr >= endIdx)) ++endPtr; + return UTF8Decoder.decode( + heapOrArray instanceof Uint8Array + ? heapOrArray.subarray(idx, endPtr) + : new Uint8Array(heapOrArray.slice(idx, endPtr)), + ); +} + +function printChar(stream: number, curr: number) { + var buffer: Array = printCharBuffers[stream]!; + if (curr === 0 || curr === 10) { + (stream === 1 ? out : err)(UTF8ArrayToString(buffer, 0)); + buffer.length = 0; + } else { + buffer.push(curr); + } +} + +export default async function (obj: any) { + let psp_module: perspective_server.MainModule; + let is_memory64 = false; + let wasm_memory: WebAssembly.Memory; + + // A `BACKING_STORE_DISK` column's bytes live in wasm linear memory; the + // residency manager flushes/loads them to/from OPFS through these imports. + const heap = () => new Uint8Array(wasm_memory.buffer); + const toAddr = (p: number | bigint) => + is_memory64 ? Number(p as bigint) : (p as number) >>> 0; + + function readCString(p: number | bigint) { + const addr = toAddr(p); + const h = heap(); + let end = addr; + while (h[end]) ++end; + return UTF8Decoder.decode(h.subarray(addr, end)); + } + + // A `DiskBridge` backs `BACKING_STORE_DISK` columns: `store`/`load` flush and + // re-read a column buffer to/from disk synchronously (the only async step, + // `ensureOpen`, is hoisted into the JS safepoint between engine calls). The + // browser default is OPFS; a host can inject an alternative (e.g. `node:fs` + // in `perspective.node.ts`) via `obj.make_disk_bridge`. `store`/`load`/ + // `remove` receive raw wasm pointers and use the heap helpers; `ensureOpen` + // receives a resolved file name string (from `victim_fname`). + const disk_helpers = { heap, toAddr, readCString }; + + function makeOpfsBridge() { + async function opfsOpenFile(name: string, create: boolean) { + const parts = name.split("/").filter((s) => s.length > 0); + // @ts-ignore — navigator.storage.getDirectory is OPFS, Worker-only. + let dir = await navigator.storage.getDirectory(); + for (let i = 0; i < parts.length - 1; i++) { + dir = await dir.getDirectoryHandle(parts[i], { create }); + } + return await dir.getFileHandle(parts[parts.length - 1], { create }); + } + + // One OPFS `FileSystemSyncAccessHandle` is kept open per disk-backed file + // for its lifetime. + const handles = new Map(); + + return { + async ensureOpen(name: string) { + if (handles.has(name)) { + return; + } + const fh = await opfsOpenFile(name, true); + // @ts-ignore — createSyncAccessHandle is Worker-only OPFS. + const ah = await fh.createSyncAccessHandle(); + handles.set(name, ah); + }, + store( + namePtr: number | bigint, + dataPtr: number | bigint, + len: number, + ): number { + try { + const ah = handles.get(readCString(namePtr)); + if (!ah) { + // `commit` only evicts victims whose handle the safepoint + // just opened, so this should be unreachable. + return -1; + } + ah.truncate(len); + if (len > 0) { + const addr = toAddr(dataPtr); + ah.write(heap().subarray(addr, addr + len), { at: 0 }); + } + ah.flush(); + return len; + } catch (e) { + err("psp_opfs_store failed", e); + return -1; + } + }, + load( + namePtr: number | bigint, + dataPtr: number | bigint, + len: number, + ): number { + try { + if (len <= 0) { + return 0; + } + const ah = handles.get(readCString(namePtr)); + // A restore always follows an evict that left the handle open; + // a miss (or never-flushed file) reads as zeros. + if (!ah) { + return 0; + } + const addr = toAddr(dataPtr); + return ah.read(heap().subarray(addr, addr + len), { + at: 0, + }); + } catch (e) { + return 0; + } + }, + remove(namePtr: number | bigint) { + try { + const name = readCString(namePtr); + const ah = handles.get(name); + if (ah) { + try { + ah.close(); + } catch (e) { + /* already closed */ + } + handles.delete(name); + } + // Fire-and-forget the (async) file deletion; the handle is + // closed, so the bytes are no longer referenced. + void (async () => { + try { + const parts = name + .split("/") + .filter((s) => s.length > 0); + // @ts-ignore + let dir = await navigator.storage.getDirectory(); + for (let i = 0; i < parts.length - 1; i++) { + dir = await dir.getDirectoryHandle(parts[i], { + create: false, + }); + } + await dir.removeEntry(parts[parts.length - 1]); + } catch (e) { + /* already gone */ + } + })(); + } catch (e) { + /* already gone */ + } + }, + }; + } + + // The host may inject a disk bridge (e.g. `node:fs`); otherwise default to + // OPFS. Synchronous `store`/`load`/`remove` env imports — no JSPI. + const disk = obj.make_disk_bridge + ? obj.make_disk_bridge(disk_helpers) + : makeOpfsBridge(); + + const opfs_imports: Record = { + psp_opfs_store: disk.store, + psp_opfs_load: disk.load, + psp_opfs_remove: disk.remove, + }; + const module = { + HaveOffsetConverter() { + console.error("HaveOffsetConverter"); + }, + __syscall_ftruncate64(...args: any[]) { + console.error("__syscall_frtuncate64", args); + }, + __syscall_getdents64(...args: any[]) { + console.error("__syscall_frtuncate64", args); + }, + __syscall_unlinkat(...args: any[]) { + console.error("__syscall_frtuncate64", args); + }, + __throw_exception_with_stack_trace(ex: number) { + // @ts-ignore + const e = new WebAssembly.Exception( + (psp_module as any).__cpp_exception, + [ex], + { + traceStack: true, + }, + ); + + // @ts-ignore This is helpful for debugging + e.message = "Unexpected internal error"; + throw e; + }, + clock_time_get( + clk_id: number, + ignored_precision: bigint, + ptime: bigint | number, + ) { + if (is_memory64) { + ptime = ptime as bigint; + ptime = Number(ptime); + if ( + !(clk_id == 0 || clk_id == 1 || clk_id == 2 || clk_id == 3) + ) { + return 28; + } + var now; + + if (clk_id === 0) { + now = Date.now(); + } else { + now = performance.now(); + } + + const nsec = Math.round(now * 1000 * 1000); + const HEAP64 = new BigInt64Array(wasm_memory.buffer); + HEAP64[ptime / 8] = BigInt(nsec); + return 0; + } else { + ptime = ptime as number; + ptime >>>= 0; + if ( + !(clk_id == 0 || clk_id == 1 || clk_id == 2 || clk_id == 3) + ) { + return 28; + } + + var now; + if (clk_id === 0) { + now = Date.now(); + } else { + now = performance.now(); + } + + var nsec = Math.round(now * 1e6); + const HEAP64 = new BigInt64Array(wasm_memory.buffer); + HEAP64[ptime >>> 3] = BigInt(nsec); + return 0; + } + }, + emscripten_asm_const_int(...args: any[]) { + return 0; + }, + emscripten_notify_memory_growth(memoryIndex: bigint | number) { + if (is_memory64) { + memoryIndex = Number(memoryIndex as bigint); + } else { + memoryIndex = memoryIndex as number; + memoryIndex >>>= 0; + } + + if (memoryIndex != 0) { + console.error("abort"); + } + }, + environ_get(...args: any[]) { + return 0; + }, + environ_sizes_get(...args: any[]) { + return 0; + }, + fd_close(...args: any[]) { + console.error("fd_close", args); + return 0; + }, + fd_read(...args: any[]) { + console.error("fd_read", args); + return 0; + }, + fd_seek(...args: any[]) { + console.error("fs_seek", args); + return 0; + }, + fd_write( + fd: number, + iov: number | bigint, + iovcnt: number | bigint, + pnum: number | bigint, + ) { + const HEAPU8 = new Uint8Array(wasm_memory.buffer); + + if (!is_memory64) { + iov = iov as number; + iovcnt = iovcnt as number; + pnum = pnum as number; + iov >>>= 0; + iovcnt >>>= 0; + pnum >>>= 0; + let num = 0; + const HEAPU32 = new Uint32Array(wasm_memory.buffer); + + for (let i = 0; i < iovcnt; i++) { + let ptr = HEAPU32[(iov >>> 2) >>> 0]; + let len = HEAPU32[((iov + 4) >>> 2) >>> 0]; + iov += 8; + for (let j = 0; j < len; j++) { + printChar(fd, HEAPU8[(ptr + j) >>> 0]); + } + num += len; + } + HEAPU32[(pnum >>> 2) >>> 0] = num; + return 0; + } else { + iov = Number(iov as bigint); + iovcnt = Number(iovcnt as bigint); + pnum = Number(pnum as bigint); + let num = 0; + const HEAPU64 = new BigUint64Array(wasm_memory.buffer); + + for (let i = 0; i < iovcnt; i++) { + let ptr = Number(HEAPU64[iov / 8]); + let len = Number(HEAPU64[(iov + 8) / 8]); + iov += 16; + for (let j = 0; j < len; j++) { + printChar(fd, HEAPU8[ptr + j]); + } + + num += len; + } + HEAPU64[pnum / 8] = BigInt(num); + return 0; + } + }, + proc_exit(e: number) { + console.error("proc_exit", e); + return 0; + }, + ...opfs_imports, + }; + + const x = await obj.instantiateWasm( + { env: module, wasi_snapshot_preview1: module }, + (instance: WebAssembly.Instance) => { + psp_module = + instance.exports as any as perspective_server.MainModule; + + // @ts-ignore + is_memory64 = !!psp_module.psp_is_memory64(); + wasm_memory = instance.exports.memory as WebAssembly.Memory; + + // @ts-ignore + psp_module._initialize(); + }, + ); + + // No JSPI: every export is exposed verbatim (emscripten `_`-prefixed alias). + // Restore is a synchronous import, so `psp_handle_request`/`psp_poll` stay + // synchronous WASM calls. + const extensions: Record = {}; + for (const [name, func] of Object.entries(x)) { + extensions[`_${name}`] = func; + } + + // JS-driven residency safepoint. The only async OPFS step — opening each + // victim's sync access handle — happens HERE, between the two synchronous C++ + // phases: `prepare` picks the cold victims (no freeing), we open their + // handles, then `commit` flushes + frees them (now a synchronous write). The + // `engine.ts` server-wide lock serializes this against engine calls. A no-op + // when nothing is over budget. + const psp_residency_safepoint = async (server: number | bigint) => { + const mod = psp_module as any; + if (!mod.psp_residency_prepare) { + return; + } + const n = Number(mod.psp_residency_prepare(server)); + for (let i = 0; i < n; i++) { + const fname = readCString( + mod.psp_residency_victim_fname(server, i), + ); + if (fname) { + await disk.ensureOpen(fname); + } + } + mod.psp_residency_commit(server); + }; + + return { + ...x, + ...extensions, + psp_residency_safepoint, + get HEAPU8() { + // @ts-ignore + return new Uint8Array(wasm_memory.buffer); + }, + }; +} diff --git a/rust/perspective-js/src/ts/websocket.ts b/rust/perspective-js/src/ts/websocket.ts new file mode 100644 index 0000000..e29d11a --- /dev/null +++ b/rust/perspective-js/src/ts/websocket.ts @@ -0,0 +1,95 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 type * as perspective_client from "../../dist/wasm/perspective-js.js"; + +function invert_promise(): [(t: T) => void, Promise, (t: any) => void] { + let sender: ((t: T) => void) | undefined = undefined, + reject: ((t: any) => void) | undefined = undefined; + let receiver: Promise = new Promise((x, u) => { + sender = x; + reject = u; + }); + + return [sender!, receiver, reject!]; +} + +export async function websocket( + WebSocket: typeof window.WebSocket, + Client: typeof perspective_client.Client, + url: string | URL, + options?: { maxPayload: number }, +): Promise { + let client: perspective_client.Client, ws: WebSocket; + + async function connect() { + if ( + ws?.readyState === WebSocket.CONNECTING || + ws?.readyState === WebSocket.OPEN + ) { + console.warn(`Already connected ${ws.readyState}`); + return; + } + + let [sender, receiver, reject] = invert_promise(); + + // @ts-ignore + ws = new WebSocket(url, options); + ws.onopen = sender; + ws.binaryType = "arraybuffer"; + ws.onerror = (event) => { + // @ts-expect-error + const msg = event.message || "Generic Websocket Error"; + client.handle_error(msg, connect); + reject(msg); + }; + + ws.onclose = (event) => { + // https://www.rfc-editor.org/rfc/rfc6455#section-7.4.1 + const msg = `WebSocket closed ${event.code}`; + client.handle_error(msg, connect); + reject(msg); + }; + + ws.onmessage = (event) => { + client.handle_response(event.data); + }; + + await receiver; + } + + async function send_message(proto: Uint8Array) { + if ( + ws.readyState === WebSocket.CLOSING || + ws.readyState === WebSocket.CLOSED + ) { + const msg = `WebSocket transport error (${ws.readyState})`; + client.handle_error(msg, connect); + throw new Error(msg); + } else if (ws.readyState === WebSocket.CONNECTING) { + const msg = `WebSocket message dropped (${ws.readyState})`; + throw new Error(msg); + } else { + const buffer = proto.slice().buffer; + ws.send(buffer); + } + } + + async function on_close() { + console.debug("Closing WebSocket"); + ws.close(); + } + + client = new Client(send_message, on_close); + await connect(); + return client; +} diff --git a/rust/perspective-js/test/arrow/all_types_small.arrow b/rust/perspective-js/test/arrow/all_types_small.arrow new file mode 100644 index 0000000000000000000000000000000000000000..cc4b5882093f8b0cb13b5de807161adc50cea06c GIT binary patch literal 3680 zcmds3O>7%Q6n;q@$4#9kMQy22Qmhq}3Pp+SIB8Px<1~$N)1+~V6PlEgIFIh7(% zO{Rp5B2Zn7z-vi4DoIci+C`@V*ODojJwd061gZ&fMIumLk-%$7IVwp|6WT?m0@so$ znSCLriUg_&aYZ6fU6H_RNjWM>P!rlkrvlfKNjp6v32HLwOfKPAkS$PC%4zP>j#FpL zxO47ofm%{?xm>3DIVn`DOTjyp6k1DWu~?{9k)S40%9``JWQyrKb#il;Q}ac}Ybjw} zvaa2^&M8#WQt*EjDw|=61*biclBx;~7 z!S^u#FpP6>H{ zAHFjB*3zw~w`@FnO{|L#AOCLD=f;m)J>3%gdZ)Ztk7(P9Q(YMBEgM&_pVv;#cn2Kj zwp;g8=bRV!5fk%N=L0ut<5uUjZkkSj!`z~ctIsR;IC@{^<$A8!xYc<)ilU$7h{3JXxO0ChHW`CeKQuW_u2vdz@tO&vWmx=AN(T2HCO) z1g?dfmkuG$Cor#-l^R^uyLKDzv1rZk#d(X*Xq&+K{u^bKyAIn}A1a>GS8?U5IOnuC zCY}n{-E9|tH(q=R zd+{(--FxV6*^4h^8!IxmgD~c`!tRCbhjIG@-_`x-d47Mv_%|5iNBIwp=aQRRmwSXu zkIMeI?7zo#+|+yhlW@x(8UOIdhi@k@|NJ=IOIsfetfRmB*n1C#(TAV@^Umk!^XG1K zucx;^d3VFTcYP-E>`OQ4o^baA`+x5!91S07zxhh;%Z~7^`(Awe*A{QM1sABs-|-RH zBd~E8ze|y^fq}E}iO|_6&X0~f85)nB9*T~QobM0BCxT4Xyxu}zWM6xJ?x91p Q`SEuzPpr1`G~ssXA3X{Ah5!Hn literal 0 HcmV?d00001 diff --git a/rust/perspective-js/test/arrow/all_types_small_multi.arrow b/rust/perspective-js/test/arrow/all_types_small_multi.arrow new file mode 100644 index 0000000000000000000000000000000000000000..b3f39929d231b5184e38252f8e0fa4df52fed316 GIT binary patch literal 5952 zcmeHLU2IfE6rOFDWvLA;6%kFPc$1v*kmq|*iq*nqp@RAHiIN?%fVJB zid;O@WS5Gv8ZTnq8J?oqiKf?XiL}w6=}n}fwh37r zj#PGY*K0d!V%V{j zBSQ*yTt%}}W+)JLR7JC~7cEciZ9UE?61H?~{+H>xQ>jF0m1nC##^&*fvDIl3`?Hx+ z7Hu?0a>@9nR8NfH<9SluTVk8CWOri1sYc#A{*=U4gNx0#OO%b{WL9z{qp0k$A!OHh zQogCFaYM3yqg|Hrw3vxSv#AV@ZQBEhcy}h68K9G4dLWv~q@Jg{w#G93@l=wm4fVnL zU;t;kfF6a#7pf1{&kgLr8Px+(ej#SydK(V2K|0VvPE-mp2uVZ25Fg}pg%AfJyC5qd zwU7}Ueh)!jf~sEZY@^D_6~=X)ympn~Y3)>n72-i)2-{Hx zOJkOmAN^h(+mGu#W$-Gt8u$yq5caDwSQ(pM&cIyMu_?E6W$-Gty5STsg!Rd}Db11A zfsPpHL_}hQi@vSGW$-i>c_u>N01ROh3M<#q+!t6!aSHo_*6F~{E7C9I8jf19=02u1L~!v7Fk$qyay)7-O5$Q1ZPG7XeqbXOY-wCM zXt59&!e%YllDMHJF{Iy?1y#s2gh9UignI2V)$8S zf0yBBeU`TGG5oBv|DfS#o&CdxpLO=je}-5wcpp&X`SEI9S+PG1zt8Zq&hta>7i(jk z{XxUeI?qqU@UzbTF2m0{=eNi3v(Em5hM#rz4;y~g*^kmPzG*Nhdm%n8R7M*{T!r@PTX2^Qg|xul;{& z{i^O62lqEqLN$5x>4%7+tPC1C z0*hNJbo$3N~4@Ui1RIQv^4 dy713FYjSVZ@4VB$^ZhR}{GEN)%~>Amac4 literal 0 HcmV?d00001 diff --git a/rust/perspective-js/test/arrow/bool.arrow b/rust/perspective-js/test/arrow/bool.arrow new file mode 100644 index 0000000000000000000000000000000000000000..31b8c391e3d7d84a7234e71f0de2fccd53bbf47d GIT binary patch literal 450 zcmcIhF%H5o47`#yM3sjOfOrBcJ4;`H)Pp8a>y~Lo8)~LatY4R^>>N$pmowd{(h>C+PmMyEl1<@^>>g84alb literal 0 HcmV?d00001 diff --git a/rust/perspective-js/test/arrow/chunked.arrow b/rust/perspective-js/test/arrow/chunked.arrow new file mode 100644 index 0000000000000000000000000000000000000000..6ec9d31b81513d563e1d0ea4cceb686b9f0df61e GIT binary patch literal 6114 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 literal 0 HcmV?d00001 diff --git a/rust/perspective-js/test/arrow/date32.arrow b/rust/perspective-js/test/arrow/date32.arrow new file mode 100644 index 0000000000000000000000000000000000000000..9487d4c90770d149894c1f979d2b8d976a99ebc8 GIT binary patch literal 1112 zcmZ|OSx*yD6u|LQ3S~n2&<8?z(+5g%FfUsSI3 zYYFNnJ@q$hZye1Zl62M#NUF1&ko$f<>m+lf!|YF}(YgR_8oJM0(fXrxncg`c9itJH z3~Kd+g3iq1_lv%BD9P#Ol13hMPD{_QU;A(ZCUlKN5pz4t-PFd9xAV=sVYzwCZH&9Q z8Q*)XU(56iy!+BNlbhEux%O$8+$_I-MPvh;*uocV<12RX4d3wtKe3BF?Bf>>@Ed<{ zh`;y;8*-Ij6q4XaGExvgDm3^!&X$_4i8SL5?xF?va32rQiidcF7#^by?dU)!p5Q6s z=)yB}<2hcS2QSf!K6oRG{hY#SoIy5na2C19Lp};nh%kx}K{3vu1m{tTGL+*2Do}}w zxP&TH<1%WX!8ugv{T2Hf!Z2Rr4Ms4EF^uCa-r+qaFo`KlV+J2Ei;wt(Ief-E7O;pV OEMo<$Froh!ef|%x6P+9Y literal 0 HcmV?d00001 diff --git a/rust/perspective-js/test/arrow/date64.arrow b/rust/perspective-js/test/arrow/date64.arrow new file mode 100644 index 0000000000000000000000000000000000000000..18d10a210ed1f12a24daa046de26470dd6eb97fa GIT binary patch literal 1576 zcmZ|PZAep57zgk(XLi$@SS(#AGz4pep{^;FrYpiG6I-O2HnN$MW!a`pNL(6fq$Otb z!pcQtlMJLpi}qn?)(FE91_g`yAYm^bMwuAwL%7cQ@7_^?2k!ly=RD6n_ndRbLZMKI zlMs?XSc@e*i6JWpM|-qMq%o0lR@Z?QDy>3rccbJ7v^ zt_@raE4=bNGPTLuiqXT#F$9dP7u0p#2F zK=V)$^u8{EieCqyJ-Gt3ha}o!a)8e zs5PZS*QG2-MsuLSp9|ef8YoLEgjSsn`Wp05=_rGp!JK~8MNK3 zgfkOWF#Ssn?BeU-oXP?<6*lN>YlgwWR#@umfFlcCkYC>m%>`GX*W`eTp4-qqIspCt z`%oPkhJ9(HFra%1wT&L=a!f)p@e&$7PeXUy8z%kk1vm%is;v!d|G|uHu=LQDc9yx|Y|8_fH}DWTrpDmncQ@3p8;8TWFCbSv1%;2#{tsFA5B4S* AsQ>@~ literal 0 HcmV?d00001 diff --git a/rust/perspective-js/test/arrow/dict.arrow b/rust/perspective-js/test/arrow/dict.arrow new file mode 100644 index 0000000000000000000000000000000000000000..62d20b50b0681fa3f899add35efd62cffa3ae148 GIT binary patch literal 936 zcmc&yyG{c^4D@C1E~jXV6N(@bKahfgGG#u1PoN;-p+G{%Cs9yPq~vq>lVxVTLpTHy z9hOF8k9U3c`cg{i-OPHxY+}(`YpjC@tAf>_C!8UVfDJ%@4-E&JCHw`z`)5FBQ=kT1 ztH83?e8;L~5uOn^}ZCD`0QY3o x+LSDbdza~b#q*LlFD!jgV&xff7pklMe~(YfJ(13drHfCC^IKl$l;?j-?E@}NG@Sqd literal 0 HcmV?d00001 diff --git a/rust/perspective-js/test/arrow/dict_update.arrow b/rust/perspective-js/test/arrow/dict_update.arrow new file mode 100644 index 0000000000000000000000000000000000000000..e57fa59030dc0d078bfe9e037a5ff6d2629bd94d GIT binary patch literal 920 zcmc(dJ5Iwu5QfL`Q_%`pC_*VyQqb@yD188qKodkHN{VnJ3JQvp9EY1M-@n7+7`dS1 zmwwN4XJ@<)d;r&eOpOe?6%3ePtJK$-P7xSlfMGxvz^|z3#;sLpW zLO}CL13FlgDYlUgIy;4a&l-E2V@anWMf8vEpnBE6$tbfjBYiGEqj0vR>yG0(@(C?G zNt)Tq^W%2EyLNHobG;G!`&{aJd>_5}|Ic&j^IZ8nACq~`rtegi^lLUi$F%=@ocgE7 tZ=gNM&|V5zs+O=x-_fsK6LMU--X_=0`|#UtlsJQ&U(dwk%5m+f>@PrlI!FKj literal 0 HcmV?d00001 diff --git a/rust/perspective-js/test/arrow/float32.arrow b/rust/perspective-js/test/arrow/float32.arrow new file mode 100644 index 0000000000000000000000000000000000000000..91fdf6e557a857297ae449db06ecfe589b01adf1 GIT binary patch literal 792 zcmZ9JK~BR!3`LzLEm2jGN+3WeiyVO^Tf&9~mnfwYl>$X&!&TrAh%GBN9EMwj_h(|7 z#v_kszU^<1r)}GIo|w%|GQ}R+f#sZnBzZ8IJrkF4PSH>F7S+8`KK2Yn(voeAzAQJ} zm5?A47$+=ALXDDpVW(te+?0Qb9?MNY$2FE!ji<%qKUD@IEN96Zce5Y*07xpfIZTQv z0&^aA)#&&~;vJ#udp0Du8LC$YNh%C9R=nYxMF%T5`XMRlZ$;tY|1Oe zpM{xgbE zNo90tc7b-1sYXfr|sKe|Lz^)l{-4?caIaSn*F+t$+`^vnvwpxk2@A~ QzUue45A?g%UH>lo2WU4l`v3p{ literal 0 HcmV?d00001 diff --git a/rust/perspective-js/test/arrow/int_float_str.arrow b/rust/perspective-js/test/arrow/int_float_str.arrow new file mode 100644 index 0000000000000000000000000000000000000000..17f8a6614c2a70567b3d791683312dbbd6a95f9e GIT binary patch literal 584 zcmaix%}oPA429qRBw0msSRsTs=0E}IAX^4Ul28I=PzdF4q?Fu-?|Ej#A|c_G{rvOB z}pEP1vzoX&`z-kCBx==mB~2NbEi%Y8m6Bf`3In3vW2! z-=mEu?wrsqIjIl+1#gDs{4S=L>x^ChL~VV3qCUa$lt{POpl6gT^9?lLlJr1Fzc58t z4TC)Qi;E$9;>2ua2k+$8Z!%>UyuZz%rW-a^iyJPjP_f0H#qwOBy(uE^{by%|@L%jb jSo#9-@9h3QcRQ8xxD-qk?*iS!=hye2FV|O6v0vjiW?vvI literal 0 HcmV?d00001 diff --git a/rust/perspective-js/test/arrow/int_float_str_file.arrow b/rust/perspective-js/test/arrow/int_float_str_file.arrow new file mode 100644 index 0000000000000000000000000000000000000000..fbb0836f1cd976185517a926efc1ee2bf529a05e GIT binary patch literal 866 zcmdUuJx;?w5QSfCC$XX}EQC2O;YKQio(_*>SsEB5Dj)rn{J_>7p;g#N&8K;`&6x)@52ljf4#_kN98WD$=VCP z$9=yOXuU1zf{MOjimGZ`x$m8mVGDSow>*OvV&gkmG7sJF%c_JcR#oK{*ELbFa`$3; z%uw$X%ya*qSr+`oJqKHpA>N(0KhK@+a=Ke*Oa;#ZUBlb>!3`M^!ClLY<9*Ux)haMnf$6TXh(kpam?$CquI30VGZtDBA1P4Wc&>+Rn z|4mUWrIa5@6E5vFYO9Sh8P+ARK$$qyC?(K4eu%2{MGwTAC$RerxNV3JhWrixUUX?D zf54kg+?nw$HK`B%nQTJk`YxuJ>Wp3g40rv1hR>)xCDt7s@Dt9J<(JcX+o(r6`h{b3 zRZ8;QFTpzO!6UORJNTsHH}$5f?OJl4t_kKNxi}$ei ZU|V+$m|u^sDPQ8WruF%_m{|7a^ADqJ9tZ#c literal 0 HcmV?d00001 diff --git a/rust/perspective-js/test/arrow/lists.arrow b/rust/perspective-js/test/arrow/lists.arrow new file mode 100644 index 0000000000000000000000000000000000000000..e93708246f546e8dc13c84ef7d3fb53d44a3c471 GIT binary patch literal 2790 zcmeHJziSjx5T4Clj=OkjqKBU1j|3YVA<;&#a1XAq2n2&*Iyp_&Te#f8-lp~n1wpV9 z)J81x2Uu8MnL@F&v9PxnL2#Y#y_wz3%I&F}N}T2WnD@PzZ{EE3cDJ>@zIJj?F}5f8 z8KC5pFqf#pc!eLxjH%(<54X0V)VO%PK_|S^2?FlB9BZ26Dvv40GGn`38a!rWa4HfD zt@3!`r4%@S^h;&_PdA>dIdstl{lkyXTX`+8KNWm`V1xgUI=`Kd$+rdHl+S#*lYf){ b{~`a{e+b^)u72R89pNH~GY5S>e6?h;N2B9cPnkO%=AzmODymtMre!opI7bT)!Nz#m{?VQC@$21^SI zODlhYKfpr7%JaRQnIxVH7J>tFvv20zo4wt=EsCO8s))3N)|6E0G9d{@u&4?wKnb2T zdy*w`ABmj8ci|oQ3vzexbNB{LAy%z{p57*b^!gv|*G~9WD z`6TcYSXZo1?LUC?=>9C%0Sc#VHdsOL^-cL|ZBo2FyQbY|L6M;l9v@Lo_uqQ2*8T8`GNrSRftHQmx z!P!u20;l*+JzM+8^E<*hGlpv!{1nKgc~msQp3l5s(J|*jv*{}s!(QJRR&?OFYx$E> z-Lcm%OLfPt^IWPs_If^CFC{wm`dO*&*!%M>uIc!?dXCZUzw5XXYW29$OebwdWgPAX zp5Is+_?lFPM4aXC^1My-obzAeeG$VOaDFTQ`;o%$cz7jfGV=eSQJbdsk@g^FADXW# z`_a6oqQBQu-M3ARNv?6qV%y@J#d(Vh7P}S~E#5*W$M_6>3x9^U`XXI;4&UqZzZcjC D|IBa= literal 0 HcmV?d00001 diff --git a/rust/perspective-js/test/arrow/partial.arrow b/rust/perspective-js/test/arrow/partial.arrow new file mode 100644 index 0000000000000000000000000000000000000000..5b0b93f0b933856c66563bedf35383bfd852f23e GIT binary patch literal 424 zcmZ8dI}XAy41HV1IJ)uLqh7cI06UZKIwZVmMUIkC(m~LE2We- zfQbT($mq~w;EM>69ApD^sv2m>Ze{_#{LjE{Pp%w^A|yLM{#cfmY!hbY=+Il%sn!h5 zfxg-PC`$D*rMG&d;Yt-L&&wGsS3Z0zUdY(hj@3XMmlS?Dfgk)>w_hor1H({nmhu&1D+A}mq zp3QbgRqB^%z4apv7rID$oKIl8^5$OkLdLF6oJ|!mLbDunXc)<}O|LiIXqr=c4xici fHcCtIxUwJD&p7liTV3VNg25rTh$-5su6MR}f?dF9>NJ(assNcK4QEwQttGU2IwRzZxykP+MM*sR|12y_# zFz~w$Xq38>gMH&Vh`ot%kEX-@gFIpwOg!J4cxXDB4hBaX^@LQrl_O=qJSoHENEu?0 zRG24aK}RZ#CV4`0c%e+*s8h{H1-cBPg+$}RLK9{Is2@Nvlc-e4RYrH&bnUoZ)A zx--W*$v7QLQt4g^({A01#`p7PgAvCz_b?cZ!#HAKXDabbRO&ATwnxZ& z$Xm!&d7|}IfjHBX*=$Cf%Sro%>E8<5FtM8` z#$o-q#c01VewFihXWV8ROlAFNY;I&?7f=`dD}IdkWSie>-1G(0r$P+i<|)3vT3>2$ zUJxAA!&7|!w!U@oy}qe_w!RmEx$|DdT-y04bfiwZrT#H>N*rUd-+yYnsQb}}AJ^pU zd`--J*flBD!6x2nLjyK$XMkE>MD zS|@bWDc%188s@lGEX(4<#Jl?{{gzUAu`gMmBZOlMzvu8`OFKU}yx3+9lsa~Jv8xt; z$$2LAzS*X`Pq%RrI!Zl2K1P0mEa@#c zrgBFYs%Fb|Tb}E1uQGAJGRb+6eJf*j_w+lQN54Fun?kWvCYRiod3AXn^npMHt=atP zvPZ`spIz1EtJf`Ee!6x;myg@qEV-v@I6({?4EYR2?Dbtdxqj?7n`u_@K>ETC?8KP* zN&YUb>rAqD>e`aW@~V#C2yh iyKeRei!jTpI>Pqys&2mBh_C(H;Fiu>Sydl(@10 literal 0 HcmV?d00001 diff --git a/rust/perspective-js/test/arrow/test_null.arrow b/rust/perspective-js/test/arrow/test_null.arrow new file mode 100644 index 0000000000000000000000000000000000000000..2d5345b96b9ed473d0d4cffcf5cc98ab8aa0c496 GIT binary patch literal 5178 zcmeHLPiP!f7=OF7*@m#z7}^p=%CLkICDd&aL&&9D^-zkS6vaZ7b+R*SM|Nk!{s}E@ zAl?*85%eM`EIpJSa!5s_$Dkghm-Zl1JV*~B@gS7qp&&K>e(%p@_wDT73;V*G?|r}T z-+SMi_szbXI(zo5_f7~Q&L0qB5;P|cimVtD<8q8g%8|Gb6Oaj!fn);o{S3ztqZ1#; zlQP<|(^nRK&5L}mDa0`-U(Sb-A6zVz>(zEGYUWqm^B>H)dBv8bEU-|?Ud~5eEr5Ey zvb30oMI{WXe#rt=q*CkBno_OSy_Qms+O@SyNrGC?@_eu5!FANGRyXF{u22i}38562 z6iP`FLMg$pP+`)lG-!niqr^4E@X#vLuY82j^HiS{| z&@jshQ6$zMM8bVBBswg_YQ0|V4`NU&jDdT@7<5>e@&&KaAI6|on06GF>wZA}-bnS; zkArfH_^?=hsC0TykU_0jWv>-{RBwc3Po|+a9yZBNAP!HvRx(!dD8GvN)Ej=#2z(r} zidSt08yh_II@Eg5XomHOXS;BGe*SnNzoF{M*J;!k^cnPjP-K2W{}SCtUq;_WZMlkm z8l83M#*@80RHKO*j8&b&0Bv)RxFdBO@bA9!x|HMC2d%Ic%tpi@8Cu5bFbUf6=6*ABcta9XeIn za@b_`iOtE-BJBvK&!sSK6Y;ikdzl(;^fWBYC-_Z`k4c$&Fnxm>m&#_Sc}rt%8#lPl zr{O0pep>UhTEA$?Ph0XeOMcOk(+!V^*(WW2>Ugf>Sw76shX3n80ohEnP`qVY&sb9|Wk%@C0+ohchbq#-*!O#rPG3R1@ z(?2`TE1vs$DbyU!Ew(Fs^Rh?7t3SRX>F!^PlD;vyB57yoBx$U_>8xKXVx-=?SN_Ohy8m8#Zs8fwGwzK~NWZ$Za*uTPwYx_T9_AU# zp`x&m@)OASjw?8aC*NnznbR!lmw#I)yhBho4lm$3!**K{;cBYL=qiIdRN@c+3U5tG vKmEG|!+u|mt}=bUl1EpW(N$)2l|kXvKbuEa8T~DK8m}@Pd{ + + + + + diff --git a/rust/perspective-js/test/html/shared_worker.html b/rust/perspective-js/test/html/shared_worker.html new file mode 100644 index 0000000..32c5809 --- /dev/null +++ b/rust/perspective-js/test/html/shared_worker.html @@ -0,0 +1,18 @@ + + + + + + diff --git a/rust/perspective-js/test/html/test.html b/rust/perspective-js/test/html/test.html new file mode 100644 index 0000000..85f5f53 --- /dev/null +++ b/rust/perspective-js/test/html/test.html @@ -0,0 +1,4 @@ + + + + diff --git a/rust/perspective-js/test/html/worker.html b/rust/perspective-js/test/html/worker.html new file mode 100644 index 0000000..8c2fe23 --- /dev/null +++ b/rust/perspective-js/test/html/worker.html @@ -0,0 +1,18 @@ + + + + + + diff --git a/rust/perspective-js/test/js/aggregates.spec.js b/rust/perspective-js/test/js/aggregates.spec.js new file mode 100644 index 0000000..67390c6 --- /dev/null +++ b/rust/perspective-js/test/js/aggregates.spec.js @@ -0,0 +1,2485 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "./perspective_client"; + +var data = [ + { x: 1, y: "a", z: true }, + { x: 2, y: "b", z: false }, + { x: 3, y: "c", z: true }, + { x: 4, y: "d", z: false }, +]; + +var data3 = [ + { x: 1, y: "a", z: true }, + { x: 2, y: "b", z: false }, + { x: 3, y: "c", z: true }, + { x: 4, y: "d", z: false }, + { x: 5, y: "a", z: true }, + { x: 6, y: "b", z: false }, + { x: 7, y: "c", z: true }, + { x: 8, y: "d", z: false }, + { x: 9, y: "a", z: true }, + { x: 10, y: "b", z: false }, + { x: 11, y: "c", z: true }, + { x: 12, y: "d", z: false }, + { x: 13, y: "a", z: true }, + { x: 14, y: "b", z: false }, + { x: 15, y: "c", z: true }, + { x: 16, y: "d", z: false }, +]; + +var data4 = [ + { x: 1, y: "a", z: true }, + { x: 2, y: "b", z: false }, + { x: 3, y: "c", z: true }, + { x: 4, y: "d", z: false }, + { x: 5, y: "a", z: true }, + { x: 6, y: "b", z: false }, + { x: 7, y: "c", z: true }, + { x: 8, y: "d", z: false }, + { x: 9, y: "a", z: true }, + { x: 10, y: "b", z: false }, + { x: 11, y: "c", z: true }, + { x: 12, y: "d", z: false }, + { x: 13, y: "a", z: true }, + { x: 14, y: "b", z: false }, + { x: 15, y: "c", z: true }, + { x: 16, y: "d", z: false }, + { x: 17, y: "e", z: true }, +]; + +var meta = { + x: "integer", + y: "string", + z: "boolean", +}; + +var data2 = [ + { x: 1, y: 1, z: true }, + { x: 2, y: 1, z: false }, + { x: 3, y: 2, z: true }, + { x: 4, y: 2, z: false }, +]; + +const float_data = { + x: [ + 0.99098243, 0.36677191, 0.58926465, 0.95701263, 0.96904283, 0.50398721, + 0.67424934, 0.32015379, 0.14877031, 0.16285932, 0.00597484, 0.90579728, + 0.01338397, 0.66893083, 0.79209796, 0.41033256, 0.92328448, 0.20791236, + 0.14874502, 0.1727802, + ], + y: [ + "a", + "b", + "c", + "d", + "e", + "a", + "b", + "c", + "d", + "e", + "a", + "b", + "c", + "d", + "e", + "a", + "b", + "c", + "d", + "e", + ], +}; + +const variance = (nums) => { + let k = 0, + m = 0, + s = 0; + + for (const num of nums) { + k++; + const next_m = m + (num - m) / k; + s += (num - m) * (num - next_m); + m = next_m; + } + + return s / nums.length; +}; + +const std = (nums) => { + return Math.sqrt(variance(nums)); +}; + +((perspective) => { + test.describe("Aggregate", function () { + test.skip("old `aggregate` syntax is backwards compatible", async function () { + var table = await perspective.table(data); + var view = await table.view({ + aggregate: [{ column: "x", op: "sum" }], + group_by: ["z"], + }); + var answer = [ + { __ROW_PATH__: [], x: 10 }, + { __ROW_PATH__: [false], x: 6 }, + { __ROW_PATH__: [true], x: 4 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("Aggregates are processed in the order of the columns array", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_by: ["z"], + columns: ["y", "z"], + aggregates: { + z: "last", + y: "last", + }, + }); + const paths = await view.column_paths(); + expect(paths).toEqual(["y", "z"]); + const answer = [ + { __ROW_PATH__: [], y: "c", z: true }, + { __ROW_PATH__: [false], y: "d", z: false }, + { __ROW_PATH__: [true], y: "c", z: true }, + ]; + const result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("`first` aggregate with a hidden sort column does not crash", async function () { + const table = await perspective.table([ + { g: "x", v: 1 }, + { g: "x", v: 2 }, + { g: "y", v: 3 }, + ]); + const view = await table.view({ + group_by: ["g"], + columns: ["g"], + aggregates: { v: "first" }, + sort: [["v", "desc"]], + }); + + const result = await view.to_json(); + const paths = result.map((r) => r.__ROW_PATH__); + expect(paths.length).toEqual(3); + expect(paths).toContainEqual([]); + expect(paths).toContainEqual(["x"]); + expect(paths).toContainEqual(["y"]); + await view.delete(); + await table.delete(); + }); + + test("`max by` with a non-visible by-column", async function () { + const table = await perspective.table([ + { g: "x", v: 1, w: 10 }, + { g: "x", v: 2, w: 20 }, + { g: "y", v: 3, w: 5 }, + ]); + const view = await table.view({ + group_by: ["g"], + columns: ["v"], + aggregates: { v: ["max by", ["w"]] }, + }); + + const result = await view.to_json(); + // `max by` picks the `v` at the row with the maximum `w`. + expect(result).toEqual([ + { __ROW_PATH__: [], v: 2 }, + { __ROW_PATH__: ["x"], v: 2 }, + { __ROW_PATH__: ["y"], v: 3 }, + ]); + await view.delete(); + await table.delete(); + }); + + test("Aggregates are not in columns are ignored", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_by: ["z"], + columns: ["y", "z"], + aggregates: { + x: "count", + }, + }); + const paths = await view.column_paths(); + expect(paths).toEqual(["y", "z"]); + const answer = [ + { __ROW_PATH__: [], y: 4, z: 4 }, + { __ROW_PATH__: [false], y: 2, z: 2 }, + { __ROW_PATH__: [true], y: 2, z: 2 }, + ]; + const result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['z'], sum", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["z"], + columns: ["x"], + }); + var answer = [ + { __ROW_PATH__: [], x: 10 }, + { __ROW_PATH__: [false], x: 6 }, + { __ROW_PATH__: [true], x: 4 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['z'], weighted mean", async function () { + var table = await perspective.table(data2); + var view = await table.view({ + group_by: ["z"], + aggregates: { x: ["weighted mean", ["y"]] }, + columns: ["x"], + }); + var answer = [ + { __ROW_PATH__: [], x: 2.8333333333333335 }, + { __ROW_PATH__: [false], x: 3.3333333333333335 }, + { __ROW_PATH__: [true], x: 2.3333333333333335 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['z'], weighted mean by expression", async function () { + var table = await perspective.table(data2); + var view = await table.view({ + group_by: ["z"], + aggregates: { x: ["weighted mean", ["q"]] }, + expressions: { q: `"y"` }, + columns: ["x"], + }); + var answer = [ + { __ROW_PATH__: [], x: 2.8333333333333335 }, + { __ROW_PATH__: [false], x: 3.3333333333333335 }, + { __ROW_PATH__: [true], x: 2.3333333333333335 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['z'], weighted mean on a table created from schema should return valid values after update", async function () { + const table = await perspective.table({ + x: "integer", + y: "integer", + z: "boolean", + }); + + const view = await table.view({ + group_by: ["z"], + columns: ["x"], + aggregates: { x: ["weighted mean", ["y"]] }, + }); + + const answer = [ + { __ROW_PATH__: [], x: 2.8333333333333335 }, + { __ROW_PATH__: [false], x: 3.3333333333333335 }, + { __ROW_PATH__: [true], x: 2.3333333333333335 }, + ]; + + await table.update(data2); + + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['z'], mean", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["z"], + columns: ["x"], + aggregates: { x: "mean" }, + }); + var answer = [ + { __ROW_PATH__: [], x: 2.5 }, + { __ROW_PATH__: [false], x: 3 }, + { __ROW_PATH__: [true], x: 2 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['z'], mean on a table created from schema should return valid values after update", async function () { + const table = await perspective.table({ + x: "integer", + y: "string", + z: "boolean", + }); + const view = await table.view({ + group_by: ["z"], + columns: ["x"], + aggregates: { x: "mean" }, + }); + const answer = [ + { __ROW_PATH__: [], x: 2.5 }, + { __ROW_PATH__: [false], x: 3 }, + { __ROW_PATH__: [true], x: 2 }, + ]; + + await table.update(data); + + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test.describe("median and quartiles", function () { + test.describe("integer", function () { + test("['z'] with 16 integer elements", async function () { + var table = await perspective.table(data3); + var view = await table.view({ + group_by: ["z"], + columns: ["x"], + aggregates: { x: "median" }, + }); + + var answer = [ + { __ROW_PATH__: [], x: 9 }, + { __ROW_PATH__: [false], x: 10 }, + { __ROW_PATH__: [true], x: 9 }, + ]; + + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['z'] with 16 elements, q1", async function () { + var table = await perspective.table(data3); + var view = await table.view({ + group_by: ["z"], + columns: ["x"], + aggregates: { x: "q1" }, + }); + + var answer = [ + { __ROW_PATH__: [], x: 5 }, + { __ROW_PATH__: [false], x: 6 }, + { __ROW_PATH__: [true], x: 5 }, + ]; + + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['z'] with 16 elements, q3", async function () { + var table = await perspective.table(data3); + var view = await table.view({ + group_by: ["z"], + columns: ["x"], + aggregates: { x: "q3" }, + }); + + var answer = [ + { __ROW_PATH__: [], x: 13 }, + { __ROW_PATH__: [false], x: 14 }, + { __ROW_PATH__: [true], x: 13 }, + ]; + + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + }); + + test.describe("float", function () { + test("['z'] with 16 elements, median", async function () { + var table = await perspective.table({ + x: "float", + y: "string", + z: "boolean", + }); + + await table.update(data3); + var view = await table.view({ + group_by: ["z"], + columns: ["x"], + aggregates: { x: "median" }, + }); + + var answer = [ + { __ROW_PATH__: [], x: 8.5 }, + { __ROW_PATH__: [false], x: 9 }, + { __ROW_PATH__: [true], x: 8 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['z'] with 16 elements, q1", async function () { + var table = await perspective.table({ + x: "float", + y: "string", + z: "boolean", + }); + + await table.update(data3); + var view = await table.view({ + group_by: ["z"], + columns: ["x"], + aggregates: { x: "q1" }, + }); + + var answer = [ + { __ROW_PATH__: [], x: 4.5 }, + { __ROW_PATH__: [false], x: 5 }, + { __ROW_PATH__: [true], x: 4 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['z'] with 16 elements, q3", async function () { + var table = await perspective.table({ + x: "float", + y: "string", + z: "boolean", + }); + + await table.update(data3); + var view = await table.view({ + group_by: ["z"], + columns: ["x"], + aggregates: { x: "q3" }, + }); + + var answer = [ + { __ROW_PATH__: [], x: 12.5 }, + { __ROW_PATH__: [false], x: 13 }, + { __ROW_PATH__: [true], x: 12 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['z'] with 17 elements, median", async function () { + var table = await perspective.table({ + x: "float", + y: "string", + z: "boolean", + }); + + await table.update(data4); + var view = await table.view({ + group_by: ["z"], + columns: ["x"], + aggregates: { x: "median" }, + }); + + var answer = [ + { __ROW_PATH__: [], x: 9 }, + { __ROW_PATH__: [false], x: 9 }, + { __ROW_PATH__: [true], x: 9 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['z'] with 17 elements, q1", async function () { + var table = await perspective.table({ + x: "float", + y: "string", + z: "boolean", + }); + + await table.update(data4); + var view = await table.view({ + group_by: ["z"], + columns: ["x"], + aggregates: { x: "q1" }, + }); + + var answer = [ + { __ROW_PATH__: [], x: 5 }, + { __ROW_PATH__: [false], x: 5 }, + { __ROW_PATH__: [true], x: 5 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['z'] with 17 elements, q3", async function () { + var table = await perspective.table({ + x: "float", + y: "string", + z: "boolean", + }); + + await table.update(data4); + var view = await table.view({ + group_by: ["z"], + columns: ["x"], + aggregates: { x: "q3" }, + }); + + var answer = [ + { __ROW_PATH__: [], x: 13 }, + { __ROW_PATH__: [false], x: 13 }, + { __ROW_PATH__: [true], x: 13 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + }); + }); + + test("['z'], first by index", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["z"], + columns: ["x"], + aggregates: { x: "first by index" }, + }); + var answer = [ + { __ROW_PATH__: [], x: 1 }, + { __ROW_PATH__: [false], x: 2 }, + { __ROW_PATH__: [true], x: 1 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['z'], join", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["z"], + columns: ["x"], + aggregates: { x: "join" }, + }); + var answer = [ + { __ROW_PATH__: [], x: "1, 2, 3, 4" }, + { __ROW_PATH__: [false], x: "2, 4" }, + { __ROW_PATH__: [true], x: "1, 3" }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['z'], join exceeds max join size", async function () { + let data2 = JSON.parse(JSON.stringify(data)); + data2.push({ + x: 5, + y: "abcdefghijklmnopqrstuvwxyz".repeat(12), + z: false, + }); + var table = await perspective.table(data2); + var view = await table.view({ + group_by: ["z"], + columns: ["y"], + aggregates: { y: "join" }, + }); + var answer = [ + { __ROW_PATH__: [], y: "a" }, + { __ROW_PATH__: [false], y: "" }, + { __ROW_PATH__: [true], y: "a, c" }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("join with nulls", async function () { + const data = [ + { country: "US", state: "New York", city: null }, + { + country: "US", + state: "Arizona", + city: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + ]; + + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["country", "state"], + columns: ["city"], + aggregates: { city: "join" }, + }); + var answer = { + __ROW_PATH__: [ + [], + ["US"], + ["US", "Arizona"], + ["US", "New York"], + ], + city: [ + "null, aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "null, aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "null", + ], + }; + let result = await view.to_columns(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['z'], first by index with appends", async function () { + var table = await perspective.table(data, { index: "y" }); + var view = await table.view({ + group_by: ["z"], + columns: ["x"], + aggregates: { x: "first by index" }, + }); + const answer = [ + { __ROW_PATH__: [], x: 1 }, + { __ROW_PATH__: [false], x: 2 }, + { __ROW_PATH__: [true], x: 1 }, + ]; + await table.update({ + x: [5], + y: ["e"], + z: [true], + }); + const result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['z'], last_minus_first", async function () { + var table = await perspective.table( + [ + { x: 1, y: "a", z: true }, + { x: 2, y: "b", z: false }, + { x: 3, y: "c", z: true }, + { x: 4, y: "d", z: false }, + ], + { index: "y" }, + ); + var view = await table.view({ + group_by: ["z"], + columns: ["x"], + aggregates: { x: "last minus first" }, + }); + const answer = [ + { __ROW_PATH__: [], x: -1 }, + { __ROW_PATH__: [false], x: 2 }, + { __ROW_PATH__: [true], x: -2 }, + ]; + await table.update({ + x: [5], + y: ["a"], + z: [true], + }); + const result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['z'], high_minus_low", async function () { + var table = await perspective.table( + [ + { x: 1, y: "a", z: true }, + { x: 2, y: "b", z: false }, + { x: 3, y: "c", z: true }, + { x: 4, y: "d", z: false }, + ], + { index: "y" }, + ); + var view = await table.view({ + group_by: ["z"], + columns: ["x"], + aggregates: { x: "high minus low" }, + }); + const answer = [ + { __ROW_PATH__: [], x: 3 }, + { __ROW_PATH__: [false], x: 2 }, + { __ROW_PATH__: [true], x: 2 }, + ]; + await table.update({ + x: [5], + y: ["a"], + z: [true], + }); + const result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['z'], first by index with partial updates", async function () { + var table = await perspective.table(data, { index: "y" }); + var view = await table.view({ + group_by: ["z"], + columns: ["x"], + aggregates: { x: "first by index" }, + }); + const answer = [ + { __ROW_PATH__: [], x: 5 }, + { __ROW_PATH__: [false], x: 2 }, + { __ROW_PATH__: [true], x: 5 }, + ]; + await table.update({ + x: [5], + y: ["a"], + z: [true], + }); + const result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("last with count and partial update", async () => { + const table = await perspective.table( + { + x: ["a"], + y: ["A"], + index: [1], + }, + { index: "index" }, + ); + + const view = await table.view({ + aggregates: { + x: "last", + y: "count", + }, + group_by: ["y"], + }); + + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[], ["A"]], + x: ["a", "a"], + y: [1, 1], + index: [1, 1], + }); + + for (let i = 0; i < 5; i++) { + await table.update({ + x: ["a"], + index: [1], + }); + } + + let result = await view.to_columns(); + expect(result["y"]).toEqual([1, 1]); + + await table.update({ + x: ["b"], + y: ["A"], + index: [1], + }); + + result = await view.to_columns(); + expect(result["x"]).toEqual(["b", "b"]); + expect(result["y"]).toEqual([1, 1]); + + await view.delete(); + await table.delete(); + }); + + test("min", async () => { + const data = { + w: [1.5, 2.5, 3.5, 4.5], + x: [1, 2, 3, 4], + y: ["a", "b", "a", "b"], + z: [ + new Date(1555126035065), + new Date(1555126035065), + new Date(1555026035065), + new Date(1555026035065), + ], + }; + + const table = await perspective.table(data); + const view = await table.view({ + aggregates: { + w: "min", + x: "min", + y: "min", + z: "min", + }, + group_by: ["y"], + }); + + const cols = await view.to_columns(); + expect(cols).toEqual({ + __ROW_PATH__: [[], ["a"], ["b"]], + w: [1.5, 1.5, 2.5], + x: [1, 1, 2], + y: ["a", "a", "b"], + z: [1555026035065, 1555026035065, 1555026035065], + }); + + await view.delete(); + await table.delete(); + }); + + test("max", async () => { + const data = { + w: [1.5, 2.5, 3.5, 4.5], + x: [1, 2, 3, 4], + y: ["a", "b", "a", "b"], + z: [ + new Date(1555126035065), + new Date(1555126035065), + new Date(1555026035065), + new Date(1555026035065), + ], + }; + + const table = await perspective.table(data); + const view = await table.view({ + aggregates: { + w: "max", + x: "max", + y: "max", + z: "max", + }, + group_by: ["y"], + }); + + const cols = await view.to_columns(); + expect(cols).toEqual({ + __ROW_PATH__: [[], ["a"], ["b"]], + w: [4.5, 3.5, 4.5], + x: [4, 3, 4], + y: ["b", "a", "b"], + z: [1555126035065, 1555126035065, 1555126035065], + }); + + await view.delete(); + await table.delete(); + }); + + test("max_by", async () => { + const data = { + a: [1, 2, 3, 4, 5, 6, 7, 8], + b: [0, 1, 0, 1, 0, 1, 0, 1], + c: ["h", "g", "f", "e", "d", "c", "b", "a"], + }; + + const table = await perspective.table(data); + const view = await table.view({ + columns: ["c"], + aggregates: { + c: ["max by", ["a"]], + }, + group_by: ["b"], + }); + + const cols = await view.to_columns(); + expect(cols).toEqual({ + __ROW_PATH__: [[], [0], [1]], + c: ["a", "b", "a"], + }); + + await view.delete(); + await table.delete(); + }); + + test("min_by", async () => { + const data = { + a: [1, 2, 3, 4, 5, 6, 7, 8], + b: [0, 1, 0, 1, 0, 1, 0, 1], + c: ["h", "g", "f", "e", "d", "c", "b", "a"], + }; + + const table = await perspective.table(data); + const view = await table.view({ + columns: ["c"], + aggregates: { + c: ["min by", ["a"]], + }, + group_by: ["b"], + }); + + const cols = await view.to_columns(); + expect(cols).toEqual({ + __ROW_PATH__: [[], [0], [1]], + c: ["h", "h", "g"], + }); + + await view.delete(); + await table.delete(); + }); + + test("max_by with empty filter", async () => { + const data = { + a: [1, 2, 3, 4, 5, 6, 7, 8], + b: [0, 1, 0, 1, 0, 1, 0, 1], + c: ["h", "g", "f", "e", "d", "c", "b", "a"], + }; + + const table = await perspective.table(data); + const view = await table.view({ + columns: ["c"], + aggregates: { + c: ["max by", ["a"]], + }, + group_by: ["b"], + filter: [["b", ">", 2]], + }); + + const cols = await view.to_columns(); + expect(cols).toEqual({ + __ROW_PATH__: [[]], + c: [""], + }); + + await view.delete(); + await table.delete(); + }); + + test("max with empty filter", async () => { + const data = { + a: [1, 2, 3, 4, 5, 6, 7, 8], + b: [0, 1, 0, 1, 0, 1, 0, 1], + c: ["h", "g", "f", "e", "d", "c", "b", "a"], + }; + + const table = await perspective.table(data); + const view = await table.view({ + columns: ["c"], + aggregates: { + c: "max", + }, + group_by: ["b"], + filter: [["b", ">", 2]], + }); + + const cols = await view.to_columns(); + expect(cols).toEqual({ + __ROW_PATH__: [[]], + c: [""], + }); + + await view.delete(); + await table.delete(); + }); + + test("high with count and partial update", async () => { + const table = await perspective.table( + { + x: ["a"], + y: [100], + index: [1], + }, + { index: "index" }, + ); + + const view = await table.view({ + aggregates: { + x: "count", + y: "high", + }, + group_by: ["x"], + }); + + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[], ["a"]], + x: [1, 1], + y: [100, 100], + index: [1, 1], + }); + + for (let i = 0; i < 5; i++) { + await table.update({ + x: ["a"], + y: [100], + index: [1], + }); + } + + let result = await view.to_columns(); + expect(result["y"]).toEqual([100, 100]); + + await table.update({ + x: ["b"], + y: [101], + index: [1], + }); + + result = await view.to_columns(); + expect(result["x"]).toEqual([1, 1]); + expect(result["y"]).toEqual([101, 101]); + + await view.delete(); + await table.delete(); + }); + + test("low with count and partial update", async () => { + const table = await perspective.table( + { + x: ["a"], + y: [100], + index: [1], + }, + { index: "index" }, + ); + + const view = await table.view({ + aggregates: { + x: "count", + y: "low", + }, + group_by: ["x"], + }); + + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[], ["a"]], + x: [1, 1], + y: [100, 100], + index: [1, 1], + }); + + for (let i = 0; i < 5; i++) { + await table.update({ + x: ["a"], + y: [100], + index: [1], + }); + } + + let result = await view.to_columns(); + expect(result["y"]).toEqual([100, 100]); + + await table.update({ + x: ["b"], + y: [101], + index: [1], + }); + + result = await view.to_columns(); + expect(result["x"]).toEqual([1, 1]); + expect(result["y"]).toEqual([100, 101]); + + await table.update({ + x: ["b"], + y: [99], + index: [1], + }); + + result = await view.to_columns(); + expect(result["x"]).toEqual([1, 1]); + expect(result["y"]).toEqual([99, 99]); + + await view.delete(); + await table.delete(); + }); + + test("low with count and multiple partial updates", async () => { + const table = await perspective.table( + { + x: ["a"], + y: [100], + index: [1], + }, + { index: "index" }, + ); + + const view = await table.view({ + aggregates: { + x: "count", + y: "low", + }, + group_by: ["x"], + }); + + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[], ["a"]], + x: [1, 1], + y: [100, 100], + index: [1, 1], + }); + + for (let i = 0; i < 100; i++) { + await table.update({ + x: ["a"], + y: [99], + index: [1], + }); + } + + let result = await view.to_columns(); + expect(result["y"]).toEqual([99, 99]); + + await table.update({ + y: [101], + index: [1], + }); + + result = await view.to_columns(); + expect(result["x"]).toEqual([1, 1]); + + // FIXME: my assumption of the `low` aggregate is that it should + // be the low of the group according to current data, but this seems + // like it's storing a "global" low? + expect(result["y"]).toEqual([99, 99]); + + await table.update({ + x: ["b"], + y: [100], + index: [1], + }); + + result = await view.to_columns(); + expect(result["x"]).toEqual([1, 1]); + expect(result["y"]).toEqual([99, 100]); + + await view.delete(); + await table.delete(); + }); + + test("last with count and multiple partial updates", async () => { + const table = await perspective.table( + { + x: ["a"], + y: ["A"], + index: [1], + }, + { index: "index" }, + ); + + const view = await table.view({ + aggregates: { + x: "last", + y: "count", + }, + group_by: ["y"], + }); + + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[], ["A"]], + x: ["a", "a"], + y: [1, 1], + index: [1, 1], + }); + + for (let i = 0; i < 100; i++) { + await table.update({ + x: ["a"], + index: [1], + }); + } + + let result = await view.to_columns(); + expect(result["y"]).toEqual([1, 1]); + + for (let i = 0; i < 100; i++) { + await table.update({ + x: ["b"], + index: [1], + }); + } + + result = await view.to_columns(); + expect(result["x"]).toEqual(["b", "b"]); + expect(result["y"]).toEqual([1, 1]); + + await view.delete(); + await table.delete(); + }); + + test("last with multiple aggs, count, and multiple partial updates", async () => { + const table = await perspective.table( + { + x: ["a"], + y: ["A"], + z: [1], + index: [1], + }, + { index: "index" }, + ); + + const view = await table.view({ + aggregates: { + x: "last", + y: "count", + }, + group_by: ["y", "z"], + }); + + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[], ["A"], ["A", 1]], + x: ["a", "a", "a"], + y: [1, 1, 1], + z: [1, 1, 1], + index: [1, 1, 1], + }); + + for (let i = 0; i < 100; i++) { + await table.update({ + x: ["a"], + index: [1], + }); + } + + let result = await view.to_columns(); + expect(result["y"]).toEqual([1, 1, 1]); + + for (let i = 0; i < 100; i++) { + await table.update({ + x: ["b"], + index: [1], + }); + } + + result = await view.to_columns(); + expect(result["x"]).toEqual(["b", "b", "b"]); + expect(result["y"]).toEqual([1, 1, 1]); + + for (let i = 2; i < 6; i++) { + await table.update({ + x: [i.toString()], + y: [i.toString()], + z: [i], + index: [i], + }); + } + + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [ + [], + ["2"], + ["2", 2], + ["3"], + ["3", 3], + ["4"], + ["4", 4], + ["5"], + ["5", 5], + ["A"], + ["A", 1], + ], + x: ["5", "2", "2", "3", "3", "4", "4", "5", "5", "b", "b"], + y: [5, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + z: [15, 2, 2, 3, 3, 4, 4, 5, 5, 1, 1], + index: [15, 2, 2, 3, 3, 4, 4, 5, 5, 1, 1], + }); + + await view.delete(); + await table.delete(); + }); + + test("last with count and invalid to valid", async () => { + // y[0] is invalid + const table = await perspective.table( + { + x: ["a"], + y: [null], + index: [1], + }, + { index: "index" }, + ); + + const view = await table.view({ + aggregates: { + x: "last", + y: "count", + }, + group_by: ["x"], + }); + + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[], ["a"]], + x: ["a", "a"], + y: [1, 1], // null is still a value for count + index: [1, 1], + }); + + const view2 = await table.view({ + aggregates: { + x: "last", + y: "last", + }, + group_by: ["x"], + }); + + expect(await view2.to_columns()).toEqual({ + __ROW_PATH__: [[], ["a"]], + x: ["a", "a"], + y: [null, null], + index: [1, 1], + }); + + for (let i = 0; i < 100; i++) { + await table.update({ + x: ["a"], + index: [1], + }); + } + + let result = await view.to_columns(); + expect(result["y"]).toEqual([1, 1]); + + // y[0] becomes valid + await table.update({ + x: ["b"], + y: ["B"], + index: [1], + }); + + result = await view.to_columns(); + let result2 = await view2.to_columns(); + expect(result["x"]).toEqual(["b", "b"]); + expect(result["y"]).toEqual([1, 1]); + expect(result2["y"]).toEqual(["B", "B"]); + + // and invalid + await table.update({ + y: [null], + index: [1], + }); + + result = await view.to_columns(); + result2 = await view2.to_columns(); + expect(result["y"]).toEqual([1, 1]); + expect(result2["y"]).toEqual([null, null]); + + await view2.delete(); + await view.delete(); + await table.delete(); + }); + + test("last with count and partial update 2-sided", async () => { + const table = await perspective.table( + { + x: ["a"], + y: ["A"], + index: [1], + }, + { index: "index" }, + ); + + const view = await table.view({ + aggregates: { + x: "last", + y: "count", + }, + group_by: ["y"], + split_by: ["x"], + }); + + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[], ["A"]], + "a|x": ["a", "a"], + "a|y": [1, 1], + "a|index": [1, 1], + }); + + for (let i = 0; i < 5; i++) { + await table.update({ + x: ["a"], + index: [1], + }); + } + + let result = await view.to_columns(); + expect(result["a|y"]).toEqual([1, 1]); + + await table.update({ + x: ["b"], + y: ["A"], + index: [1], + }); + + result = await view.to_columns(); + expect(result["b|x"]).toEqual(["b", "b"]); + expect(result["b|y"]).toEqual([1, 1]); + + await view.delete(); + await table.delete(); + }); + + test("last with count and partial update flip", async () => { + const table = await perspective.table( + { + x: ["a"], + y: ["A"], + index: [1], + }, + { index: "index" }, + ); + + const view = await table.view({ + aggregates: { + x: "last", + y: "count", + }, + group_by: ["y"], + }); + + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[], ["A"]], + x: ["a", "a"], + y: [1, 1], + index: [1, 1], + }); + + await table.update({ + x: ["b"], + index: [1], + }); + + // x should flip, y should remain the same + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[], ["A"]], + x: ["b", "b"], + y: [1, 1], + index: [1, 1], + }); + + await table.update({ + x: ["c"], + index: [1], + }); + + // and again + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[], ["A"]], + x: ["c", "c"], + y: [1, 1], + index: [1, 1], + }); + + await table.update({ + x: ["d"], + y: ["B"], + index: [2], + }); + + // another row + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[], ["A"], ["B"]], + x: ["d", "c", "d"], + y: [2, 1, 1], + index: [3, 1, 2], + }); + + await view.delete(); + await table.delete(); + }); + + test("last with count and partial update flip, filtered", async () => { + const table = await perspective.table( + { + x: ["a"], + y: ["A"], + index: [1], + }, + { index: "index" }, + ); + + const view = await table.view({ + aggregates: { + x: "last", + y: "count", + }, + group_by: ["y"], + filter: [["x", "==", "a"]], + }); + + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[], ["A"]], + x: ["a", "a"], + y: [1, 1], + index: [1, 1], + }); + + await table.update({ + x: ["b"], + index: [1], + }); + + // x should flip, y should remain the same + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[]], + x: [""], + y: [0], + index: [0], + }); + + await table.update({ + x: ["a"], + index: [1], + }); + + // and again + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[], ["A"]], + x: ["a", "a"], + y: [1, 1], + index: [1, 1], + }); + + await table.update({ + x: ["a"], + y: ["B"], + index: [2], + }); + + // another row + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[], ["A"], ["B"]], + x: ["a", "a", "a"], + y: [2, 1, 1], + index: [3, 1, 2], + }); + + await view.delete(); + await table.delete(); + }); + + test("last with count and append flip", async () => { + const table = await perspective.table({ + x: ["a"], + y: ["A"], + index: [1], + }); + + const view = await table.view({ + aggregates: { + x: "last", + y: "count", + }, + group_by: ["y"], + }); + + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[], ["A"]], + x: ["a", "a"], + y: [1, 1], + index: [1, 1], + }); + + await table.update({ + x: ["b"], + index: [1], + }); + + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[], [null], ["A"]], + x: ["b", "b", "a"], + y: [2, 1, 1], + index: [2, 1, 1], + }); + + await table.update({ + x: ["c"], + index: [1], + }); + + // and again + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[], [null], ["A"]], + x: ["c", "c", "a"], + y: [3, 2, 1], + index: [3, 2, 1], + }); + + await table.update({ + x: ["d"], + y: ["B"], + index: [2], + }); + + // another row + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[], [null], ["A"], ["B"]], + x: ["d", "c", "a", "d"], + y: [4, 2, 1, 1], + index: [5, 2, 1, 2], + }); + + await view.delete(); + await table.delete(); + }); + + test("['z'], last by index", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["z"], + columns: ["x"], + aggregates: { x: "last by index" }, + }); + var answer = [ + { __ROW_PATH__: [], x: 4 }, + { __ROW_PATH__: [false], x: 4 }, + { __ROW_PATH__: [true], x: 3 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['z'], last by index with appends", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_by: ["z"], + columns: ["x"], + aggregates: { x: "last by index" }, + }); + const answer = [ + { __ROW_PATH__: [], x: 5 }, + { __ROW_PATH__: [false], x: 4 }, + { __ROW_PATH__: [true], x: 5 }, + ]; + await table.update({ + x: [5], + y: ["e"], + z: [true], + }); + const result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['z'], last by index with partial updates", async function () { + const table = await perspective.table(data, { index: "y" }); + const view = await table.view({ + group_by: ["z"], + columns: ["x"], + aggregates: { x: "last by index" }, + }); + const answer = [ + { __ROW_PATH__: [], x: 4 }, + { __ROW_PATH__: [false], x: 4 }, + { __ROW_PATH__: [true], x: 5 }, + ]; + await table.update({ + x: [5], + y: ["c"], + z: [true], + }); + const result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['z'], last", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["z"], + columns: ["x"], + aggregates: { x: "last" }, + }); + var answer = [ + { __ROW_PATH__: [], x: 3 }, + { __ROW_PATH__: [false], x: 4 }, + { __ROW_PATH__: [true], x: 3 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + + await table.update([ + { x: 1, y: "c", z: true }, + { x: 2, y: "d", z: false }, + ]); + var answerAfterUpdate = [ + { __ROW_PATH__: [], x: 1 }, + { __ROW_PATH__: [false], x: 2 }, + { __ROW_PATH__: [true], x: 1 }, + ]; + let result2 = await view.to_json(); + expect(result2).toEqual(answerAfterUpdate); + view.delete(); + table.delete(); + }); + + test("unique", async function () { + const table = await perspective.table( + { + x: [100, 200, 100, 200], + y: [1, 2, 3, 4], + z: ["a", "a", "a", "b"], + }, + { index: "y" }, + ); + + const view = await table.view({ + group_by: ["x"], + aggregates: { + x: "unique", + y: "unique", + z: "unique", + }, + }); + + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[], [100], [200]], + x: [null, 100, 200], + y: [null, null, null], + z: [null, "a", null], + }); + + await table.update({ + y: [4], + z: ["a"], + }); + + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[], [100], [200]], + x: [null, 100, 200], + y: [null, null, null], + z: ["a", "a", "a"], + }); + + await table.update({ + y: [5], + z: ["x"], + }); + + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[], [null], [100], [200]], + x: [null, null, 100, 200], + y: [null, 5, null, null], + z: [null, "x", "a", "a"], + }); + + await view.delete(); + await table.delete(); + }); + + test("variance", async function () { + const table = await perspective.table(float_data); + const view = await table.view({ + group_by: ["y"], + columns: ["x"], + aggregates: { x: "var" }, + }); + + const result = await view.to_columns(); + + const expected = [ + 0.33597085443953206, 0.35043269693156814, 0.22510197203101087, + 0.20827220910070432, 0.3473746254711007, 0.3618418050868363, + ].map((x) => Math.pow(x, 2)); + + for (let i = 0; i < result.x.length; i++) { + expect(result.x[i]).toBeCloseTo(expected[i], 6); + } + + await view.delete(); + await table.delete(); + }); + + test("variance multi-pivot", async function () { + const table = await perspective.table({ + x: [ + 0.62817744, 0.16903811, 0.77902867, 0.92330087, 0.10583306, + 0.59794354, + ], + y: ["a", "a", "b", "b", "c", "c"], + z: [1, 1, 1, 1, 1, 1], + }); + const view = await table.view({ + group_by: ["y", "z"], + columns: ["x"], + aggregates: { x: "var" }, + }); + + const result = await view.to_columns(); + const expected = [ + 0.3002988555851961, 0.22956966499999998, 0.22956966499999998, + 0.07213610000000004, 0.07213610000000004, 0.24605524, + 0.24605524, + ].map((x) => Math.pow(x, 2)); + + for (let i = 0; i < result.x.length; i++) { + expect(result.x[i]).toBeCloseTo(expected[i], 6); + } + + await view.delete(); + await table.delete(); + }); + + test("variance append", async function () { + const table = await perspective.table(float_data); + const view = await table.view({ + group_by: ["y"], + columns: ["x"], + aggregates: { x: "var" }, + }); + + let result = await view.to_columns(); + + const expected = [ + 0.33597085443953206, 0.35043269693156814, 0.22510197203101087, + 0.20827220910070432, 0.3473746254711007, 0.3618418050868363, + ].map((x) => Math.pow(x, 2)); + + for (let i = 0; i < result.x.length; i++) { + expect(result.x[i]).toBeCloseTo(expected[i], 6); + } + + await table.update([{ x: 0.64294039, y: "a" }]); + result = await view.to_columns(); + + const expected2 = [ + 0.32935140956170517, 0.32031993493462224, 0.22510197203101087, + 0.20827220910070432, 0.3473746254711007, 0.3618418050868363, + ].map((x) => Math.pow(x, 2)); + for (let i = 0; i < result.x.length; i++) { + expect(result.x[i]).toBeCloseTo(expected2[i], 6); + } + + await view.delete(); + await table.delete(); + }); + + test("variance partial update", async function () { + const table = await perspective.table( + { + x: [ + 0.99098243, 0.36677191, 0.58926465, 0.95701263, + 0.96904283, 0.50398721, + ], + y: ["a", "a", "b", "b", "c", "c"], + z: [1, 2, 3, 4, 5, 6], + }, + { index: "z" }, + ); + + const view = await table.view({ + group_by: ["y"], + columns: ["x"], + aggregates: { x: "var" }, + }); + + let result = await view.to_columns(); + + const expected = [ + 0.25153179517283897, 0.31210526, 0.18387399000000004, + 0.23252781, + ].map((x) => Math.pow(x, 2)); + + for (let i = 0; i < result.x.length; i++) { + expect(result.x[i]).toBeCloseTo(expected[i], 6); + } + + await table.update([{ x: 0.284169685, z: 3 }]); + result = await view.to_columns(); + + const expected2 = [ + 0.3007643174035643, 0.31210526, 0.33642147250000004, 0.23252781, + ].map((x) => Math.pow(x, 2)); + + for (let i = 0; i < result.x.length; i++) { + expect(result.x[i]).toBeCloseTo(expected2[i], 6); + } + + await view.delete(); + await table.delete(); + }); + + test("variance of range", async function () { + const float_data_copy = JSON.parse(JSON.stringify(float_data)); + float_data_copy["y"] = Array(float_data_copy["x"].length).fill("a"); + + const table = await perspective.table(float_data_copy); + const view = await table.view({ + group_by: ["y"], + columns: ["x"], + aggregates: { x: "var" }, + }); + + const result = await view.to_columns(); + + const expected = [0.33597085443953206, 0.33597085443953206].map( + (x) => Math.pow(x, 2), + ); + + for (let i = 0; i < result.x.length; i++) { + expect(result.x[i]).toBeCloseTo(expected[i], 6); + } + + await view.delete(); + await table.delete(); + }); + + test("variance of size 1", async function () { + const table = await perspective.table({ + x: [0.61801758], + y: ["a"], + }); + const view = await table.view({ + group_by: ["y"], + columns: ["x"], + aggregates: { x: "var" }, + }); + + const result = await view.to_columns(); + expect(result.x).toEqual([null, null]); + + await view.delete(); + await table.delete(); + }); + + test("variance of size 2", async function () { + const table = await perspective.table({ + x: [0.61801758, 0.11283123], + y: ["a", "a"], + }); + const view = await table.view({ + group_by: ["y"], + columns: ["x"], + aggregates: { x: "var" }, + }); + + const result = await view.to_columns(); + expect(result.x).toEqual([ + 0.06380331205658062, 0.06380331205658062, + ]); + + await view.delete(); + await table.delete(); + }); + + test("standard deviation", async function () { + const table = await perspective.table(float_data); + const view = await table.view({ + group_by: ["y"], + columns: ["x"], + aggregates: { x: "stddev" }, + }); + + const result = await view.to_columns(); + + // using np.std() + const expected = [ + 0.33597085443953206, 0.35043269693156814, 0.22510197203101087, + 0.20827220910070432, 0.3473746254711007, 0.3618418050868363, + ]; + + // Check we are within 6 digits of the result from np.std() + for (let i = 0; i < result.x.length; i++) { + expect(result.x[i]).toBeCloseTo(expected[i], 6); + } + + await view.delete(); + await table.delete(); + }); + + test("standard deviation multi-pivot", async function () { + const table = await perspective.table({ + x: [ + 0.62817744, 0.16903811, 0.77902867, 0.92330087, 0.10583306, + 0.59794354, + ], + y: ["a", "a", "b", "b", "c", "c"], + z: [1, 1, 1, 1, 1, 1], + }); + const view = await table.view({ + group_by: ["y", "z"], + columns: ["x"], + aggregates: { x: "stddev" }, + }); + + const result = await view.to_columns(); + + // using np.std() + const expected = [ + 0.3002988555851961, 0.22956966499999998, 0.22956966499999998, + 0.07213610000000004, 0.07213610000000004, 0.24605524, + 0.24605524, + ]; + + // Check we are within 6 digits of the result from np.std() + for (let i = 0; i < result.x.length; i++) { + expect(result.x[i]).toBeCloseTo(expected[i], 6); + } + + await view.delete(); + await table.delete(); + }); + + test("standard deviation append", async function () { + const table = await perspective.table(float_data); + const view = await table.view({ + group_by: ["y"], + columns: ["x"], + aggregates: { x: "stddev" }, + }); + + let result = await view.to_columns(); + + // using np.std() + const expected = [ + 0.33597085443953206, 0.35043269693156814, 0.22510197203101087, + 0.20827220910070432, 0.3473746254711007, 0.3618418050868363, + ]; + + // Check we are within 6 digits of the result from np.std() + for (let i = 0; i < result.x.length; i++) { + expect(result.x[i]).toBeCloseTo(expected[i], 6); + } + + await table.update([{ x: 0.64294039, y: "a" }]); + result = await view.to_columns(); + + const expected2 = [ + 0.32935140956170517, 0.32031993493462224, 0.22510197203101087, + 0.20827220910070432, 0.3473746254711007, 0.3618418050868363, + ]; + // Check we are within 6 digits of the result from np.std() + for (let i = 0; i < result.x.length; i++) { + expect(result.x[i]).toBeCloseTo(expected2[i], 6); + } + + await view.delete(); + await table.delete(); + }); + + test("standard deviation partial update", async function () { + const table = await perspective.table( + { + x: [ + 0.99098243, 0.36677191, 0.58926465, 0.95701263, + 0.96904283, 0.50398721, + ], + y: ["a", "a", "b", "b", "c", "c"], + z: [1, 2, 3, 4, 5, 6], + }, + { index: "z" }, + ); + + const view = await table.view({ + group_by: ["y"], + columns: ["x"], + aggregates: { x: "stddev" }, + }); + + let result = await view.to_columns(); + + // using np.std() + const expected = [ + 0.25153179517283897, 0.31210526, 0.18387399000000004, + 0.23252781, + ]; + + // Check we are within 6 digits of the result from np.std() + for (let i = 0; i < result.x.length; i++) { + expect(result.x[i]).toBeCloseTo(expected[i], 6); + } + + await table.update([{ x: 0.284169685, z: 3 }]); + result = await view.to_columns(); + + const expected2 = [ + 0.3007643174035643, 0.31210526, 0.33642147250000004, 0.23252781, + ]; + + // Check we are within 6 digits of the result from np.std() + for (let i = 0; i < result.x.length; i++) { + expect(result.x[i]).toBeCloseTo(expected2[i], 6); + } + + await view.delete(); + await table.delete(); + }); + + test("standard deviation of range", async function () { + const float_data_copy = JSON.parse(JSON.stringify(float_data)); + float_data_copy["y"] = Array(float_data_copy["x"].length).fill("a"); + + const table = await perspective.table(float_data_copy); + const view = await table.view({ + group_by: ["y"], + columns: ["x"], + aggregates: { x: "stddev" }, + }); + + const result = await view.to_columns(); + + // using np.std() + const expected = [0.33597085443953206, 0.33597085443953206]; + expect(result.x).toEqual(expected); + + // using `std` function + const expected2 = std(float_data["x"]); + expect(expected[0]).toEqual(expected2); + + await view.delete(); + await table.delete(); + }); + + test("standard deviation of size 1", async function () { + const table = await perspective.table({ + x: [0.61801758], + y: ["a"], + }); + const view = await table.view({ + group_by: ["y"], + columns: ["x"], + aggregates: { x: "stddev" }, + }); + + const result = await view.to_columns(); + expect(result.x).toEqual([null, null]); + + await view.delete(); + await table.delete(); + }); + + test("standard deviation of size 2", async function () { + const table = await perspective.table({ + x: [0.61801758, 0.11283123], + y: ["a", "a"], + }); + const view = await table.view({ + group_by: ["y"], + columns: ["x"], + aggregates: { x: "stddev" }, + }); + + const result = await view.to_columns(); + expect(result.x).toEqual([0.252593175, 0.252593175]); + + await view.delete(); + await table.delete(); + }); + }); + + test.describe("Aggregates with nulls", function () { + test("mean", async function () { + var table = await perspective.table([ + { x: 3, y: 1 }, + { x: 2, y: 1 }, + { x: null, y: 1 }, + { x: null, y: 1 }, + { x: 4, y: 2 }, + { x: null, y: 2 }, + ]); + var view = await table.view({ + group_by: ["y"], + columns: ["x"], + aggregates: { x: "mean" }, + }); + var answer = [ + { __ROW_PATH__: [], x: 3 }, + { __ROW_PATH__: [1], x: 2.5 }, + { __ROW_PATH__: [2], x: 4 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("mean with 0", async function () { + var table = await perspective.table([ + { x: 3, y: 1 }, + { x: 3, y: 1 }, + { x: 0, y: 1 }, + { x: null, y: 1 }, + { x: null, y: 1 }, + { x: 4, y: 2 }, + { x: null, y: 2 }, + ]); + var view = await table.view({ + group_by: ["y"], + columns: ["x"], + aggregates: { x: "mean" }, + }); + var answer = [ + { __ROW_PATH__: [], x: 2.5 }, + { __ROW_PATH__: [1], x: 2 }, + { __ROW_PATH__: [2], x: 4 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("mean with 0.0 (floats)", async function () { + var table = await perspective.table({ x: "float", y: "integer" }); + await table.update([ + { x: 3, y: 1 }, + { x: 3, y: 1 }, + { x: 0, y: 1 }, + { x: null, y: 1 }, + { x: null, y: 1 }, + { x: 4, y: 2 }, + { x: null, y: 2 }, + ]); + var view = await table.view({ + group_by: ["y"], + columns: ["x"], + aggregates: { x: "mean" }, + }); + var answer = [ + { __ROW_PATH__: [], x: 2.5 }, + { __ROW_PATH__: [1], x: 2 }, + { __ROW_PATH__: [2], x: 4 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("sum", async function () { + var table = await perspective.table([ + { x: 3, y: 1 }, + { x: 2, y: 1 }, + { x: null, y: 1 }, + { x: null, y: 1 }, + { x: 4, y: 2 }, + { x: null, y: 2 }, + ]); + var view = await table.view({ + group_by: ["y"], + columns: ["x"], + }); + var answer = [ + { __ROW_PATH__: [], x: 9 }, + { __ROW_PATH__: [1], x: 5 }, + { __ROW_PATH__: [2], x: 4 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("abs sum", async function () { + var table = await perspective.table([ + { x: 3, y: 1 }, + { x: 2, y: 1 }, + { x: null, y: 1 }, + { x: null, y: 1 }, + { x: -4, y: 2 }, + { x: null, y: 2 }, + ]); + var view = await table.view({ + group_by: ["y"], + columns: ["x"], + aggregates: { x: "abs sum" }, + }); + var answer = [ + { __ROW_PATH__: [], x: 1 }, + { __ROW_PATH__: [1], x: 5 }, + { __ROW_PATH__: [2], x: 4 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("mean after update", async function () { + var table = await perspective.table([ + { x: 3, y: 1 }, + { x: null, y: 1 }, + { x: null, y: 2 }, + ]); + await table.update([ + { x: 2, y: 1 }, + { x: null, y: 1 }, + { x: 4, y: 2 }, + ]); + var view = await table.view({ + group_by: ["y"], + columns: ["x"], + aggregates: { x: "mean" }, + }); + var answer = [ + { __ROW_PATH__: [], x: 3 }, + { __ROW_PATH__: [1], x: 2.5 }, + { __ROW_PATH__: [2], x: 4 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("mean at aggregate level", async function () { + var table = await perspective.table([ + { x: 4, y: 1, z: "a" }, + { x: null, y: 1, z: "a" }, + { x: null, y: 2, z: "a" }, + ]); + await table.update([ + { x: 1, y: 1, z: "b" }, + { x: 1, y: 1, z: "b" }, + { x: null, y: 1, z: "b" }, + { x: 4, y: 2, z: "b" }, + { x: null, y: 2, z: "b" }, + ]); + await table.update([ + { x: 2, y: 2, z: "c" }, + { x: 3, y: 2, z: "c" }, + { x: null, y: 2, z: "c" }, + { x: 7, y: 2, z: "c" }, + ]); + var view = await table.view({ + group_by: ["y", "z"], + columns: ["x"], + aggregates: { x: "mean" }, + }); + var answer = [ + { __ROW_PATH__: [], x: 3.142857142857143 }, + { __ROW_PATH__: [1], x: 2 }, + { __ROW_PATH__: [1, "a"], x: 4 }, + { __ROW_PATH__: [1, "b"], x: 1 }, + { __ROW_PATH__: [2], x: 4 }, + { __ROW_PATH__: [2, "a"], x: null }, + { __ROW_PATH__: [2, "b"], x: 4 }, + { __ROW_PATH__: [2, "c"], x: 4 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("null in pivot column", async function () { + var table = await perspective.table([ + { x: null }, + { x: "x" }, + { x: "y" }, + ]); + var view = await table.view({ + group_by: ["x"], + columns: ["x"], + aggregates: { x: "distinct count" }, + }); + var answer = [ + { __ROW_PATH__: [], x: 3 }, + { __ROW_PATH__: [null], x: 1 }, + { __ROW_PATH__: ["x"], x: 1 }, + { __ROW_PATH__: ["y"], x: 1 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("weighted mean", async function () { + var table = await perspective.table([ + { a: "a", x: 1, y: 200 }, + { a: "a", x: 2, y: 100 }, + { a: "a", x: 3, y: null }, + ]); + var view = await table.view({ + group_by: ["a"], + aggregates: { y: ["weighted mean", ["x"]] }, + columns: ["y"], + }); + var answer = [ + { __ROW_PATH__: [], y: (1 * 200 + 2 * 100) / (1 + 2) }, + { __ROW_PATH__: ["a"], y: (1 * 200 + 2 * 100) / (1 + 2) }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + }); + + test.describe("Aggregates with negatives", function () { + test("sum abs", async function () { + var table = await perspective.table([ + { x: 3, y: 1 }, + { x: 2, y: 1 }, + { x: 1, y: 1 }, + { x: -1, y: 1 }, + { x: -2, y: 2 }, + { x: -3, y: 2 }, + ]); + var view = await table.view({ + group_by: ["y"], + columns: ["x"], + aggregates: { x: "sum abs" }, + }); + var answer = [ + { __ROW_PATH__: [], x: 12 }, + { __ROW_PATH__: [1], x: 7 }, + { __ROW_PATH__: [2], x: 5 }, + ]; + let result = await view.to_json(); + expect(answer).toEqual(result); + view.delete(); + table.delete(); + }); + + test("abs sum", async function () { + var table = await perspective.table([ + { x: 3, y: 1 }, + { x: 2, y: 1 }, + { x: -1, y: 1 }, + { x: -1, y: 1 }, + { x: -2, y: 2 }, + { x: -3, y: 2 }, + ]); + var view = await table.view({ + group_by: ["y"], + columns: ["x"], + aggregates: { x: "abs sum" }, + }); + var answer = [ + { __ROW_PATH__: [], x: 2 }, + { __ROW_PATH__: [1], x: 3 }, + { __ROW_PATH__: [2], x: 5 }, + ]; + let result = await view.to_json(); + expect(answer).toEqual(result); + view.delete(); + table.delete(); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/clear.spec.ts b/rust/perspective-js/test/js/clear.spec.ts new file mode 100644 index 0000000..f9617d8 --- /dev/null +++ b/rust/perspective-js/test/js/clear.spec.ts @@ -0,0 +1,209 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "./perspective_client.ts"; +import type * as psp_types from "@perspective-dev/client"; + +((perspective) => { + test.describe("Clear", function () { + test("removes the rows from the table", async function () { + const table = await perspective.table([{ x: 1 }]); + const view = await table.view(); + let json = await view.to_json(); + expect(json).toHaveLength(1); + table.clear(); + json = await view.to_json(); + expect(json).toHaveLength(0); + view.delete(); + table.delete(); + }); + + test("to_columns output is empty", async function () { + const table = await perspective.table([{ x: 1 }]); + const view = await table.view(); + let result = await view.to_columns(); + expect(result).toEqual({ + x: [1], + }); + table.clear(); + result = await view.to_columns(); + expect(result).toEqual({ x: [] }); + view.delete(); + table.delete(); + }); + }); + + test.describe("Replace", function () { + test("replaces the rows in the table with the input data", async function () { + const table = await perspective.table([ + { x: 1, y: 2 }, + { x: 3, y: 4 }, + ]); + const view = await table.view(); + let json = await view.to_json(); + expect(json).toHaveLength(2); + expect(json).toEqual([ + { x: 1, y: 2 }, + { x: 3, y: 4 }, + ]); + await table.replace([{ x: 5, y: 6 }]); + json = await view.to_json(); + expect(json).toHaveLength(1); + expect(json).toEqual([{ x: 5, y: 6 }]); + view.delete(); + table.delete(); + }); + + test("Replaces CSV Table with high precision datetimes", async function () { + const a = '"start"\n2024-08-14T14:06:07.826Z'; + const b = '"start"\n2024-08-14T14:06:09.876667543Z'; + const table = await perspective.table(a); + const view = await table.view(); + const csv1 = await view.to_csv(); + expect(csv1).toEqual('"start"\n2024-08-14 14:06:07.826\n'); + + await table.replace(b); + const csv2 = await view.to_csv(); + expect(csv2).toEqual('"start"\n2024-08-14 14:06:09.876\n'); + view.delete(); + table.delete(); + }); + + test("replaces the rows in the table with the input data and fires an on_update", async function () { + const table = await perspective.table([ + { x: 1, y: 2 }, + { x: 3, y: 4 }, + ]); + + const view = await table.view(); + + let emit: ((x: unknown) => void) | undefined; + const callback = async function (updated: psp_types.OnUpdateArgs) { + expect(updated.port_id).toEqual(0); + const json = await view.to_json(); + expect(json).toHaveLength(1); + expect(json).toEqual([{ x: 5, y: 6 }]); + view.delete(); + table.delete(); + emit!(undefined); + }; + + let result = new Promise((x) => { + emit = x; + }); + + view.on_update(callback); + let json = await view.to_json(); + expect(json).toHaveLength(2); + expect(json).toEqual([ + { x: 1, y: 2 }, + { x: 3, y: 4 }, + ]); + + await table.replace([{ x: 5, y: 6 }]); + await result; + }); + + test("replaces the rows in the table with the input data and fires an on_update with the correct delta", async function () { + const table = await perspective.table([ + { x: 1, y: 2 }, + { x: 3, y: 4 }, + ]); + + const view = await table.view(); + let emit: ((x: unknown) => void) | undefined; + const callback = async function (updated: psp_types.OnUpdateArgs) { + expect(updated.port_id).toEqual(0); + const table2 = await perspective.table(updated.delta!); + const view2 = await table2.view(); + + const json = await view.to_json(); + expect(json).toHaveLength(1); + expect(json).toEqual([{ x: 5, y: 6 }]); + + const json2 = await view2.to_json(); + expect(json2).toEqual(json); + + view2.delete(); + table2.delete(); + view.delete(); + table.delete(); + emit!(undefined); + }; + + let result = new Promise((x) => { + emit = x; + }); + + view.on_update(callback, { mode: "row" }); + + let json = await view.to_json(); + expect(json).toHaveLength(2); + expect(json).toEqual([ + { x: 1, y: 2 }, + { x: 3, y: 4 }, + ]); + + await table.replace([{ x: 5, y: 6 }]); + await result; + }); + + test("replace the rows in the table atomically", async function () { + const table = await perspective.table([ + { x: 1, y: 2 }, + { x: 3, y: 4 }, + ]); + const view = await table.view(); + setTimeout(() => table.replace([{ x: 5, y: 6 }])); + let json = await view.to_json(); + expect(json).toHaveLength(2); + expect(json).toEqual([ + { x: 1, y: 2 }, + { x: 3, y: 4 }, + ]); + + await new Promise((x, y) => setTimeout(x)); + json = await view.to_json(); + expect(json).toHaveLength(1); + expect(json).toEqual([{ x: 5, y: 6 }]); + view.delete(); + table.delete(); + }); + + test("Preserves sort order with 2-sided pivot", async function () { + const input = [ + { x: 1, y: 7, z: "a" }, + { x: 1, y: 6, z: "b" }, + { x: 2, y: 5, z: "a" }, + { x: 2, y: 4, z: "b" }, + { x: 3, y: 3, z: "a" }, + { x: 3, y: 2, z: "b" }, + ]; + const table = await perspective.table(input); + const view = await table.view({ + group_by: ["z"], + split_by: ["x"], + sort: [["y", "asc"]], + columns: ["y"], + }); + + setTimeout(() => table.replace(input)); + let json = await view.to_json(); + await new Promise((x, y) => setTimeout(x)); + let json2 = await view.to_json(); + expect(json).toEqual(json2); + view.delete(); + table.delete(); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/constructors.spec.js b/rust/perspective-js/test/js/constructors.spec.js new file mode 100644 index 0000000..5dec9b3 --- /dev/null +++ b/rust/perspective-js/test/js/constructors.spec.js @@ -0,0 +1,2175 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "./perspective_client"; + +import moment from "moment"; +import * as arrows from "./test_arrows.js"; + +const data = [ + { x: 1, y: "a", z: true }, + { x: 2, y: "b", z: false }, + { x: 3, y: "c", z: true }, + { x: 4, y: "d", z: false }, +]; + +const col_data = { + x: [1, 2, 3, 4], + y: ["a", "b", "c", "d"], + z: [true, false, true, false], +}; + +const meta = { + x: "integer", + y: "string", + z: "boolean", +}; + +const data_3 = [ + { w: 1.5, x: 1, y: "a", z: true }, + { w: 2.5, x: 2, y: "b", z: false }, + { w: 3.5, x: 3, y: "c", z: true }, + { w: 4.5, x: 4, y: "d", z: false }, +]; + +const data_7 = { + w: [1.5, 2.5, 3.5, 4.5], + x: [1, 2, 3, 4], + y: ["a", "b", "c", "d"], + z: [true, false, true, false], +}; + +const int_in_string = [{ a: "1" }, { a: "2" }, { a: "12345" }]; + +const float_in_string = [{ a: "1.5" }, { a: "2.5" }, { a: "12345.56789" }]; + +const meta_3 = { + w: "float", + x: "integer", + y: "string", + z: "boolean", +}; + +const arrow_result = [ + { + f32: 1.5, + f64: 1.5, + i64: 1, + i32: 1, + i16: 1, + i8: 1, + bool: true, + char: "a", + dict: "a", + "datetime(ms)": +new Date("2018-01-25"), + "datetime(us)": +new Date("2018-01-25"), + "datetime(ns)": +new Date("2018-01-25"), + }, + { + f32: 2.5, + f64: 2.5, + i64: 2, + i32: 2, + i16: 2, + i8: 2, + bool: false, + char: "b", + dict: "b", + "datetime(ms)": +new Date("2018-01-26"), + "datetime(us)": +new Date("2018-01-26"), + "datetime(ns)": +new Date("2018-01-26"), + }, + { + f32: 3.5, + f64: 3.5, + i64: 3, + i32: 3, + i16: 3, + i8: 3, + bool: true, + char: "c", + dict: "c", + "datetime(ms)": +new Date("2018-01-27"), + "datetime(us)": +new Date("2018-01-27"), + "datetime(ns)": +new Date("2018-01-27"), + }, + { + f32: 4.5, + f64: 4.5, + i64: 4, + i32: 4, + i16: 4, + i8: 4, + bool: false, + char: "", + dict: "", + "datetime(ms)": +new Date("2018-01-28"), + "datetime(us)": +new Date("2018-01-28"), + "datetime(ns)": +new Date("2018-01-28"), + }, + { + f32: null, + f64: null, + i64: null, + i32: null, + i16: null, + i8: null, + bool: null, + char: null, + dict: null, + "datetime(ms)": null, + "datetime(us)": null, + "datetime(ns)": null, + }, +]; + +let arrow_lists_data = { + uint32_arr: ["[1,2,3,4]", "[5]", "[43215]", "[]"], + string_arr: [ + `["#1","second","third","fourth"]`, + `["fifth"]`, + `[]`, + `["Last!"]`, + ], + bool_arr: [ + "[true,false,false,true,true,false,false,true,true]", + "[]", + "[true]", + "[false]", + ], + float_arr: [ + "[]", + "[3.14,3.141592653589793,6.283185307179586,1.4142135623730951]", + "[12.00001]", + "[100000000.0,100000000.0,100000000.0,100000000.0]", + ], +}; + +let arrow_date_data = { + "jan-2019": [ + "2019-01-01", + "2019-01-02", + "2019-01-03", + "2019-01-04", + "2019-01-05", + "2019-01-06", + "2019-01-07", + "2019-01-08", + "2019-01-09", + "2019-01-10", + "2019-01-11", + "2019-01-12", + "2019-01-13", + "2019-01-14", + "2019-01-15", + "2019-01-16", + "2019-01-17", + "2019-01-18", + "2019-01-19", + "2019-01-20", + "2019-01-21", + "2019-01-22", + "2019-01-23", + "2019-01-24", + "2019-01-25", + "2019-01-26", + "2019-01-27", + "2019-01-28", + "2019-01-29", + "2019-01-30", + "2019-01-31", + ], + "feb-2020": [ + "2020-02-01", + "2020-02-02", + "2020-02-03", + "2020-02-04", + "2020-02-05", + "2020-02-06", + "2020-02-07", + "2020-02-08", + "2020-02-09", + "2020-02-10", + "2020-02-11", + "2020-02-12", + "2020-02-13", + "2020-02-14", + "2020-02-15", + "2020-02-16", + "2020-02-17", + "2020-02-18", + "2020-02-19", + "2020-02-20", + "2020-02-21", + "2020-02-22", + "2020-02-23", + "2020-02-24", + "2020-02-25", + "2020-02-26", + "2020-02-27", + "2020-02-28", + "2020-02-29", + null, + null, + ], + "mar-2019": [ + "2019-03-01", + "2019-03-02", + "2019-03-03", + "2019-03-04", + "2019-03-05", + "2019-03-06", + "2019-03-07", + "2019-03-08", + "2019-03-09", + "2019-03-10", + "2019-03-11", + "2019-03-12", + "2019-03-13", + "2019-03-14", + "2019-03-15", + "2019-03-16", + "2019-03-17", + "2019-03-18", + "2019-03-19", + "2019-03-20", + "2019-03-21", + "2019-03-22", + "2019-03-23", + "2019-03-24", + "2019-03-25", + "2019-03-26", + "2019-03-27", + "2019-03-28", + "2019-03-29", + "2019-03-30", + "2019-03-31", + ], + "apr-2020": [ + "2020-04-01", + "2020-04-02", + "2020-04-03", + "2020-04-04", + "2020-04-05", + "2020-04-06", + "2020-04-07", + "2020-04-08", + "2020-04-09", + "2020-04-10", + "2020-04-11", + "2020-04-12", + "2020-04-13", + "2020-04-14", + "2020-04-15", + "2020-04-16", + "2020-04-17", + "2020-04-18", + "2020-04-19", + "2020-04-20", + "2020-04-21", + "2020-04-22", + "2020-04-23", + "2020-04-24", + "2020-04-25", + "2020-04-26", + "2020-04-27", + "2020-04-28", + "2020-04-29", + "2020-04-30", + null, + ], +}; + +// transform arrow strings into timestamps +for (const k in arrow_date_data) { + arrow_date_data[k] = arrow_date_data[k].map((d) => + d ? new Date(d).getTime() : null, + ); +} + +const dt = () => { + let dt = new Date(); + dt.setHours(4); + dt.setMinutes(12); + return dt; +}; + +const data_4 = [{ v: dt() }]; + +const data_5 = [{ v: "11-09-2017" }]; + +const meta_4 = { v: "datetime" }; + +const csv = "x,y,z\n1,a,true\n2,b,false\n3,c,true\n4,d,false"; + +const data_6 = [{ x: "š" }]; + +const int_float_data = [ + { int: 1, float: 2.25 }, + { int: 2, float: 3.5 }, + { int: 3, float: 4.75 }, + { int: 4, float: 5.25 }, +]; +const int_float_string_data = [ + { int: 1, float: 2.25, string: "a" }, + { int: 2, float: 3.5, string: "b" }, + { int: 3, float: 4.75, string: "c" }, + { int: 4, float: 5.25, string: "d" }, +]; + +// out of order to make sure we can read out of perspective in insertion +// order, not pkey order. +const all_types_data = [ + { + int: 4, + float: 5.25, + string: "d", + date: new Date(2020, 3, 15), + datetime: new Date(2020, 0, 15, 23, 30), + boolean: true, + }, + { + int: 3, + float: 4.75, + string: "c", + date: new Date(2020, 2, 15), + datetime: new Date(2020, 0, 15, 18, 30), + boolean: false, + }, + { + int: 2, + float: 3.5, + string: "b", + date: new Date(2020, 1, 15), + datetime: new Date(2020, 0, 15, 12, 30), + boolean: true, + }, + { + int: 1, + float: 2.25, + string: "a", + date: new Date(2020, 0, 15), + datetime: new Date(2020, 0, 15, 6, 30), + boolean: false, + }, + // values above should be replaced with these values below, due to + // indexing + { + int: 4, + float: 5.25, + string: "d", + date: new Date(2020, 3, 15), + datetime: new Date(2020, 0, 15, 23, 30), + boolean: true, + }, + { + int: 3, + float: 4.75, + string: "c", + date: new Date(2020, 2, 15), + datetime: new Date(2020, 0, 15, 18, 30), + boolean: false, + }, + { + int: 2, + float: 3.5, + string: "b", + date: new Date(2020, 1, 15), + datetime: new Date(2020, 0, 15, 12, 30), + boolean: true, + }, + { + int: 1, + float: 2.25, + string: "a", + date: new Date(2020, 0, 15), + datetime: new Date(2020, 0, 15, 6, 30), + boolean: false, + }, +]; + +const datetime_data = [ + { datetime: new Date(), int: 1 }, + { datetime: new Date(), int: 1 }, + { datetime: new Date(), int: 2 }, + { datetime: new Date(), int: 2 }, +]; + +// utility for checking typed arrays +function validate_typed_array(typed_array, column_data) { + let is_valid = true; + for (let i = 0; i < typed_array.length; i++) { + if (column_data[i] !== null) { + if (typed_array[i] !== column_data[i]) { + is_valid = false; + break; + } + } + } + return is_valid; +} + +((perspective) => { + // test.describe("Execute", function () { + // test.skip("serialized functions in a worker", async function () { + // var table = await perspective.table({ + // x: "integer", + // y: "string", + // z: "boolean", + // }); + // table.execute((t) => { + // t.update([ + // { x: 1, y: "a", z: true }, + // { x: 2, y: "b", z: false }, + // { x: 3, y: "c", z: true }, + // { x: 4, y: "d", z: false }, + // ]); + // }); + // let view = await table.view({}); + // let js = await view.to_json(); + // expect(js).toEqual([ + // { x: 1, y: "a", z: true }, + // { x: 2, y: "b", z: false }, + // { x: 3, y: "c", z: true }, + // { x: 4, y: "d", z: false }, + // ]); + // view.delete(); + // table.delete(); + // }); + // }); + + test.describe("Destructors", function () { + test("calls delete() on table with no views", async function () { + let table = await perspective.table(data); + await table.delete(); + expect(true).toEqual(true); + }); + + test("calls delete on a view, then a table", async function () { + var table = await perspective.table(data); + var view = await table.view(); + await view.delete(); + await table.delete(); + expect(true).toEqual(true); + }); + + test("calls delete on multiple views, then a table", async function () { + var table = await perspective.table(data); + var view1 = await table.view(); + var view2 = await table.view(); + await view1.delete(); + await view2.delete(); + await table.delete(); + expect(true).toEqual(true); + }); + }); + + test.describe("Schema", function () { + test("0-sided view", async function () { + const table = await perspective.table(int_float_string_data); + const view = await table.view(); + const schema = await view.schema(); + expect(schema).toEqual({ + int: "integer", + float: "float", + string: "string", + }); + view.delete(); + table.delete(); + }); + + test("0-sided view with columns selection", async function () { + const table = await perspective.table(int_float_string_data); + const view = await table.view({ columns: ["float", "string"] }); + const schema = await view.schema(); + expect(schema).toEqual({ float: "float", string: "string" }); + view.delete(); + table.delete(); + }); + }); + + test.describe.skip("Typed Arrays", function () { + test("Respects start/end rows", async function () { + var table = await perspective.table(int_float_data); + var view = await table.view(); + const result = await view.col_to_js_typed_array("int", { + start_row: 1, + end_row: 2, + }); + expect(result[0].byteLength).toEqual(4); + view.delete(); + table.delete(); + }); + + test("Int, 0-sided view", async function () { + var table = await perspective.table(int_float_data); + var view = await table.view(); + const result = await view.col_to_js_typed_array("int"); + expect(result[0].byteLength).toEqual(16); + view.delete(); + table.delete(); + }); + + test("Float, 0-sided view", async function () { + var table = await perspective.table(int_float_data); + var view = await table.view(); + const result = await view.col_to_js_typed_array("float"); + expect(result[0].byteLength).toEqual(32); + view.delete(); + table.delete(); + }); + + test("Datetime, 0-sided view", async function () { + var table = await perspective.table(datetime_data); + var view = await table.view(); + var schema = await view.schema(); + expect(schema).toEqual({ datetime: "datetime", int: "integer" }); + const result = await view.col_to_js_typed_array("datetime"); + expect(result[0].byteLength).toEqual(32); + view.delete(); + table.delete(); + }); + + test("Int, 1-sided view", async function () { + var table = await perspective.table(int_float_data); + var view = await table.view({ + group_by: ["int"], + columns: ["int", "float"], + }); + const result = await view.col_to_js_typed_array("int"); + // should include aggregate row + expect(result[0].byteLength).toEqual(20); + view.delete(); + table.delete(); + }); + + test("Float, 1-sided view", async function () { + var table = await perspective.table(int_float_data); + var view = await table.view({ + group_by: ["int"], + columns: ["int", "float"], + }); + const result = await view.col_to_js_typed_array("float"); + expect(result[0].byteLength).toEqual(40); + view.delete(); + table.delete(); + }); + + test("Datetime, 1-sided view", async function () { + var table = await perspective.table(datetime_data); + var view = await table.view({ + group_by: ["int"], + columns: ["datetime"], + aggregates: { datetime: "high" }, + }); + const result = await view.col_to_js_typed_array("datetime"); + expect(result[0].byteLength).toEqual(24); + view.delete(); + table.delete(); + }); + + test("Int, 2-sided view with group by", async function () { + var table = await perspective.table(int_float_data); + var view = await table.view({ + split_by: ["float"], + group_by: ["int"], + columns: ["int", "float"], + }); + const result = await view.col_to_js_typed_array("3.5|int"); + expect(result[0].byteLength).toEqual(20); + view.delete(); + table.delete(); + }); + + test("Float, 2-sided view with group by", async function () { + var table = await perspective.table(int_float_data); + var view = await table.view({ + split_by: ["float"], + group_by: ["int"], + columns: ["int", "float"], + }); + const result = await view.col_to_js_typed_array("3.5|float"); + expect(result[0].byteLength).toEqual(40); + view.delete(); + table.delete(); + }); + + test("Int, 2-sided view, no group by", async function () { + var table = await perspective.table(int_float_data); + var view = await table.view({ split_by: ["float"] }); + const result = await view.col_to_js_typed_array("3.5|int"); + // bytelength should not include the aggregate row + expect(result[0].byteLength).toEqual(16); + view.delete(); + table.delete(); + }); + + test("Float, 2-sided view, no group by", async function () { + var table = await perspective.table(int_float_data); + var view = await table.view({ split_by: ["float"] }); + const result = await view.col_to_js_typed_array("3.5|float"); + expect(result[0].byteLength).toEqual(32); + view.delete(); + table.delete(); + }); + + test("Symmetric output with to_columns, 0-sided", async function () { + let table = await perspective.table(int_float_data); + let view = await table.view(); + let cols = await view.to_columns(); + + for (let col in cols) { + let ta = await view.col_to_js_typed_array(col); + let column = cols[col]; + if (ta !== undefined && column !== undefined) { + expect(ta[0].length).toEqual(cols[col].length); + expect(validate_typed_array(ta[0], cols[col])).toEqual( + true, + ); + } + } + view.delete(); + table.delete(); + }); + + test("Symmetric output with to_columns, 1-sided", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + group_by: ["int"], + columns: ["int", "float"], + }); + let cols = await view.to_columns(); + + for (let col in cols) { + let ta = await view.col_to_js_typed_array(col); + let column = cols[col]; + if (ta !== undefined && column !== undefined) { + expect(ta[0].length).toEqual(cols[col].length); + expect(validate_typed_array(ta[0], cols[col])).toEqual( + true, + ); + } + } + view.delete(); + table.delete(); + }); + }); + + test.describe("Other `View`s", function () { + test("Construct a table from another view", async function () { + const table = await perspective.table(int_float_string_data); + const view = await table.view(); + const table2 = await perspective.table(view); + const view2 = await table2.view(); + const data = await view2.to_json(); + expect(data).toEqual(int_float_string_data); + view2.delete(); + table2.delete(); + view.delete(); + table.delete(); + }); + }); + + test.describe("Errors", function () { + test("Table constructor should throw an exception and reject promise", async function () { + expect.assertions(1); + perspective.table([1, 2, 3]).catch((error) => { + expect(error.message).toContain( + "Abort(): Cannot determine data types without column names!\n", + ); + }); + }); + + test("View constructor should throw an exception and reject promise", async function () { + expect.assertions(1); + const table = await perspective.table(int_float_string_data); + table + .view({ + group_by: ["abcd"], + }) + .catch((error) => { + expect(error.message).toContain( + "Abort(): Invalid column 'abcd' found in View group_by.\n", + ); + table.delete(); + }); + }); + + test("Table constructor should throw an exception on await", async function () { + expect.assertions(1); + + try { + await perspective.table([1, 2, 3]); + } catch (error) { + expect(error.message).toContain( + "Abort(): Cannot determine data types without column names!\n", + ); + } + }); + + test("View constructor should throw an exception on await", async function () { + expect.assertions(1); + const table = await perspective.table(int_float_string_data); + + try { + await table.view({ + group_by: ["abcd"], + }); + } catch (error) { + expect(error.message).toContain( + "Abort(): Invalid column 'abcd' found in View group_by.\n", + ); + table.delete(); + } + }); + + test("Table constructor pads short trailing columns with null", async function () { + const table = await perspective.table({ + overflow: [1, 2, 3, 4, 5, 6, 7, 8, 9], + short: [1], + }); + const view = await table.view(); + const result = await view.to_columns(); + expect(result).toEqual({ + overflow: [1, 2, 3, 4, 5, 6, 7, 8, 9], + short: [1, null, null, null, null, null, null, null, null], + }); + await view.delete(); + await table.delete(); + }); + + test("Table constructor pads short leading columns with null", async function () { + const table = await perspective.table({ + short: [1], + overflow: [1, 2, 3, 4, 5, 6, 7, 8, 9], + }); + const view = await table.view(); + const result = await view.to_columns(); + expect(result).toEqual({ + short: [1, null, null, null, null, null, null, null, null], + overflow: [1, 2, 3, 4, 5, 6, 7, 8, 9], + }); + await view.delete(); + await table.delete(); + }); + + test("Table constructor handles columns of equal length", async function () { + const table = await perspective.table({ + a: [1, 2, 3], + b: ["x", "y", "z"], + }); + const view = await table.view(); + const result = await view.to_columns(); + expect(result).toEqual({ + a: [1, 2, 3], + b: ["x", "y", "z"], + }); + await view.delete(); + await table.delete(); + }); + + test("Table update pads short columns with null", async function () { + const table = await perspective.table({ + a: "integer", + b: "integer", + }); + await table.update({ + a: [1, 2, 3, 4, 5], + b: [1], + }); + const view = await table.view(); + const result = await view.to_columns(); + expect(result).toEqual({ + a: [1, 2, 3, 4, 5], + b: [1, null, null, null, null], + }); + + // The table must remain usable for subsequent updates. + await table.update({ a: [10], b: [20] }); + const result2 = await view.to_columns(); + expect(result2).toEqual({ + a: [1, 2, 3, 4, 5, 10], + b: [1, null, null, null, null, 20], + }); + await view.delete(); + await table.delete(); + }); + }); + + test.describe("Formatters", function () { + test("Serializes a simple view to CSV", async function () { + var table = await perspective.table(data); + var view = await table.view({}); + var answer = `"x","y","z"\n1,"a",true\n2,"b",false\n3,"c",true\n4,"d",false\n`; + let result = await view.to_csv(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("Serializes 1 sided view to CSV", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["z"], + columns: ["x"], + }); + var answer = `"z (Group by 1)","x"\n,10\nfalse,6\ntrue,4\n`; + let result = await view.to_csv(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("Serializes a 2 sided view to CSV", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["z"], + split_by: ["y"], + columns: ["x"], + }); + var answer = `"z (Group by 1)","a|x","b|x","c|x","d|x"\n,1,2,3,4\nfalse,,2,,4\ntrue,1,,3,\n`; + let result = await view.to_csv(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("Serializes a simple view to column-oriented JSON", async function () { + var table = await perspective.table(data_3); + var view = await table.view({}); + let result = await view.to_columns(); + expect(result).toEqual(data_7); + view.delete(); + table.delete(); + }); + }); + + test.describe("CSV parsing", function () { + test("Does not lose leading 0's when a CSV column is declared as a string", async function () { + let table = await perspective.table({ x: "string", y: "integer" }); + await table.update("x,y\n000123,000123"); + let view = await table.view(); + let result = await view.to_json(); + expect(result).toEqual([{ x: "000123", y: 123 }]); + view.delete(); + table.delete(); + }); + + // Added + test("Does not lose leading 0's when a CSV column is declared as a string (async)", async function () { + let table = await perspective.table({ x: "string", y: "integer" }); + await table.update("x,y\n000123,000123"); + let view = await table.view(); + let result = await view.to_json(); + expect(result).toEqual([{ x: "000123", y: 123 }]); + view.delete(); + table.delete(); + }); + + test("Handles duplicate column names with different types", async function () { + const csv = "A,A\ntest,1"; + let table = await perspective.table(csv); + let view = await table.view(); + let csv2 = await view.to_json(); + expect(csv2).toEqual([{ A: "test", "A*": 1 }]); + view.delete(); + table.delete(); + }); + + test("Handles duplicate column names with rename collisions", async function () { + const csv = "A,A,A*\ntest,1,2"; + let table = await perspective.table(csv); + let view = await table.view(); + let csv2 = await view.to_json(); + expect(csv2).toEqual([{ A: "test", "A*": 1, "A**": 2 }]); + view.delete(); + table.delete(); + }); + + test("Handles strings with quotation characters and commas", async function () { + let table = await perspective.table({ x: "string", y: "integer" }); + await table.update([ + { x: "Test, hello!", y: 1 }, + { x: 'Test2"', y: 2 }, + { x: 'Test3, Hello!"', y: 3 }, + ]); + let view = await table.view(); + let result = await view.to_csv(); + expect(result).toEqual( + `"x","y"\n"Test, hello!",1\n"Test2""",2\n"Test3, Hello!""",3\n`, + ); + view.delete(); + table.delete(); + }); + + // Added + test("Handles strings with quotation characters and commas (async)", async function () { + let table = await perspective.table({ x: "string", y: "integer" }); + await table.update([ + { x: "Test, hello!", y: 1 }, + { x: 'Test2"', y: 2 }, + { x: 'Test3, Hello!"', y: 3 }, + ]); + let view = await table.view(); + let result = await view.to_csv(); + expect(result).toEqual( + `"x","y"\n"Test, hello!",1\n"Test2""",2\n"Test3, Hello!""",3\n`, + ); + view.delete(); + table.delete(); + }); + + test("Transitively loads a CSV created from `to_csv()` on a table with a datetime column", async function () { + // Assert that the CSV parser can handle POSIX timestamps. + let table = await perspective.table(arrow_date_data); + let view = await table.view(); + let schema = await table.schema(); + let csv = await view.to_csv(); + let table2 = await perspective.table(csv); + let schema2 = await table2.schema(); + let view2 = await table2.view(); + let csv2 = await view2.to_csv(); + expect(schema2).toEqual(schema); + expect(csv2).toEqual(csv); + view2.delete(); + table2.delete(); + view.delete(); + table.delete(); + }); + }); + + test.describe("ndjson", function () { + test("basic constructor", async function () { + var table = await perspective.table(`{"x":1}\n{"x":2}`, { + format: "ndjson", + }); + var view = await table.view(); + let result = await view.to_json(); + expect(result).toEqual([{ x: 1 }, { x: 2 }]); + view.delete(); + table.delete(); + }); + + test("date types", async function () { + const ndjson = []; + for (const row of data_4) { + ndjson.push(row); + } + var table = await perspective.table( + ndjson.map(JSON.stringify).join("\n"), + { + format: "ndjson", + }, + ); + var view = await table.view(); + let result = await view.to_json(); + expect(result).toEqual([{ v: +data_4[0]["v"] }]); + view.delete(); + table.delete(); + }); + + test("Handles dates when provided a schema", async function () { + var table = await perspective.table(meta_4); + await table.update(data_4.map(JSON.stringify).join("\n"), { + format: "ndjson", + }); + + let view = await table.view(); + let result = await view.to_json(); + expect(result).toEqual([{ v: +data_4[0]["v"] }]); + view.delete(); + table.delete(); + }); + + test("Handles many objects without newline separators", async function () { + const expected = []; + for (let i = 0; i < 64; i++) { + expected.push({ a: `row-${i}` }); + } + const data = expected.map(JSON.stringify).join(""); + const table = await perspective.table(data, { format: "ndjson" }); + const view = await table.view(); + expect(await table.size()).toEqual(64); + expect(await view.to_json()).toEqual(expected); + await view.delete(); + await table.delete(); + }); + }); + + test.describe("Constructors", function () { + test("JSON constructor", async function () { + var table = await perspective.table(data); + var view = await table.view(); + let result = await view.to_json(); + expect(result).toEqual(data); + view.delete(); + table.delete(); + }); + + test("JSON column oriented constructor", async function () { + var table = await perspective.table(col_data); + var view = await table.view(); + let result = await view.to_json(); + expect(result).toEqual(data); + view.delete(); + table.delete(); + }); + + test("Arrow constructor", async function () { + var table = await perspective.table(arrows.test_null_arrow.slice()); + var view = await table.view(); + let result = await view.to_json(); + expect(result).toEqual(arrow_result); + view.delete(); + table.delete(); + }); + + test.skip("Arrow (chunked format) constructor", async function () { + var table = await perspective.table(arrows.chunked_arrow.slice()); + var view = await table.view(); + let result = await view.to_json(); + expect(result.length).toEqual(10); + view.delete(); + table.delete(); + }); + + test("Arrow date32 constructor", async function () { + const table = await perspective.table(arrows.date32_arrow.slice()); + const view = await table.view(); + const result = await view.to_columns(); + expect(result).toEqual(arrow_date_data); + view.delete(); + table.delete(); + }); + + test("Arrow float32 constructor", async function () { + const table = await perspective.table(arrows.float32_arrow.slice()); + const view = await table.view(); + const result = await view.to_columns(); + expect(result).toEqual({ + id: [1, 2, 3], + name: ["Alice", "Bob", "Charlie"], + score: [92.5, 87.30000305175781, 95.80000305175781], + }); + view.delete(); + table.delete(); + }); + + test("Arrow date64 constructor", async function () { + const table = await perspective.table(arrows.date64_arrow.slice()); + const view = await table.view(); + const result = await view.to_columns(); + expect(result).toEqual(arrow_date_data); + view.delete(); + table.delete(); + }); + + test("Arrow Lists constructor", async function () { + const table = await perspective.table(arrows.lists_arrow.slice()); + const view = await table.view(); + const result = await view.to_columns(); + expect(result).toEqual(arrow_lists_data); + view.delete(); + table.delete(); + }); + + test("Arrow dictionary constructor", async function () { + const table = await perspective.table(arrows.dict_arrow.slice()); + const view = await table.view(); + const result = await view.to_columns(); + expect(result).toEqual({ + a: ["abc", "def", "def", null, "abc"], + b: ["klm", "hij", null, "hij", "klm"], + }); + view.delete(); + table.delete(); + }); + + test("CSV constructor with null columns", async function () { + const table = await perspective.table("x,y\n1,"); + const view = await table.view(); + expect(await table.schema()).toEqual({ + x: "integer", + y: "string", + }); + const result = await view.to_columns(); + expect(result).toEqual({ + x: [1], + y: [null], + }); + await view.delete(); + await table.delete(); + }); + + test("CSV constructor indexed, with null columns, and update", async function () { + const table = await perspective.table("x,y\n1,", { index: "x" }); + const view = await table.view(); + expect(await table.schema()).toEqual({ + x: "integer", + y: "string", + }); + const result = await view.to_columns(); + expect(result).toEqual({ + x: [1], + y: [null], + }); + await table.update("x,y\n1,abc\n2,123"); + const result2 = await view.to_columns(); + expect(result2).toEqual({ + x: [1, 2], + y: ["abc", "123"], + }); + await view.delete(); + await table.delete(); + }); + + test("Meta constructor", async function () { + var table = await perspective.table(meta); + var view = await table.view(); + let result = await view.to_json(); + expect(result).toEqual([]); + view.delete(); + table.delete(); + }); + + test("Handles floats", async function () { + var table = await perspective.table(data_3); + var view = await table.view(); + let result = await view.to_json(); + expect(result).toEqual(data_3); + view.delete(); + table.delete(); + }); + + test("Infers ints wrapped in strings", async function () { + var table = await perspective.table(int_in_string); + var view = await table.view(); + let result = await view.schema(); + expect(result).toEqual({ a: "integer" }); + view.delete(); + table.delete(); + }); + + test("Infers floats wrapped in strings", async function () { + var table = await perspective.table(float_in_string); + var view = await table.view(); + let result = await view.schema(); + expect(result).toEqual({ a: "float" }); + view.delete(); + table.delete(); + }); + + test("Infers correct type for empty string columns", async function () { + var table = await perspective.table([ + { x: "", y: 1 }, + { x: "", y: 2 }, + { x: "", y: 3 }, + { x: "", y: 4 }, + ]); + var view = await table.view(); + let result = await view.schema(); + expect(result).toEqual({ x: "string", y: "integer" }); + view.delete(); + table.delete(); + }); + + test("Returns the correct number of rows for column-only views", async function () { + var table = await perspective.table(data); + var view = await table.view(); + var num_rows = await view.num_rows(); + var view2 = await table.view({ + split_by: ["x"], + }); + var num_rows_col_only = await view2.num_rows(); + expect(num_rows_col_only).toEqual(num_rows); + view.delete(); + view2.delete(); + table.delete(); + }); + + test.skip("Handles inconsistent rows with same width", async function () { + const int_to_float = [ + { x: 1, y: 2 }, + { y: 2, z: 3 }, + ]; + var table = await perspective.table(int_to_float); + var view = await table.view(); + var json = await view.to_json(); + expect(json).toEqual([ + { x: 1, y: 2, z: null }, + { x: null, y: 2, z: 3 }, + ]); + view.delete(); + table.delete(); + }); + + test.skip("Handles inconsistent rows", async function () { + const int_to_float = [{ x: 1 }, { y: 2, z: 3 }]; + var table = await perspective.table(int_to_float); + var schema = await table.schema(); + expect(schema).toEqual({ + x: "integer", + y: "integer", + z: "integer", + }); + var view = await table.view(); + var json = await view.to_json(); + expect(json).toEqual([ + { x: 1, y: null, z: null }, + { x: null, y: 2, z: 3 }, + ]); + view.delete(); + table.delete(); + }); + + test("Upgrades integer columns to strings", async function () { + const int_to_float = { + a: [1, 2, 3, "x", "y"], + }; + + var table = await perspective.table(int_to_float); + var schema_1 = await table.schema(); + expect(schema_1["a"]).toEqual("string"); + var view = await table.view(); + var json = await view.to_json(); + expect(json).toEqual([ + { a: "1" }, + { a: "2" }, + { a: "3" }, + { a: "x" }, + { a: "y" }, + ]); + view.delete(); + table.delete(); + }); + + test("Upgrades integer columns with values beyond max/min_int to float", async function () { + const int_to_float = { + a: [1, 2, 3, 2147483667, 5], + }; + + var table = await perspective.table(int_to_float); + var view = await table.view(); + var json = await view.to_columns(); + expect(json).toEqual(int_to_float); + var schema_2 = await table.schema(); + expect(schema_2["a"]).toEqual("float"); + view.delete(); + table.delete(); + }); + + // This currently won't work, and I'm unclear we want it to - upgrading + // ths column in place is easy, but once the gnode and potentially + // contexts have been created, this becomes much more difficult. + test.skip("Upgrades integer columns with values beyond max/min_int to float old", async function () { + const schema = { + a: "integer", + }; + + const int_to_float = { + a: [1, 2, 3, 2147483667, 5], + }; + + var table = await perspective.table(schema); + var schema_1 = await table.schema(); + expect(schema_1["a"]).toEqual("integer"); + + await table.update(int_to_float); + + var schema_2 = await table.schema(); + expect(schema_2["a"]).toEqual("float"); + table.delete(); + }); + + test("Does not infer float column as integers", async function () { + const int_to_float = []; + for (let x = 0; x < 200; x++) { + int_to_float.push({ a: 1 }); + } + int_to_float.push({ a: 2147483667 }); + + var table = await perspective.table(int_to_float); + var schema_1 = await table.schema(); + expect(schema_1["a"]).toEqual("float"); + let view = await table.view(); + var data = await view.to_json(); + expect(data).toEqual(int_to_float); + + view.delete(); + table.delete(); + }); + + test("has correct size", async function () { + var table = await perspective.table(data); + let result = await table.size(); + expect(result).toEqual(4); + table.delete(); + }); + + test("has a schema", async function () { + var table = await perspective.table(data); + let result = await table.schema(); + expect(result).toEqual(meta); + table.delete(); + }); + + test("has columns", async function () { + var table = await perspective.table(data); + let result = await table.columns(); + expect(result).toEqual(["x", "y", "z"]); + table.delete(); + }); + + test("Handles floats schemas", async function () { + var table = await perspective.table(data_3); + let result = await table.schema(); + expect(result).toEqual(meta_3); + table.delete(); + }); + + test("Generates correct date schemas", async function () { + var table = await perspective.table(data_4); + let result = await table.schema(); + expect(result).toEqual(meta_4); + table.delete(); + }); + + test("Handles date updates when constructed from a schema", async function () { + var table = await perspective.table(meta_4); + await table.update(data_4); + let view = await table.view(); + let result = await view.to_json(); + expect(result).toEqual([{ v: +data_4[0]["v"] }]); + view.delete(); + table.delete(); + }); + + test("Handles datetime values", async function () { + var table = await perspective.table(data_4); + let view = await table.view(); + let result = await view.to_json(); + expect(result).toEqual([{ v: +data_4[0]["v"] }]); + view.delete(); + table.delete(); + }); + + test("Handles datetime strings", async function () { + var table = await perspective.table(data_5); + let view = await table.view(); + let result = await view.to_json(); + expect(result).toEqual([ + { v: +moment(data_5[0]["v"], "MM-DD-YYYY") }, + ]); + view.delete(); + table.delete(); + }); + + test("Handles datetime strings in US locale string old", async function () { + // FIXME: 1/1/2020, 12:30:45 PM = 1/1/2020, 7:30:45 AM UTC, but + // because C++ strptime in Emscripten parses the time as 12:30:45AM, + // the output is 12/31/2019 19:30:45 PM UTC. This is clearly wrong. + const data = { + x: [ + "1/1/2020, 12:30:45 PM", + "1/1/2020, 12:30:45 AM", + "03/15/2020, 11:30:45 AM", + "06/30/2020, 01:30:45 AM", + "06/30/2020, 01:30:45 PM", + "12/31/2020, 11:59:59 PM", + ], + }; + const table = await perspective.table(data); + const schema = await table.schema(); + + expect(schema).toEqual({ + x: "datetime", + }); + + const view = await table.view(); + const result = await view.to_columns(); + + const expected = { + x: [ + "1/1/2020, 12:30:45 PM", + "1/1/2020, 12:30:45 AM", + "03/15/2020, 11:30:45 AM", + "06/30/2020, 01:30:45 AM", + "06/30/2020, 01:30:45 PM", + "12/31/2020, 11:59:59 PM", + ].map((v) => new Date(v).getTime()), + }; + + expect(result).toEqual(expected); + view.delete(); + table.delete(); + }); + + test("Handles datetime strings in US locale string", async function () { + const data = { + x: [ + "1/1/2020, 05:30:45 AM", + "03/15/2020, 11:30:45 AM", + "06/30/2020, 01:30:45 AM", + "12/31/2020, 11:59:59 PM", + ], + }; + const table = await perspective.table(data); + const schema = await table.schema(); + + expect(schema).toEqual({ + x: "datetime", + }); + + const view = await table.view(); + const result = await view.to_columns(); + + const expected = { + x: [ + "1/1/2020, 05:30:45 AM", + "03/15/2020, 11:30:45 AM", + "06/30/2020, 01:30:45 AM", + "12/31/2020, 11:59:59 PM", + ].map((v) => new Date(v).getTime()), + }; + + expect(result).toEqual(expected); + view.delete(); + table.delete(); + }); + + test("Handles datetime values with mixed formats", async function () { + var table = await perspective.table({ datetime: "datetime" }); + await table.update([ + { datetime: new Date(1549257586108) }, + { datetime: "2019-01-30" }, + { datetime: 11 }, + ]); + let view = await table.view(); + let result = await view.to_json(); + expect(result).toEqual([ + { datetime: 1549257586108 }, + { datetime: 1548806400000 }, + { datetime: 11 }, + ]); + view.delete(); + table.delete(); + }); + + test("Handles date values", async function () { + var table = await perspective.table({ v: "date" }); + await table.update(data_4); + let view = await table.view(); + let result = await view.to_json(); + let d = new Date(data_4[0]["v"]); + d.setHours(0); + d.setMinutes(0); + d.setSeconds(0); + d.setMilliseconds(0); + expect(result).toEqual([{ v: +d }]); + view.delete(); + table.delete(); + }); + + test("CSV Parses an ISO-8601 formatted string with milliseconds and timezone", async function () { + let table = await perspective.table( + "d\n2008-09-15T10:00:00.123+05:45", + ); + let view = await table.view(); + let result = await view.to_csv(); + expect(result).toEqual('"d"\n2008-09-15 04:15:00.123\n'); + await view.delete(); + await table.delete(); + }); + + test("JSON Parses an ISO-8601 formatted string with milliseconds and timezone", async function () { + let table = await perspective.table({ + d: ["2008-09-15T10:00:00.123+05:45"], + }); + let view = await table.view(); + let result = await view.to_csv(); + expect(result).toEqual('"d"\n2008-09-15 04:15:00.123\n'); + await view.delete(); + await table.delete(); + }); + + test("CSV Updates an ISO-8601 formatted string with milliseconds and timezone", async function () { + let table = await perspective.table({ d: "datetime" }); + await table.update("d\n2008-09-15T10:00:00.123+05:45"); + let view = await table.view(); + let result = await view.to_csv(); + expect(result).toEqual('"d"\n2008-09-15 04:15:00.123\n'); + await view.delete(); + await table.delete(); + }); + + test("JSON Updates an ISO-8601 formatted string with milliseconds and timezone", async function () { + let table = await perspective.table({ d: "datetime" }); + await table.update({ + d: ["2008-09-15T10:00:00.123+05:45"], + }); + let view = await table.view(); + let result = await view.to_csv(); + expect(result).toEqual('"d"\n2008-09-15 04:15:00.123\n'); + await view.delete(); + await table.delete(); + }); + + test("CSV Parses an ISO-8601 formatted string with microseconds and timezone", async function () { + let table = await perspective.table( + "d\n2008-09-15T15:30:00.123456+05:30", + ); + let view = await table.view(); + let result = await view.to_csv(); + expect(result).toEqual('"d"\n2008-09-15 10:00:00.123\n'); + await view.delete(); + await table.delete(); + }); + + test("JSON Parses an ISO-8601 formatted string with microseconds and timezone", async function () { + let table = await perspective.table({ + d: ["2008-09-15T15:30:00.123456+05:30"], + }); + let view = await table.view(); + let result = await view.to_csv(); + expect(result).toEqual('"d"\n2008-09-15 10:00:00.123\n'); + await view.delete(); + await table.delete(); + }); + + test("CSV Updates an ISO-8601 formatted string with microseconds and timezone", async function () { + let table = await perspective.table({ d: "datetime" }); + await table.update("d\n2008-09-15T15:30:00.123456+05:30"); + let view = await table.view(); + let result = await view.to_csv(); + expect(result).toEqual('"d"\n2008-09-15 10:00:00.123\n'); + await view.delete(); + await table.delete(); + }); + + test("JSON Updates an ISO-8601 formatted string with microseconds and timezone", async function () { + let table = await perspective.table({ d: "datetime" }); + await table.update({ + d: ["2008-09-15T15:30:00.123456+05:30"], + }); + let view = await table.view(); + let result = await view.to_csv(); + expect(result).toEqual('"d"\n2008-09-15 10:00:00.123\n'); + await view.delete(); + await table.delete(); + }); + + test("CSV Parses an ISO-8601 formatted string with nanoseconds and timezone", async function () { + let table = await perspective.table( + "d\n2008-09-15T15:15:00.123456789-05:15", + ); + let view = await table.view(); + let result = await view.to_csv(); + expect(result).toEqual('"d"\n2008-09-15 20:30:00.123\n'); + await view.delete(); + await table.delete(); + }); + + test("JSON Parses an ISO-8601 formatted string with nanoseconds and timezone", async function () { + let table = await perspective.table({ + d: ["2008-09-15T15:15:00.123456789-05:15"], + }); + let view = await table.view(); + let result = await view.to_csv(); + expect(result).toEqual('"d"\n2008-09-15 20:30:00.123\n'); + await view.delete(); + await table.delete(); + }); + + test("CSV Updates an ISO-8601 formatted string with nanoseconds and timezone", async function () { + let table = await perspective.table({ d: "datetime" }); + await table.update("d\n2008-09-15T15:15:00.123456789-05:15"); + let view = await table.view(); + let result = await view.to_csv(); + expect(result).toEqual('"d"\n2008-09-15 20:30:00.123\n'); + await view.delete(); + await table.delete(); + }); + + test("JSON Updates an ISO-8601 formatted string with nanoseconds and timezone", async function () { + let table = await perspective.table({ d: "datetime" }); + await table.update({ + d: ["2008-09-15T15:15:00.123456789-05:15"], + }); + let view = await table.view(); + let result = await view.to_csv(); + expect(result).toEqual('"d"\n2008-09-15 20:30:00.123\n'); + await view.delete(); + await table.delete(); + }); + + test("CSV Reader parses milliseconds", async function () { + const a = '"start"\n2024-08-14T14:06:07.826Z'; + const table = await perspective.table(a); + const view = await table.view(); + const csv1 = await view.to_csv(); + expect(csv1).toEqual('"start"\n2024-08-14 14:06:07.826\n'); + view.delete(); + table.delete(); + }); + + test("CSV Reader update parses milliseconds", async function () { + const a = '"d"\n2024-08-14T14:06:07.826Z'; + const table = await perspective.table({ + d: "datetime", + }); + await table.update(a); + const view = await table.view(); + const csv1 = await view.to_csv(); + expect(csv1).toEqual('"d"\n2024-08-14 14:06:07.826\n'); + view.delete(); + table.delete(); + }); + + test("CSV Reader update updates microseconds", async function () { + const a = '"d"\n2024-08-14T14:06:07.826789Z'; + const table = await perspective.table({ + d: "datetime", + }); + await table.update(a); + const view = await table.view(); + const csv1 = await view.to_csv(); + expect(csv1).toEqual('"d"\n2024-08-14 14:06:07.826\n'); + view.delete(); + table.delete(); + }); + + test("CSV Reader update updates nanoseconds", async function () { + const a = '"d"\n2024-08-14T14:06:07.826789789Z'; + const table = await perspective.table({ + d: "datetime", + }); + await table.update(a); + const view = await table.view(); + const csv1 = await view.to_csv(); + expect(csv1).toEqual('"d"\n2024-08-14 14:06:07.826\n'); + view.delete(); + table.delete(); + }); + + test("CSV Reader update parses microseconds", async function () { + const a = '"d"\n2024-08-14T14:06:07.826789Z'; + const table = await perspective.table(a); + const view = await table.view(); + const csv1 = await view.to_csv(); + expect(csv1).toEqual('"d"\n2024-08-14 14:06:07.826\n'); + view.delete(); + table.delete(); + }); + + test("CSV Reader update parses nanoseconds", async function () { + const a = '"d"\n2024-08-14T14:06:07.826789789Z'; + const table = await perspective.table(a); + const view = await table.view(); + const csv1 = await view.to_csv(); + expect(csv1).toEqual('"d"\n2024-08-14 14:06:07.826\n'); + view.delete(); + table.delete(); + }); + + test("JSON Reader parses milliseconds", async function () { + const a = "2024-08-14T14:06:07.826Z"; + const table = await perspective.table({ + d: "datetime", + }); + await table.update({ d: [a] }); + const view = await table.view(); + const csv1 = await view.to_csv(); + expect(csv1).toEqual('"d"\n2024-08-14 14:06:07.826\n'); + view.delete(); + table.delete(); + }); + + test("Handles utf16 column names", async function () { + var table = await perspective.table({ š: [1, 2, 3] }); + let view = await table.view({}); + let result = await view.schema(); + expect(result).toEqual({ š: "integer" }); + view.delete(); + table.delete(); + }); + + test("Handles utf16", async function () { + var table = await perspective.table(data_6); + let view = await table.view({}); + let result = await view.to_json(); + expect(result).toEqual(data_6); + view.delete(); + table.delete(); + }); + + test.describe("Datetime constructors", function () { + test("Correctly parses an ISO-8601 formatted string", async function () { + let table = await perspective.table({ + d: ["2011-10-05T14:48:00"], + }); + let view = await table.view({}); + let result = await view.schema(); + expect(result["d"]).toEqual("datetime"); + await view.delete(); + await table.delete(); + }); + + test("Correctly parses an ISO-8601 formatted string 2", async function () { + let table = await perspective.table({ + d: ["2011-10-05T14:48:00.000"], + }); + let view = await table.view({}); + let result = await view.schema(); + expect(result["d"]).toEqual("datetime"); + await view.delete(); + await table.delete(); + }); + + test("Correctly parses an ISO-8601 formatted string 3", async function () { + let table = await perspective.table({ + d: ["2011-10-05T14:48:00Z"], + }); + let view = await table.view({}); + let result = await view.schema(); + expect(result["d"]).toEqual("datetime"); + await view.delete(); + await table.delete(); + }); + + test("Correctly parses an ISO-8601 formatted string 4", async function () { + let table = await perspective.table({ + d: ["2011-10-05T14:48:00.000Z"], + }); + let view = await table.view({}); + let result = await view.schema(); + expect(result["d"]).toEqual("datetime"); + await view.delete(); + await table.delete(); + }); + + test("Correctly parses an ISO-8601 formatted string with timezone", async function () { + let table = await perspective.table({ + d: ["2008-09-15T15:53:00+05:00"], + }); + let view = await table.view({}); + let result = await view.schema(); + expect(result["d"]).toEqual("datetime"); + await view.delete(); + await table.delete(); + }); + + test.skip("Correctly parses an RFC 2822 formatted string", async function () { + let table = await perspective.table({ + d: ["Wed, 05 Oct 2011 22:26:12 -0400"], + }); + let view = await table.view({}); + let result = await view.schema(); + expect(result["d"]).toEqual("datetime"); + await view.delete(); + await table.delete(); + }); + + // Not all formats covered by JS parser, test intended for C++ parser + test.skip("Correctly parses all m-d-y formatted strings", async function () { + let datestrings = [ + "08-15-2009", + "08/15/2009", + "08-15-2009", + "02 28 2009", + "08/15/10", + "31 08 2009", + ]; + for (let str of datestrings) { + let table = await perspective.table({ d: [str] }); + let view = await table.view({}); + let result = await view.schema(); + expect(result["d"]).toEqual("datetime"); + await view.delete(); + await table.delete(); + } + }); + + // Only implemented in the C++ date parser - skip + test.skip("Correctly parses a 'dd mm yyyy' formatted string", async function () { + let table = await perspective.table({ d: ["15 08 08"] }); + let view = await table.view({}); + let result = await view.schema(); + expect(result["d"]).toEqual("datetime"); + await view.delete(); + await table.delete(); + }); + + test("Does parse a date string in non-US formatting", async function () { + let table = await perspective.table({ d: ["2018/07/30"] }); + let view = await table.view({}); + let result = await view.schema(); + expect(result["d"]).toEqual("date"); + await view.delete(); + await table.delete(); + }); + + test("Does not mistakenly parse a date-like string", async function () { + let table = await perspective.table({ d: ["Jan 14, 14"] }); + let view = await table.view({}); + let result = await view.schema(); + expect(result["d"]).toEqual("string"); + await view.delete(); + await table.delete(); + }); + }); + + test("allocates a large tables", async function () { + function makeid() { + var text = ""; + var possible = Array.from(Array(26).keys()).map((x) => + String.fromCharCode(x + 65), + ); + for (var i = 0; i < 15; i++) + text += + possible[Math.floor(Math.random() * possible.length)]; + return text; + } + let data = []; + for (let i = 0; i < 35000; i++) { + data.push({ + a: makeid(), + b: makeid(), + c: makeid(), + d: makeid(), + w: i + 0.5, + x: i, + y: makeid(), + }); + } + let table = await perspective.table(data); + let view = await table.view(); + let result = await view.to_json(); + expect(result.length).toEqual(35000); + view.delete(); + table.delete(); + }, 3000); + + test.describe("get_index/get_limit", function () { + test("get_index() on unindexed table", async function () { + const table = await perspective.table(data); + expect(await table.get_index()).toBeUndefined(); + await table.delete(); + }); + + test("get_index() on an indexed table", async function () { + const table = await perspective.table(data, { index: "x" }); + expect(await table.get_index()).toEqual("x"); + await table.delete(); + }); + + test("get_limit() on table without limit", async function () { + const table = await perspective.table(data); + expect(await table.get_limit()).toBeUndefined(); + await table.delete(); + }); + + test("get_limit() on table with limit", async function () { + const table = await perspective.table(data, { limit: 2 }); + expect(await table.get_limit()).toEqual(2); + await table.delete(); + }); + }); + + test.describe("Limit table constructors", function () { + // This is a better version of the OG but still questionable. + // IMO we should not allow this without an explicit `__INDEX__`. + test("Should not apply partial updates on limit tables when index wraps", async function () { + const table = await perspective.table( + { a: "float", b: "float" }, + { + limit: 3, + }, + ); + + await table.update([ + { a: 10, b: 1 }, + { a: 20, b: 2 }, + ]); + + await table.update([{ a: 30 }, { a: 40 }]); + const view = await table.view(); + const records = await view.to_json(); + await view.delete(); + await table.delete(); + expect(records).toEqual([ + { a: 20, b: 2 }, + { a: 30, b: null }, + { a: 40, b: null }, + ]); + }); + + // We don' tsupport mixed-partial updates anymore! + test.skip("OG - Should not apply partial updates on limit tables when index wraps", async function () { + const table = await perspective.table( + { a: "float", b: "float" }, + { + limit: 3, + }, + ); + + await table.update([ + { a: 10 }, + { b: 1 }, + { a: 20 }, + { a: null, b: 2 }, + ]); + const view = await table.view(); + const records = await view.to_json(); + await view.delete(); + await table.delete(); + + const table2 = await perspective.table( + { a: "float", b: "float" }, + { + limit: 3, + }, + ); + + table2.update([{ a: 10 }, { b: 1 }, { a: 20 }, { b: 2 }]); + const view2 = await table2.view(); + const records2 = await view2.to_json(); + await view2.delete(); + await table2.delete(); + + expect(records).toEqual(records2); + }); + }); + + test.describe("Indexed table constructors", function () { + test("Should index on an integer column", async function () { + const table = await perspective.table(all_types_data, { + index: "int", + }); + const view = await table.view(); + expect(await table.size()).toEqual(4); + expect(await view.to_json()).toEqual([ + { + int: 1, + float: 2.25, + string: "a", + date: 1579046400000, + datetime: 1579069800000, + boolean: false, + }, + { + int: 2, + float: 3.5, + string: "b", + date: 1581724800000, + datetime: 1579091400000, + boolean: true, + }, + { + int: 3, + float: 4.75, + string: "c", + date: 1584230400000, + datetime: 1579113000000, + boolean: false, + }, + { + int: 4, + float: 5.25, + string: "d", + date: 1586908800000, + datetime: 1579131000000, + boolean: true, + }, + ]); + await view.delete(); + await table.delete(); + }); + + test("Should index on a float column", async function () { + const table = await perspective.table(all_types_data, { + index: "float", + }); + const view = await table.view(); + expect(await table.size()).toEqual(4); + expect(await view.to_json()).toEqual([ + { + int: 1, + float: 2.25, + string: "a", + date: 1579046400000, + datetime: 1579069800000, + boolean: false, + }, + { + int: 2, + float: 3.5, + string: "b", + date: 1581724800000, + datetime: 1579091400000, + boolean: true, + }, + { + int: 3, + float: 4.75, + string: "c", + date: 1584230400000, + datetime: 1579113000000, + boolean: false, + }, + { + int: 4, + float: 5.25, + string: "d", + date: 1586908800000, + datetime: 1579131000000, + boolean: true, + }, + ]); + await view.delete(); + await table.delete(); + }); + + test("Should index on a string column", async function () { + const table = await perspective.table(all_types_data, { + index: "string", + }); + const view = await table.view(); + expect(await table.size()).toEqual(4); + expect(await view.to_json()).toEqual([ + { + int: 1, + float: 2.25, + string: "a", + date: 1579046400000, + datetime: 1579069800000, + boolean: false, + }, + { + int: 2, + float: 3.5, + string: "b", + date: 1581724800000, + datetime: 1579091400000, + boolean: true, + }, + { + int: 3, + float: 4.75, + string: "c", + date: 1584230400000, + datetime: 1579113000000, + boolean: false, + }, + { + int: 4, + float: 5.25, + string: "d", + date: 1586908800000, + datetime: 1579131000000, + boolean: true, + }, + ]); + await view.delete(); + await table.delete(); + }); + + test("Should index on a date column", async function () { + const table = await perspective.table(all_types_data, { + index: "date", + }); + const view = await table.view(); + expect(await table.size()).toEqual(4); + expect(await view.to_json()).toEqual([ + { + int: 1, + float: 2.25, + string: "a", + date: 1579046400000, + datetime: 1579069800000, + boolean: false, + }, + { + int: 2, + float: 3.5, + string: "b", + date: 1581724800000, + datetime: 1579091400000, + boolean: true, + }, + { + int: 3, + float: 4.75, + string: "c", + date: 1584230400000, + datetime: 1579113000000, + boolean: false, + }, + { + int: 4, + float: 5.25, + string: "d", + date: 1586908800000, + datetime: 1579131000000, + boolean: true, + }, + ]); + await view.delete(); + await table.delete(); + }); + + test("Should index on a datetime column", async function () { + const table = await perspective.table(all_types_data, { + index: "datetime", + }); + const view = await table.view(); + expect(await table.size()).toEqual(4); + expect(await view.to_json()).toEqual([ + { + int: 1, + float: 2.25, + string: "a", + date: 1579046400000, + datetime: 1579069800000, + boolean: false, + }, + { + int: 2, + float: 3.5, + string: "b", + date: 1581724800000, + datetime: 1579091400000, + boolean: true, + }, + { + int: 3, + float: 4.75, + string: "c", + date: 1584230400000, + datetime: 1579113000000, + boolean: false, + }, + { + int: 4, + float: 5.25, + string: "d", + date: 1586908800000, + datetime: 1579131000000, + boolean: true, + }, + ]); + await view.delete(); + await table.delete(); + }); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/constructors/arrow.spec.ts b/rust/perspective-js/test/js/constructors/arrow.spec.ts new file mode 100644 index 0000000..711666b --- /dev/null +++ b/rust/perspective-js/test/js/constructors/arrow.spec.ts @@ -0,0 +1,191 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 arrow from "apache-arrow"; +import { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client"; +import * as fs from "node:fs"; + +import * as url from "node:url"; + +const __dirname = url.fileURLToPath(new URL(".", import.meta.url)).slice(0, -1); + +test.describe("Arrow", function () { + test.describe("Date columns", function () { + // https://github.com/perspective-dev/perspective/issues/2894 + // https://github.com/jdangerx/repro-perspective-float-filter/tree/dates + test("Date columns are preserved through Arrow in and out", async function () { + const tableData = arrow.tableFromArrays({ + date: arrow.vectorFromArray([20089], new arrow.Date_()), + }); + + const table = await perspective.table(arrow.tableToIPC(tableData)); + const view = await table.view(); + const json = await view.to_json(); + + const d = new Date(json[0].date); + expect(json[0].date).toEqual(1735689600000); + + // This doesn't test anything except my math + expect(d.getUTCFullYear()).toEqual(2025); + expect(d.getUTCDate()).toEqual(1); + expect(d.getUTCMonth()).toEqual(0); + expect(d.getUTCHours()).toEqual(0); + expect(d.getUTCMinutes()).toEqual(0); + expect(d.getUTCSeconds()).toEqual(0); + expect(d.getTimezoneOffset()).toEqual(0); + await view.delete(); + await table.delete(); + }); + + test("Date columns are preserved through Arrow in and out, in a negative timezone", async function () { + process.env.TZ = `America/New_York`; + const tableData = arrow.tableFromArrays({ + date: arrow.vectorFromArray([20089], new arrow.Date_()), + }); + + const table = await perspective.table(arrow.tableToIPC(tableData)); + const view = await table.view(); + const json = await view.to_json(); + + const d = new Date(json[0].date); + expect(json[0].date).toEqual(1735689600000); + expect(d.getUTCFullYear()).toEqual(2025); + expect(d.getUTCDate()).toEqual(1); + expect(d.getUTCMonth()).toEqual(0); + expect(d.getUTCHours()).toEqual(0); + expect(d.getUTCMinutes()).toEqual(0); + expect(d.getUTCSeconds()).toEqual(0); + + // NY now ... + expect(d.getTimezoneOffset()).toEqual(300); + await view.delete(); + await table.delete(); + process.env.TZ = `UTC`; + }); + }); + + test.describe("regressions", () => { + // https://github.com/perspective-dev/perspective/issues/3169 + test("null values are preserved across multi-batch Arrow IPC streams", async function () { + function row( + identifier: string, + value: number | null, + date: Date | null, + ) { + return arrow.tableFromArrays({ + Identifier: arrow.vectorFromArray( + [identifier], + new arrow.Utf8(), + ), + Value: arrow.vectorFromArray([value], new arrow.Float64()), + Date: arrow.vectorFromArray([date], new arrow.DateDay()), + }); + } + + const t1 = row("A", null, null); + const t2 = row("B", 5, null); + const t3 = row("C", null, new Date(Date.UTC(2025, 5, 15))); + + const multiBatchTable = new arrow.Table([ + ...t1.batches, + ...t2.batches, + ...t3.batches, + ]); + expect(multiBatchTable.batches.length).toEqual(3); + + const ipc = arrow.tableToIPC(multiBatchTable, "stream"); + const table = await perspective.table(ipc.buffer as ArrayBuffer); + const view = await table.view(); + const json = await view.to_json(); + await view.delete(); + await table.delete(); + + expect(json).toStrictEqual([ + { Identifier: "A", Value: null, Date: null }, + { Identifier: "B", Value: 5, Date: null }, + { Identifier: "C", Value: null, Date: 1749945600000 }, + ]); + }); + + test("null equality works correctly in updates", async function () { + async function write_to_json( + buffer: ArrayBuffer, + filename: string, + ) { + const table = await perspective.table(buffer); + const view = await table.view({ + columns: ["ENTITY_TYPE"], + }); + + const json = await view.to_columns_string(); + fs.writeFileSync(filename, json); + await view.delete(); + await table.delete(); + } + + const file = JSON.parse( + fs.readFileSync( + `${__dirname}/../../arrow/untitled.json`, + "utf8", + ), + ); + + const table = await perspective.table(file, { + name: "arrow_null_test", + }); + + const view = await table.view({ group_by: ["ENTITY_TYPE"] }); + for (let i = 2; i < 6; i++) { + const file = JSON.parse( + fs.readFileSync( + `${__dirname}/../../arrow/untitled${i}.json`, + "utf8", + ), + ); + + await table.update(file); + } + + const cols = await view.to_columns({ end_row: 4 }); + expect(cols).toStrictEqual({ + ENTITY_TYPE: [2158, 985, 168, 311], + __ROW_PATH__: [[], [null], [""], ["AAAA"]], + }); + }); + + test("Loads a time32 (millisecond) column without overflow", async function () { + const expected = Array.from({ length: 64 }, (_, i) => i); + const tableData = arrow.tableFromArrays({ + t: arrow.vectorFromArray(expected, new arrow.TimeMillisecond()), + }); + + const table = await perspective.table(arrow.tableToIPC(tableData)); + const view = await table.view(); + expect(await table.size()).toEqual(64); + expect(await view.to_columns()).toEqual({ t: expected }); + await view.delete(); + await table.delete(); + }); + }); + + test.describe("Malformed input", function () { + test("Rejects an Arrow with a row count that exceeds 32 bits", async function () { + const bytes = new Uint8Array( + fs.readFileSync(`${__dirname}/../../arrow/bad_row_count.arrow`), + ); + await expect(perspective.table(bytes)).rejects.toThrow( + /row count exceeds maximum supported size/, + ); + }); + }); +}); diff --git a/rust/perspective-js/test/js/constructors/csv.spec.ts b/rust/perspective-js/test/js/constructors/csv.spec.ts new file mode 100644 index 0000000..7ea8ca2 --- /dev/null +++ b/rust/perspective-js/test/js/constructors/csv.spec.ts @@ -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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client"; + +const CSV = "x,y,z\n1,2,3\n4,5,6"; + +test.describe("CSV Constructors", function () { + test("Handles String format", async function () { + const table = await perspective.table(CSV); + const v = await table.view(); + const json = await v.to_json(); + expect(json).toEqual([ + { x: 1, y: 2, z: 3 }, + { x: 4, y: 5, z: 6 }, + ]); + }); + + test("Handles String format with explicit format option", async function () { + const table = await perspective.table(CSV, { + format: "csv", + }); + const v = await table.view(); + const json = await v.to_json(); + expect(json).toEqual([ + { x: 1, y: 2, z: 3 }, + { x: 4, y: 5, z: 6 }, + ]); + }); + + test("Handles ArrayBuffer format", async function () { + var enc = new TextEncoder(); + const table = await perspective.table(enc.encode(CSV).buffer, { + format: "csv", + }); + const v = await table.view(); + const json = await v.to_json(); + expect(json).toEqual([ + { x: 1, y: 2, z: 3 }, + { x: 4, y: 5, z: 6 }, + ]); + }); + + test("Handles UInt8Arry format", async function () { + var enc = new TextEncoder(); + const table = await perspective.table(enc.encode(CSV), { + format: "csv", + }); + const v = await table.view(); + const json = await v.to_json(); + expect(json).toEqual([ + { x: 1, y: 2, z: 3 }, + { x: 4, y: 5, z: 6 }, + ]); + }); +}); diff --git a/rust/perspective-js/test/js/constructors/date_timezone.spec.ts b/rust/perspective-js/test/js/constructors/date_timezone.spec.ts new file mode 100644 index 0000000..65f5a2e --- /dev/null +++ b/rust/perspective-js/test/js/constructors/date_timezone.spec.ts @@ -0,0 +1,111 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client"; + +const JAN_1_2024_UTC = 1704067200000; + +test.describe("Date timezone invariance", function () { + let SAVED_TZ: string | undefined; + + test.beforeEach(() => { + SAVED_TZ = process.env.TZ; + process.env.TZ = "Europe/Amsterdam"; + }); + + test.afterEach(() => { + process.env.TZ = SAVED_TZ; + }); + + test("date strings serialize to UTC midnight epoch ms", async function () { + const table = await perspective.table({ d: "date" }); + await table.update({ d: ["2024-01-01"] }); + const view = await table.view(); + const cols = (await view.to_columns()) as { d: number[] }; + expect(cols.d).toEqual([JAN_1_2024_UTC]); + await view.delete(); + await table.delete(); + }); + + test("date epoch ms input round-trips exactly", async function () { + const table = await perspective.table({ d: "date" }); + await table.update({ d: [JAN_1_2024_UTC] }); + const view = await table.view(); + const cols = (await view.to_columns()) as { d: number[] }; + expect(cols.d).toEqual([JAN_1_2024_UTC]); + await view.delete(); + await table.delete(); + }); + + test("formatted output prints the stored calendar day", async function () { + const table = await perspective.table({ d: "date" }); + await table.update({ d: ["2024-01-01"] }); + const view = await table.view(); + expect(await view.to_columns_string({ formatted: true })).toEqual( + '{"d":["2024-01-01"]}', + ); + expect(await view.to_csv()).toEqual('"d"\n2024-01-01\n'); + await view.delete(); + await table.delete(); + }); + + test("day_bucket stays on the UTC calendar day at the day boundary", async function () { + const table = await perspective.table({ t: "datetime" }); + + // 23:59 UTC is already the next day in Europe/Amsterdam + await table.update({ t: ["2020-01-31 23:59:00"] }); + const view = await table.view({ + expressions: { bucket: `bucket("t", 'D')` }, + }); + + const cols = (await view.to_columns()) as { bucket: number[] }; + + // 2020-01-31T00:00:00Z + expect(cols.bucket).toEqual([1580428800000]); + await view.delete(); + await table.delete(); + }); + + test("day_of_week and hour_of_day compute in UTC", async function () { + const table = await perspective.table({ t: "datetime" }); + await table.update({ t: ["2020-01-31 23:59:00"] }); + const view = await table.view({ + expressions: { + dow: `day_of_week("t")`, + hod: `hour_of_day("t")`, + }, + }); + + const cols = (await view.to_columns()) as { + dow: string[]; + hod: number[]; + }; + + // Friday 23:00 UTC, not Saturday 00:59 Amsterdam + expect(cols.dow).toEqual(["6 Friday"]); + expect(cols.hod).toEqual([23]); + await view.delete(); + await table.delete(); + }); + + test("JSON date output agrees with Arrow date32 day arithmetic", async function () { + const table = await perspective.table({ d: "date" }); + await table.update({ d: ["2024-01-01"] }); + const view = await table.view(); + const cols = (await view.to_columns()) as { d: number[] }; + expect(cols.d[0] % 86400000).toEqual(0); + expect(cols.d[0] / 86400000).toEqual(19723); // days since epoch + await view.delete(); + await table.delete(); + }); +}); diff --git a/rust/perspective-js/test/js/constructors/json.spec.ts b/rust/perspective-js/test/js/constructors/json.spec.ts new file mode 100644 index 0000000..7dd07e7 --- /dev/null +++ b/rust/perspective-js/test/js/constructors/json.spec.ts @@ -0,0 +1,90 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client"; + +const TEST_JSON = { x: [1, 4], y: [2, 5], z: [3, 6] }; + +test.describe("JSON", function () { + test.describe("Integer columns", function () { + test("Integer columns can be updated with all JSON numeric types and cousins", async function () { + const table = await perspective.table({ x: "integer" }); + await table.update([{ x: 0 }]); + await table.update([{ x: 1 }]); + await table.update([{ x: undefined }]); + await table.update([{ x: null }]); + await table.update([{ x: 7.23 }]); + await table.update([{ x: -6 }]); + await table.update([{ x: Infinity }]); + await table.update([{ x: "100.1" }]); + await table.update([{ x: "11" }]); + const v = await table.view(); + const json = await v.to_json(); + expect(json).toEqual([ + { x: 0 }, + { x: 1 }, + { x: null }, + { x: null }, + { x: 7 }, + { x: -6 }, + { x: null }, + { x: 100 }, + { x: 11 }, + ]); + }); + }); + + test("Handles String format", async function () { + const table = await perspective.table(JSON.stringify(TEST_JSON), { + format: "columns", + }); + const v = await table.view(); + const json = await v.to_json(); + expect(json).toEqual([ + { x: 1, y: 2, z: 3 }, + { x: 4, y: 5, z: 6 }, + ]); + }); + + test("Handles UInt8Array format", async function () { + const enc = new TextEncoder(); + const table = await perspective.table( + enc.encode(JSON.stringify(TEST_JSON)), + { + format: "columns", + }, + ); + const v = await table.view(); + const json = await v.to_json(); + expect(json).toEqual([ + { x: 1, y: 2, z: 3 }, + { x: 4, y: 5, z: 6 }, + ]); + }); + + test("Handles ArrayBuffer format", async function () { + const enc = new TextEncoder(); + const table = await perspective.table( + enc.encode(JSON.stringify(TEST_JSON)).buffer, + { + format: "columns", + }, + ); + const v = await table.view(); + const json = await v.to_json(); + expect(json).toEqual([ + { x: 1, y: 2, z: 3 }, + { x: 4, y: 5, z: 6 }, + ]); + }); +}); diff --git a/rust/perspective-js/test/js/delete.spec.js b/rust/perspective-js/test/js/delete.spec.js new file mode 100644 index 0000000..a0cdf4b --- /dev/null +++ b/rust/perspective-js/test/js/delete.spec.js @@ -0,0 +1,121 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "./perspective_client"; + +function it_old_behavior(name, capture) { + test(name, async function () { + let done; + let result = new Promise((x) => { + done = x; + }); + + await capture(done); + await result; + }); +} + +function mock_fn() { + let count = 0; + let fun = () => { + count += 1; + }; + fun.count = () => count; + return fun; +} + +((perspective) => { + test.describe("Delete", function () { + test("calls all delete callbacks registered on table", async function () { + const table = await perspective.table([{ x: 1 }]); + const cb1 = mock_fn(); + const cb2 = mock_fn(); + table.on_delete(cb1); + table.on_delete(cb2); + await table.delete(); + expect(cb1.count()).toEqual(1); + expect(cb2.count()).toEqual(1); + }); + + test("remove_delete unregisters table delete callbacks", async function () { + const table = await perspective.table([{ x: 1 }]); + const cb1 = mock_fn(); + const cb2 = mock_fn(); + const sub1 = await table.on_delete(cb1); + table.on_delete(cb2); + await table.remove_delete(sub1); + await table.delete(); + expect(cb1.count()).toEqual(0); + expect(cb2.count()).toEqual(1); + }); + + test("calls all delete callbacks registered on view", async function () { + const table = await perspective.table([{ x: 1 }]); + const view = await table.view(); + const cb1 = mock_fn(); + const cb2 = mock_fn(); + view.on_delete(cb1); + view.on_delete(cb2); + await view.delete(); + expect(cb1.count()).toEqual(1); + expect(cb2.count()).toEqual(1); + await table.delete(); + }); + + test("remove_delete unregisters view delete callbacks", async function () { + const table = await perspective.table([{ x: 1 }]); + const view = await table.view(); + const cb1 = mock_fn(); + const cb2 = mock_fn(); + const sub1 = await view.on_delete(cb1); + view.on_delete(cb2); + view.remove_delete(sub1); + await view.delete(); + expect(cb1.count()).toEqual(0); + expect(cb2.count()).toEqual(1); + await table.delete(); + }); + + it_old_behavior( + "properly removes a failed delete callback on a table", + async function (done) { + const table = await perspective.table([{ x: 1 }]); + + // when a callback throws, it should delete that callback + table.on_delete(() => { + throw new Error("something went wrong!"); + }); + + table.delete(); + done(); + }, + ); + + it_old_behavior( + "properly removes a failed delete callback on a view", + async function (done) { + const table = await perspective.table([{ x: 1 }]); + const view = await table.view(); + + // when a callback throws, it should delete that callback + view.on_delete(() => { + throw new Error("something went wrong!"); + }); + + view.delete(); + table.delete(); + done(); + }, + ); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/delta.spec.js b/rust/perspective-js/test/js/delta.spec.js new file mode 100644 index 0000000..435d087 --- /dev/null +++ b/rust/perspective-js/test/js/delta.spec.js @@ -0,0 +1,1699 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "./perspective_client"; + +import _ from "lodash"; + +let data = [ + { x: 1, y: "a", z: true }, + { x: 2, y: "b", z: false }, + { x: 3, y: "c", z: true }, + { x: 4, y: "d", z: false }, +]; + +let partial_change_y = [ + { x: 1, y: "string1" }, + { x: 2, y: "string2" }, +]; +let partial_change_z = [ + { x: 1, z: false }, + { x: 2, z: true }, +]; +let partial_change_y_z = [ + { x: 1, y: "string1", z: false }, + { x: 2, y: "string2", z: true }, +]; +let partial_change_nonseq = [ + { x: 1, y: "string1", z: false }, + { x: 4, y: "string2", z: true }, +]; + +async function match_delta( + perspective, + delta, + expected, + formatter = "to_json", +) { + const table = await perspective.table(delta); + const view = await table.view(); + const result = await view[formatter](); + expect(result).toEqual(expected); + await view.delete(); + await table.delete(); +} + +function it_old_behavior(name, capture) { + test(name, async function () { + let done; + let result = new Promise((x) => { + done = x; + }); + + await capture(done); + await result; + }); +} + +((perspective) => { + test.describe("Row delta", function () { + test.describe("0-sided row delta", function () { + it_old_behavior("returns changed rows", async function (done) { + let table = await perspective.table(data, { index: "x" }); + let view = await table.view(); + view.on_update( + async function (updated) { + const expected = [ + { x: 1, y: "string1", z: true }, + { x: 2, y: "string2", z: false }, + ]; + await match_delta(perspective, updated.delta, expected); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_y); + }); + + it_old_behavior( + "returns changed rows, hidden sort", + async function (done) { + let table = await perspective.table(data, { index: "x" }); + let view = await table.view({ + columns: ["x"], + sort: [["y", "desc"]], + }); + console.log(await view.to_json()); + view.on_update( + async function (updated) { + const expected = [{ x: 2 }, { x: 1 }]; + await match_delta( + perspective, + updated.delta, + expected, + ); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_y); + }, + ); + + it_old_behavior( + "returns changed rows from schema", + async function (done) { + let table = await perspective.table( + { + x: "integer", + y: "string", + z: "boolean", + }, + { index: "x" }, + ); + let view = await table.view(); + view.on_update( + async function (updated) { + const expected = [ + { x: 1, y: "d", z: false }, + { x: 2, y: "b", z: false }, + { x: 3, y: "c", z: true }, + ]; + await match_delta( + perspective, + updated.delta, + expected, + ); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update([ + { x: 1, y: "a", z: true }, + { x: 2, y: "b", z: false }, + { x: 3, y: "c", z: true }, + { x: 1, y: "d", z: false }, + ]); + }, + ); + + it_old_behavior("returns added rows", async function (done) { + let table = await perspective.table(data); + let view = await table.view(); + view.on_update( + async function (updated) { + const expected = [ + { x: 1, y: "string1", z: null }, + { x: 2, y: "string2", z: null }, + ]; + await match_delta(perspective, updated.delta, expected); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_y); + }); + + it_old_behavior( + "returns added rows from schema", + async function (done) { + let table = await perspective.table({ + x: "integer", + y: "string", + z: "boolean", + }); + let view = await table.view(); + view.on_update( + async function (updated) { + await match_delta(perspective, updated.delta, data); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(data); + }, + ); + + it_old_behavior("returns deleted columns", async function (done) { + let table = await perspective.table(data, { index: "x" }); + let view = await table.view(); + view.on_update( + async function (updated) { + const expected = [ + { x: 1, y: null, z: true }, + { x: 4, y: null, z: false }, + ]; + await match_delta(perspective, updated.delta, expected); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update([ + { x: 1, y: null }, + { x: 4, y: null }, + ]); + }); + + it_old_behavior( + "returns changed rows in sorted context", + async function (done) { + let table = await perspective.table(data, { index: "x" }); + let view = await table.view({ + sort: [["x", "desc"]], + }); + view.on_update( + async function (updated) { + const expected = [ + { x: 2, y: "string2", z: false }, + { x: 1, y: "string1", z: true }, + ]; + await match_delta( + perspective, + updated.delta, + expected, + ); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_y); + }, + ); + + it_old_behavior( + "returns changed rows in sorted context from schema", + async function (done) { + let table = await perspective.table( + { + x: "integer", + y: "string", + z: "boolean", + }, + { index: "x" }, + ); + let view = await table.view({ + sort: [["x", "desc"]], + }); + view.on_update( + async function (updated) { + const expected = [ + { x: 4, y: "a", z: true }, + { x: 3, y: "c", z: true }, + { x: 2, y: "b", z: false }, + { x: 1, y: "d", z: false }, + ]; + await match_delta( + perspective, + updated.delta, + expected, + ); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update([ + { x: 1, y: "a", z: true }, + { x: 2, y: "b", z: false }, + { x: 3, y: "c", z: true }, + { x: 1, y: "d", z: false }, + { x: 4, y: "a", z: true }, + ]); + }, + ); + + it_old_behavior( + "returns added rows in filtered context from schema", + async function (done) { + let table = await perspective.table({ + x: "integer", + y: "string", + z: "boolean", + }); + let view = await table.view({ + filter: [["x", ">", 3]], + }); + view.on_update( + async function (updated) { + await match_delta(perspective, updated.delta, [ + { x: 4, y: "d", z: false }, + ]); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(data); + }, + ); + + it_old_behavior( + "returns changed rows in non-sequential update", + async function (done) { + let table = await perspective.table(data, { index: "x" }); + let view = await table.view(); + view.on_update( + async function (updated) { + const expected = partial_change_nonseq; + await match_delta( + perspective, + updated.delta, + expected, + ); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_nonseq); + }, + ); + }); + + test.describe("0-sided row delta, randomized column order", function () { + it_old_behavior("returns changed rows", async function (done) { + let table = await perspective.table(data, { index: "x" }); + let columns = _.shuffle(await table.columns()); + let view = await table.view({ + columns: columns, + }); + view.on_update( + async function (updated) { + const expected = [ + { x: 1, y: "string1", z: true }, + { x: 2, y: "string2", z: false }, + ]; + await match_delta(perspective, updated.delta, expected); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_y); + }); + + it_old_behavior( + "returns changed rows from schema", + async function (done) { + let table = await perspective.table( + { + x: "integer", + y: "string", + z: "boolean", + }, + { index: "x" }, + ); + let columns = _.shuffle(await table.columns()); + let view = await table.view({ + columns: columns, + }); + view.on_update( + async function (updated) { + const expected = [ + { x: 1, y: "d", z: false }, + { x: 2, y: "b", z: false }, + { x: 3, y: "c", z: true }, + ]; + await match_delta( + perspective, + updated.delta, + expected, + ); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update([ + { x: 1, y: "a", z: true }, + { x: 2, y: "b", z: false }, + { x: 3, y: "c", z: true }, + { x: 1, y: "d", z: false }, + ]); + }, + ); + + it_old_behavior("returns added rows", async function (done) { + let table = await perspective.table(data); + let columns = _.shuffle(await table.columns()); + let view = await table.view({ + columns: columns, + }); + view.on_update( + async function (updated) { + const expected = [ + { x: 1, y: "string1", z: null }, + { x: 2, y: "string2", z: null }, + ]; + await match_delta(perspective, updated.delta, expected); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_y); + }); + + it_old_behavior( + "returns added rows, hidden sort", + async function (done) { + let table = await perspective.table(data); + let view = await table.view({ + columns: ["x"], + sort: [["y", "desc"]], + }); + view.on_update( + async function (updated) { + const expected = [{ x: 2 }, { x: 1 }]; + await match_delta( + perspective, + updated.delta, + expected, + ); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_y); + }, + ); + + it_old_behavior( + "returns added rows from schema", + async function (done) { + let table = await perspective.table({ + x: "integer", + y: "string", + z: "boolean", + }); + let columns = _.shuffle(await table.columns()); + let view = await table.view({ + columns: columns, + }); + view.on_update( + async function (updated) { + await match_delta(perspective, updated.delta, data); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(data); + }, + ); + + it_old_behavior("returns deleted columns", async function (done) { + let table = await perspective.table(data, { index: "x" }); + let columns = _.shuffle(await table.columns()); + let view = await table.view({ + columns: columns, + }); + view.on_update( + async function (updated) { + const expected = [ + { x: 1, y: null, z: true }, + { x: 4, y: null, z: false }, + ]; + await match_delta(perspective, updated.delta, expected); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update([ + { x: 1, y: null }, + { x: 4, y: null }, + ]); + }); + + it_old_behavior( + "returns changed rows in non-sequential update", + async function (done) { + let table = await perspective.table(data, { index: "x" }); + let columns = _.shuffle(await table.columns()); + let view = await table.view({ + columns: columns, + }); + view.on_update( + async function (updated) { + const expected = partial_change_nonseq; + await match_delta( + perspective, + updated.delta, + expected, + ); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_nonseq); + }, + ); + }); + + test.describe("0-sided row delta, column order subset", function () { + it_old_behavior("returns changed rows", async function (done) { + let table = await perspective.table(data, { index: "x" }); + let view = await table.view({ + columns: ["y"], + }); + view.on_update( + async function (updated) { + const expected = [{ y: "string1" }, { y: "string2" }]; + await match_delta(perspective, updated.delta, expected); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_y); + }); + + it_old_behavior( + "returns changed rows from schema", + async function (done) { + let table = await perspective.table( + { + x: "integer", + y: "string", + z: "boolean", + }, + { index: "x" }, + ); + let view = await table.view({ + columns: ["z"], + }); + view.on_update( + async function (updated) { + const expected = [ + { z: false }, + { z: false }, + { z: true }, + ]; + await match_delta( + perspective, + updated.delta, + expected, + ); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update([ + { x: 1, y: "a", z: true }, + { x: 2, y: "b", z: false }, + { x: 3, y: "c", z: true }, + { x: 1, y: "d", z: false }, + ]); + }, + ); + + it_old_behavior("returns added rows", async function (done) { + let table = await perspective.table(data); + let view = await table.view({ + columns: ["y"], + }); + view.on_update( + async function (updated) { + const expected = [{ y: "string1" }, { y: "string2" }]; + await match_delta(perspective, updated.delta, expected); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_y); + }); + + it_old_behavior( + "returns added rows from schema", + async function (done) { + let table = await perspective.table({ + x: "integer", + y: "string", + z: "boolean", + }); + let view = await table.view({ + columns: ["z"], + }); + view.on_update( + async function (updated) { + await match_delta(perspective, updated.delta, [ + { z: true }, + { z: false }, + { z: true }, + { z: false }, + ]); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(data); + }, + ); + + it_old_behavior("returns deleted rows", async function (done) { + let table = await perspective.table(data, { index: "x" }); + let view = await table.view({ + columns: ["y"], + }); + view.on_update( + async function (updated) { + const expected = [{ y: null }, { y: null }]; + await match_delta(perspective, updated.delta, expected); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update([ + { x: 1, y: null }, + { x: 4, y: null }, + ]); + }); + + it_old_behavior( + "returns changed rows in non-sequential update", + async function (done) { + let table = await perspective.table(data, { index: "x" }); + let view = await table.view({ + columns: ["y"], + }); + view.on_update( + async function (updated) { + await match_delta(perspective, updated.delta, [ + { y: "string1" }, + { y: "string2" }, + ]); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_nonseq); + }, + ); + }); + + test.describe("1-sided row delta", function () { + it_old_behavior("returns changed rows", async function (done) { + let table = await perspective.table(data, { index: "x" }); + let view = await table.view({ + group_by: ["y"], + aggregates: { y: "distinct count", z: "distinct count" }, + }); + view.on_update( + async function (updated) { + const expected = [ + { x: 1, y: 1, z: 1 }, + { x: 2, y: 1, z: 1 }, + ]; + await match_delta(perspective, updated.delta, expected); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_y); + }); + + test.skip("returns changed rows, unique", async function () { + // FIXME: the delta doesn't seem to trigger if the + // cell is invalidated, only if the actual values are + // different. This feels off behavior-wise. + const table = await perspective.table( + { + x: [1, 2, 3, 4], + y: ["a", "a", "a", "a"], + z: [100, 200, 100, 200], + }, + { index: "x" }, + ); + + const view = await table.view({ + group_by: ["z"], + aggregates: { y: "unique" }, + }); + + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[], [100], [200]], + x: [10, 4, 6], + y: ["a", "a", "a"], + z: [600, 200, 400], + }); + + view.on_update( + async function (updated) { + console.log(await view.to_columns()); + const expected = [ + { x: 10, y: null, z: 600 }, // total + { x: 6, y: null, z: 400 }, + ]; + await match_delta(perspective, updated.delta, expected); + await view.delete(); + await table.delete(); + done(); + }, + { mode: "row" }, + ); + + await table.update({ + x: [4], + y: ["a"], + z: [200], + }); + }); + + it_old_behavior( + "returns changed rows, column range", + async function (done) { + let table = await perspective.table(data, { index: "x" }); + let view = await table.view({ + group_by: ["y"], + columns: ["x"], + aggregates: { + y: "distinct count", + z: "distinct count", + }, + }); + view.on_update( + async function (updated) { + const expected = [{ x: 1 }, { x: 2 }]; + await match_delta( + perspective, + updated.delta, + expected, + ); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_y); + }, + ); + + it_old_behavior( + "returns nothing when updated data is not in pivot", + async function (done) { + let table = await perspective.table(data, { index: "x" }); + let view = await table.view({ + group_by: ["y"], + aggregates: { + y: "distinct count", + z: "distinct count", + }, + }); + view.on_update( + async function (updated) { + await match_delta(perspective, updated.delta, []); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_z); + }, + ); + + it_old_behavior("returns added rows", async function (done) { + let table = await perspective.table(data); + let view = await table.view({ + group_by: ["y"], + aggregates: { y: "distinct count", z: "distinct count" }, + }); + view.on_update( + async function (updated) { + const expected = [ + { x: 13, y: 6, z: 3 }, + { x: 1, y: 1, z: 1 }, + { x: 2, y: 1, z: 1 }, + ]; + await match_delta(perspective, updated.delta, expected); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_y); + }); + + it_old_behavior( + "returns added rows, column range", + async function (done) { + let table = await perspective.table(data); + let view = await table.view({ + group_by: ["y"], + columns: ["z"], + aggregates: { + y: "distinct count", + z: "distinct count", + }, + }); + view.on_update( + async function (updated) { + const expected = [{ z: 3 }, { z: 1 }, { z: 1 }]; + await match_delta( + perspective, + updated.delta, + expected, + ); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_y); + }, + ); + + it_old_behavior("returns deleted columns", async function (done) { + let table = await perspective.table(data, { index: "x" }); + let view = await table.view({ + group_by: ["y"], + aggregates: { y: "distinct count", z: "distinct count" }, + }); + view.on_update( + async function (updated) { + // underlying data changes, but only total aggregate row is affected + const expected = [{ x: 10, y: 3, z: 2 }]; + await match_delta(perspective, updated.delta, expected); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update([ + { x: 1, y: null }, + { x: 4, y: null }, + ]); + }); + + it_old_behavior( + "returns changed rows in non-sequential update", + async function (done) { + let table = await perspective.table(data, { index: "x" }); + let view = await table.view({ + group_by: ["y"], + aggregates: { + y: "distinct count", + z: "distinct count", + }, + }); + view.on_update( + async function (updated) { + // aggregates are sorted, in this case by string comparator - "string1" and "string2" are at the end + const expected = [ + { x: 1, y: 1, z: 1 }, + { x: 4, y: 1, z: 1 }, + ]; + await match_delta( + perspective, + updated.delta, + expected, + ); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_nonseq); + }, + ); + + it_old_behavior( + "Returns appended rows, hidden sort", + async function (done) { + const table = await perspective.table({ + x: [1, 2, 3, 4], + y: ["A", "B", "C", "D"], + }); + const view = await table.view({ + group_by: ["y"], + sort: [["y", "desc"]], + columns: ["x"], + }); + + view.on_update( + async function (updated) { + const expected = [{ x: 13 }, { x: 2 }, { x: 1 }]; + await match_delta( + perspective, + updated.delta, + expected, + ); + await view.delete(); + await table.delete(); + done(); + }, + { mode: "row" }, + ); + + await table.update(partial_change_y); + }, + ); + }); + + test.describe("2-sided row delta", function () { + it_old_behavior( + "returns changed rows when updated data in group by", + async function (done) { + let table = await perspective.table(data, { index: "y" }); + let view = await table.view({ + group_by: ["y"], + split_by: ["x"], + }); + view.on_update( + async function (updated) { + const json = await view.to_json(); + json.map((d) => { + delete d["__ROW_PATH__"]; + }); + const expected = json.slice(0, 3); + await match_delta( + perspective, + updated.delta, + expected, + ); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_y); + }, + ); + + it_old_behavior( + "returns changed rows when updated data in group by, column range", + async function (done) { + let table = await perspective.table(data, { index: "y" }); + let view = await table.view({ + group_by: ["y"], + split_by: ["x"], + columns: ["x"], + }); + view.on_update( + async function (updated) { + const json = await view.to_json(); + json.map((d) => { + delete d["__ROW_PATH__"]; + }); + const expected = json.slice(0, 3); + await match_delta( + perspective, + updated.delta, + expected, + ); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_y); + }, + ); + + it_old_behavior( + "returns changed rows when updated data in group by, hidden sort", + async function (done) { + let table = await perspective.table(data, { index: "y" }); + let view = await table.view({ + group_by: ["y"], + split_by: ["x"], + sort: [["y", "desc"]], + columns: ["x"], + }); + view.on_update( + async function (updated) { + const expected = await view.to_json(); + await match_delta( + perspective, + updated.delta, + expected.slice(0, 3).map((x) => { + delete x.__ROW_PATH__; + return x; + }), + ); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_y); + }, + ); + + test.skip("returns changed rows when updated data in group by multi, hidden sort", async function () { + let table = await perspective.table(data, { index: "y" }); + let view = await table.view({ + group_by: ["y", "x", "z"], + split_by: ["x"], + sort: [["y", "desc"]], + columns: ["x"], + }); + view.on_update( + async function (updated) { + const expected = { + "1|x": [14, 0, 0], + "1|y": ["-", null, null], + "2|x": [2, 3, null], + "2|y": ["-", "WORLD", null], + "3|x": [2, null, 1], + "3|y": ["b", null, "HELLO"], + "4|x": [6, null, 1], + "4|y": ["-", null, "HELLO"], + }; + await match_delta( + perspective, + updated.delta, + expected, + "to_columns", + ); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_y); + }); + + it_old_behavior( + "returns changed rows when updated data in split by", + async function (done) { + let table = await perspective.table(data, { index: "x" }); + let view = await table.view({ + group_by: ["y"], + split_by: ["z"], + }); + view.on_update( + async function (updated) { + const json = await view.to_json(); + json.map((d) => { + delete d["__ROW_PATH__"]; + }); + const expected = json.slice(0, 3); + await match_delta( + perspective, + updated.delta, + expected, + ); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_z); + }, + ); + + it_old_behavior( + "returns changed rows when updated data in split by, column range", + async function (done) { + let table = await perspective.table(data, { index: "x" }); + let view = await table.view({ + group_by: ["y"], + split_by: ["z"], + columns: ["y"], + }); + view.on_update( + async function (updated) { + const json = await view.to_json(); + json.map((d) => { + delete d["__ROW_PATH__"]; + }); + const expected = json.slice(0, 3); + await match_delta( + perspective, + updated.delta, + expected, + ); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_z); + }, + ); + + it_old_behavior( + "returns changed rows when updated data in row and split by", + async function (done) { + let table = await perspective.table(data, { index: "x" }); + let view = await table.view({ + group_by: ["y"], + split_by: ["z"], + }); + view.on_update( + async function (updated) { + const json = await view.to_json(); + json.map((d) => { + delete d["__ROW_PATH__"]; + }); + const expected = [json[0], json[3], json[4]]; + await match_delta( + perspective, + updated.delta, + expected, + ); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_y_z); + }, + ); + + it_old_behavior( + "returns changed rows when updated data in row and split by, column range", + async function (done) { + let table = await perspective.table(data, { index: "x" }); + let view = await table.view({ + group_by: ["y"], + split_by: ["z"], + columns: ["x"], + }); + view.on_update( + async function (updated) { + const json = await view.to_json(); + json.map((d) => { + delete d["__ROW_PATH__"]; + }); + const expected = [json[0], json[3], json[4]]; + await match_delta( + perspective, + updated.delta, + expected, + ); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_y_z); + }, + ); + + it_old_behavior( + "returns nothing when updated data is not in pivot", + async function (done) { + let table = await perspective.table(data, { index: "x" }); + let view = await table.view({ + group_by: ["y"], + split_by: ["x"], + aggregates: { + y: "distinct count", + z: "distinct count", + }, + }); + view.on_update( + async function (updated) { + await match_delta(perspective, updated.delta, []); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_z); + }, + ); + + it_old_behavior("returns added rows", async function (done) { + let table = await perspective.table(data); + let view = await table.view({ + group_by: ["y"], + split_by: ["x"], + }); + view.on_update( + async function (updated) { + const json = await view.to_json(); + json.map((d) => { + delete d["__ROW_PATH__"]; + }); + const expected = json.slice(0, 3); + await match_delta(perspective, updated.delta, expected); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_y); + }); + + it_old_behavior("returns deleted columns", async function (done) { + let table = await perspective.table(data, { index: "x" }); + let view = await table.view({ + group_by: ["y"], + split_by: ["x"], + aggregates: { y: "unique" }, + columns: ["x", "y", "z"], + }); + view.on_update( + async function (updated) { + // underlying data changes, but only total aggregate row is affected + const expected = await view.to_json(); + expected.splice(3, 1); + expected.map((d) => { + delete d["__ROW_PATH__"]; + }); + await match_delta(perspective, updated.delta, expected); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update([ + { x: 1, y: null }, + { x: 2, y: null }, + { x: 4, y: null }, + ]); + }); + + it_old_behavior( + "returns deleted columns, column range", + async function (done) { + let table = await perspective.table(data, { index: "x" }); + let view = await table.view({ + group_by: ["y"], + split_by: ["x"], + aggregates: { y: "unique" }, + columns: ["y"], + }); + view.on_update( + async function (updated) { + // underlying data changes, but only total aggregate row is affected + const expected = await view.to_json(); + expected.splice(3, 1); + expected.map((d) => { + delete d["__ROW_PATH__"]; + }); + await match_delta( + perspective, + updated.delta, + expected, + ); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update([ + { x: 1, y: null }, + { x: 2, y: null }, + { x: 4, y: null }, + ]); + }, + ); + + it_old_behavior( + "returns changed rows in non-sequential update", + async function (done) { + let table = await perspective.table(data, { index: "x" }); + let view = await table.view({ + group_by: ["y"], + split_by: ["x"], + aggregates: { + y: "distinct count", + z: "distinct count", + }, + }); + view.on_update( + async function (updated) { + // aggregates are sorted, in this case by string comparator - "string1" and "string2" are at the end + const json = await view.to_json(); + json.map((d) => { + delete d["__ROW_PATH__"]; + }); + const expected = [json[3], json[4]]; + await match_delta( + perspective, + updated.delta, + expected, + ); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_nonseq); + }, + ); + + it_old_behavior( + "returns changed rows in column-only pivots", + async function (done) { + let table = await perspective.table(data, { index: "x" }); + let view = await table.view({ + split_by: ["x"], + }); + view.on_update( + async function (updated) { + const json = await view.to_json(); + const expected = [ + { + "1|x": 1, + "1|y": "string1", + "1|z": false, + "2|x": 2, + "2|y": "b", + "2|z": false, + "3|x": 3, + "3|y": "c", + "3|z": true, + "4|x": 4, + "4|y": "string2", + "4|z": true, + }, + json[0], + json[3], + ]; + await match_delta( + perspective, + updated.delta, + expected, + ); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_nonseq); + }, + ); + + it_old_behavior( + "returns changed rows, col only", + async function (done) { + let table = await perspective.table(data, { + index: "x", + }); + let view = await table.view({ + split_by: ["y"], + }); + view.on_update( + async function (updated) { + const expected = [ + { + "c|x": 3, + "c|y": "c", + "c|z": true, + "d|x": 4, + "d|y": "d", + "d|z": false, + "string1|x": 1, + "string1|y": "string1", + "string1|z": true, + "string2|x": 2, + "string2|y": "string2", + "string2|z": false, + }, + { + "c|x": null, + "c|y": null, + "c|z": null, + "d|x": null, + "d|y": null, + "d|z": null, + "string1|x": 1, + "string1|y": "string1", + "string1|z": true, + "string2|x": null, + "string2|y": null, + "string2|z": null, + }, + { + "c|x": null, + "c|y": null, + "c|z": null, + "d|x": null, + "d|y": null, + "d|z": null, + "string1|x": null, + "string1|y": null, + "string1|z": null, + "string2|x": 2, + "string2|y": "string2", + "string2|z": false, + }, + ]; + await match_delta( + perspective, + updated.delta, + expected, + ); + view.delete(); + table.delete(); + done(); + }, + { + mode: "row", + }, + ); + await table.update(partial_change_y); + }, + ); + + it_old_behavior( + "returns changed rows, col only agg", + async function (done) { + const table = await perspective.table(data, { + index: "x", + }); + + const view = await table.view({ + split_by: ["x"], + sort: [["y", "desc"]], + columns: ["y"], + aggregates: { y: "last" }, + }); + + view.on_update( + async function (updated) { + // TODO: deltas return a total row for column only + // which they probably shouldn't. + const expected = [ + { + "1|y": "string1", + "2|y": "string2", + "3|y": "c", + "4|y": "d", + }, + { + "1|y": null, + "2|y": "string2", + "3|y": null, + "4|y": null, + }, + { + "1|y": "string1", + "2|y": null, + "3|y": null, + "4|y": null, + }, + ]; + await match_delta( + perspective, + updated.delta, + expected, + ); + await view.delete(); + await table.delete(); + done(); + }, + { mode: "row" }, + ); + + await table.update(partial_change_y); + }, + ); + + it_old_behavior( + "returns changed rows, col only col range", + async function (done) { + let table = await perspective.table(data, { + index: "x", + }); + let view = await table.view({ + split_by: ["y"], + columns: ["x"], + }); + view.on_update( + async function (updated) { + const expected = [ + { + "c|x": 3, + "d|x": 4, + "string1|x": 1, + "string2|x": 2, + }, + { + "c|x": null, + "d|x": null, + "string1|x": 1, + "string2|x": null, + }, + { + "c|x": null, + "d|x": null, + "string1|x": null, + "string2|x": 2, + }, + ]; + await match_delta( + perspective, + updated.delta, + expected, + ); + view.delete(); + table.delete(); + done(); + }, + { + mode: "row", + }, + ); + await table.update(partial_change_y); + }, + ); + + it_old_behavior( + "returns changed rows, col only sorted", + async function (done) { + const table = await perspective.table(data, { + index: "x", + }); + const view = await table.view({ + split_by: ["y"], + columns: ["x"], + sort: [["x", "desc"]], + }); + console.log(await view.to_json()); + view.on_update( + async function (updated) { + const expected = [ + { + "c|x": 3, + "d|x": 4, + "string1|x": 1, + "string2|x": 2, + }, + { + "c|x": null, + "d|x": null, + "string1|x": null, + "string2|x": 2, + }, + { + "c|x": null, + "d|x": null, + "string1|x": 1, + "string2|x": null, + }, + ]; + await match_delta( + perspective, + updated.delta, + expected, + ); + await view.delete(); + await table.delete(); + done(); + }, + { + mode: "row", + }, + ); + await table.update(partial_change_y); + }, + ); + + it_old_behavior( + "returns changed rows, col only sorted change not in pivot", + async function (done) { + let table = await perspective.table( + { x: [1], y: [100] }, + { + index: "x", + }, + ); + let view = await table.view({ + split_by: ["y"], + columns: ["x"], + sort: [["x", "desc"]], + }); + console.log(await view.to_json()); + view.on_update( + async function (updated) { + const expected = [{ "100|x": 3 }]; + await match_delta( + perspective, + updated.delta, + expected, + ); + view.delete(); + table.delete(); + done(); + }, + { + mode: "row", + }, + ); + await table.update([{ x: 3, y: 100 }]); + }, + ); + + it_old_behavior( + "returns changed rows, col only hidden sort", + async function (done) { + const table = await perspective.table(data, { index: "x" }); + const view = await table.view({ + split_by: ["y"], + columns: ["x"], + sort: [["y", "desc"]], + }); + view.on_update( + async function (updated) { + const expected = [ + { + "c|x": 3, + "d|x": 4, + "string1|x": 1, + "string2|x": 2, + }, + { + "c|x": null, + "d|x": null, + "string1|x": null, + "string2|x": 2, + }, + { + "c|x": null, + "d|x": null, + "string1|x": 1, + "string2|x": null, + }, + ]; + await match_delta( + perspective, + updated.delta, + expected, + ); + await view.delete(); + await table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial_change_y); + }, + ); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/duckdb/client.spec.js b/rust/perspective-js/test/js/duckdb/client.spec.js new file mode 100644 index 0000000..34e193f --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/client.spec.js @@ -0,0 +1,22 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import { describeDuckDB } from "./setup.js"; + +describeDuckDB("client", (getClient) => { + test("get_hosted_table_names()", async function () { + const client = getClient(); + const tables = await client.get_hosted_table_names(); + expect(tables).toEqual(["memory.superstore"]); + }); +}); diff --git a/rust/perspective-js/test/js/duckdb/combined.spec.js b/rust/perspective-js/test/js/duckdb/combined.spec.js new file mode 100644 index 0000000..1c8c018 --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/combined.spec.js @@ -0,0 +1,286 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import { describeDuckDB } from "./setup.js"; + +describeDuckDB("combined operations", (getClient) => { + test("group_by + filter + sort", async function () { + const table = await getClient().open_table("memory.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).toEqual([ + { __ROW_PATH__: [], Sales: 725457.8245000006 }, + { + __ROW_PATH__: ["Furniture"], + Sales: 252612.7435000003, + }, + { + __ROW_PATH__: ["Technology"], + Sales: 251991.83199999997, + }, + { + __ROW_PATH__: ["Office Supplies"], + Sales: 220853.24900000007, + }, + ]); + await view.delete(); + }); + + test("split_by + group_by + filter", async function () { + const table = await getClient().open_table("memory.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).toEqual([ + "Central_Sales", + "East_Sales", + "South_Sales", + "West_Sales", + ]); + + const numRows = await view.num_rows(); + expect(numRows).toBe(4); + + const json = await view.to_json(); + expect(json).toEqual([ + { + __ROW_PATH__: [], + "Central|Sales": 332883.0567999998, + "East|Sales": 455143.735, + "South|Sales": 274208.7699999999, + "West|Sales": 470561.28350000136, + }, + { + __ROW_PATH__: ["Furniture"], + "Central|Sales": 111457.73279999988, + "East|Sales": 140376.95899999997, + "South|Sales": 80859.618, + "West|Sales": 165219.5734999998, + }, + { + __ROW_PATH__: ["Office Supplies"], + "Central|Sales": 103937.78599999992, + "East|Sales": 135823.893, + "South|Sales": 84393.3579999999, + "West|Sales": 140206.93099999975, + }, + { + __ROW_PATH__: ["Technology"], + "Central|Sales": 117487.53800000002, + "East|Sales": 178942.883, + "South|Sales": 108955.79400000005, + "West|Sales": 165134.77900000007, + }, + ]); + + await view.delete(); + }); + + test("split_by only", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + split_by: ["Region"], + filter: [["Quantity", ">", 3]], + }); + + const paths = await view.column_paths(); + expect(paths).toEqual([ + "Central_Sales", + "East_Sales", + "South_Sales", + "West_Sales", + ]); + + const numRows = await view.num_rows(); + expect(numRows).toBe(4284); + const json = await view.to_json({ start_row: 0, end_row: 1 }); + expect(json).toEqual([ + { + "Central|Sales": null, + "East|Sales": null, + "South|Sales": 957.5775, + "West|Sales": null, + }, + ]); + + await view.delete(); + }); + + test("split_by only + sort", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + split_by: ["Region"], + sort: [["Sales", "desc"]], + filter: [["Quantity", ">", 3]], + }); + + const paths = await view.column_paths(); + expect(paths).toEqual([ + "Central_Sales", + "East_Sales", + "South_Sales", + "West_Sales", + ]); + + const numRows = await view.num_rows(); + expect(numRows).toBe(4284); + const json = await view.to_json({ start_row: 0, end_row: 1 }); + expect(json).toEqual([ + { + "Central|Sales": null, + "East|Sales": null, + "South|Sales": 22638.48, + "West|Sales": null, + }, + ]); + + await view.delete(); + }); + + test("flat + multi group_by + sort", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Region", "Category"], + sort: [["Sales", "desc"]], + aggregates: { Sales: "sum" }, + group_rollup_mode: "flat", + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { + __ROW_PATH__: ["West", "Furniture"], + Sales: 252612.7435000003, + }, + { + __ROW_PATH__: ["West", "Technology"], + Sales: 251991.83199999997, + }, + { + __ROW_PATH__: ["West", "Office Supplies"], + Sales: 220853.24900000007, + }, + { + __ROW_PATH__: ["East", "Technology"], + Sales: 264973.9810000003, + }, + { + __ROW_PATH__: ["East", "Furniture"], + Sales: 208291.20400000009, + }, + ]); + await view.delete(); + }); + + test("flat + multi group_by + split_by + sort", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Region", "Category"], + split_by: ["Ship Mode"], + sort: [["Sales", "desc"]], + aggregates: { Sales: "sum" }, + group_rollup_mode: "flat", + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { + __ROW_PATH__: ["West", "Furniture"], + "First Class|Sales": 40018.829499999985, + "Same Day|Sales": 14527.978000000001, + "Second Class|Sales": 54155.6555, + "Standard Class|Sales": 143910.28049999996, + }, + { + __ROW_PATH__: ["West", "Technology"], + "First Class|Sales": 61107.98900000001, + "Same Day|Sales": 19218.053999999993, + "Second Class|Sales": 38610.979999999996, + "Standard Class|Sales": 133054.809, + }, + { + __ROW_PATH__: ["West", "Office Supplies"], + "First Class|Sales": 28635.06999999996, + "Same Day|Sales": 9857.678000000002, + "Second Class|Sales": 52572.79199999999, + "Standard Class|Sales": 129787.70900000003, + }, + { + __ROW_PATH__: ["East", "Technology"], + "First Class|Sales": 47693.312999999995, + "Same Day|Sales": 21349.464999999997, + "Second Class|Sales": 29304.490000000005, + "Standard Class|Sales": 166626.71300000005, + }, + { + __ROW_PATH__: ["East", "Furniture"], + "First Class|Sales": 29410.643999999997, + "Same Day|Sales": 12852.570999999996, + "Second Class|Sales": 44035.937000000005, + "Standard Class|Sales": 121992.05199999997, + }, + ]); + await view.delete(); + }); + + test("expressions + group_by + sort", async function () { + const table = await getClient().open_table("memory.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).toEqual([ + { + __ROW_PATH__: [], + profitmargin: 12.031392972104467, + }, + { + __ROW_PATH__: ["West"], + profitmargin: 21.948661793784012, + }, + { + __ROW_PATH__: ["East"], + profitmargin: 16.722695960406636, + }, + { + __ROW_PATH__: ["South"], + profitmargin: 16.35190329218107, + }, + { + __ROW_PATH__: ["Central"], + profitmargin: -10.407293926323575, + }, + ]); + + await view.delete(); + }); +}); diff --git a/rust/perspective-js/test/js/duckdb/data_types.spec.js b/rust/perspective-js/test/js/duckdb/data_types.spec.js new file mode 100644 index 0000000..617ca6c --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/data_types.spec.js @@ -0,0 +1,100 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import { describeDuckDB } from "./setup.js"; + +describeDuckDB("data types", (getClient) => { + test("integer columns", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Quantity"], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { Quantity: 2 }, + { Quantity: 3 }, + { Quantity: 2 }, + { Quantity: 5 }, + { Quantity: 2 }, + ]); + await view.delete(); + }); + + test("float columns", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Profit"], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { Sales: 261.96, Profit: 41.9136 }, + { Sales: 731.94, Profit: 219.582 }, + { Sales: 14.62, Profit: 6.8714 }, + { Sales: 957.5775, Profit: -383.031 }, + { Sales: 22.368, Profit: 2.5164 }, + ]); + await view.delete(); + }); + + test("string columns", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Region", "State", "City"], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { + Region: "South", + State: "Kentucky", + City: "Henderson", + }, + { + Region: "South", + State: "Kentucky", + City: "Henderson", + }, + { + Region: "West", + State: "California", + City: "Los Angeles", + }, + { + Region: "South", + State: "Florida", + City: "Fort Lauderdale", + }, + { + Region: "South", + State: "Florida", + City: "Fort Lauderdale", + }, + ]); + await view.delete(); + }); + + test("date columns", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Order Date"], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { "Order Date": 1478563200000 }, + { "Order Date": 1478563200000 }, + { "Order Date": 1465689600000 }, + { "Order Date": 1444521600000 }, + { "Order Date": 1444521600000 }, + ]); + await view.delete(); + }); +}); diff --git a/rust/perspective-js/test/js/duckdb/expressions.spec.js b/rust/perspective-js/test/js/duckdb/expressions.spec.js new file mode 100644 index 0000000..c0b2036 --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/expressions.spec.js @@ -0,0 +1,89 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import { describeDuckDB } from "./setup.js"; + +describeDuckDB("expressions", (getClient) => { + test("simple expression", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "doublesales"], + expressions: { doublesales: '"Sales" * 2' }, + }); + + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { Sales: 261.96, doublesales: 523.92 }, + { Sales: 731.94, doublesales: 1463.88 }, + { Sales: 14.62, doublesales: 29.24 }, + { Sales: 957.5775, doublesales: 1915.155 }, + { Sales: 22.368, doublesales: 44.736 }, + ]); + + await view.delete(); + }); + + test("expression with multiple columns", async function () { + const table = await getClient().open_table("memory.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: 5 }); + expect(json).toEqual([ + { + Sales: 261.96, + Profit: 41.9136, + margin: 0.16000000000000003, + }, + { Sales: 731.94, Profit: 219.582, margin: 0.3 }, + { + Sales: 14.62, + Profit: 6.8714, + margin: 0.47000000000000003, + }, + { Sales: 957.5775, Profit: -383.031, margin: -0.4 }, + { Sales: 22.368, Profit: 2.5164, margin: 0.1125 }, + ]); + + await view.delete(); + }); + + test("expression with group_by", async function () { + const table = await getClient().open_table("memory.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).toEqual([ + { __ROW_PATH__: [], total: 2583597.882000014 }, + { + __ROW_PATH__: ["Central"], + total: 540946.2532999996, + }, + { __ROW_PATH__: ["East"], total: 770304.0199999991 }, + { + __ROW_PATH__: ["South"], + total: 438471.33530000027, + }, + { __ROW_PATH__: ["West"], total: 833876.2733999988 }, + ]); + + await view.delete(); + }); +}); diff --git a/rust/perspective-js/test/js/duckdb/filter.spec.js b/rust/perspective-js/test/js/duckdb/filter.spec.js new file mode 100644 index 0000000..902c436 --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/filter.spec.js @@ -0,0 +1,184 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import { describeDuckDB } from "./setup.js"; + +describeDuckDB("filter", (getClient) => { + test("filter with equals", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Region"], + filter: [["Region", "==", "West"]], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { Sales: 14.62, Region: "West" }, + { Sales: 48.86, Region: "West" }, + { Sales: 7.28, Region: "West" }, + { Sales: 907.152, Region: "West" }, + { Sales: 18.504, Region: "West" }, + ]); + await view.delete(); + }); + + test("filter with not equals", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Region"], + filter: [["Region", "!=", "West"]], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { Sales: 261.96, Region: "South" }, + { Sales: 731.94, Region: "South" }, + { Sales: 957.5775, Region: "South" }, + { Sales: 22.368, Region: "South" }, + { Sales: 15.552, Region: "South" }, + ]); + await view.delete(); + }); + + test("filter with greater than", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Quantity"], + filter: [["Quantity", ">", 5]], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { Sales: 48.86, Quantity: 7 }, + { Sales: 907.152, Quantity: 6 }, + { Sales: 1706.184, Quantity: 9 }, + { Sales: 665.88, Quantity: 6 }, + { Sales: 19.46, Quantity: 7 }, + ]); + await view.delete(); + }); + + test("filter with less than", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Quantity"], + filter: [["Quantity", "<", 3]], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { Sales: 261.96, Quantity: 2 }, + { Sales: 14.62, Quantity: 2 }, + { Sales: 22.368, Quantity: 2 }, + { Sales: 55.5, Quantity: 2 }, + { Sales: 8.56, Quantity: 2 }, + ]); + await view.delete(); + }); + + test("filter with greater than or equal", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Quantity"], + filter: [["Quantity", ">=", 10]], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { Sales: 40.096, Quantity: 14 }, + { Sales: 43.12, Quantity: 14 }, + { Sales: 384.45, Quantity: 11 }, + { Sales: 3347.37, Quantity: 13 }, + { Sales: 100.24, Quantity: 10 }, + ]); + await view.delete(); + }); + + test("filter with less than or equal", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Quantity"], + filter: [["Quantity", "<=", 2]], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { Sales: 261.96, Quantity: 2 }, + { Sales: 14.62, Quantity: 2 }, + { Sales: 22.368, Quantity: 2 }, + { Sales: 55.5, Quantity: 2 }, + { Sales: 8.56, Quantity: 2 }, + ]); + await view.delete(); + }); + + test("filter with LIKE", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "State"], + filter: [["State", "LIKE", "Cal%"]], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { Sales: 14.62, State: "California" }, + { Sales: 48.86, State: "California" }, + { Sales: 7.28, State: "California" }, + { Sales: 907.152, State: "California" }, + { Sales: 18.504, State: "California" }, + ]); + await view.delete(); + }); + + test("multiple filters", async function () { + const table = await getClient().open_table("memory.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: 5 }); + expect(json).toEqual([ + { Sales: 48.86, Region: "West", Quantity: 7 }, + { Sales: 7.28, Region: "West", Quantity: 4 }, + { Sales: 907.152, Region: "West", Quantity: 6 }, + { Sales: 114.9, Region: "West", Quantity: 5 }, + { Sales: 1706.184, Region: "West", Quantity: 9 }, + ]); + await view.delete(); + }); + + test("filter with group_by", async function () { + const table = await getClient().open_table("memory.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); + const json = await view.to_json(); + expect(json).toEqual([ + { __ROW_PATH__: [], Sales: 725457.8245000006 }, + { + __ROW_PATH__: ["Furniture"], + Sales: 252612.7435000003, + }, + { + __ROW_PATH__: ["Office Supplies"], + Sales: 220853.24900000007, + }, + { + __ROW_PATH__: ["Technology"], + Sales: 251991.83199999997, + }, + ]); + await view.delete(); + }); +}); diff --git a/rust/perspective-js/test/js/duckdb/group_by.spec.js b/rust/perspective-js/test/js/duckdb/group_by.spec.js new file mode 100644 index 0000000..1e0d09e --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/group_by.spec.js @@ -0,0 +1,194 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import { describeDuckDB } from "./setup.js"; + +describeDuckDB("group_by", (getClient) => { + test("single group_by", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Region"], + aggregates: { Sales: "sum" }, + }); + const numRows = await view.num_rows(); + expect(numRows).toBe(5); + const json = await view.to_json(); + expect(json).toEqual([ + { __ROW_PATH__: [], Sales: 2297200.860299955 }, + { + __ROW_PATH__: ["Central"], + Sales: 501239.8908000005, + }, + { __ROW_PATH__: ["East"], Sales: 678781.2399999979 }, + { + __ROW_PATH__: ["South"], + Sales: 391721.9050000003, + }, + { __ROW_PATH__: ["West"], Sales: 725457.8245000006 }, + ]); + await view.delete(); + }); + + test("multi-level group_by", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Region", "Category"], + aggregates: { Sales: "sum" }, + }); + const json = await view.to_json(); + expect(json).toEqual([ + { __ROW_PATH__: [], Sales: 2297200.860299955 }, + { + __ROW_PATH__: ["Central"], + Sales: 501239.8908000005, + }, + { + __ROW_PATH__: ["Central", "Furniture"], + Sales: 163797.16380000004, + }, + { + __ROW_PATH__: ["Central", "Office Supplies"], + Sales: 167026.41500000027, + }, + { + __ROW_PATH__: ["Central", "Technology"], + Sales: 170416.3119999999, + }, + { __ROW_PATH__: ["East"], Sales: 678781.2399999979 }, + { + __ROW_PATH__: ["East", "Furniture"], + Sales: 208291.20400000009, + }, + { + __ROW_PATH__: ["East", "Office Supplies"], + Sales: 205516.0549999999, + }, + { + __ROW_PATH__: ["East", "Technology"], + Sales: 264973.9810000003, + }, + { + __ROW_PATH__: ["South"], + Sales: 391721.9050000003, + }, + { + __ROW_PATH__: ["South", "Furniture"], + Sales: 117298.6840000001, + }, + { + __ROW_PATH__: ["South", "Office Supplies"], + Sales: 125651.31299999992, + }, + { + __ROW_PATH__: ["South", "Technology"], + Sales: 148771.9079999999, + }, + { __ROW_PATH__: ["West"], Sales: 725457.8245000006 }, + { + __ROW_PATH__: ["West", "Furniture"], + Sales: 252612.7435000003, + }, + { + __ROW_PATH__: ["West", "Office Supplies"], + Sales: 220853.24900000007, + }, + { + __ROW_PATH__: ["West", "Technology"], + Sales: 251991.83199999997, + }, + ]); + await view.delete(); + }); + + test("group_by with count aggregate", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Region"], + aggregates: { Sales: "count" }, + }); + const json = await view.to_json(); + expect(json).toEqual([ + { __ROW_PATH__: [], Sales: 9994 }, + { __ROW_PATH__: ["Central"], Sales: 2323 }, + { __ROW_PATH__: ["East"], Sales: 2848 }, + { __ROW_PATH__: ["South"], Sales: 1620 }, + { __ROW_PATH__: ["West"], Sales: 3203 }, + ]); + await view.delete(); + }); + + test("group_by with avg aggregate", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Category"], + aggregates: { Sales: "avg" }, + }); + const json = await view.to_json(); + expect(json).toEqual([ + { __ROW_PATH__: [], Sales: 229.8580008304938 }, + { + __ROW_PATH__: ["Furniture"], + Sales: 349.83488698727007, + }, + { + __ROW_PATH__: ["Office Supplies"], + Sales: 119.32410089611732, + }, + { + __ROW_PATH__: ["Technology"], + Sales: 452.70927612344155, + }, + ]); + await view.delete(); + }); + + test("group_by with min aggregate", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Quantity"], + group_by: ["Region"], + aggregates: { Quantity: "min" }, + }); + const json = await view.to_json(); + expect(json).toEqual([ + { __ROW_PATH__: [], Quantity: 1 }, + { __ROW_PATH__: ["Central"], Quantity: 1 }, + { __ROW_PATH__: ["East"], Quantity: 1 }, + { __ROW_PATH__: ["South"], Quantity: 1 }, + { __ROW_PATH__: ["West"], Quantity: 1 }, + ]); + await view.delete(); + }); + + test("group_by with max aggregate", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Quantity"], + group_by: ["Region"], + aggregates: { Quantity: "max" }, + }); + const json = await view.to_json(); + expect(json).toEqual([ + { __ROW_PATH__: [], Quantity: 14 }, + { __ROW_PATH__: ["Central"], Quantity: 14 }, + { __ROW_PATH__: ["East"], Quantity: 14 }, + { __ROW_PATH__: ["South"], Quantity: 14 }, + { __ROW_PATH__: ["West"], Quantity: 14 }, + ]); + await view.delete(); + }); +}); diff --git a/rust/perspective-js/test/js/duckdb/group_rollup_mode.spec.js b/rust/perspective-js/test/js/duckdb/group_rollup_mode.spec.js new file mode 100644 index 0000000..bb86a40 --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/group_rollup_mode.spec.js @@ -0,0 +1,256 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import { describeDuckDB } from "./setup.js"; + +describeDuckDB("group_rollup_mode", (getClient) => { + test("flat mode with group_by", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Region"], + aggregates: { Sales: "sum" }, + group_rollup_mode: "flat", + }); + const numRows = await view.num_rows(); + expect(numRows).toBe(4); + const json = await view.to_json(); + expect(json).toEqual([ + { + __ROW_PATH__: ["Central"], + Sales: 501239.8908000005, + }, + { __ROW_PATH__: ["East"], Sales: 678781.2399999979 }, + { + __ROW_PATH__: ["South"], + Sales: 391721.9050000003, + }, + { __ROW_PATH__: ["West"], Sales: 725457.8245000006 }, + ]); + await view.delete(); + }); + + test("flat mode with multi-level group_by", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Region", "Category"], + aggregates: { Sales: "sum" }, + group_rollup_mode: "flat", + }); + const numRows = await view.num_rows(); + expect(numRows).toBe(12); + const json = await view.to_json(); + expect(json).toEqual([ + { + __ROW_PATH__: ["Central", "Furniture"], + Sales: 163797.16380000004, + }, + { + __ROW_PATH__: ["Central", "Office Supplies"], + Sales: 167026.41500000027, + }, + { + __ROW_PATH__: ["Central", "Technology"], + Sales: 170416.3119999999, + }, + { + __ROW_PATH__: ["East", "Furniture"], + Sales: 208291.20400000009, + }, + { + __ROW_PATH__: ["East", "Office Supplies"], + Sales: 205516.0549999999, + }, + { + __ROW_PATH__: ["East", "Technology"], + Sales: 264973.9810000003, + }, + { + __ROW_PATH__: ["South", "Furniture"], + Sales: 117298.6840000001, + }, + { + __ROW_PATH__: ["South", "Office Supplies"], + Sales: 125651.31299999992, + }, + { + __ROW_PATH__: ["South", "Technology"], + Sales: 148771.9079999999, + }, + { + __ROW_PATH__: ["West", "Furniture"], + Sales: 252612.7435000003, + }, + { + __ROW_PATH__: ["West", "Office Supplies"], + Sales: 220853.24900000007, + }, + { + __ROW_PATH__: ["West", "Technology"], + Sales: 251991.83199999997, + }, + ]); + await view.delete(); + }); + + test("flat mode with group_by and split_by", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Category"], + split_by: ["Region"], + aggregates: { Sales: "sum" }, + group_rollup_mode: "flat", + }); + const numRows = await view.num_rows(); + expect(numRows).toBe(3); + const json = await view.to_json(); + expect(json).toEqual([ + { + __ROW_PATH__: ["Furniture"], + "Central|Sales": 163797.16380000004, + "East|Sales": 208291.20400000009, + "South|Sales": 117298.6840000001, + "West|Sales": 252612.7435000003, + }, + { + __ROW_PATH__: ["Office Supplies"], + "Central|Sales": 167026.41500000027, + "East|Sales": 205516.0549999999, + "South|Sales": 125651.31299999992, + "West|Sales": 220853.24900000007, + }, + { + __ROW_PATH__: ["Technology"], + "Central|Sales": 170416.3119999999, + "East|Sales": 264973.9810000003, + "South|Sales": 148771.9079999999, + "West|Sales": 251991.83199999997, + }, + ]); + await view.delete(); + }); + + test("flat mode with group_by and split_by and sort", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Category"], + split_by: ["Region"], + sort: [["Sales", "desc"]], + aggregates: { Sales: "sum" }, + group_rollup_mode: "flat", + }); + const numRows = await view.num_rows(); + expect(numRows).toBe(3); + const json = await view.to_json(); + expect(json).toEqual([ + { + __ROW_PATH__: ["Technology"], + "Central|Sales": 170416.3119999999, + "East|Sales": 264973.9810000003, + "South|Sales": 148771.9079999999, + "West|Sales": 251991.83199999997, + }, + { + __ROW_PATH__: ["Furniture"], + "Central|Sales": 163797.16380000004, + "East|Sales": 208291.20400000009, + "South|Sales": 117298.6840000001, + "West|Sales": 252612.7435000003, + }, + { + __ROW_PATH__: ["Office Supplies"], + "Central|Sales": 167026.41500000027, + "East|Sales": 205516.0549999999, + "South|Sales": 125651.31299999992, + "West|Sales": 220853.24900000007, + }, + ]); + await view.delete(); + }); + + test("flat mode with group_by and sort", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Region"], + sort: [["Sales", "desc"]], + aggregates: { Sales: "sum" }, + group_rollup_mode: "flat", + }); + const json = await view.to_json(); + expect(json).toEqual([ + { __ROW_PATH__: ["West"], Sales: 725457.8245000006 }, + { __ROW_PATH__: ["East"], Sales: 678781.2399999979 }, + { + __ROW_PATH__: ["Central"], + Sales: 501239.8908000005, + }, + { + __ROW_PATH__: ["South"], + Sales: 391721.9050000003, + }, + ]); + await view.delete(); + }); + + test("total mode", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + aggregates: { Sales: "sum" }, + group_rollup_mode: "total", + }); + const numRows = await view.num_rows(); + expect(numRows).toBe(1); + const json = await view.to_json(); + expect(json).toEqual([{ Sales: 2297200.860299955 }]); + await view.delete(); + }); + + test("total mode with multiple columns", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Quantity"], + aggregates: { Sales: "sum", Quantity: "sum" }, + group_rollup_mode: "total", + }); + const json = await view.to_json(); + expect(json).toEqual([{ Sales: 2297200.860299955, Quantity: 37873 }]); + await view.delete(); + }); + + test("total mode with split_by", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + split_by: ["Region"], + aggregates: { Sales: "sum" }, + group_rollup_mode: "total", + }); + const numRows = await view.num_rows(); + expect(numRows).toBe(1); + const json = await view.to_json(); + expect(json).toEqual([ + { + "Central|Sales": 501239.8908000005, + "East|Sales": 678781.2399999979, + "South|Sales": 391721.9050000003, + "West|Sales": 725457.8245000006, + }, + ]); + await view.delete(); + }); +}); diff --git a/rust/perspective-js/test/js/duckdb/min_max.spec.js b/rust/perspective-js/test/js/duckdb/min_max.spec.js new file mode 100644 index 0000000..18a8465 --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/min_max.spec.js @@ -0,0 +1,69 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import { describeDuckDB } from "./setup.js"; + +describeDuckDB("min_max", (getClient) => { + test("get_min_max() on integer column", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ columns: ["Quantity"] }); + const result = await view.get_min_max("Quantity"); + expect(result[0]).toBe(1); + expect(result[1]).toBe(14); + await view.delete(); + }); + + test("get_min_max() on float column", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ columns: ["Sales"] }); + const result = await view.get_min_max("Sales"); + expect(result[0]).toBe(0.444); + expect(result[1]).toBe(22638.48); + await view.delete(); + }); + + test("get_min_max() on string column", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ columns: ["Category"] }); + const result = await view.get_min_max("Category"); + expect(result[0]).toBe("Furniture"); + expect(result[1]).toBe("Technology"); + await view.delete(); + }); + + test("get_min_max() with group_by", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Region"], + aggregates: { Sales: "sum" }, + }); + const result = await view.get_min_max("Sales"); + expect(result[0]).toBeGreaterThan(0); + expect(result[1]).toBeGreaterThan(0); + expect(result[1]).toBeGreaterThanOrEqual(result[0]); + await view.delete(); + }); + + test("get_min_max() with filter", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Quantity"], + filter: [["Quantity", ">", 10]], + }); + const result = await view.get_min_max("Quantity"); + expect(result[0]).toBeGreaterThanOrEqual(11); + expect(result[1]).toBe(14); + await view.delete(); + }); +}); diff --git a/rust/perspective-js/test/js/duckdb/setup.js b/rust/perspective-js/test/js/duckdb/setup.js new file mode 100644 index 0000000..a8a16b3 --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/setup.js @@ -0,0 +1,90 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 } from "@perspective-dev/test"; +import { + default as perspective, + createMessageHandler, + wasmModule, +} from "@perspective-dev/client"; +import { DuckDBHandler } from "@perspective-dev/client/src/ts/virtual_servers/duckdb.ts"; + +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, + }); +} + +export function describeDuckDB(name, fn) { + test.describe("DuckDB Virtual Server " + name, function () { + let db; + let client; + + test.beforeAll(async () => { + db = await initializeDuckDB(); + const server = createMessageHandler( + new DuckDBHandler(db, wasmModule), + ); + client = await perspective.worker(server); + await loadSuperstoreData(db); + }); + + fn(() => client); + }); +} diff --git a/rust/perspective-js/test/js/duckdb/sort.spec.js b/rust/perspective-js/test/js/duckdb/sort.spec.js new file mode 100644 index 0000000..06fd1db --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/sort.spec.js @@ -0,0 +1,95 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import { describeDuckDB } from "./setup.js"; + +describeDuckDB("sort", (getClient) => { + test("sort ascending", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Quantity"], + sort: [["Sales", "asc"]], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { Sales: 0.444, Quantity: 1 }, + { Sales: 0.556, Quantity: 1 }, + { Sales: 0.836, Quantity: 1 }, + { Sales: 0.852, Quantity: 1 }, + { Sales: 0.876, Quantity: 1 }, + ]); + await view.delete(); + }); + + test("sort descending", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Quantity"], + sort: [["Sales", "desc"]], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { Sales: 22638.48, Quantity: 6 }, + { Sales: 17499.95, Quantity: 5 }, + { Sales: 13999.96, Quantity: 4 }, + { Sales: 11199.968, Quantity: 4 }, + { Sales: 10499.97, Quantity: 3 }, + ]); + await view.delete(); + }); + + test("sort with group_by", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Region"], + sort: [["Sales", "desc"]], + aggregates: { Sales: "sum" }, + }); + const json = await view.to_json(); + expect(json).toEqual([ + { __ROW_PATH__: [], Sales: 2297200.860299955 }, + { __ROW_PATH__: ["West"], Sales: 725457.8245000006 }, + { __ROW_PATH__: ["East"], Sales: 678781.2399999979 }, + { + __ROW_PATH__: ["Central"], + Sales: 501239.8908000005, + }, + { + __ROW_PATH__: ["South"], + Sales: 391721.9050000003, + }, + ]); + await view.delete(); + }); + + test("multi-column sort", async function () { + const table = await getClient().open_table("memory.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: 5 }); + expect(json).toEqual([ + { Region: "Central", Sales: 17499.95, Quantity: 5 }, + { Region: "Central", Sales: 9892.74, Quantity: 13 }, + { Region: "Central", Sales: 9449.95, Quantity: 5 }, + { Region: "Central", Sales: 8159.952, Quantity: 8 }, + { Region: "Central", Sales: 5443.96, Quantity: 4 }, + ]); + await view.delete(); + }); +}); diff --git a/rust/perspective-js/test/js/duckdb/split_by.spec.js b/rust/perspective-js/test/js/duckdb/split_by.spec.js new file mode 100644 index 0000000..53820f2 --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/split_by.spec.js @@ -0,0 +1,124 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import { describeDuckDB } from "./setup.js"; + +describeDuckDB("split_by", (getClient) => { + test("single split_by", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales"], + split_by: ["Region"], + group_by: ["Category"], + aggregates: { Sales: "sum" }, + }); + + const columns = await view.column_paths(); + expect(columns).toEqual([ + "Central_Sales", + "East_Sales", + "South_Sales", + "West_Sales", + ]); + + const json = await view.to_json(); + expect(json).toEqual([ + { + __ROW_PATH__: [], + "Central|Sales": 501239.8908000005, + "East|Sales": 678781.2399999979, + "South|Sales": 391721.9050000003, + "West|Sales": 725457.8245000006, + }, + { + __ROW_PATH__: ["Furniture"], + "Central|Sales": 163797.16380000004, + "East|Sales": 208291.20400000009, + "South|Sales": 117298.6840000001, + "West|Sales": 252612.7435000003, + }, + { + __ROW_PATH__: ["Office Supplies"], + "Central|Sales": 167026.41500000027, + "East|Sales": 205516.0549999999, + "South|Sales": 125651.31299999992, + "West|Sales": 220853.24900000007, + }, + { + __ROW_PATH__: ["Technology"], + "Central|Sales": 170416.3119999999, + "East|Sales": 264973.9810000003, + "South|Sales": 148771.9079999999, + "West|Sales": 251991.83199999997, + }, + ]); + await view.delete(); + }); + + // https://github.com/perspective-dev/perspective/issues/3148 + test("split_by with group_by and count aggregate", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Row ID"], + split_by: ["Region"], + group_by: ["Category"], + aggregates: { "Row ID": "count" }, + }); + + const json = await view.to_json({ start_row: 0, end_row: 4 }); + expect(json).toEqual([ + { + __ROW_PATH__: [], + "Central|Row ID": 2323, + "East|Row ID": 2848, + "South|Row ID": 1620, + "West|Row ID": 3203, + }, + { + __ROW_PATH__: ["Furniture"], + "Central|Row ID": 481, + "East|Row ID": 601, + "South|Row ID": 332, + "West|Row ID": 707, + }, + { + __ROW_PATH__: ["Office Supplies"], + "Central|Row ID": 1422, + "East|Row ID": 1712, + "South|Row ID": 995, + "West|Row ID": 1897, + }, + { + __ROW_PATH__: ["Technology"], + "Central|Row ID": 420, + "East|Row ID": 535, + "South|Row ID": 293, + "West|Row ID": 599, + }, + ]); + await view.delete(); + }); + + test.skip("split_by without group_by", async function () { + const table = await getClient().open_table("memory.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(); + }); +}); diff --git a/rust/perspective-js/test/js/duckdb/table.spec.js b/rust/perspective-js/test/js/duckdb/table.spec.js new file mode 100644 index 0000000..f7984df --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/table.spec.js @@ -0,0 +1,78 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import { describeDuckDB } from "./setup.js"; + +describeDuckDB("table", (getClient) => { + test("schema()", async function () { + const table = await getClient().open_table("memory.superstore"); + const schema = await table.schema(); + expect(schema).toEqual({ + "Product Name": "string", + "Ship Date": "date", + City: "string", + "Row ID": "integer", + "Customer Name": "string", + Quantity: "integer", + Discount: "float", + "Sub-Category": "string", + Segment: "string", + Category: "string", + "Order Date": "date", + "Order ID": "string", + Sales: "float", + State: "string", + "Postal Code": "float", + Country: "string", + "Customer ID": "string", + "Ship Mode": "string", + Region: "string", + Profit: "float", + "Product ID": "string", + }); + }); + + test("columns()", async function () { + const table = await getClient().open_table("memory.superstore"); + const columns = await table.columns(); + expect(columns).toEqual([ + "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", + ]); + }); + + test("size()", async function () { + const table = await getClient().open_table("memory.superstore"); + const size = await table.size(); + expect(size).toBe(9994); + }); +}); diff --git a/rust/perspective-js/test/js/duckdb/view.spec.js b/rust/perspective-js/test/js/duckdb/view.spec.js new file mode 100644 index 0000000..aa21716 --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/view.spec.js @@ -0,0 +1,91 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import { describeDuckDB } from "./setup.js"; + +describeDuckDB("view", (getClient) => { + test("num_rows()", async function () { + const table = await getClient().open_table("memory.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 getClient().open_table("memory.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 getClient().open_table("memory.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 getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Quantity"], + }); + const json = await view.to_json({ start_row: 0, end_row: 5 }); + expect(json).toEqual([ + { Sales: 261.96, Quantity: 2 }, + { Sales: 731.94, Quantity: 3 }, + { Sales: 14.62, Quantity: 2 }, + { Sales: 957.5775, Quantity: 5 }, + { Sales: 22.368, Quantity: 2 }, + ]); + await view.delete(); + }); + + test("to_columns()", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Quantity"], + }); + const columns = await view.to_columns({ + start_row: 0, + end_row: 5, + }); + expect(columns).toEqual({ + Sales: [261.96, 731.94, 14.62, 957.5775, 22.368], + Quantity: [2, 3, 2, 5, 2], + }); + await view.delete(); + }); + + test("column_paths()", async function () { + const table = await getClient().open_table("memory.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(); + }); +}); diff --git a/rust/perspective-js/test/js/duckdb/viewport.spec.js b/rust/perspective-js/test/js/duckdb/viewport.spec.js new file mode 100644 index 0000000..9b46ee2 --- /dev/null +++ b/rust/perspective-js/test/js/duckdb/viewport.spec.js @@ -0,0 +1,53 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import { describeDuckDB } from "./setup.js"; + +describeDuckDB("viewport", (getClient) => { + test("start_row and end_row", async function () { + const table = await getClient().open_table("memory.superstore"); + const view = await table.view({ + columns: ["Sales", "Profit"], + }); + const json = await view.to_json({ start_row: 10, end_row: 15 }); + expect(json).toEqual([ + { Sales: 1706.184, Profit: 85.3092 }, + { Sales: 911.424, Profit: 68.3568 }, + { Sales: 15.552, Profit: 5.4432 }, + { Sales: 407.976, Profit: 132.5922 }, + { Sales: 68.81, Profit: -123.858 }, + ]); + await view.delete(); + }); + + test("start_col and end_col", async function () { + const table = await getClient().open_table("memory.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).toEqual([ + { Profit: 41.9136, Quantity: 2 }, + { Profit: 219.582, Quantity: 3 }, + { Profit: 6.8714, Quantity: 2 }, + { Profit: -383.031, Quantity: 5 }, + { Profit: 2.5164, Quantity: 2 }, + ]); + await view.delete(); + }); +}); diff --git a/rust/perspective-js/test/js/expressions/common.js b/rust/perspective-js/test/js/expressions/common.js new file mode 100644 index 0000000..5f37a7e --- /dev/null +++ b/rust/perspective-js/test/js/expressions/common.js @@ -0,0 +1,163 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 arrows from "../test_arrows"; + +export const data = [ + { x: 1, y: "a", z: true }, + { x: 2, y: "b", z: false }, + { x: 3, y: "c", z: true }, + { x: 4, y: "d", z: false }, +]; + +export const int_float_data = [ + { w: 1.5, x: 1, y: "a", z: true }, + { w: 2.5, x: 2, y: "b", z: false }, + { w: 3.5, x: 3, y: "c", z: true }, + { w: 4.5, x: 4, y: "d", z: false }, +]; + +export const int_float_subtract_data = [ + { u: 2.5, v: 2, w: 1.5, x: 1, y: "a", z: true }, + { u: 3.5, v: 3, w: 2.5, x: 2, y: "b", z: false }, + { u: 4.5, v: 4, w: 3.5, x: 3, y: "c", z: true }, + { u: 5.5, v: 5, w: 4.5, x: 4, y: "d", z: false }, +]; + +export const comparison_data = [ + { u: 0, v: 1.5, w: 1.5, x: 1, x2: 1, y: "a", z: true }, + { u: 1, v: 2.55, w: 2.5, x: 2, x2: 10, y: "b", z: false }, + { u: 0, v: 3.5, w: 3.5, x: 3, x2: 3, y: "c", z: true }, + { u: 1, v: 4.55, w: 4.5, x: 4, x2: 10, y: "d", z: false }, +]; + +export const cols = [ + "i8", + "ui8", + "i16", + "ui16", + "i32", + "ui32", + "i64", + "ui64", + "f32", + "f64", +]; +export const arrow = arrows.numbers_arrow; +export const all_types_arrow = arrows.all_types_arrow; +export const all_types_multi_arrow = arrows.all_types_multi_arrow; + +export const days_of_week = [ + "1 Sunday", + "2 Monday", + "3 Tuesday", + "4 Wednesday", + "5 Thursday", + "6 Friday", + "7 Saturday", +]; +export const months_of_year = [ + "01 January", + "02 February", + "03 March", + "04 April", + "05 May", + "06 June", + "07 July", + "08 August", + "09 September", + "10 October", + "11 November", + "12 December", +]; + +export const second_bucket = function (val, multiplicity) { + if (multiplicity === undefined) { + multiplicity = 1; + } + const mult = 1000 * multiplicity; + return new Date(Math.floor(new Date(val).getTime() / mult) * mult); +}; + +export const minute_bucket = function (val, multiplicity) { + if (multiplicity === undefined) { + multiplicity = 1; + } + let date = new Date(val); + date.setSeconds(0); + date.setMilliseconds(0); + date.setMinutes( + Math.floor(date.getMinutes() / multiplicity) * multiplicity, + ); + return date; +}; + +export const hour_bucket = function (val, multiplicity) { + if (multiplicity === undefined) { + multiplicity = 1; + } + let date = new Date(val); + date.setMinutes(0); + date.setSeconds(0); + date.setMilliseconds(0); + date.setHours(Math.floor(date.getHours() / multiplicity) * multiplicity); + return date; +}; + +export const day_bucket = function (val) { + let date = new Date(val); + date.setHours(0); + date.setMinutes(0); + date.setSeconds(0); + date.setMilliseconds(0); + return date; +}; + +export const week_bucket = function (val) { + let date = new Date(val); + let day = date.getDay(); + let diff = date.getDate() - day + (day == 0 ? -6 : 1); + date.setHours(0); + date.setMinutes(0); + date.setSeconds(0); + date.setDate(diff); + return date; +}; + +export const month_bucket = function (val, multiplicity) { + if (multiplicity === undefined) { + multiplicity = 1; + } + let date = new Date(val); + date.setHours(0); + date.setMinutes(0); + date.setSeconds(0); + date.setDate(1); + date.setMonth(Math.floor(date.getMonth() / multiplicity) * multiplicity); + return date; +}; + +export const year_bucket = function (val, multiplicity) { + if (multiplicity === undefined) { + multiplicity = 1; + } + let date = new Date(val); + date.setHours(0); + date.setMinutes(0); + date.setSeconds(0); + date.setDate(1); + date.setMonth(0); + date.setFullYear( + Math.floor(date.getFullYear() / multiplicity) * multiplicity, + ); + return date; +}; diff --git a/rust/perspective-js/test/js/expressions/conversions.spec.js b/rust/perspective-js/test/js/expressions/conversions.spec.js new file mode 100644 index 0000000..e517d01 --- /dev/null +++ b/rust/perspective-js/test/js/expressions/conversions.spec.js @@ -0,0 +1,1033 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client"; + +/** + * Tests the correctness of each datetime computation function in various + * environments and parameters - different types, nulls, undefined, etc. + */ +((perspective) => { + test.describe("string()", () => { + test("Should create string from all column types", async () => { + const table = await perspective.table({ + a: "date", + b: "datetime", + c: "integer", + d: "float", + e: "string", + f: "boolean", + }); + + const view = await table.view({ + expressions: { + computed: ' string("a")', + computed2: ' string("b")', + computed3: ' string("c")', + computed4: ' string("d")', + computed5: ' string("e")', + computed6: ' string("f")', + computed7: " string(1234.5678)", + }, + }); + + await table.update({ + a: [new Date(2020, 4, 30), new Date(2021, 6, 13)], + b: [ + new Date(2015, 10, 29, 23, 59, 59), + new Date(2016, 10, 29, 23, 59, 59), + ], + c: [12345678, 1293879852], + d: [1.2792013981, 19.218975981], + e: ["abcdefghijklmnop", "def"], + f: [false, true], + }); + + expect(await view.expression_schema()).toEqual({ + computed: "string", + computed2: "string", + computed3: "string", + computed4: "string", + computed5: "string", + computed6: "string", + computed7: "string", + }); + + const result = await view.to_columns(); + + expect(result["computed"]).toEqual(["2020-05-30", "2021-07-13"]); + expect(result["computed2"]).toEqual([ + "2015-11-29 23:59:59.000", + "2016-11-29 23:59:59.000", + ]); + expect(result["computed3"]).toEqual(["12345678", "1293879852"]); + expect(result["computed4"]).toEqual(["1.2792", "19.219"]); + expect(result["computed5"]).toEqual(["abcdefghijklmnop", "def"]); + expect(result["computed6"]).toEqual(["false", "true"]); + expect(result["computed7"]).toEqual(["1234.57", "1234.57"]); + + await view.delete(); + await table.delete(); + }); + + test("Should create string from scalars", async () => { + const table = await perspective.table({ + x: [1], + }); + + const view = await table.view({ + group_by: ["computed"], + aggregates: { + computed: "last", + computed2: "last", + computed3: "last", + }, + expressions: { + computed: " string('abcdefg')", + computed2: " string(1234)", + computed3: " string(1234.5678)", + }, + }); + + expect(await view.expression_schema()).toEqual({ + computed: "string", + computed2: "string", + computed3: "string", + }); + + const result = await view.to_columns(); + + expect(result["__ROW_PATH__"]).toEqual([[], ["abcdefg"]]); + expect(result["computed2"]).toEqual(["1234", "1234"]); + expect(result["computed3"]).toEqual(["1234.57", "1234.57"]); + + await view.delete(); + await table.delete(); + }); + }); + + test.describe("integer()", () => { + test("Should create integers from scalars", async () => { + const table = await perspective.table({ x: [1] }); + const view = await table.view({ + expressions: { + [`computed`]: `integer(999999.999)`, + [`computed2`]: `integer(2147483648)`, + [`computed3`]: `integer(-2147483649)`, + [`computed4`]: `integer(123456789)`, + [`computed5`]: `integer(0.00001)`, + [`computed6`]: `integer(1.9999999999)`, + [`computed7`]: `integer(2147483647)`, + [`computed8`]: `integer(-2147483647)`, + [`computed9`]: `integer('957187281')`, + [`computed10`]: `integer('1928.2817')`, + [`computed11`]: `integer('1234abcd')`, + [`computed12`]: `integer('abcdefg1234')`, + [`computed13`]: `integer('2147483648')`, + [`computed14`]: `integer('-2147483649')`, + }, + }); + + const result = await view.to_columns(); + + expect(result["computed"]).toEqual([999999]); + expect(result["computed2"]).toEqual([null]); + expect(result["computed3"]).toEqual([null]); + expect(result["computed4"]).toEqual([123456789]); + expect(result["computed5"]).toEqual([0]); + expect(result["computed6"]).toEqual([1]); + expect(result["computed7"]).toEqual([2147483647]); + expect(result["computed8"]).toEqual([-2147483647]); + expect(result["computed9"]).toEqual([957187281]); + expect(result["computed10"]).toEqual([1928]); + + // unparsable strings + expect(result["computed11"]).toEqual([null]); + expect(result["computed12"]).toEqual([null]); + // check for overflow + expect(result["computed13"]).toEqual([null]); + expect(result["computed14"]).toEqual([null]); + + await view.delete(); + await table.delete(); + }); + + // TODO Is this behavior correct? The purpose of this test is to check + // that integer->integer casting works, but it is also implicitly + // testing that `float`s are coerced to `integer`s when the column + // already has `integer` type. I think the new API is correct to reject + // this behavior. + test.skip("Should create integers from integer columns", async () => { + const table = await perspective.table({ x: "integer" }); + const view = await table.view({ + expressions: { computed: `integer("x")` }, + }); + + await table.update({ + x: [100, -17238.8123, 0.890798, -1.1295, null, 12836215.128937], + }); + + const result = await view.to_columns(); + expect(result["computed"]).toEqual([ + 100, + -17238, + 0, + -1, + null, + 12836215, + ]); + + await view.delete(); + await table.delete(); + }); + + test("Should create integers from float columns", async () => { + const table = await perspective.table({ x: "float" }); + + const view = await table.view({ + expressions: { computed: `integer("x")` }, + }); + + await table.update({ + x: [ + 100.9999999, + -17238.8123, + 0.890798, + -1.1295, + null, + 12836215.128937, + ], + }); + + const result = await view.to_columns(); + expect(result["computed"]).toEqual([ + 100, + -17238, + 0, + -1, + null, + 12836215, + ]); + + await view.delete(); + await table.delete(); + }); + + test("Should create integers from date columns", async () => { + const table = await perspective.table({ x: "date" }); + + const view = await table.view({ + expressions: { computed: `integer("x")` }, + }); + + const value = new Date(2020, 5, 30); + + await table.update({ + x: [value], + }); + + const result = await view.to_columns(); + + // The custom format that Perspective uses to store dates - we + // can validate correctness by applying bitmask/shifting. + const expected = 132384030; + + expect(result["computed"]).toEqual([expected]); + + const year = (expected & 0xffff0000) >> 16; + const month = (expected & 0x0000ff00) >> 8; + const day = (expected & 0x000000ff) >> 0; + + expect(year).toEqual(2020); + expect(month).toEqual(5); + expect(day).toEqual(30); + + await view.delete(); + await table.delete(); + }); + + test("Should create integers from datetime columns", async () => { + const table = await perspective.table({ x: "datetime" }); + + const view = await table.view({ + expressions: { computed: `integer("x")` }, + }); + + // first will not overflow, second will + await table.update({ + x: [new Date(1970, 0, 1, 1), new Date(2020, 0, 1, 1)], + }); + + const result = await view.to_columns(); + expect(result["computed"]).toEqual([ + new Date(1970, 0, 1, 1).getTime(), + null, + ]); + + await view.delete(); + await table.delete(); + }); + + test("Should create integers from string columns", async () => { + const table = await perspective.table({ + x: [ + "1", + "2", + "3", + "abc", + "4.5", + "0.101928317581729083", + "-123456", + ], + }); + + const view = await table.view({ + expressions: { computed: `integer("x")` }, + }); + + const result = await view.to_columns(); + expect(result["computed"]).toEqual([1, 2, 3, null, 4, 0, -123456]); + + await view.delete(); + await table.delete(); + }); + }); + + test.describe("float()", () => { + test("Should create float from scalars", async () => { + const table = await perspective.table({ x: [1] }); + const view = await table.view({ + expressions: { + [`computed`]: `float(999999.999)`, + [`computed2`]: `float(2147483648)`, + [`computed3`]: `float(-2147483649)`, + [`computed4`]: `float(123456789)`, + [`computed5`]: `float(0.00001)`, + [`computed6`]: `float(1.9999999992)`, + [`computed7`]: `float(2147483647.1234567)`, + [`computed8`]: `float(-2147483647)`, + [`computed9`]: `float('957187281.00000001')`, + [`computed10`]: `float('1928.2817')`, + [`computed11`]: `float('abcdefg')`, + [`computed12`]: `float('abcdefg1234.123125')`, + [`computed13`]: `float('2147483648.1234566')`, + [`computed14`]: `float('-2147483649')`, + [`computed15`]: `float('inf')`, + [`computed16`]: `float('-inf')`, + [`computed17`]: `float(inf)`, + [`computed18`]: `float(-inf)`, + }, + }); + + expect(await view.expression_schema()).toEqual({ + computed: "float", + computed2: "float", + computed3: "float", + computed4: "float", + computed5: "float", + computed6: "float", + computed7: "float", + computed8: "float", + computed9: "float", + computed10: "float", + computed11: "float", + computed12: "float", + computed13: "float", + computed14: "float", + computed15: "float", + computed16: "float", + computed17: "float", + computed18: "float", + }); + + const result = await view.to_columns(); + expect(result["computed"]).toEqual([999999.999]); + expect(result["computed2"]).toEqual([2147483648]); + expect(result["computed3"]).toEqual([-2147483649]); + expect(result["computed4"]).toEqual([123456789]); + expect(result["computed5"]).toEqual([0.00001]); + expect(result["computed6"]).toEqual([1.9999999992]); + expect(result["computed7"]).toEqual([2147483647.1234567]); + expect(result["computed8"]).toEqual([-2147483647]); + expect(result["computed9"]).toEqual([957187281.00000001]); + expect(result["computed10"]).toEqual([1928.2817]); + expect(result["computed11"]).toEqual([null]); + expect(result["computed12"]).toEqual([null]); + expect(result["computed13"]).toEqual([2147483648.1234566]); + expect(result["computed14"]).toEqual([-2147483649]); + expect(result["computed15"]).toEqual([null]); + expect(result["computed16"]).toEqual([null]); + expect(result["computed17"]).toEqual([null]); + expect(result["computed18"]).toEqual([null]); + + await view.delete(); + await table.delete(); + }); + + // TODO same deal as above - not sure this behavior is correct. + // `await table.update()` should be invalid here. + test.skip("Should create float from integer columns", async () => { + const table = await perspective.table({ x: "integer" }); + + const view = await table.view({ + expressions: { computed: `float("x")` }, + }); + + await table.update({ + x: [100, -17238.8123, 0.890798, -1.1295, null, 12836215.128937], + }); + + const result = await view.to_columns(); + expect(result["computed"]).toEqual([ + 100, + -17238, + 0, + -1, + null, + 12836215, + ]); + + await view.delete(); + await table.delete(); + }); + + test("Should create float from float columns", async () => { + const table = await perspective.table({ x: "float" }); + + const view = await table.view({ + expressions: { computed: ` float("x")` }, + }); + + await table.update({ + x: [ + 100.9999999, + -17238.8123, + 0.890798, + -1.1295, + null, + 12836215.128937, + ], + }); + + const result = await view.to_columns(); + expect(result["computed"]).toEqual([ + 100.9999999, + -17238.8123, + 0.890798, + -1.1295, + null, + 12836215.128937, + ]); + + await view.delete(); + await table.delete(); + }); + + test("Should create float from date columns", async () => { + const table = await perspective.table({ x: "date" }); + + const view = await table.view({ + expressions: { computed: `float("x")` }, + }); + + const value = new Date(2020, 5, 30); + + await table.update({ + x: [value], + }); + + const result = await view.to_columns(); + + // The custom format that Perspective uses to store dates - we + // can validate correctness by applying bitmask/shifting. + const expected = 132384030; + + expect(result["computed"]).toEqual([expected]); + + const year = (expected & 0xffff0000) >> 16; + const month = (expected & 0x0000ff00) >> 8; + const day = (expected & 0x000000ff) >> 0; + + expect(year).toEqual(2020); + expect(month + 1).toEqual(6); + expect(day).toEqual(30); + + await view.delete(); + await table.delete(); + }); + + test("Should create float from datetime columns", async () => { + const table = await perspective.table({ x: "datetime" }); + + const view = await table.view({ + expressions: { computed: `float("x")` }, + }); + + await table.update({ + x: [new Date(2020, 0, 1, 1), new Date(2020, 0, 1, 1, 50)], + }); + + const result = await view.to_columns(); + expect(result["computed"]).toEqual([ + new Date(2020, 0, 1, 1).getTime(), + new Date(2020, 0, 1, 1, 50).getTime(), + ]); + + await view.delete(); + await table.delete(); + }); + + // TODO This behavior is questionable at best, and given Perspective's + // more advanced expression aupport, its probably better to error + // here. + test.skip("Should create floats from string columns", async () => { + const table = await perspective.table({ + x: [ + "1.1238757869112321", + "2.0000001", + "abcdefg1234.12878591", + "12354.1827389abc", + "4.555555555", + "0.101928317581729083", + "-123456.21831729054781", + ], + }); + + const view = await table.view({ + expressions: { computed: ` float("x")` }, + }); + + const result = await view.to_columns(); + expect(result["computed"]).toEqual([ + 1.1238757869112321, + 2.0000001, + null, + null, + 4.555555555, + 0.101928317581729083, + -123456.21831729054781, + ]); + + await view.delete(); + await table.delete(); + }); + }); + + test.describe("boolean()", () => { + test("Should create a boolean value", async () => { + const table = await perspective.table({ + x: [1, 2, null, 4], + }); + + const view = await table.view({ + expressions: { 'boolean("x")': 'boolean("x")' }, + }); + + expect(await view.expression_schema()).toEqual({ + 'boolean("x")': "boolean", + }); + + expect(await view.to_columns()).toEqual({ + x: [1, 2, null, 4], + 'boolean("x")': [true, true, false, true], + }); + + await view.delete(); + await table.delete(); + }); + + test("Empty strings and nulls", async () => { + const table = await perspective.table({ + x: ["abcd", "", null, "abc"], + }); + + const view = await table.view({ + expressions: { 'boolean("x")': 'boolean("x")' }, + }); + + expect(await view.expression_schema()).toEqual({ + 'boolean("x")': "boolean", + }); + + expect(await view.to_columns()).toEqual({ + x: ["abcd", "", null, "abc"], + 'boolean("x")': [true, true, false, true], + }); + + await view.delete(); + await table.delete(); + }); + + test("Should respond to updates", async () => { + const table = await perspective.table( + { + idx: [1, 2, 3, 4], + x: [1, 2, null, 4], + }, + { index: "idx" }, + ); + + const view = await table.view({ + expressions: { 'boolean("x")': 'boolean("x")' }, + }); + + expect(await view.expression_schema()).toEqual({ + 'boolean("x")': "boolean", + }); + + expect(await view.to_columns()).toEqual({ + idx: [1, 2, 3, 4], + x: [1, 2, null, 4], + 'boolean("x")': [true, true, false, true], + }); + + await table.update({ + idx: [3, 2], + x: [100, null], + }); + + expect(await view.to_columns()).toEqual({ + idx: [1, 2, 3, 4], + x: [1, null, 100, 4], + 'boolean("x")': [true, false, true, true], + }); + + await view.delete(); + await table.delete(); + }); + + test("Should respond to removes", async () => { + const table = await perspective.table( + { + idx: [1, 2, 3, 4], + x: [1, 2, null, 4], + }, + { index: "idx" }, + ); + + const view = await table.view({ + expressions: { 'boolean("x")': 'boolean("x")' }, + }); + + expect(await view.expression_schema()).toEqual({ + 'boolean("x")': "boolean", + }); + + expect(await view.to_columns()).toEqual({ + idx: [1, 2, 3, 4], + x: [1, 2, null, 4], + 'boolean("x")': [true, true, false, true], + }); + + await table.remove([1, 4]); + + expect(await view.to_columns()).toEqual({ + idx: [2, 3], + x: [2, null], + 'boolean("x")': [true, false], + }); + + await view.delete(); + await table.delete(); + }); + }); + + test.describe("date()", () => { + test("Should create a date from scalars", async () => { + const table = await perspective.table({ + x: [1, 2, 3, 4], + }); + + const view = await table.view({ + expressions: { + [`computed`]: `date(2020, 7, 15)`, + [`computed2`]: `date(1970, 10, 29)`, + [`computed3`]: `date(2020, 1, "x")`, + }, + }); + + const result = await view.to_columns(); + expect(result["computed"]).toEqual( + Array(4).fill(new Date(2020, 6, 15).getTime()), + ); + expect(result["computed2"]).toEqual( + Array(4).fill(new Date(1970, 9, 29).getTime()), + ); + expect(result["computed3"]).toEqual( + Array(4) + .fill(true) + .map((_, idx) => new Date(2020, 0, idx + 1).getTime()), + ); + await view.delete(); + await table.delete(); + }); + + test("Should create a date from int columns", async () => { + // NOTE: This test originally used dates that would cause an underflow issue + // in the epoch time conversion that occurs within the c++ engine. + // Revert these changes once the c++ engine is updated to use std::chrono. + const table = await perspective.table({ + y: "integer", + m: "integer", + d: "integer", + }); + + const view = await table.view({ + expressions: { computed: ` date("y", "m", "d")` }, + }); + + await table.update({ + // y: [0, 2020, 1776, 2018, 2020, 2020], // old values, see note above. + y: [1970, 2020, 2000, 2018, 2020, 2020], + m: [1, 2, 5, 2, 12, null], + d: [1, 29, 31, 29, 31, 1], + }); + + const result = await view.to_columns(); + const expected = [ + // new Date(1900, 0, 1), // old values, see note above. + new Date(1970, 0, 1), + new Date(2020, 1, 29), + // new Date(1776, 4, 31), // old values, see note above. + new Date(2000, 4, 31), + new Date(2018, 1, 29), + new Date(2020, 11, 31), + ].map((x) => x.getTime()); + expected.push(null); + expect(result["computed"]).toEqual(expected); + await view.delete(); + await table.delete(); + }); + + test("Should create a date from float columns", async () => { + // NOTE: This test originally used dates that would cause an underflow issue + // in the epoch time conversion that occurs within the c++ engine. + // Revert these changes once the c++ engine is updated to use std::chrono. + const table = await perspective.table({ + y: "float", + m: "float", + d: "float", + }); + + const view = await table.view({ + expressions: { computed: ` date("y", "m", "d")` }, + }); + + await table.update({ + // y: [0, 2020, 1776, 2018, 2020, 2020], // old values, see note above. + y: [1970, 2020, 2000, 2018, 2020, 2020], + m: [1, 2, 5, 2, 12, null], + d: [1, 29, 31, 29, 31, 1], + }); + + const result = await view.to_columns(); + const expected = [ + // new Date(1900, 0, 1), // old values, see note above. + new Date(1970, 0, 1), + new Date(2020, 1, 29), + // new Date(1776, 4, 31), // old values, see note above. + new Date(2000, 4, 31), + new Date(2018, 1, 29), + new Date(2020, 11, 31), + ].map((x) => x.getTime()); + expected.push(null); + expect(result["computed"]).toEqual(expected); + await view.delete(); + await table.delete(); + }); + + test("Should create a date from numeric columns and skip invalid values", async () => { + // NOTE: The original test used `y: [-100, 0, 2000, 3000]`, but the `3000` value + // has been replaced with `2030` due to an underflow issue with the computed + // epoch time. This test should be returned to its original state once + // the c++ engine is updated to use std::chrono. + const table = await perspective.table({ + y: [-100, 0, 2000, 2030], + m: [12, 0, 12, 11], + d: [1, 10, 32, 10], + }); + + const view = await table.view({ + expressions: { computed: ` date("y", "m", "d")` }, + }); + + const result = await view.to_columns(); + expect(result["computed"]).toEqual([ + null, + null, + null, + new Date(2030, 10, 10).getTime(), + ]); + await view.delete(); + await table.delete(); + }); + + test("Should create a date from variables inside expression", async () => { + const table = await perspective.table({ + x: [20200101, 20090531, 19801220, 20200229], + }); + + const view = await table.view({ + expressions: { + [`computed`]: `var year := floor("x" / 10000); var month := floor("x" % 10000 / 100); var day := floor("x" % 100); date(year, month, day)`, + }, + }); + + const result = await view.to_columns(); + expect(result["computed"]).toEqual( + [ + new Date(2020, 0, 1), + new Date(2009, 4, 31), + new Date(1980, 11, 20), + new Date(2020, 1, 29), + ].map((x) => x.getTime()), + ); + await view.delete(); + await table.delete(); + }); + + test("Should validate inputs", async () => { + const table = await perspective.table({ + y: [-100, 0, 2000, 3000], + m: [12, 0, 12, 11], + d: [1, 10, 32, 10], + }); + + const validated = await table.validate_expressions({ + [`computed`]: `date()`, + [`computed2`]: `date('abc', 'def', '123')`, + [`computed3`]: `date("y", "m", "d")`, + }); + + expect(validated.expression_schema).toEqual({ + computed3: "date", + }); + + expect(validated.errors).toEqual({ + computed: { + column: 6, + error_message: + "Zero parameter call to generic function: date not allowed", + line: 0, + }, + computed2: { + column: 0, + error_message: + "Type Error - inputs do not resolve to a valid expression.", + line: 0, + }, + }); + + await table.delete(); + }); + }); + + test.describe("datetime()", () => { + test("Should create a datetime from scalars", async () => { + const table = await perspective.table({ + x: [1, 2, 3, 4], + }); + + const a = new Date(2005, 6, 31, 11, 59, 32).getTime(); + const b = new Date(2005, 6, 31, 11, 59, 32).getTime(); + + const view = await table.view({ + expressions: { + [`computed`]: `datetime(${a})`, + [`computed2`]: `datetime(${b})`, + }, + }); + + expect(await view.expression_schema()).toEqual({ + computed: "datetime", + computed2: "datetime", + }); + + const result = await view.to_columns(); + expect(result["computed"]).toEqual(Array(4).fill(a)); + expect(result["computed2"]).toEqual(Array(4).fill(b)); + await view.delete(); + await table.delete(); + }); + + test("Should create a datetime from a float()", async () => { + const table = await perspective.table({ + x: [new Date(2005, 6, 31, 11, 59, 32)], + }); + + const view = await table.view({ + expressions: { computed: ` datetime(float("x"))` }, + }); + + expect(await view.expression_schema()).toEqual({ + computed: "datetime", + }); + + const result = await view.to_columns(); + expect(result["computed"]).toEqual([ + new Date(2005, 6, 31, 11, 59, 32).getTime(), + ]); + await view.delete(); + await table.delete(); + }); + + // TODO Previous behavior was to fill this column with null - new + // behavior is to error, which IMO is more correct. + test.skip("Should not create a datetime from int columns as int32 is too small", async () => { + const table = await perspective.table({ + x: "integer", + }); + + const view = await table.view({ + expressions: { computed: ` datetime("x")` }, + }); + + const data = [ + new Date(2020, 1, 29, 5, 1, 2), + new Date(1776, 4, 31, 13, 23, 18), + new Date(2018, 1, 29, 19, 39, 43), + new Date(2020, 11, 31, 23, 59, 59), + ].map((x) => x.getTime()); + data.push(null); + + await table.update({ + x: data, + }); + + let result = await view.to_columns(); + + expect(result["computed"]).toEqual(Array(5).fill(null)); + await view.delete(); + await table.delete(); + }); + + test("Should create a datetime from float columns", async () => { + // NOTE: This test originally used dates that would cause an underflow issue + // in the epoch time conversion that occurs within the c++ engine. + // Revert these changes once the c++ engine is updated to use std::chrono. + const table = await perspective.table({ + x: "float", + }); + + const view = await table.view({ + expressions: { computed: ` datetime("x")` }, + }); + + const data = [ + new Date(2020, 1, 29, 5, 1, 2), + new Date(2000, 4, 31, 13, 23, 18), + // new Date(1776, 4, 31, 13, 23, 18), // old values, see note above. + new Date(2018, 1, 29, 19, 39, 43), + new Date(2020, 11, 31, 23, 59, 59), + ].map((x) => x.getTime()); + data.push(null); + + await table.update({ + x: data, + }); + + const result = await view.to_columns(); + expect(result["computed"]).toEqual(data); + await view.delete(); + await table.delete(); + }); + + test("Should create a datetime from numeric scalars < 0", async () => { + const table = await perspective.table({ + x: [1], + }); + + const view = await table.view({ + expressions: { + [`computed1`]: `datetime(-1)`, + [`computed2`]: `datetime(0)`, + computed3: `datetime(${new Date( + 2002, + 11, + 12, + 13, + 14, + 15, + ).getTime()})`, + }, + }); + + expect(await view.expression_schema()).toEqual({ + computed1: "datetime", + computed2: "datetime", + computed3: "datetime", + }); + + const result = await view.to_columns(); + + expect(result["computed1"]).toEqual([-1]); + expect(result["computed2"]).toEqual([0]); + expect(result["computed3"]).toEqual([ + new Date(2002, 11, 12, 13, 14, 15).getTime(), + ]); + await view.delete(); + await table.delete(); + }); + + test("Should validate inputs", async () => { + const table = await perspective.table({ + x: [1], + }); + + const validated = await table.validate_expressions({ + [`computed1`]: `datetime()`, + [`computed2`]: `datetime('abcd')`, + [`computed3`]: `datetime(today())`, + [`computed4`]: `datetime(now())`, + [`computed5`]: `datetime(123456, 7)`, + }); + + expect(validated.expression_schema).toEqual({}); + + expect(validated.errors).toEqual({ + computed1: { + column: 10, + error_message: + "Zero parameter call to generic function: datetime not allowed", + line: 0, + }, + computed2: { + error_message: + "Type Error - inputs do not resolve to a valid expression.", + column: 0, + line: 0, + }, + computed3: { + error_message: + "Type Error - inputs do not resolve to a valid expression.", + column: 0, + line: 0, + }, + computed4: { + error_message: + "Type Error - inputs do not resolve to a valid expression.", + column: 0, + line: 0, + }, + computed5: { + error_message: + "Failed parameter type check for function 'datetime', Expected 'T' call set: 'TT'", + column: 19, + line: 0, + }, + }); + + await table.delete(); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/expressions/datetime.spec.js b/rust/perspective-js/test/js/expressions/datetime.spec.js new file mode 100644 index 0000000..d4b6a95 --- /dev/null +++ b/rust/perspective-js/test/js/expressions/datetime.spec.js @@ -0,0 +1,1636 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 common from "./common.js"; + +import { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client"; + +/** + * Tests the correctness of each datetime computation function in various + * environments and parameters - different types, nulls, undefined, etc. + */ +((perspective) => { + test.describe("Date comparisons", function () { + test("equality", async function () { + const table = await perspective.table({ + a: "date", + b: "date", + }); + + const view = await table.view({ + expressions: { + '"a" == "b"': '"a" == "b"', + '"a" != "b"': '"a" != "b"', + '"a" == "b" ? 100 : 0': '"a" == "b" ? 100 : 0', + }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 26), + new Date(2020, 0, 27), + new Date(2020, 0, 28), + new Date(2020, 0, 29), + new Date(2020, 0, 30), + ], + b: [ + new Date(2020, 0, 26), + new Date(2020, 0, 27), + new Date(2020, 0, 28), + new Date(2020, 0, 29), + new Date(2020, 1, 30), + ], + }); + + let result = await view.to_columns(); + expect(result['"a" == "b"']).toEqual([ + true, + true, + true, + true, + false, + ]); + expect(result['"a" != "b"']).toEqual([ + false, + false, + false, + false, + true, + ]); + expect(result['"a" == "b" ? 100 : 0']).toEqual([ + 100, 100, 100, 100, 0, + ]); + view.delete(); + table.delete(); + }); + + test("greater", async function () { + const table = await perspective.table({ + a: "date", + b: "date", + }); + + const view = await table.view({ + expressions: { + '"a" > "b"': '"a" > "b"', + '"a" >= "b"': '"a" >= "b"', + '"a" >= "b" ? 100 : 0': '"a" >= "b" ? 100 : 0', + }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 26), + new Date(2020, 0, 27), + new Date(2020, 0, 29), + new Date(2020, 0, 30), + new Date(2020, 1, 30), + ], + b: [ + new Date(2020, 1, 26), + new Date(2020, 0, 28), + new Date(2020, 0, 28), + new Date(2020, 0, 29), + new Date(2020, 1, 30), + ], + }); + + let result = await view.to_columns(); + expect(result['"a" > "b"']).toEqual([ + false, + false, + true, + true, + false, + ]); + expect(result['"a" >= "b"']).toEqual([ + false, + false, + true, + true, + true, + ]); + expect(result['"a" >= "b" ? 100 : 0']).toEqual([ + 0, 0, 100, 100, 100, + ]); + view.delete(); + table.delete(); + }); + + test("less than", async function () { + const table = await perspective.table({ + a: "date", + b: "date", + }); + + const view = await table.view({ + expressions: { + '"a" < "b"': '"a" < "b"', + '"a" <= "b"': '"a" <= "b"', + '"a" <= "b" ? 100 : 0': '"a" <= "b" ? 100 : 0', + }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 26), + new Date(2020, 0, 27), + new Date(2020, 0, 29), + new Date(2020, 0, 30), + new Date(2020, 1, 30), + ], + b: [ + new Date(2020, 1, 26), + new Date(2020, 0, 28), + new Date(2020, 0, 28), + new Date(2020, 0, 29), + new Date(2020, 1, 30), + ], + }); + + let result = await view.to_columns(); + expect(result['"a" < "b"']).toEqual([ + true, + true, + false, + false, + false, + ]); + expect(result['"a" <= "b"']).toEqual([ + true, + true, + false, + false, + true, + ]); + expect(result['"a" <= "b" ? 100 : 0']).toEqual([ + 100, 100, 0, 0, 100, + ]); + view.delete(); + table.delete(); + }); + }); + + test.describe("Date functions", function () { + test("today()", async function () { + const table = await perspective.table({ + a: "date", + }); + + const view = await table.view({ + expressions: { 'today() == "a"': 'today() == "a"' }, + }); + + await table.update({ + a: [new Date(), new Date(), new Date()], + }); + + let result = await view.to_columns(); + expect(result['today() == "a"']).toEqual([true, true, true]); + view.delete(); + table.delete(); + }); + + test("Hour of day, date", async function () { + const table = await perspective.table({ + a: "date", + }); + + const view = await table.view({ + expressions: { 'hour_of_day("a")': 'hour_of_day("a")' }, + }); + + await table.update({ + a: [new Date(), new Date(), new Date()], + }); + + let result = await view.to_columns(); + expect(result['hour_of_day("a")']).toEqual([0, 0, 0]); + view.delete(); + table.delete(); + }); + + test("Hour of day, date with null", async function () { + const table = await perspective.table({ + a: "date", + }); + + const view = await table.view({ + expressions: { 'hour_of_day("a")': 'hour_of_day("a")' }, + }); + + await table.update({ + a: [new Date(), null, undefined, new Date()], + }); + + let result = await view.to_columns(); + expect(result['hour_of_day("a")']).toEqual([0, null, null, 0]); + view.delete(); + table.delete(); + }); + + test("Day of week, date", async function () { + const table = await perspective.table({ + a: "date", + }); + + const view = await table.view({ + expressions: { 'day_of_week("a")': 'day_of_week("a")' }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 26), + new Date(2020, 0, 27), + new Date(2020, 0, 28), + new Date(2020, 0, 29), + new Date(2020, 0, 30), + ], + }); + + let result = await view.to_columns(); + expect(result['day_of_week("a")']).toEqual( + result.a.map((x) => common.days_of_week[new Date(x).getDay()]), + ); + view.delete(); + table.delete(); + }); + + test("Day of week, date with null", async function () { + const table = await perspective.table({ + a: "date", + }); + + const view = await table.view({ + expressions: { 'day_of_week("a")': 'day_of_week("a")' }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 26), + null, + undefined, + new Date(2020, 0, 29), + new Date(2020, 0, 30), + ], + }); + + let result = await view.to_columns(); + expect(result['day_of_week("a")']).toEqual( + result.a.map((x) => + x ? common.days_of_week[new Date(x).getDay()] : null, + ), + ); + view.delete(); + table.delete(); + }); + + test("Month of year, date", async function () { + const table = await perspective.table({ + a: "date", + }); + + const view = await table.view({ + expressions: { 'month_of_year("a")': 'month_of_year("a")' }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 15), + new Date(2020, 1, 27), + new Date(2020, 2, 28), + new Date(2020, 3, 29), + new Date(2020, 4, 30), + new Date(2020, 5, 31), + new Date(2020, 6, 1), + ], + }); + + let result = await view.to_columns(); + expect(result['month_of_year("a")']).toEqual( + result.a.map( + (x) => common.months_of_year[new Date(x).getMonth()], + ), + ); + view.delete(); + table.delete(); + }); + + test("Month of year, date with null", async function () { + const table = await perspective.table({ + a: "date", + }); + + const view = await table.view({ + expressions: { 'month_of_year("a")': 'month_of_year("a")' }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 15), + null, + undefined, + new Date(2020, 3, 29), + new Date(2020, 4, 30), + new Date(2020, 5, 31), + new Date(2020, 6, 1), + ], + }); + + let result = await view.to_columns(); + expect(result['month_of_year("a")']).toEqual( + result.a.map((x) => + x ? common.months_of_year[new Date(x).getMonth()] : null, + ), + ); + view.delete(); + table.delete(); + }); + + test("Bucket (s), date", async function () { + const table = await perspective.table({ + a: "date", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 's')": "bucket(\"a\", 's')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 15), + new Date(2020, 1, 27), + new Date(2020, 2, 28), + new Date(2020, 3, 29), + new Date(2020, 4, 30), + new Date(2020, 5, 31), + new Date(2020, 6, 1), + ], + }); + + let result = await view.to_columns(); + expect(result["bucket(\"a\", 's')"]).toEqual(result.a); + view.delete(); + table.delete(); + }); + + test("Certain bucket multiplicities do not validate.", async function () { + const table = await perspective.table({ + x: [1], + }); + + const validated = await table.validate_expressions({ + [`bucket0`]: `bucket("x", '2W')`, + [`bucket1`]: `bucket("x", '3W')`, + [`bucket2`]: `bucket("x", '4W')`, + [`bucket3`]: `bucket("x", '5W')`, + [`bucket4`]: `bucket("x", '2D')`, + [`bucket5`]: `bucket("x", '3D')`, + [`bucket6`]: `bucket("x", '7D')`, + [`bucket7`]: `bucket("x", '10D')`, + [`bucket8`]: `bucket("x", '15D')`, + [`bucket9`]: `bucket("x", '30D')`, + }); + + expect(validated.expression_schema).toEqual({}); + + expect(validated.errors).toEqual({ + bucket0: { + error_message: + "Type Error - inputs do not resolve to a valid expression.", + column: 0, + line: 0, + }, + bucket1: { + error_message: + "Type Error - inputs do not resolve to a valid expression.", + column: 0, + line: 0, + }, + bucket2: { + error_message: + "Type Error - inputs do not resolve to a valid expression.", + column: 0, + line: 0, + }, + bucket3: { + error_message: + "Type Error - inputs do not resolve to a valid expression.", + column: 0, + line: 0, + }, + bucket4: { + error_message: + "Type Error - inputs do not resolve to a valid expression.", + column: 0, + line: 0, + }, + bucket5: { + error_message: + "Type Error - inputs do not resolve to a valid expression.", + column: 0, + line: 0, + }, + bucket6: { + error_message: + "Type Error - inputs do not resolve to a valid expression.", + column: 0, + line: 0, + }, + bucket7: { + error_message: + "Type Error - inputs do not resolve to a valid expression.", + column: 0, + line: 0, + }, + bucket8: { + error_message: + "Type Error - inputs do not resolve to a valid expression.", + column: 0, + line: 0, + }, + bucket9: { + error_message: + "Type Error - inputs do not resolve to a valid expression.", + column: 0, + line: 0, + }, + }); + + await table.delete(); + }); + + test("Bucket (s), date with nulls", async function () { + const table = await perspective.table({ + a: "date", + }); + const view = await table.view({ + expressions: { "bucket(\"a\", 's')": "bucket(\"a\", 's')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 15), + null, + undefined, + new Date(2020, 3, 29), + new Date(2020, 4, 30), + new Date(2020, 5, 31), + new Date(2020, 6, 1), + ], + }); + + let result = await view.to_columns(); + expect(result["bucket(\"a\", 's')"]).toEqual( + result.a.map((x) => (x ? x : null)), + ); + view.delete(); + table.delete(); + }); + + test("Bucket (m), date", async function () { + const table = await perspective.table({ + a: "date", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 'm')": "bucket(\"a\", 'm')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 15), + new Date(2020, 1, 27), + new Date(2020, 2, 28), + new Date(2020, 3, 29), + new Date(2020, 4, 30), + new Date(2020, 5, 31), + new Date(2020, 6, 1), + ], + }); + + let result = await view.to_columns(); + expect(result["bucket(\"a\", 'm')"]).toEqual(result.a); + view.delete(); + table.delete(); + }); + + test("Bucket (m), date with nulls", async function () { + const table = await perspective.table({ + a: "date", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 'm')": "bucket(\"a\", 'm')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 15), + null, + undefined, + new Date(2020, 3, 29), + new Date(2020, 4, 30), + new Date(2020, 5, 31), + new Date(2020, 6, 1), + ], + }); + + let result = await view.to_columns(); + expect(result["bucket(\"a\", 'm')"]).toEqual( + result.a.map((x) => (x ? x : null)), + ); + view.delete(); + table.delete(); + }); + + test("Bucket (h), date", async function () { + const table = await perspective.table({ + a: "date", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 'h')": "bucket(\"a\", 'h')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 15), + new Date(2020, 1, 27), + new Date(2020, 2, 28), + new Date(2020, 3, 29), + new Date(2020, 4, 30), + new Date(2020, 5, 31), + new Date(2020, 6, 1), + ], + }); + + let result = await view.to_columns(); + expect(result["bucket(\"a\", 'h')"]).toEqual(result.a); + view.delete(); + table.delete(); + }); + + test("Bucket (h), date with nulls", async function () { + const table = await perspective.table({ + a: "date", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 'h')": "bucket(\"a\", 'h')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 15), + null, + undefined, + new Date(2020, 3, 29), + new Date(2020, 4, 30), + new Date(2020, 5, 31), + new Date(2020, 6, 1), + ], + }); + + let result = await view.to_columns(); + expect(result["bucket(\"a\", 'h')"]).toEqual( + result.a.map((x) => (x ? x : null)), + ); + view.delete(); + table.delete(); + }); + + test("Bucket (D), date", async function () { + const table = await perspective.table({ + a: "date", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 'D')": "bucket(\"a\", 'D')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 15), + new Date(2020, 1, 27), + new Date(2020, 2, 28), + new Date(2020, 3, 29), + new Date(2020, 4, 30), + new Date(2020, 5, 31), + new Date(2020, 6, 1), + ], + }); + + let result = await view.to_columns(); + expect(result["bucket(\"a\", 'D')"]).toEqual(result.a); + view.delete(); + table.delete(); + }); + + test("Bucket (D), date with null", async function () { + const table = await perspective.table({ + a: "date", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 'D')": "bucket(\"a\", 'D')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 15), + null, + undefined, + new Date(2020, 3, 29), + new Date(2020, 4, 30), + new Date(2020, 5, 31), + new Date(2020, 6, 1), + ], + }); + + let result = await view.to_columns(); + expect(result["bucket(\"a\", 'D')"]).toEqual( + result.a.map((x) => (x ? x : null)), + ); + view.delete(); + table.delete(); + }); + + test("Bucket (W), date", async function () { + const table = await perspective.table({ + a: "date", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 'W')": "bucket(\"a\", 'W')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 12), + new Date(2020, 0, 15), + new Date(2020, 0, 17), + new Date(2020, 0, 18), + new Date(2020, 0, 29), + ], + }); + + let result = await view.to_columns(); + + expect( + result["bucket(\"a\", 'W')"].map((x) => + x ? new Date(x) : null, + ), + ).toEqual(result.a.map((x) => common.week_bucket(x))); + view.delete(); + table.delete(); + }); + + test("Bucket (W), date with null", async function () { + const table = await perspective.table({ + a: "date", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 'W')": "bucket(\"a\", 'W')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 12), + new Date(2020, 0, 15), + new Date(2020, 0, 17), + new Date(2020, 0, 18), + new Date(2020, 0, 29), + ], + }); + + let result = await view.to_columns(); + + expect( + result["bucket(\"a\", 'W')"].map((x) => + x ? new Date(x) : null, + ), + ).toEqual(result.a.map((x) => (x ? common.week_bucket(x) : null))); + view.delete(); + table.delete(); + }); + + test("Bucket (W), date shouldn't ever overflow at beginning of year", async function () { + const table = await perspective.table({ + a: "date", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 'W')": "bucket(\"a\", 'W')" }, + }); + + await table.update({ + a: [new Date(2015, 0, 3, 15), new Date(2015, 0, 4)], + }); + + let result = await view.to_columns(); + + expect( + result["bucket(\"a\", 'W')"].map((x) => new Date(x)), + ).toEqual(result.a.map((x) => common.week_bucket(x))); + view.delete(); + table.delete(); + }); + + test("Bucket (M), date", async function () { + const table = await perspective.table({ + a: "date", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 'M')": "bucket(\"a\", 'M')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 12), + new Date(2020, 0, 15), + new Date(2020, 1, 17), + new Date(2020, 2, 18), + new Date(2020, 2, 29), + ], + }); + + let result = await view.to_columns(); + + expect( + result["bucket(\"a\", 'M')"].map((x) => + x ? new Date(x) : null, + ), + ).toEqual(result.a.map((x) => common.month_bucket(x))); + view.delete(); + table.delete(); + }); + + test("Bucket (M), date with null", async function () { + const table = await perspective.table({ + a: "date", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 'M')": "bucket(\"a\", 'M')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 12), + null, + undefined, + new Date(2020, 2, 18), + new Date(2020, 2, 29), + ], + }); + + let result = await view.to_columns(); + + expect( + result["bucket(\"a\", 'M')"].map((x) => + x ? new Date(x) : null, + ), + ).toEqual(result.a.map((x) => (x ? common.month_bucket(x) : null))); + view.delete(); + table.delete(); + }); + + test("Bucket (M), date with multiplicity", async () => { + const table = await perspective.table({ + a: "date", + }); + + const col_name = "bucket(\"a\", '3M')"; + + const view = await table.view({ + expressions: { [col_name]: col_name }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 12), + new Date(2020, 1, 15), + new Date(2020, 2, 17), + new Date(2020, 3, 18), + new Date(2020, 4, 29), + new Date(2020, 5, 6), + new Date(2020, 6, 10), + new Date(2020, 7, 30), + new Date(2020, 8, 22), + new Date(2020, 9, 7), + new Date(2020, 10, 1), + ], + }); + + let result = await view.to_columns(); + + expect( + result[col_name].map((x) => (x ? new Date(x) : null)), + ).toEqual(result.a.map((x) => common.month_bucket(x, 3))); + view.delete(); + table.delete(); + }); + + test("Bucket (Y), date", async function () { + const table = await perspective.table({ + a: "date", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 'Y')": "bucket(\"a\", 'Y')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 12), + new Date(2020, 0, 15), + new Date(2021, 1, 17), + new Date(2019, 2, 18), + new Date(2019, 2, 29), + ], + }); + + let result = await view.to_columns(); + + expect( + result["bucket(\"a\", 'Y')"].map((x) => + x ? new Date(x) : null, + ), + ).toEqual(result.a.map((x) => common.year_bucket(x))); + view.delete(); + table.delete(); + }); + + test("Bucket (Y), date with null", async function () { + const table = await perspective.table({ + a: "date", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 'Y')": "bucket(\"a\", 'Y')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 12), + null, + undefined, + new Date(2019, 2, 18), + new Date(2019, 2, 29), + ], + }); + + let result = await view.to_columns(); + + expect( + result["bucket(\"a\", 'Y')"].map((x) => + x ? new Date(x) : null, + ), + ).toEqual(result.a.map((x) => (x ? common.year_bucket(x) : null))); + view.delete(); + table.delete(); + }); + + test("Bucket (Y), date with multiplicity", async () => { + const table = await perspective.table({ + a: "date", + }); + + const col_name = "bucket(\"a\", '7Y')"; + + const view = await table.view({ + expressions: { [col_name]: col_name }, + }); + + await table.update({ + a: [ + new Date(2010, 0, 12), + new Date(2011, 1, 15), + new Date(2012, 2, 17), + new Date(2013, 3, 18), + new Date(2014, 4, 29), + new Date(2015, 5, 6), + new Date(2016, 6, 10), + new Date(2017, 7, 30), + new Date(2018, 8, 22), + new Date(2019, 9, 7), + new Date(2020, 10, 1), + ], + }); + + let result = await view.to_columns(); + + expect( + result[col_name].map((x) => (x ? new Date(x) : null)), + ).toEqual(result.a.map((x) => common.year_bucket(x, 7))); + view.delete(); + table.delete(); + }); + }); + + test.describe("Datetime, Arity 1 computed", function () { + test("Hour of day, datetime", async function () { + const table = await perspective.table({ + a: "datetime", + }); + + const view = await table.view({ + expressions: { 'hour_of_day("a")': 'hour_of_day("a")' }, + }); + + await table.update({ + a: [new Date(), new Date(), new Date()], + }); + + let result = await view.to_columns(); + expect(result[`hour_of_day("a")`]).toEqual( + result.a.map((x) => new Date(x).getUTCHours()), + ); + view.delete(); + table.delete(); + }); + + test("Hour of day, datetime with null", async function () { + const table = await perspective.table({ + a: "datetime", + }); + + const view = await table.view({ + expressions: { 'hour_of_day("a")': 'hour_of_day("a")' }, + }); + + await table.update({ + a: [new Date(), null, undefined, new Date()], + }); + + let result = await view.to_columns(); + expect(result[`hour_of_day("a")`]).toEqual( + result.a.map((x) => (x ? new Date(x).getUTCHours() : null)), + ); + view.delete(); + table.delete(); + }); + + test("Day of week, datetime", async function () { + const table = await perspective.table({ + a: "datetime", + }); + + const view = await table.view({ + expressions: { 'day_of_week("a")': 'day_of_week("a")' }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 26, 1), + new Date(2020, 0, 27, 2), + new Date(2020, 0, 28, 3), + new Date(2020, 0, 29, 4), + new Date(2020, 0, 30, 5), + ], + }); + + let result = await view.to_columns(); + expect(result[`day_of_week("a")`]).toEqual( + result.a.map( + (x) => common.days_of_week[new Date(x).getUTCDay()], + ), + ); + view.delete(); + table.delete(); + }); + + test("Day of week, datetime with null", async function () { + const table = await perspective.table({ + a: "datetime", + }); + + const view = await table.view({ + expressions: { 'day_of_week("a")': 'day_of_week("a")' }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 26, 1), + null, + undefined, + new Date(2020, 0, 29, 4), + new Date(2020, 0, 30, 5), + ], + }); + + let result = await view.to_columns(); + expect(result[`day_of_week("a")`]).toEqual( + result.a.map((x) => + x ? common.days_of_week[new Date(x).getUTCDay()] : null, + ), + ); + view.delete(); + table.delete(); + }); + + test("Month of year, datetime", async function () { + const table = await perspective.table({ + a: "datetime", + }); + + const view = await table.view({ + expressions: { 'month_of_year("a")': 'month_of_year("a")' }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 15), + new Date(2020, 1, 27), + new Date(2020, 2, 28), + new Date(2020, 3, 29), + new Date(2020, 4, 30), + new Date(2020, 5, 31), + new Date(2020, 6, 1), + ], + }); + + let result = await view.to_columns(); + expect(result[`month_of_year("a")`]).toEqual( + result.a.map( + (x) => common.months_of_year[new Date(x).getUTCMonth()], + ), + ); + view.delete(); + table.delete(); + }); + + test("Month of year, datetime with missing values", async function () { + const table = await perspective.table({ + a: "datetime", + }); + + const view = await table.view({ + expressions: { 'month_of_year("a")': 'month_of_year("a")' }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 15), + null, + undefined, + new Date(2020, 3, 29), + new Date(2020, 4, 30), + new Date(2020, 5, 31), + new Date(2020, 6, 1), + ], + }); + + let result = await view.to_columns(); + expect(result[`month_of_year("a")`]).toEqual( + result.a.map((x) => + x ? common.months_of_year[new Date(x).getUTCMonth()] : null, + ), + ); + view.delete(); + table.delete(); + }); + + test("Bucket (s), datetime", async function () { + const table = await perspective.table({ + a: "datetime", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 's')": "bucket(\"a\", 's')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 15, 1, 30, 15), + new Date(2020, 1, 27, 1, 30, 30), + new Date(2020, 2, 28, 1, 30, 45), + new Date(2020, 3, 29, 1, 30, 0), + new Date(2020, 4, 30, 1, 30, 15), + ], + }); + + let result = await view.to_columns(); + expect( + result[`bucket("a", 's')`].map((x) => (x ? new Date(x) : null)), + ).toEqual(result.a.map((x) => common.second_bucket(x))); + view.delete(); + table.delete(); + }); + + test("Bucket (s), datetime with null", async function () { + const table = await perspective.table({ + a: "datetime", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 's')": "bucket(\"a\", 's')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 15, 1, 30, 15), + null, + undefined, + new Date(2020, 3, 29, 1, 30, 0), + new Date(2020, 4, 30, 1, 30, 15), + ], + }); + + let result = await view.to_columns(); + expect( + result[`bucket("a", 's')`].map((x) => (x ? new Date(x) : null)), + ).toEqual( + result.a.map((x) => (x ? common.second_bucket(x) : null)), + ); + view.delete(); + table.delete(); + }); + + test("Bucket (s), datetime with multiplicity", async function () { + const table = await perspective.table({ + a: "datetime", + }); + + const col_name = `bucket("a", '20s')`; + + const view = await table.view({ + expressions: { [col_name]: col_name }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 15, 1, 30, 5), + new Date(2020, 3, 29, 1, 30, 10), + new Date(2020, 4, 30, 1, 30, 19), + new Date(2020, 4, 30, 1, 30, 30), + new Date(2020, 4, 30, 1, 30, 50), + ], + }); + + let result = await view.to_columns(); + expect( + result[col_name].map((x) => (x ? new Date(x) : null)), + ).toEqual( + result.a.map((x) => (x ? common.second_bucket(x, 20) : null)), + ); + view.delete(); + table.delete(); + }); + + test("Bucket (m), datetime", async function () { + const table = await perspective.table({ + a: "datetime", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 'm')": "bucket(\"a\", 'm')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 15, 1, 30, 15), + new Date(2020, 1, 27, 1, 30, 30), + new Date(2020, 2, 28, 1, 30, 45), + new Date(2020, 3, 29, 1, 30, 0), + new Date(2020, 4, 30, 1, 30, 15), + ], + }); + + let result = await view.to_columns(); + expect( + result[`bucket("a", 'm')`].map((x) => (x ? new Date(x) : null)), + ).toEqual(result.a.map((x) => common.minute_bucket(x))); + view.delete(); + table.delete(); + }); + + test("Bucket (m), datetime with null", async function () { + const table = await perspective.table({ + a: "datetime", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 'm')": "bucket(\"a\", 'm')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 15, 1, 30, 15), + null, + undefined, + new Date(2020, 3, 29, 1, 30, 0), + new Date(2020, 4, 30, 1, 30, 15), + ], + }); + + let result = await view.to_columns(); + expect( + result[`bucket("a", 'm')`].map((x) => (x ? new Date(x) : null)), + ).toEqual( + result.a.map((x) => (x ? common.minute_bucket(x) : null)), + ); + view.delete(); + table.delete(); + }); + + test("Bucket (m), datetime with multiplicity", async function () { + const table = await perspective.table({ + a: "datetime", + }); + + const col_name = `bucket("a", '15m')`; + + const view = await table.view({ + expressions: { [col_name]: col_name }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 15, 1, 0, 0), + new Date(2020, 0, 15, 1, 6, 0), + new Date(2020, 0, 15, 1, 15, 0), + new Date(2020, 0, 15, 1, 29, 0), + new Date(2020, 0, 15, 1, 30, 0), + new Date(2020, 0, 15, 1, 59, 0), + ], + }); + + let result = await view.to_columns(); + expect( + result[col_name].map((x) => (x ? new Date(x) : null)), + ).toEqual(result.a.map((x) => common.minute_bucket(x, 15))); + view.delete(); + table.delete(); + }); + + test("Bucket (h), datetime", async function () { + const table = await perspective.table({ + a: "datetime", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 'h')": "bucket(\"a\", 'h')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 15, 1, 30, 15), + new Date(2020, 1, 27, 1, 30, 30), + new Date(2020, 2, 28, 1, 30, 45), + new Date(2020, 3, 29, 1, 30, 0), + new Date(2020, 4, 30, 1, 30, 15), + ], + }); + + let result = await view.to_columns(); + expect( + result[`bucket("a", 'h')`].map((x) => (x ? new Date(x) : null)), + ).toEqual(result.a.map((x) => common.hour_bucket(x))); + view.delete(); + table.delete(); + }); + + test("Bucket (h), datetime with null", async function () { + const table = await perspective.table({ + a: "datetime", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 'h')": "bucket(\"a\", 'h')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 15, 1, 30, 15), + null, + undefined, + new Date(2020, 3, 29, 1, 30, 0), + new Date(2020, 4, 30, 1, 30, 15), + ], + }); + + let result = await view.to_columns(); + expect( + result[`bucket("a", 'h')`].map((x) => (x ? new Date(x) : null)), + ).toEqual(result.a.map((x) => (x ? common.hour_bucket(x) : null))); + view.delete(); + table.delete(); + }); + + test("Bucket (h), datetime with multiplicity", async function () { + const table = await perspective.table({ + a: "datetime", + }); + + const col_name = `bucket("a", '6h')`; + + const view = await table.view({ + expressions: { [col_name]: col_name }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 15, 0, 30, 15), + new Date(2020, 0, 15, 5, 30, 15), + new Date(2020, 0, 15, 6, 30, 15), + new Date(2020, 0, 15, 9, 30, 15), + new Date(2020, 0, 15, 15, 30, 15), + new Date(2020, 0, 15, 20, 30, 15), + new Date(2020, 0, 15, 23, 30, 15), + ], + }); + + let result = await view.to_columns(); + expect( + result[col_name].map((x) => (x ? new Date(x) : null)), + ).toEqual(result.a.map((x) => common.hour_bucket(x, 6))); + view.delete(); + table.delete(); + }); + + test("Bucket (D), datetime", async function () { + const table = await perspective.table({ + a: "datetime", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 'D')": "bucket(\"a\", 'D')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 15, 1, 30, 15), + new Date(2020, 1, 27, 1, 30, 30), + new Date(2020, 2, 28, 1, 30, 45), + new Date(2020, 3, 29, 1, 30, 0), + new Date(2020, 4, 30, 1, 30, 15), + ], + }); + + let result = await view.to_columns(); + expect( + result[`bucket("a", 'D')`].map((x) => (x ? new Date(x) : null)), + ).toEqual(result.a.map((x) => common.day_bucket(x))); + view.delete(); + table.delete(); + }); + + test("Bucket (D), datetime with null", async function () { + const table = await perspective.table({ + a: "datetime", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 'D')": "bucket(\"a\", 'D')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 15, 1, 30, 15), + null, + undefined, + new Date(2020, 3, 29, 1, 30, 0), + new Date(2020, 4, 30, 1, 30, 15), + ], + }); + + let result = await view.to_columns(); + expect( + result[`bucket("a", 'D')`].map((x) => (x ? new Date(x) : null)), + ).toEqual(result.a.map((x) => (x ? common.day_bucket(x) : null))); + view.delete(); + table.delete(); + }); + + test("Bucket (D), datetime at UTC edge", async function () { + const table = await perspective.table({ + a: "datetime", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 'D')": "bucket(\"a\", 'D')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 15, 23, 30, 15), + null, + undefined, + new Date(2020, 3, 29, 23, 30, 0), + new Date(2020, 4, 30, 23, 30, 15), + ], + }); + + let result = await view.to_columns(); + expect( + result[`bucket("a", 'D')`].map((x) => (x ? new Date(x) : null)), + ).toEqual(result.a.map((x) => (x ? common.day_bucket(x) : null))); + view.delete(); + table.delete(); + }); + + test("Bucket (W), datetime", async function () { + const table = await perspective.table({ + a: "datetime", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 'W')": "bucket(\"a\", 'W')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 12), + new Date(2020, 0, 15), + new Date(2020, 0, 17), + new Date(2020, 0, 18), + new Date(2020, 0, 29), + ], + }); + + let result = await view.to_columns(); + + expect( + result[`bucket("a", 'W')`].map((x) => (x ? new Date(x) : null)), + ).toEqual(result.a.map((x) => common.week_bucket(x))); + view.delete(); + table.delete(); + }); + + test("Bucket (W), datetime with null", async function () { + const table = await perspective.table({ + a: "datetime", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 'W')": "bucket(\"a\", 'W')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 12), + null, + undefined, + new Date(2020, 0, 18), + new Date(2020, 0, 29), + ], + }); + + let result = await view.to_columns(); + + expect( + result[`bucket("a", 'W')`].map((x) => (x ? new Date(x) : null)), + ).toEqual(result.a.map((x) => (x ? common.week_bucket(x) : null))); + view.delete(); + table.delete(); + }); + + test("Bucket (W), datetime shouldn't ever overflow at beginning of year", async function () { + const table = await perspective.table({ + a: "datetime", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 'W')": "bucket(\"a\", 'W')" }, + }); + + await table.update({ + a: [new Date(2015, 0, 3, 15), new Date(2015, 0, 4)], + }); + + let result = await view.to_columns(); + + expect(result[`bucket("a", 'W')`].map((x) => new Date(x))).toEqual( + result.a.map((x) => common.week_bucket(x)), + ); + view.delete(); + table.delete(); + }); + + test("Bucket (M), datetime", async function () { + const table = await perspective.table({ + a: "datetime", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 'M')": "bucket(\"a\", 'M')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 12), + new Date(2020, 0, 15), + new Date(2020, 1, 17), + new Date(2020, 2, 18), + new Date(2020, 2, 29), + ], + }); + + let result = await view.to_columns(); + + expect( + result[`bucket("a", 'M')`].map((x) => (x ? new Date(x) : null)), + ).toEqual(result.a.map((x) => common.month_bucket(x))); + view.delete(); + table.delete(); + }); + + test("Bucket (M), datetime with nulls", async function () { + const table = await perspective.table({ + a: "datetime", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 'M')": "bucket(\"a\", 'M')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 12), + null, + undefined, + new Date(2020, 2, 18), + new Date(2020, 2, 29), + ], + }); + + let result = await view.to_columns(); + + expect( + result[`bucket("a", 'M')`].map((x) => (x ? new Date(x) : null)), + ).toEqual(result.a.map((x) => (x ? common.month_bucket(x) : null))); + view.delete(); + table.delete(); + }); + + test("Bucket (Y), datetime", async function () { + const table = await perspective.table({ + a: "datetime", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 'Y')": "bucket(\"a\", 'Y')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 12), + new Date(2020, 0, 15), + new Date(2021, 11, 17), + new Date(2019, 2, 18), + new Date(2019, 2, 29), + ], + }); + + let result = await view.to_columns(); + + expect( + result[`bucket("a", 'Y')`].map((x) => (x ? new Date(x) : null)), + ).toEqual(result.a.map((x) => common.year_bucket(x))); + view.delete(); + table.delete(); + }); + + test("Bucket (Y), datetime with nulls", async function () { + const table = await perspective.table({ + a: "datetime", + }); + + const view = await table.view({ + expressions: { "bucket(\"a\", 'Y')": "bucket(\"a\", 'Y')" }, + }); + + await table.update({ + a: [ + new Date(2020, 0, 12), + null, + undefined, + new Date(2019, 2, 18), + new Date(2019, 2, 29), + ], + }); + + let result = await view.to_columns(); + + expect( + result[`bucket("a", 'Y')`].map((x) => (x ? new Date(x) : null)), + ).toEqual(result.a.map((x) => (x ? common.year_bucket(x) : null))); + view.delete(); + table.delete(); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/expressions/deltas.spec.js b/rust/perspective-js/test/js/expressions/deltas.spec.js new file mode 100644 index 0000000..1340d33 --- /dev/null +++ b/rust/perspective-js/test/js/expressions/deltas.spec.js @@ -0,0 +1,591 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client"; + +const match_delta = async function (perspective, delta, expected) { + let table = await perspective.table(delta); + let view = await table.view(); + let json = await view.to_json(); + expect(json).toEqual(expected); + await view.delete(); + await table.delete(); +}; + +function it_old_behavior(name, capture) { + test(name, async function () { + let done; + let result = new Promise((x) => { + done = x; + }); + + await capture(done); + await result; + }); +} + +/** + * Tests the correctness of updates on Tables with expression columns created + * through `View` and deltas created through `on_update`. + */ +((perspective) => { + test.describe("Expression column update deltas", function () { + test.describe("0-sided expression column deltas", function () { + it_old_behavior( + "Returns appended rows for normal and expression columns", + async function (done) { + const table = await perspective.table({ + x: [1, 2, 3, 4], + y: ["A", "B", "C", "D"], + }); + + const view = await table.view({ + expressions: { + 'lower("y")': 'lower("y")', + '-"x"': '-"x"', + }, + }); + + view.on_update( + async function (updated) { + const expected = [ + { + x: 1, + y: "HELLO", + 'lower("y")': "hello", + '-"x"': -1, + }, + { + x: 3, + y: "WORLD", + 'lower("y")': "world", + '-"x"': -3, + }, + ]; + await match_delta( + perspective, + updated.delta, + expected, + ); + await view.delete(); + await table.delete(); + done(); + }, + { mode: "row" }, + ); + + await table.update({ x: [1, 3], y: ["HELLO", "WORLD"] }); + }, + ); + + it_old_behavior( + "Returns appended rows for normal and expression columns from schema", + async function (done) { + const table = await perspective.table({ + x: "integer", + y: "string", + }); + const view = await table.view({ + expressions: { 'upper("y")': 'upper("y")' }, + }); + + view.on_update( + async function (updated) { + const expected = [ + { x: 1, y: "a", 'upper("y")': "A" }, + { x: 2, y: "b", 'upper("y")': "B" }, + { x: 3, y: "c", 'upper("y")': "C" }, + { x: 4, y: "d", 'upper("y")': "D" }, + ]; + await match_delta( + perspective, + updated.delta, + expected, + ); + await view.delete(); + await table.delete(); + done(); + }, + { mode: "row" }, + ); + + await table.update({ + x: [1, 2, 3, 4], + y: ["a", "b", "c", "d"], + }); + }, + ); + + it_old_behavior( + "Returns partially updated rows for normal and expression columns", + async function (done) { + const table = await perspective.table( + { + x: [1, 2, 3, 4], + y: ["A", "B", "C", "D"], + }, + { index: "x" }, + ); + const view = await table.view({ + expressions: { 'lower("y")': 'lower("y")' }, + }); + + view.on_update( + async function (updated) { + const full = await view.to_columns(); + const expected = [ + { x: 1, y: "HELLO", 'lower("y")': "hello" }, + { x: 3, y: "WORLD", 'lower("y")': "world" }, + ]; + expect(full).toEqual({ + x: [1, 2, 3, 4], + y: ["HELLO", "B", "WORLD", "D"], + 'lower("y")': ["hello", "b", "world", "d"], + }); + await match_delta( + perspective, + updated.delta, + expected, + ); + await view.delete(); + await table.delete(); + done(); + }, + { mode: "row" }, + ); + + await table.update({ x: [1, 3], y: ["HELLO", "WORLD"] }); + }, + ); + + it_old_behavior( + "Returns appended rows with missing columns for normal and expression columns", + async function (done) { + const table = await perspective.table({ + x: [1, 2, 3, 4], + y: ["A", "B", "C", "D"], + }); + const view = await table.view({ + expressions: { 'lower("y")': 'lower("y")' }, + }); + + view.on_update( + async function (updated) { + const full = await view.to_columns(); + const expected = [ + { x: 1, y: null, 'lower("y")': null }, + { x: 3, y: null, 'lower("y")': null }, + ]; + expect(full).toEqual({ + x: [1, 2, 3, 4, 1, 3], + y: ["A", "B", "C", "D", null, null], + 'lower("y")': ["a", "b", "c", "d", null, null], + }); + await match_delta( + perspective, + updated.delta, + expected, + ); + await view.delete(); + await table.delete(); + done(); + }, + { mode: "row" }, + ); + + await table.update({ x: [1, 3] }); + }, + ); + }); + + test.describe("1-sided expression column deltas", function () { + it_old_behavior( + "Returns appended rows for normal and expression columns, 1-sided", + async function (done) { + const table = await perspective.table({ + x: [1, 2, 3, 4], + y: ["A", "B", "C", "D"], + }); + const view = await table.view({ + group_by: ['lower("y")'], + expressions: { 'lower("y")': 'lower("y")' }, + }); + + view.on_update( + async function (updated) { + const expected = [ + { x: 14, y: 6, 'lower("y")': 6 }, + { x: 1, y: 1, 'lower("y")': 1 }, + { x: 3, y: 1, 'lower("y")': 1 }, + ]; + await match_delta( + perspective, + updated.delta, + expected, + ); + await view.delete(); + await table.delete(); + done(); + }, + { mode: "row" }, + ); + + await table.update({ x: [1, 3], y: ["HELLO", "WORLD"] }); + }, + ); + + it_old_behavior( + "Returns appended rows for normal and expression columns, 1-sided hidden sorted", + async function (done) { + const table = await perspective.table({ + x: [1, 2, 3, 4], + y: ["A", "B", "C", "D"], + }); + const view = await table.view({ + group_by: ['lower("y")'], + expressions: { 'lower("y")': 'lower("y")' }, + sort: [['lower("y")', "desc"]], + columns: ["x"], + }); + + view.on_update( + async function (updated) { + const expected = [{ x: 14 }, { x: 3 }, { x: 1 }]; + await match_delta( + perspective, + updated.delta, + expected, + ); + await view.delete(); + await table.delete(); + done(); + }, + { mode: "row" }, + ); + + await table.update({ x: [1, 3], y: ["HELLO", "WORLD"] }); + }, + ); + }); + + test.describe("2-sided expression column deltas", function () { + it_old_behavior( + "Returns appended rows for normal and expression columns, 2-sided", + async function (done) { + const table = await perspective.table({ + x: [1, 2, 3, 4], + y: ["A", "B", "C", "D"], + }); + const view = await table.view({ + aggregates: { + 'lower("y")': "last", + }, + group_by: ['lower("y")'], + split_by: ["y"], + expressions: { 'lower("y")': 'lower("y")' }, + }); + + view.on_update( + async function (updated) { + const expected = [ + { + 'A|lower("y")': "a", + "A|x": 1, + "A|y": 1, + 'B|lower("y")': "b", + "B|x": 2, + "B|y": 1, + 'C|lower("y")': "c", + "C|x": 3, + "C|y": 1, + 'D|lower("y")': "d", + "D|x": 4, + "D|y": 1, + 'HELLO|lower("y")': "hello", + "HELLO|x": 1, + "HELLO|y": 1, + 'WORLD|lower("y")': "world", + "WORLD|x": 3, + "WORLD|y": 1, + }, + { + 'A|lower("y")': null, + "A|x": null, + "A|y": null, + 'B|lower("y")': null, + "B|x": null, + "B|y": null, + 'C|lower("y")': null, + "C|x": null, + "C|y": null, + 'D|lower("y")': null, + "D|x": null, + "D|y": null, + 'HELLO|lower("y")': "hello", + "HELLO|x": 1, + "HELLO|y": 1, + 'WORLD|lower("y")': null, + "WORLD|x": null, + "WORLD|y": null, + }, + { + 'A|lower("y")': null, + "A|x": null, + "A|y": null, + 'B|lower("y")': null, + "B|x": null, + "B|y": null, + 'C|lower("y")': null, + "C|x": null, + "C|y": null, + 'D|lower("y")': null, + "D|x": null, + "D|y": null, + 'HELLO|lower("y")': null, + "HELLO|x": null, + "HELLO|y": null, + 'WORLD|lower("y")': "world", + "WORLD|x": 3, + "WORLD|y": 1, + }, + ]; + await match_delta( + perspective, + updated.delta, + expected, + ); + await view.delete(); + await table.delete(); + done(); + }, + { mode: "row" }, + ); + + await table.update({ x: [1, 3], y: ["HELLO", "WORLD"] }); + }, + ); + }); + + test.describe("0-sided expression column deltas with multiple views", function () { + it_old_behavior( + "`on_update` on a view with expression column should contain expression delta when columns are appended", + async function (done) { + const table = await perspective.table({ + x: [1, 2, 3, 4], + y: ["A", "B", "C", "D"], + }); + const view = await table.view({ + expressions: { 'lower("y")': 'lower("y")' }, + }); + + const pre_update = await view.to_columns(); + expect(pre_update).toEqual({ + x: [1, 2, 3, 4], + y: ["A", "B", "C", "D"], + 'lower("y")': ["a", "b", "c", "d"], + }); + + view.on_update( + async function (updated) { + const expected = [ + { x: 1, y: null, 'lower("y")': null }, + { x: 3, y: null, 'lower("y")': null }, + ]; + const full = await view.to_columns(); + expect(full).toEqual({ + x: [1, 2, 3, 4, 1, 3], + y: ["A", "B", "C", "D", null, null], + 'lower("y")': ["a", "b", "c", "d", null, null], + }); + await match_delta( + perspective, + updated.delta, + expected, + ); + await view.delete(); + await table.delete(); + done(); + }, + { mode: "row" }, + ); + + await table.update({ x: [1, 3] }); + }, + ); + + it_old_behavior( + "`on_update` on a view with expression column should contain expression delta when columns are updated", + async function (done) { + const table = await perspective.table( + { + x: [1, 2, 3, 4], + y: ["A", "B", "C", "D"], + }, + { index: "x" }, + ); + const view = await table.view({ + expressions: { 'lower("y")': 'lower("y")' }, + }); + + const pre_update = await view.to_columns(); + + expect(pre_update).toEqual({ + x: [1, 2, 3, 4], + y: ["A", "B", "C", "D"], + 'lower("y")': ["a", "b", "c", "d"], + }); + + view.on_update( + async function (updated) { + const expected = [ + { x: 1, y: "ABCD", 'lower("y")': "abcd" }, + { x: 3, y: null, 'lower("y")': null }, + ]; + const full = await view.to_columns(); + expect(full).toEqual({ + x: [1, 2, 3, 4], + y: ["ABCD", "B", null, "D"], + 'lower("y")': ["abcd", "b", null, "d"], + }); + await match_delta( + perspective, + updated.delta, + expected, + ); + await view.delete(); + await table.delete(); + done(); + }, + { mode: "row" }, + ); + + await table.update({ x: [1, 3], y: ["ABCD", null] }); + }, + ); + + it_old_behavior( + "`on_update` on different views with different expression columns should only be notified of their columns", + async function (done) { + const table = await perspective.table( + { + x: [1, 2, 3, 4], + y: ["A", "B", "C", "D"], + }, + { + index: "x", + }, + ); + + const view = await table.view({ + expressions: { 'lower("y")': 'lower("y")' }, + }); + + const view2 = await table.view({ + expressions: { '-"x"': '-"x"' }, + }); + + view.on_update( + async function (updated) { + const expected = [ + { x: 1, y: "HELLO", 'lower("y")': "hello" }, + { x: 3, y: "WORLD", 'lower("y")': "world" }, + ]; + const full = await view.to_columns(); + expect(full).toEqual({ + x: [1, 2, 3, 4], + y: ["HELLO", "B", "WORLD", "D"], + 'lower("y")': ["hello", "b", "world", "d"], + }); + await match_delta( + perspective, + updated.delta, + expected, + ); + }, + { mode: "row" }, + ); + + view2.on_update( + async function (updated) { + const expected = [ + { x: 1, y: "HELLO", '-"x"': -1 }, + { x: 3, y: "WORLD", '-"x"': -3 }, + ]; + const full = await view2.to_columns(); + expect(full).toEqual({ + x: [1, 2, 3, 4], + y: ["HELLO", "B", "WORLD", "D"], + '-"x"': [-1, -2, -3, -4], + }); + await match_delta( + perspective, + updated.delta, + expected, + ); + + await view.delete(); + await view2.delete(); + await table.delete(); + done(); + }, + { mode: "row" }, + ); + + await table.update({ x: [1, 3], y: ["HELLO", "WORLD"] }); + }, + ); + + it_old_behavior( + "`on_update` on view without expression column should not be notified of expression column", + async function (done) { + const table = await perspective.table({ + x: [1, 2, 3, 4], + y: ["A", "B", "C", "D"], + }); + const view = await table.view(); + + const view2 = await table.view({ + expressions: { 'lower("y")': 'lower("y")' }, + }); + + expect(await view2.to_columns()).toEqual({ + x: [1, 2, 3, 4], + y: ["A", "B", "C", "D"], + 'lower("y")': ["a", "b", "c", "d"], + }); + + view.on_update( + async function (updated) { + const expected = [ + { x: 1, y: "abc" }, + { x: 3, y: "def" }, + ]; + await match_delta( + perspective, + updated.delta, + expected, + ); + await view2.delete(); + await view.delete(); + await table.delete(); + done(); + }, + { mode: "row" }, + ); + + await table.update({ x: [1, 3], y: ["abc", "def"] }); + }, + ); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/expressions/functionality.spec.js b/rust/perspective-js/test/js/expressions/functionality.spec.js new file mode 100644 index 0000000..685c7f8 --- /dev/null +++ b/rust/perspective-js/test/js/expressions/functionality.spec.js @@ -0,0 +1,3374 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 expressions_common from "./common.js"; + +import { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client"; + +/** + * Tests the functionality of `View`-based expressions, specifically that + * existing column/view semantics (pivots, aggregates, columns, sorts, filters) + * continue to be functional on expressions. + */ +((perspective) => { + test.describe("Expression structures", function () { + test.describe("if statements", function () { + test("if without else should be invalid", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const validate = await table.validate_expressions([ + 'if ("w" > 1) 5;', + 'if ("w" > 1) 5', + ]); + + expect( + validate.expression_schema['if ("w" > 1) 5;'], + ).toBeUndefined(); + expect( + validate.expression_schema['if ("w" > 1) 5'], + ).toBeUndefined(); + + expect(validate.errors).toEqual({ + 'if ("w" > 1) 5': { + column: 0, + error_message: + "Type Error - inputs do not resolve to a valid expression.", + line: 0, + }, + 'if ("w" > 1) 5;': { + column: 0, + error_message: + "Type Error - inputs do not resolve to a valid expression.", + line: 0, + }, + }); + + table.delete(); + }); + + test.skip("if else with multiple return types should be invalid", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: { + "if (\"w\" == 1.5) 5; else 'abc'": + "if (\"w\" == 1.5) 5; else 'abc'", + }, + }); + const results = await view.to_columns(); + + // TODO: we should prevent this polymorphic return from being + // allowed, but the machinery around how if/else returns is + // opaque to me at the moment. + expect(results["if (\"w\" == 1.5) 5; else 'abc'"]).toEqual([ + "", + "abc", + "abc", + "abc", + ]); + view.delete(); + table.delete(); + }); + + test("functional if", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: [ + 'if ("w" > 2, 5, 10);', + 'if ("w" == 1.5, 5, 10)', + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results = await view.to_columns(); + expect(results['if ("w" > 2, 5, 10);']).toEqual([10, 5, 5, 5]); + expect(results['if ("w" == 1.5, 5, 10)']).toEqual([ + 5, 10, 10, 10, + ]); + view.delete(); + table.delete(); + }); + + test("functional if string", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: [ + "if (\"y\" == 'a', 5, 10);", + "if (\"y\" != 'a', 5, 10)", + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results = await view.to_columns(); + expect(results["if (\"y\" == 'a', 5, 10);"]).toEqual([ + 5, 10, 10, 10, + ]); + expect(results["if (\"y\" != 'a', 5, 10)"]).toEqual([ + 10, 5, 5, 5, + ]); + view.delete(); + table.delete(); + }); + + test.skip("functional if bool", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: [ + 'if ("z" == true, 5, 10);', + 'if ("z" != true, 5, 10)', + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results = await view.to_columns(); + expect(results['if ("z" == 1, 5, 10);']).toEqual([ + 5, 10, 5, 10, + ]); + expect(results['if ("z" != true, 5, 10)']).toEqual([ + 10, 5, 10, 5, + ]); + view.delete(); + table.delete(); + }); + + test("functional if date", async function () { + const table = await perspective.table({ + a: "date", + b: "date", + }); + + const view = await table.view({ + expressions: [ + 'if ("a" == "b", 5, 10);', + 'if ("a" < "b", 5, 10)', + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + await table.update({ + a: [ + new Date(2020, 3, 1), + new Date(2012, 0, 30), + new Date(2018, 9, 31), + new Date(2020, 1, 29), + ], + b: [ + new Date(2020, 3, 1), + new Date(2012, 1, 30), + new Date(2018, 10, 31), + new Date(2020, 1, 29), + ], + }); + + const results = await view.to_columns(); + expect(results['if ("a" == "b", 5, 10);']).toEqual([ + 5, 10, 10, 5, + ]); + expect(results['if ("a" < "b", 5, 10)']).toEqual([ + 10, 5, 5, 10, + ]); + + view.delete(); + table.delete(); + }); + + test("functional if datetime", async function () { + const table = await perspective.table({ + a: "datetime", + b: "datetime", + }); + + const view = await table.view({ + expressions: [ + 'if ("a" >= "b", 5, 10);', + 'if ("a" < "b", 5, 10)', + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + await table.update({ + a: [ + new Date(2020, 3, 1, 12, 30, 45, 850), + new Date(2012, 0, 30, 19, 25), + new Date(2018, 9, 31, 3, 13), + new Date(2020, 1, 29, 23, 59, 59), + ], + b: [ + new Date(2020, 3, 1, 12, 30, 45, 850), + new Date(2012, 1, 30), + new Date(2018, 10, 31), + new Date(2020, 1, 29), + ], + }); + + const results = await view.to_columns(); + expect(results['if ("a" >= "b", 5, 10);']).toEqual([ + 5, 10, 10, 5, + ]); + expect(results['if ("a" < "b", 5, 10)']).toEqual([ + 10, 5, 5, 10, + ]); + view.delete(); + table.delete(); + }); + + test("ternary if", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: { + '"w" > 2 ? 5 : 10;': '"w" > 2 ? 5 : 10;', + '"w" == 1.5 ? 5 : 10': '"w" == 1.5 ? 5 : 10', + }, + }); + + const results = await view.to_columns(); + expect(results['"w" > 2 ? 5 : 10;']).toEqual([10, 5, 5, 5]); + expect(results['"w" == 1.5 ? 5 : 10']).toEqual([5, 10, 10, 10]); + view.delete(); + table.delete(); + }); + + test("ternary if string", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: [ + "\"y\" == 'a' ? 5 : 10;", + "\"y\" != 'a' ? 5 : 10", + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results = await view.to_columns(); + expect(results["\"y\" == 'a' ? 5 : 10;"]).toEqual([ + 5, 10, 10, 10, + ]); + expect(results["\"y\" != 'a' ? 5 : 10"]).toEqual([10, 5, 5, 5]); + view.delete(); + table.delete(); + }); + + test.skip("ternary if bool", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: [ + '"z" == true ? 5 : 10;', + '"z" != true ? 5 : 10', + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results = await view.to_columns(); + expect(results['"z" == 1 ? 5 : 10;']).toEqual([5, 10, 5, 10]); + expect(results['"z" != true ? 5 : 10']).toEqual([10, 5, 10, 5]); + view.delete(); + table.delete(); + }); + + test("ternary if date", async function () { + const table = await perspective.table({ + a: "date", + b: "date", + }); + + const view = await table.view({ + expressions: { + '"a" == "b" ? 5 : 10;': '"a" == "b" ? 5 : 10;', + '"a" < "b" ? 5 : 10': '"a" < "b" ? 5 : 10', + }, + }); + + await table.update({ + a: [ + new Date(2020, 3, 1), + new Date(2012, 0, 30), + new Date(2018, 9, 31), + new Date(2020, 1, 29), + ], + b: [ + new Date(2020, 3, 1), + new Date(2012, 1, 30), + new Date(2018, 10, 31), + new Date(2020, 1, 29), + ], + }); + + const results = await view.to_columns(); + expect(results['"a" == "b" ? 5 : 10;']).toEqual([5, 10, 10, 5]); + expect(results['"a" < "b" ? 5 : 10']).toEqual([10, 5, 5, 10]); + + view.delete(); + table.delete(); + }); + + test("ternary if datetime", async function () { + const table = await perspective.table({ + a: "datetime", + b: "datetime", + }); + + const view = await table.view({ + expressions: { + '"a" >= "b" ? 5 : 10;': '"a" >= "b" ? 5 : 10;', + '"a" < "b" ? 5 : 10': '"a" < "b" ? 5 : 10', + }, + }); + + await table.update({ + a: [ + new Date(2020, 3, 1, 12, 30, 45, 850), + new Date(2012, 0, 30, 19, 25), + new Date(2018, 9, 31, 3, 13), + new Date(2020, 1, 29, 23, 59, 59), + ], + b: [ + new Date(2020, 3, 1, 12, 30, 45, 850), + new Date(2012, 1, 30), + new Date(2018, 10, 31), + new Date(2020, 1, 29), + ], + }); + + const results = await view.to_columns(); + expect(results['"a" >= "b" ? 5 : 10;']).toEqual([5, 10, 10, 5]); + expect(results['"a" < "b" ? 5 : 10']).toEqual([10, 5, 5, 10]); + view.delete(); + table.delete(); + }); + + test("if else", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: [ + 'if ("w" > 2) 5; else 10', + 'if ("w" > 2) 5; else 10;', + 'if ("w" > 2) { \n5; } else { \n10;}', + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results = await view.to_columns(); + expect(results['if ("w" > 2) 5; else 10']).toEqual([ + 10, 5, 5, 5, + ]); + expect(results['if ("w" > 2) 5; else 10;']).toEqual([ + 10, 5, 5, 5, + ]); + expect(results['if ("w" > 2) { \n5; } else { \n10;}']).toEqual([ + 10, 5, 5, 5, + ]); + view.delete(); + table.delete(); + }); + + test("if else if", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: [ + 'if ("w" == 3.5) 15; else if ("w" > 2) 5; else 10', + 'if ("w" == 3.5) 15; else if ("w" > 2) 5; else 10;', + 'if ("w" == 3.5) { \n15; } else if ("w" > 2) { \n5; } else { \n10;}', + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results = await view.to_columns(); + expect( + results['if ("w" == 3.5) 15; else if ("w" > 2) 5; else 10'], + ).toEqual([10, 5, 15, 5]); + expect( + results[ + 'if ("w" == 3.5) 15; else if ("w" > 2) 5; else 10;' + ], + ).toEqual([10, 5, 15, 5]); + expect( + results[ + 'if ("w" == 3.5) { \n15; } else if ("w" > 2) { \n5; } else { \n10;}' + ], + ).toEqual([10, 5, 15, 5]); + view.delete(); + table.delete(); + }); + }); + + test.describe("Switch", function () { + test("Switch on multiple columns", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: [ + `switch { case "w" > 2: sqrt(144); case "y" == 'a': sqrt(121); default: 0; }`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results = await view.to_columns(); + expect( + results[ + `switch { case "w" > 2: sqrt(144); case "y" == 'a': sqrt(121); default: 0; }` + ], + ).toEqual([11, 12, 12, 12]); + + await view.delete(); + await table.delete(); + }); + + test("Switch on multiple columns with resulting expression", async function () { + const table = await perspective.table({ + w: "float", + x: "float", + y: "string", + z: "boolean", + }); + await table.update(expressions_common.int_float_data); + const expr = `switch { case "w" > 2: (sqrt(144) * "w"); case "y" == 'a': (sqrt(121) * "w"); default: "x"; }`; + const view = await table.view({ + expressions: { [expr]: expr }, + }); + + const results = await view.to_columns(); + const schema = await view.schema(); + expect(schema[expr]).toEqual("float"); + expect(results[expr]).toEqual([16.5, 30, 42, 54]); + + await view.delete(); + await table.delete(); + }); + }); + + test.describe("While loop", function () { + test("Scalar while", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + + // 2 ^ 10 = 1024 + const view = await table.view({ + expressions: [ + `var x := 1; var y := 0; while (y < 10) { x := x * 2; y += 1; }; x`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results = await view.to_columns(); + expect( + results[ + `var x := 1; var y := 0; while (y < 10) { x := x * 2; y += 1; }; x` + ], + ).toEqual([1024, 1024, 1024, 1024]); + + await view.delete(); + await table.delete(); + }); + + test("Scalar while 2", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + + // 2 * 2 = 4, 4 * 4 = 16, 16 * 16 = 256 + const view = await table.view({ + expressions: [ + `var x := 2; var y := 0; while (y < 3) { x := x * x; y += 1; }; x`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results = await view.to_columns(); + expect( + results[ + `var x := 2; var y := 0; while (y < 3) { x := x * x; y += 1; }; x` + ], + ).toEqual([256, 256, 256, 256]); + + await view.delete(); + await table.delete(); + }); + + test("While without semicolon should not validate", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + + const schema = await table.validate_expressions({ + [`expr`]: `var x := 2; var y := 0; while (y < 3) { x := x * x; y += 1; } x`, + }); + + expect(schema.expression_schema["expr"]).toBeUndefined(); + expect(schema.errors["expr"]).toEqual({ + column: 62, + line: 0, + error_message: + "Invalid syntax 'x' possible missing operator or context", + }); + + await table.delete(); + }); + + test("Column while", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: [ + `var x := 0; var y := 0; while (y < 10) { x := "w" * 2; y += 1; }; x`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results = await view.to_columns(); + expect( + results[ + `var x := 0; var y := 0; while (y < 10) { x := "w" * 2; y += 1; }; x` + ], + ).toEqual([3, 5, 7, 9]); + + await view.delete(); + await table.delete(); + }); + + test.skip("Column while with break", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: [ + `var x := 0; var y := 0; while (y < 10) { if (y % 2 == 0) { break; }; x := "w" * 2; y += 1; }; x`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results = await view.to_columns(); + expect( + results[ + `var x := 0; var y := 0; while (y < 10) { if (y % 2 == 0) { break; }; x := "w" * 2; y += 1; }; x` + ], + ).toEqual([16.5, 30, 42, 54]); + + await view.delete(); + await table.delete(); + }); + }); + + test.describe("For loop", function () { + test("For loop", async () => { + const expr = `var x := 0; for (var i := 0; i < 3; i += 1) { x += 1; }; x`; + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: { expression: expr }, + }); + + const results = await view.to_columns(); + expect(results["expression"]).toEqual([3, 3, 3, 3]); + + await view.delete(); + await table.delete(); + }); + + test("Scalar for", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: [ + `for (var x := 0; x < 10; x += 1) { var y := x + 1; y}`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results = await view.to_columns(); + expect( + results[ + `for (var x := 0; x < 10; x += 1) { var y := x + 1; y}` + ], + ).toEqual([10, 10, 10, 10]); + + await view.delete(); + await table.delete(); + }); + + test("Column for", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: [ + `for (var x := 0; x < 10; x += 1) { var y := "w" + 1; y}`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results = await view.to_columns(); + expect( + results[ + `for (var x := 0; x < 10; x += 1) { var y := "w" + 1; y}` + ], + ).toEqual([2.5, 3.5, 4.5, 5.5]); + + await view.delete(); + await table.delete(); + }); + + test.skip("for loop only valid on numbers", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + // `for (var x := today(); x < 10; x += 1) { var y := "w" + 1; y}` + const expressions = [ + `for (var x := 'abc'; x < 10; x += 1) { var y := "w" + 1; y}`, + ]; + const validate = await table.validate_expressions(expressions); + + for (const expr of expressions) { + expect(validate.expression_schema[expr]).toBeUndefined(); + } + + await table.delete(); + }); + }); + + test.describe.skip("Repeat loop", function () { + test("Scalar repeat", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: [ + `var x := 1; var y := 1; repeat x := x + (y * 2) until (x > 10)`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results = await view.to_columns(); + expect( + results[ + `var x := 1; var y := 1; repeat x := x + (y * 2) until (x > 10)` + ], + ).toEqual([11, 11, 11, 11]); + + await view.delete(); + await table.delete(); + }); + + test("Column repeat", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: [ + `var x := 1; repeat x := x + ("x" * 2) until (x > 10)`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results = await view.to_columns(); + expect( + results[ + `var x := 1; repeat x := x + ("x" * 2) until (x > 10)` + ], + ).toEqual([16.5, 30, 42, 54]); + + await view.delete(); + await table.delete(); + }); + }); + }); + + test.describe("Local variables", function () { + test("Declare numeric", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: { 1: "1", [`100.0000001`]: "100.0000001" }, + }); + + const results = await view.to_columns(); + expect(results["1"]).toEqual([1, 1, 1, 1]); + expect(results["100.0000001"]).toEqual([ + 100.0000001, 100.0000001, 100.0000001, 100.0000001, + ]); + + await view.delete(); + await table.delete(); + }); + + test("Declare string", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: [ + "'hello'", + "'very long string that is very long and has many characters'", + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results = await view.to_columns(); + expect(results["'hello'"]).toEqual([ + "hello", + "hello", + "hello", + "hello", + ]); + expect( + results[ + "'very long string that is very long and has many characters'" + ], + ).toEqual([ + "very long string that is very long and has many characters", + "very long string that is very long and has many characters", + "very long string that is very long and has many characters", + "very long string that is very long and has many characters", + ]); + + await view.delete(); + await table.delete(); + }); + + test("Clear() and declare string", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + + const view = await table.view({ + expressions: [ + "'hello'", + "'very long string that is very long and has many characters'", + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results = await view.to_columns(); + expect(results["'hello'"]).toEqual([ + "hello", + "hello", + "hello", + "hello", + ]); + expect( + results[ + "'very long string that is very long and has many characters'" + ], + ).toEqual([ + "very long string that is very long and has many characters", + "very long string that is very long and has many characters", + "very long string that is very long and has many characters", + "very long string that is very long and has many characters", + ]); + + await view.delete(); + await table.clear(); + + await table.update([expressions_common.int_float_data[0]]); + + const view2 = await table.view({ + expressions: [ + "'hello'", + "'another very long string with many characters'", + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results2 = await view2.to_columns(); + expect(results2["'hello'"]).toEqual(["hello"]); + expect( + results2["'another very long string with many characters'"], + ).toEqual(["another very long string with many characters"]); + + await view2.delete(); + await table.delete(); + }); + + test("Declare numeric var", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: [ + `var x := 10; var y := -100.00005; var z := x + y; abs(z)`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results = await view.to_columns(); + expect( + results[ + `var x := 10; var y := -100.00005; var z := x + y; abs(z)` + ], + ).toEqual([90.00005, 90.00005, 90.00005, 90.00005]); + + await view.delete(); + await table.delete(); + }); + + test("Declare numeric var 0 and 1", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const expressions = ["1", "0", "var x := 1; x", "var x := 0; x"]; + const view = await table.view({ + expressions, + }); + + const schema = await view.expression_schema(); + + for (const expr of expressions) { + expect(schema[expr]).toEqual("float"); + } + + const results = await view.to_columns(); + expect(results["1"]).toEqual(Array(4).fill(1)); + expect(results["0"]).toEqual(Array(4).fill(0)); + expect(results["var x := 1; x"]).toEqual(Array(4).fill(1)); + expect(results["var x := 0; x"]).toEqual(Array(4).fill(0)); + + await view.delete(); + await table.delete(); + }); + + test("Declare string var", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: [ + `var x := 'long literal string here'; var y := 'long literal string here'; x == y ? concat('strings: ', x, ', ', y) : 'nope'`, + `var x := 'hello'; var y := upper(x); lower(y);`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results = await view.to_columns(); + expect( + results[ + `var x := 'long literal string here'; var y := 'long literal string here'; x == y ? concat('strings: ', x, ', ', y) : 'nope'` + ], + ).toEqual([ + "strings: long literal string here, long literal string here", + "strings: long literal string here, long literal string here", + "strings: long literal string here, long literal string here", + "strings: long literal string here, long literal string here", + ]); + expect( + results[`var x := 'hello'; var y := upper(x); lower(y);`], + ).toEqual(["hello", "hello", "hello", "hello"]); + + await view.delete(); + await table.delete(); + }); + + test("Declare string var one long one short", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: [ + `var x := 'abcdefghijklmnop'; var y := '123'; concat(x, y)`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results = await view.to_columns(); + expect( + results[ + `var x := 'abcdefghijklmnop'; var y := '123'; concat(x, y)` + ], + ).toEqual([ + "abcdefghijklmnop123", + "abcdefghijklmnop123", + "abcdefghijklmnop123", + "abcdefghijklmnop123", + ]); + + await view.delete(); + await table.delete(); + }); + + test("Declare string var short", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: { + "var x := 'abc'; var y := '123'; concat(x, y)": + "var x := 'abc'; var y := '123'; concat(x, y)", + }, + }); + + const results = await view.to_columns(); + expect( + results[`var x := 'abc'; var y := '123'; concat(x, y)`], + ).toEqual(["abc123", "abc123", "abc123", "abc123"]); + + await view.delete(); + await table.delete(); + }); + + test("Clear() and Declare string var", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: [ + "var x := 'Eabcdefghijklmn'; var y := '0123456789'; var z := 'ABCD'; var zz := 'EFG'; concat(x, y, z, zz)", + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results = await view.to_columns(); + expect( + results[ + "var x := 'Eabcdefghijklmn'; var y := '0123456789'; var z := 'ABCD'; var zz := 'EFG'; concat(x, y, z, zz)" + ], + ).toEqual([ + "Eabcdefghijklmn0123456789ABCDEFG", + "Eabcdefghijklmn0123456789ABCDEFG", + "Eabcdefghijklmn0123456789ABCDEFG", + "Eabcdefghijklmn0123456789ABCDEFG", + ]); + + await view.delete(); + + await table.clear(); + + await table.update([expressions_common.int_float_data[0]]); + + const view2 = await table.view({ + expressions: [ + "var x := 'Eabcdefghijklmn'; var y := '0123456789'; var z := 'ABCD'; var zz := 'EFG'; concat(x, y, z, zz)", + "var x := 'abcdefghijklmn'; var y := 'ABCDEFG123456789'; x", + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results2 = await view2.to_columns(); + expect( + results2[ + "var x := 'Eabcdefghijklmn'; var y := '0123456789'; var z := 'ABCD'; var zz := 'EFG'; concat(x, y, z, zz)" + ], + ).toEqual(["Eabcdefghijklmn0123456789ABCDEFG"]); + + expect( + results2[ + "var x := 'abcdefghijklmn'; var y := 'ABCDEFG123456789'; x" + ], + ).toEqual(["abcdefghijklmn"]); + + await view2.delete(); + await table.delete(); + }); + + test("Declare date var", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: [ + `var x := today(); var y := today(); x == y ? 1 : 0;`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results = await view.to_columns(); + expect( + results[`var x := today(); var y := today(); x == y ? 1 : 0;`], + ).toEqual([1, 1, 1, 1]); + + await view.delete(); + await table.delete(); + }); + + test("Declare datetime var", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const now = new Date().getTime(); + const view = await table.view({ + expressions: { "now()": "now()" }, + }); + + const results = await view.to_columns(); + + for (const x of results["now()"]) { + expect(now - 1000 < x < now + 1000).toBe(true); + } + + await view.delete(); + await table.delete(); + }); + + test("Declare boolean var", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: [ + `var x := true; var y := false; x and y ? 1 : 0`, + `var x := true; var y := false; x or y`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results = await view.to_columns(); + expect( + results[`var x := true; var y := false; x and y ? 1 : 0`], + ).toEqual([0, 0, 0, 0]); + expect(results["var x := true; var y := false; x or y"]).toEqual([ + true, + true, + true, + true, + ]); + + await view.delete(); + await table.delete(); + }); + + test("Declare column as var", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: { + 'var x := "w"; var y := 1.5; x > 2.5 ? x : y': + 'var x := "w"; var y := 1.5; x > 2.5 ? x : y', + }, + }); + + const results = await view.to_columns(); + expect( + results[`var x := "w"; var y := 1.5; x > 2.5 ? x : y`], + ).toEqual([1.5, 1.5, 3.5, 4.5]); + + await view.delete(); + await table.delete(); + }); + + test("Assign one type to var of another", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: [ + `var x := 'w'; var y := 1.5; x := y; x`, + `var x := 'w'; var y := 1.5; y := x; y`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results = await view.to_columns(); + expect(results[`var x := 'w'; var y := 1.5; x := y; x`]).toEqual([ + 1.5, 1.5, 1.5, 1.5, + ]); + expect(results[`var x := 'w'; var y := 1.5; y := x; y`]).toEqual([ + "w", + "w", + "w", + "w", + ]); + + await view.delete(); + await table.delete(); + }); + + test("Assign to declared numeric var", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: [ + `var x := 100; var y := 200.5; x := y; x`, + `var x := 10; var y := pow(x, 3); y := x; y`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results = await view.to_columns(); + expect(results[`var x := 100; var y := 200.5; x := y; x`]).toEqual([ + 200.5, 200.5, 200.5, 200.5, + ]); + expect( + results[`var x := 10; var y := pow(x, 3); y := x; y`], + ).toEqual([10, 10, 10, 10]); + + await view.delete(); + await table.delete(); + }); + + test("Assign to declared string var", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: [ + `var x := 'long literal string here'; var y := lower('ANOTHER VERY LONG STRING HERE'); x := y`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results = await view.to_columns(); + expect( + results[ + `var x := 'long literal string here'; var y := lower('ANOTHER VERY LONG STRING HERE'); x := y` + ], + ).toEqual([ + "another very long string here", + "another very long string here", + "another very long string here", + "another very long string here", + ]); + + await view.delete(); + await table.delete(); + }); + + test("Assign to declared date var", async function () { + const table = await perspective.table({ + a: "date", + }); + const view = await table.view({ + expressions: [ + `var x := today(); var y := today(); var z := "a"; (x > z) and (y > z) ? 1 : 0`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const tomorrow = new Date(); + tomorrow.setDate(new Date().getDate() + 1); + + await table.update({ + a: [ + new Date(2020, 3, 1), + new Date(2012, 0, 30), + new Date(2018, 9, 31), + tomorrow, + ], + }); + + const results = await view.to_columns(); + expect( + results[ + `var x := today(); var y := today(); var z := "a"; (x > z) and (y > z) ? 1 : 0` + ], + ).toEqual([1, 1, 1, 0]); + + await view.delete(); + await table.delete(); + }); + + test("Assign to declared datetime var", async function () { + const table = await perspective.table({ + a: "datetime", + }); + const view = await table.view({ + expressions: [ + `var x := now(); var y := now(); var z := "a"; (x > z) and (y > z) ? 1 : 0`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const tomorrow = new Date(); + tomorrow.setDate(new Date().getDate() + 1); + + await table.update({ + a: [ + new Date(2020, 3, 1, 12, 30, 45, 850), + new Date(2012, 0, 30, 19, 25), + new Date(2018, 9, 31, 3, 13), + tomorrow, + ], + }); + + const results = await view.to_columns(); + expect( + results[ + `var x := now(); var y := now(); var z := "a"; (x > z) and (y > z) ? 1 : 0` + ], + ).toEqual([1, 1, 1, 0]); + + await view.delete(); + await table.delete(); + }); + + test("Assign to declared column as var", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + + // test type transitions as assignments happen - from str to int, + // from float to str, etc. + const view = await table.view({ + expressions: [ + `var x := "w"; var y := x; x := "y"; x;`, + `var x := "z"; var y := x; x := "w"; x;`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const results = await view.to_columns(); + expect(results[`var x := "w"; var y := x; x := "y"; x;`]).toEqual([ + "a", + "b", + "c", + "d", + ]); + expect(results[`var x := "z"; var y := x; x := "w"; x;`]).toEqual([ + 1.5, 2.5, 3.5, 4.5, + ]); + + await view.delete(); + await table.delete(); + }); + }); + + test.describe("Functionality", function () { + test("Should be able to create an expression column in `view()`", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: { '"w" + "x"': '"w" + "x"' }, + }); + const result = await view.to_columns(); + expect(result['"w" + "x"']).toEqual([2.5, 4.5, 6.5, 8.5]); + view.delete(); + table.delete(); + }); + + test("Should only parse first comment as an alias", async () => { + const table = await perspective.table({ a: [1, 2, 3] }); + const view = await table.view({ + expressions: [ + "var x := 1 + 2;\n// another comment\nx + 3 + 4 # comment", + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + const schema = await view.expression_schema(); + expect(schema).toEqual({ + "var x := 1 + 2;\n// another comment\nx + 3 + 4 # comment": + "float", + }); + const result = await view.to_columns(); + expect( + result[ + "var x := 1 + 2;\n// another comment\nx + 3 + 4 # comment" + ], + ).toEqual(Array(3).fill(10)); + await view.delete(); + await table.delete(); + }); + + test("Should be able to alias a real column in `view()`", async function () { + const table = await perspective.table( + expressions_common.all_types_arrow.slice(), + ); + const columns = await table.columns(); + const view = await table.view({ + expressions: columns.map((col) => `"${col}"`), + }); + + const schema = await view.schema(); + const expression_schema = await view.expression_schema(); + const result = await view.to_columns(); + + for (const col of columns) { + expect(expression_schema[`"${col}"`]).toEqual(schema[col]); + expect(result[`"${col}"`]).toEqual(result[col]); + } + + view.delete(); + table.delete(); + }); + + test("Should be able to create an expression column with scalars in `view()`", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: { + "1 + 2 + 3 + 4 + 5 + 6": "1 + 2 + 3 + 4 + 5 + 6", + }, + }); + const result = await view.to_columns(); + expect(result["1 + 2 + 3 + 4 + 5 + 6"]).toEqual([21, 21, 21, 21]); + view.delete(); + table.delete(); + }); + + test("Should be able to create a string expression column with scalars in `view()`", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + group_by: ["'abc'"], // checks that the strings are interned + expressions: { "'abc'": "'abc'" }, + }); + const result = await view.to_columns(); + expect(result["__ROW_PATH__"]).toEqual([[], ["abc"]]); + expect(result["'abc'"]).toEqual([4, 4]); + view.delete(); + table.delete(); + }); + + test("Should be able to create a boolean expression column with scalars in `view()`", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: [ + "0 and 1", + "0 or 1", + "true and true", + "false or true", + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + expect(await view.schema()).toEqual({ + w: "float", + x: "integer", + y: "string", + z: "boolean", + "0 and 1": "boolean", + "0 or 1": "boolean", + "true and true": "boolean", + "false or true": "boolean", + }); + const result = await view.to_columns(); + expect(result["0 and 1"]).toEqual([false, false, false, false]); + expect(result["0 or 1"]).toEqual([true, true, true, true]); + expect(result["true and true"]).toEqual([true, true, true, true]); + expect(result["false or true"]).toEqual([true, true, true, true]); + view.delete(); + table.delete(); + }); + + test("Boolean scalars should not compare to numeric scalars", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: [ + "0 and 1", + "0 or 1", + "true and true", + "false or true", + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + expect(await view.schema()).toEqual({ + w: "float", + x: "integer", + y: "string", + z: "boolean", + "0 and 1": "boolean", + "0 or 1": "boolean", + "true and true": "boolean", + "false or true": "boolean", + }); + const result = await view.to_columns(); + expect(result["0 and 1"]).toEqual([false, false, false, false]); + expect(result["0 or 1"]).toEqual([true, true, true, true]); + expect(result["true and true"]).toEqual([true, true, true, true]); + expect(result["false or true"]).toEqual([true, true, true, true]); + view.delete(); + table.delete(); + }); + + test("Should be able to create an expression column with scalars and columns `view()`", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + columns: ['("w" + "x") ^ 2'], + expressions: { '("w" + "x") ^ 2': '("w" + "x") ^ 2' }, + }); + const result = await view.to_columns(); + expect(result['("w" + "x") ^ 2']).toEqual( + [2.5, 4.5, 6.5, 8.5].map((x) => Math.pow(x, 2)), + ); + view.delete(); + table.delete(); + }); + + test("Should be able to create a boolean expression column with scalars and columns in `view()`", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: [ + '"x" and 1', + '"x" or 1', + '"x" and true', + '"x" and false', + '"x" or true', + '"x" or false', + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + expect(await view.schema()).toEqual({ + w: "float", + x: "integer", + y: "string", + z: "boolean", + '"x" and 1': "boolean", + '"x" or 1': "boolean", + '"x" and true': "boolean", + '"x" and false': "boolean", + '"x" or true': "boolean", + '"x" or false': "boolean", + }); + const result = await view.to_columns(); + expect(result['"x" and 1']).toEqual([true, true, true, true]); + expect(result['"x" or 1']).toEqual([true, true, true, true]); + expect(result['"x" and true']).toEqual([true, true, true, true]); + expect(result['"x" and false']).toEqual([ + false, + false, + false, + false, + ]); + expect(result['"x" or true']).toEqual([true, true, true, true]); + expect(result['"x" or false']).toEqual([true, true, true, true]); + view.delete(); + table.delete(); + }); + + test("Should be able to create an expression column in `view()` from schema, and updates propagate", async function () { + const table = await perspective.table({ + w: "float", + x: "integer", + y: "string", + z: "boolean", + }); + const view = await table.view({ + expressions: { '("w" + "x") * 10': '("w" + "x") * 10' }, + }); + + const result = await view.to_columns(); + expect(result).toEqual({ + '("w" + "x") * 10': [], + w: [], + x: [], + y: [], + z: [], + }); + + await table.update(expressions_common.int_float_data); + + const new_result = await view.to_columns(); + expect(new_result['("w" + "x") * 10']).toEqual([25, 45, 65, 85]); + + await table.update(expressions_common.int_float_data); + + const new_result2 = await view.to_columns(); + expect(new_result2['("w" + "x") * 10']).toEqual([ + 25, 45, 65, 85, 25, 45, 65, 85, + ]); + + view.delete(); + table.delete(); + }); + + test("Should be able to create an expression column using an alias.", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: { "new column": '"w" + "x"' }, + }); + const result = await view.to_columns(); + expect(result["new column"]).toEqual([2.5, 4.5, 6.5, 8.5]); + await view.delete(); + await table.delete(); + }); + + test("Alias should be trimmed.", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: { "new column": '"w" + "x"' }, + }); + + const result = await view.to_columns(); + expect(result["new column"]).toEqual([2.5, 4.5, 6.5, 8.5]); + + const view2 = await table.view({ + expressions: { "new column": '"w" - "x"' }, + }); + + const result2 = await view2.to_columns(); + expect(result2["new column"]).toEqual([0.5, 0.5, 0.5, 0.5]); + + await view2.delete(); + await view.delete(); + await table.delete(); + }); + + test("Should be able to create an expression column using a very long alias.", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: { + "new column with lots of different strings and names and lots of good characters": + '"w" + "x"', + }, + }); + const result = await view.to_columns(); + expect( + result[ + "new column with lots of different strings and names and lots of good characters" + ], + ).toEqual([2.5, 4.5, 6.5, 8.5]); + await view.delete(); + await table.delete(); + }); + + test("Comments after the alias are not picked up.", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: { + alias: 'var x := "w" + "x"\n// a comment\n# another comment\n/* another comment here */\n', + }, + }); + const result = await view.to_columns(); + expect(result["alias"]).toEqual([2.5, 4.5, 6.5, 8.5]); + await view.delete(); + await table.delete(); + }); + + test("Duplicate alias within the same view should resolve to the last expression.", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + + const view = await table.view({ + expressions: { + "new column": '"w" + "x"', + "new column 1": '"w" - "x"', + "new column": "100 * 10", + "new column 1": '"w" + "x"', + "new column": '"w" - "x"', + }, + }); + + expect(await view.expression_schema()).toEqual({ + "new column": "float", + "new column 1": "float", + }); + + const result = await view.to_columns(); + + expect(result["new column"]).toEqual([0.5, 0.5, 0.5, 0.5]); + expect(result["new column 1"]).toEqual([2.5, 4.5, 6.5, 8.5]); + + await view.delete(); + await table.delete(); + }); + + test("Duplicate alias within the same view should resolve to the last expression, different types.", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + + const view = await table.view({ + expressions: { + "new column": '"w" + "x"', + "new column 1": '"w" - "x"', + "new column": "100", + "new column": "upper('abc')", + "new column 1": '"w" + "x"', + }, + }); + + expect(await view.expression_schema()).toEqual({ + "new column": "string", + "new column 1": "float", + }); + + const result = await view.to_columns(); + + expect(result["new column"]).toEqual(Array(4).fill("ABC")); + expect(result["new column 1"]).toEqual([2.5, 4.5, 6.5, 8.5]); + + await view.delete(); + await table.delete(); + }); + + test("Duplicate alias across new views will not overwrite results in old view.", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: { "new column": '"w" + "x"' }, + }); + + let result = await view.to_columns(); + expect(result["new column"]).toEqual([2.5, 4.5, 6.5, 8.5]); + + const view2 = await table.view({ + expressions: { "new column": "100 * 10" }, + }); + + expect(await view.expression_schema()).toEqual( + await view2.expression_schema(), + ); + + result = await view.to_columns(); + const result2 = await view2.to_columns(); + expect(result["new column"]).toEqual([2.5, 4.5, 6.5, 8.5]); + expect(result2["new column"]).toEqual([1000, 1000, 1000, 1000]); + + const view3 = await table.view({ + expressions: { "new column": "100 * 100" }, + group_by: ["y"], + }); + + const result3 = await view3.to_columns(); + expect(result3["new column"]).toEqual([ + 40000, 10000, 10000, 10000, 10000, + ]); + + await view3.delete(); + await view2.delete(); + await view.delete(); + await table.delete(); + }); + + test("Should not be able to overwrite table column with an expression", async function () { + expect.assertions(1); + const table = await perspective.table( + expressions_common.int_float_data, + ); + let emit; + let emitReject; + let result = new Promise((success, reject) => { + emit = success; + emitReject = reject; + }); + + table + .view({ + expressions: { w: "upper('abc')" }, + }) + .catch((e) => { + expect(e.message.split("\n")[0]).toMatch( + `Abort(): Value Error - expression \"w\" cannot overwrite an existing column.`, + ); + table.delete(); + emit(); + }) + .then(() => { + emitReject("Promise should have thrown an error"); + }); + + await result; + }); + + test("Should be able to overwrite expression column with one that returns a different type", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: { "new column": '"w" + "x"' }, + }); + + let result = await view.to_columns(); + expect(result["new column"]).toEqual([2.5, 4.5, 6.5, 8.5]); + + const view2 = await table.view({ + expressions: { "new column": " upper('abc')" }, + }); + + const result2 = await view2.to_columns(); + expect(result2["new column"]).toEqual(Array(4).fill("ABC")); + + view2.delete(); + view.delete(); + table.delete(); + }); + + test("A new view should not reference expression columns it did not create.", async function () { + expect.assertions(2); + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: { '"w" + "x"': '"w" + "x"' }, + }); + const result = await view.to_columns(); + expect(result['"w" + "x"']).toEqual([2.5, 4.5, 6.5, 8.5]); + let emit; + let result2 = new Promise((x) => { + emit = x; + }); + table + .view({ + columns: ['"w" + "x"', "x"], + }) + .catch((e) => { + expect(e.message).toMatch( + `Abort(): Invalid column '"w" + "x"' found in View columns.\n`, + ); + view.delete(); + table.delete(); + emit(); + }); + + await result2; + }); + + test("A view should be able to shadow real columns with an expression column", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: { + '"w"': '"w"', + '"x"': '"x"', + '"y"': '"y"', + '"z"': '"z"', + }, + }); + expect(await view.expression_schema()).toEqual({ + '"w"': "float", + '"x"': "integer", + '"y"': "string", + '"z"': "boolean", + }); + expect(await view.to_json()).toEqual([ + { + w: 1.5, + x: 1, + y: "a", + z: true, + '"w"': 1.5, + '"x"': 1, + '"y"': "a", + '"z"': true, + }, + { + w: 2.5, + x: 2, + y: "b", + z: false, + '"w"': 2.5, + '"x"': 2, + '"y"': "b", + '"z"': false, + }, + { + w: 3.5, + x: 3, + y: "c", + z: true, + '"w"': 3.5, + '"x"': 3, + '"y"': "c", + '"z"': true, + }, + { + w: 4.5, + x: 4, + y: "d", + z: false, + '"w"': 4.5, + '"x"': 4, + '"y"': "d", + '"z"': false, + }, + ]); + view.delete(); + table.delete(); + }); + + test("Should be able to create multiple expression columns in `view()`", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + + const view = await table.view({ + expressions: { + '"w" + "x"': '"w" + "x"', + 'upper("y")': 'upper("y")', + }, + }); + + expect(await view.expression_schema()).toEqual({ + '"w" + "x"': "float", + 'upper("y")': "string", + }); + + const result = await view.to_columns(); + expect(result['"w" + "x"']).toEqual([2.5, 4.5, 6.5, 8.5]); + expect(result['upper("y")']).toEqual(["A", "B", "C", "D"]); + view.delete(); + table.delete(); + }); + + test("Should be able to create multiple expression columns with unique names in multiple `view()`s", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: { + '"w" + "x"': '"w" + "x"', + '"w" - "x"': '"w" - "x"', + '"w" * "x"': '"w" * "x"', + }, + }); + const view2 = await table.view({ + expressions: { + '"w" / "x"': '"w" / "x"', + '"x" * "w"': '"x" * "w"', + }, + }); + + const schema = await view.schema(); + const schema2 = await view2.schema(); + + expect(schema).toEqual({ + w: "float", + x: "integer", + y: "string", + z: "boolean", + '"w" + "x"': "float", + '"w" - "x"': "float", + '"w" * "x"': "float", + }); + + expect(schema2).toEqual({ + w: "float", + x: "integer", + y: "string", + z: "boolean", + '"w" / "x"': "float", + '"x" * "w"': "float", + }); + + const result = await view.to_columns(); + const result2 = await view2.to_columns(); + + expect(result).toEqual({ + w: [1.5, 2.5, 3.5, 4.5], + x: [1, 2, 3, 4], + y: ["a", "b", "c", "d"], + z: [true, false, true, false], + '"w" + "x"': [2.5, 4.5, 6.5, 8.5], + '"w" - "x"': [0.5, 0.5, 0.5, 0.5], + '"w" * "x"': [1.5, 5, 10.5, 18], + }); + + expect(result2).toEqual({ + w: [1.5, 2.5, 3.5, 4.5], + x: [1, 2, 3, 4], + y: ["a", "b", "c", "d"], + z: [true, false, true, false], + '"w" / "x"': [1.5, 1.25, 1.1666666666666667, 1.125], + '"x" * "w"': [1.5, 5, 10.5, 18], + }); + + view2.delete(); + view.delete(); + table.delete(); + }); + + test("Should be able to create multiple expression columns in multiple `view()`s, and arbitarily delete views.", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: { '"w" + "x"': '"w" + "x"' }, + }); + const view2 = await table.view({ + expressions: { '"w" - "x"': '"w" - "x"' }, + }); + + const schema = await view.schema(); + + expect(schema).toEqual({ + w: "float", + x: "integer", + y: "string", + z: "boolean", + '"w" + "x"': "float", + }); + const result = await view.to_columns(); + + expect(result).toEqual({ + w: [1.5, 2.5, 3.5, 4.5], + x: [1, 2, 3, 4], + y: ["a", "b", "c", "d"], + z: [true, false, true, false], + '"w" + "x"': [2.5, 4.5, 6.5, 8.5], + }); + + view.delete(); + + // force a process() + expect(await table.size()).toEqual(4); + + const schema2 = await view2.schema(); + + expect(schema2).toEqual({ + w: "float", + x: "integer", + y: "string", + z: "boolean", + '"w" - "x"': "float", + }); + + const result2 = await view2.to_columns(); + + expect(result2).toEqual({ + w: [1.5, 2.5, 3.5, 4.5], + x: [1, 2, 3, 4], + y: ["a", "b", "c", "d"], + z: [true, false, true, false], + '"w" - "x"': [0.5, 0.5, 0.5, 0.5], + }); + + view2.delete(); + table.delete(); + }); + + test("Should be able to create multiple duplicate expression columns in multiple `view()`s, and delete preceding views without affecting later columns.", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: { '"w" + "x"': '"w" + "x"' }, + }); + + const view2 = await table.view({ + expressions: { + '"w" + "x"': '"w" + "x"', + '"w" - "x"': '"w" - "x"', + }, + }); + + const schema = await view.schema(); + + expect(schema).toEqual({ + w: "float", + x: "integer", + y: "string", + z: "boolean", + '"w" + "x"': "float", + }); + const result = await view.to_columns(); + + expect(result).toEqual({ + w: [1.5, 2.5, 3.5, 4.5], + x: [1, 2, 3, 4], + y: ["a", "b", "c", "d"], + z: [true, false, true, false], + '"w" + "x"': [2.5, 4.5, 6.5, 8.5], + }); + + view.delete(); + + const schema2 = await view2.schema(); + + // force a process() + expect(await table.size()).toEqual(4); + + expect(schema2).toEqual({ + w: "float", + x: "integer", + y: "string", + z: "boolean", + '"w" + "x"': "float", + '"w" - "x"': "float", + }); + + const result2 = await view2.to_columns(); + + expect(result2).toEqual({ + w: [1.5, 2.5, 3.5, 4.5], + x: [1, 2, 3, 4], + y: ["a", "b", "c", "d"], + z: [true, false, true, false], + '"w" + "x"': [2.5, 4.5, 6.5, 8.5], + '"w" - "x"': [0.5, 0.5, 0.5, 0.5], + }); + + view2.delete(); + table.delete(); + }); + + test("Multiple views inheriting the same expression columns with the same names should not conflict", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + + const view = await table.view({ + expressions: { '"w" + "x"': '"w" + "x"' }, + }); + + expect(await view.schema()).toEqual({ + w: "float", + x: "integer", + y: "string", + z: "boolean", + '"w" + "x"': "float", + }); + + const view2 = await table.view({ + expressions: { + '"w" + "x"': '"w" + "x"', + '"w" - "x"': '"w" - "x"', + }, + }); + + expect(await view2.schema()).toEqual({ + w: "float", + x: "integer", + y: "string", + z: "boolean", + '"w" + "x"': "float", + '"w" - "x"': "float", + }); + + const view3 = await table.view({ + expressions: { + '"w" + "x"': '"w" + "x"', + '"w" - "x"': '"w" - "x"', + '("w" - "x") + "x"': '("w" - "x") + "x"', + }, + }); + + expect(await view3.schema()).toEqual({ + w: "float", + x: "integer", + y: "string", + z: "boolean", + '"w" + "x"': "float", + '"w" - "x"': "float", + '("w" - "x") + "x"': "float", + }); + + const result = await view3.to_columns(); + expect(result['("w" - "x") + "x"']).toEqual(result["w"]); + + view3.delete(); + view2.delete(); + view.delete(); + table.delete(); + }); + + test("A view should be able to create an expression column with the same name as another deleted view's expression columns.", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: { '"w" + "x"': '"w" + "x"' }, + }); + + const result = await view.to_columns(); + expect(result['"w" + "x"']).toEqual([2.5, 4.5, 6.5, 8.5]); + view.delete(); + + const view2 = await table.view({ + expressions: { '"w" - "x"': '"w" - "x"' }, + }); + + const result2 = await view2.to_columns(); + expect(result2['"w" - "x"']).toEqual([0.5, 0.5, 0.5, 0.5]); + + view2.delete(); + table.delete(); + }); + + test("A view without expression columns should not serialize expression columns from other views.", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: { '"w" + "x"': '"w" + "x"' }, + }); + + const result = await view.to_columns(); + expect(result['"w" + "x"']).toEqual([2.5, 4.5, 6.5, 8.5]); + view.delete(); + + const view2 = await table.view(); + + const result2 = await view2.to_json(); + expect(result2).toEqual(expressions_common.int_float_data); + + view2.delete(); + table.delete(); + }); + + test("When expression columns are repeated between views, column indices should grow linearly.", async function () { + let expressions = [ + '"w" + "x"', + '"w" - "x"', + '"w" * "x"', + '"w" / "x"', + ]; + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: [expressions[0]].reduce( + (x, y) => Object.assign(x, { [y]: y }), + {}, + ), + }); + const view2 = await table.view({ + expressions: [expressions[0], expressions[1]].reduce( + (x, y) => Object.assign(x, { [y]: y }), + {}, + ), + }); + const view3 = await table.view({ + expressions: [ + expressions[0], + expressions[1], + expressions[2], + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + const view4 = await table.view({ expressions: expressions }); + + const schema = await view.schema(); + const schema2 = await view2.schema(); + const schema3 = await view3.schema(); + const schema4 = await view4.schema(); + + expect(schema).toEqual({ + w: "float", + x: "integer", + y: "string", + z: "boolean", + '"w" + "x"': "float", + }); + + expect(schema2).toEqual({ + w: "float", + x: "integer", + y: "string", + z: "boolean", + '"w" + "x"': "float", + '"w" - "x"': "float", + }); + + expect(schema3).toEqual({ + w: "float", + x: "integer", + y: "string", + z: "boolean", + '"w" + "x"': "float", + '"w" - "x"': "float", + '"w" * "x"': "float", + }); + + expect(schema4).toEqual({ + w: "float", + x: "integer", + y: "string", + z: "boolean", + '"w" + "x"': "float", + '"w" - "x"': "float", + '"w" * "x"': "float", + '"w" / "x"': "float", + }); + + view4.delete(); + view3.delete(); + view2.delete(); + view.delete(); + table.delete(); + }); + + test("Should be able to create duplicate expressions in multiple views, and updates should propagate to both", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: { '"w" + "x"': '"w" + "x"' }, + }); + const view2 = await table.view({ + expressions: { '"w" + "x"': '"w" + "x"' }, + }); + + const schema = await view.schema(); + const schema2 = await view2.schema(); + + expect(schema).toEqual({ + w: "float", + x: "integer", + y: "string", + z: "boolean", + '"w" + "x"': "float", + }); + + expect(schema2).toEqual({ + w: "float", + x: "integer", + y: "string", + z: "boolean", + '"w" + "x"': "float", + }); + + const result = await view.to_columns(); + const result2 = await view2.to_columns(); + + expect(result).toEqual({ + w: [1.5, 2.5, 3.5, 4.5], + x: [1, 2, 3, 4], + y: ["a", "b", "c", "d"], + z: [true, false, true, false], + '"w" + "x"': [2.5, 4.5, 6.5, 8.5], + }); + + expect(result2).toEqual({ + w: [1.5, 2.5, 3.5, 4.5], + x: [1, 2, 3, 4], + y: ["a", "b", "c", "d"], + z: [true, false, true, false], + '"w" + "x"': [2.5, 4.5, 6.5, 8.5], + }); + + await table.update({ + w: [5, 6, 7, 8], + x: [1, 2, 3, 4], + }); + + expect(await view.to_columns()).toEqual({ + w: [1.5, 2.5, 3.5, 4.5, 5, 6, 7, 8], + x: [1, 2, 3, 4, 1, 2, 3, 4], + y: ["a", "b", "c", "d", null, null, null, null], + z: [true, false, true, false, null, null, null, null], + '"w" + "x"': [2.5, 4.5, 6.5, 8.5, 6, 8, 10, 12], + }); + + expect(await view2.to_columns()).toEqual({ + w: [1.5, 2.5, 3.5, 4.5, 5, 6, 7, 8], + x: [1, 2, 3, 4, 1, 2, 3, 4], + y: ["a", "b", "c", "d", null, null, null, null], + z: [true, false, true, false, null, null, null, null], + '"w" + "x"': [2.5, 4.5, 6.5, 8.5, 6, 8, 10, 12], + }); + + view2.delete(); + view.delete(); + table.delete(); + }); + + test("A new view should not inherit expression columns if not created.", async function () { + expect.assertions(2); + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: { '"w" + "x"': '"w" + "x"' }, + }); + const result = await view.to_columns(); + expect(result['"w" + "x"']).toEqual([2.5, 4.5, 6.5, 8.5]); + + try { + await table.view({ + columns: ['"w" + "x"', "x"], + }); + } catch (e) { + expect(e.message.split("\n")[0]).toEqual( + `Abort(): Invalid column '"w" + "x"' found in View columns.`, + ); + } + + view.delete(); + table.delete(); + }); + + test("The view's underlying table should not have a mutated schema.", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + expressions: { '"w" + "x"': '"w" + "x"' }, + }); + const result = await view.to_columns(); + expect(result['"w" + "x"']).toEqual([2.5, 4.5, 6.5, 8.5]); + expect(await table.schema()).toEqual({ + w: "float", + x: "integer", + y: "string", + z: "boolean", + }); + view.delete(); + table.delete(); + }); + + test("Should be able to show an expression column.", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + columns: ['"w" + "x"'], + expressions: { '"w" + "x"': '"w" + "x"' }, + }); + const result = await view.to_columns(); + expect(result['"w" + "x"']).toEqual([2.5, 4.5, 6.5, 8.5]); + view.delete(); + table.delete(); + }); + + test("Should be able to hide an expression column.", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + columns: ["x"], + expressions: { '"w" + "x"': '"w" + "x"' }, + }); + expect(await view.schema()).toEqual({ + x: "integer", + }); + expect(await view.expression_schema()).toEqual({ + '"w" + "x"': "float", + }); + const result = await view.to_columns(); + expect(result['"w" + "x"']).toEqual(undefined); + expect(result["x"]).toEqual([1, 2, 3, 4]); + view.delete(); + table.delete(); + }); + + test("Should be able to group by on a non-expression column and get correct results for the expression column.", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + group_by: ["w"], + expressions: { '"w" + "x"': '"w" + "x"' }, + }); + const result = await view.to_columns(); + expect(result).toEqual({ + __ROW_PATH__: [[], [1.5], [2.5], [3.5], [4.5]], + '"w" + "x"': [22, 2.5, 4.5, 6.5, 8.5], + w: [12, 1.5, 2.5, 3.5, 4.5], + x: [10, 1, 2, 3, 4], + y: [4, 1, 1, 1, 1], + z: [4, 1, 1, 1, 1], + }); + view.delete(); + table.delete(); + }); + + test("Should be able to group by on an expression column.", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + group_by: ['"w" + "x"'], + expressions: { '"w" + "x"': '"w" + "x"' }, + }); + const result = await view.to_columns(); + expect(result).toEqual({ + __ROW_PATH__: [[], [2.5], [4.5], [6.5], [8.5]], + '"w" + "x"': [22, 2.5, 4.5, 6.5, 8.5], + w: [12, 1.5, 2.5, 3.5, 4.5], + x: [10, 1, 2, 3, 4], + y: [4, 1, 1, 1, 1], + z: [4, 1, 1, 1, 1], + }); + view.delete(); + table.delete(); + }); + + test("Row-pivoted expression columns return correct column_paths()", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + + // default order + let view = await table.view({ + group_by: ["y"], + expressions: { column: '"w" + "x"' }, + }); + + let paths = await view.column_paths(); + expect(paths).toEqual(["w", "x", "y", "z", "column"]); + + await view.delete(); + + const expected_paths = [ + ["x", "column"], + ["column"], + ["x", "column", "y"], + ]; + + for (const expected of expected_paths) { + const output = expected.slice(); + view = await table.view({ + group_by: ["y"], + expressions: { column: '"w" + "x"' }, + columns: expected, + }); + paths = await view.column_paths(); + expect(paths).toEqual(output); + view.delete(); + } + + for (const expected of expected_paths) { + const output = expected.slice(); + view = await table.view({ + group_by: ["column"], + expressions: { column: '"w" + "x"' }, + columns: expected, + }); + paths = await view.column_paths(); + expect(paths).toEqual(output); + view.delete(); + } + + table.delete(); + }); + + test("Row-pivoted numeric expression columns return correct column_paths()", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const config = { + group_by: ["y"], + expressions: { 1234: "1234" }, + aggregates: { + x: "sum", + y: "count", + 1234: "sum", + }, + }; + // default order + let view = await table.view(config); + + let paths = await view.column_paths(); + expect(paths).toEqual(["w", "x", "y", "z", "1234"]); + + await view.delete(); + + const expected_paths = [ + ["x", "1234"], + ["1234"], + ["x", "1234", "y"], + ]; + + for (const expected of expected_paths) { + const output = expected.slice(); + view = await table.view({ + ...config, + columns: expected, + }); + paths = await view.column_paths(); + expect(paths).toEqual(output); + view.delete(); + } + + for (const expected of expected_paths) { + const output = expected.slice(); + view = await table.view({ + ...config, + columns: expected, + }); + paths = await view.column_paths(); + expect(paths).toEqual(output); + view.delete(); + } + + table.delete(); + }); + + test("Should be able to split by on an expression column.", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + split_by: ['"w" + "x"'], + expressions: { '"w" + "x"': '"w" + "x"' }, + }); + const result = await view.to_columns(); + expect(result).toEqual({ + "2.5|w": [1.5, null, null, null], + "2.5|x": [1, null, null, null], + "2.5|y": ["a", null, null, null], + "2.5|z": [true, null, null, null], + '2.5|"w" + "x"': [2.5, null, null, null], + "4.5|w": [null, 2.5, null, null], + "4.5|x": [null, 2, null, null], + "4.5|y": [null, "b", null, null], + "4.5|z": [null, false, null, null], + '4.5|"w" + "x"': [null, 4.5, null, null], + "6.5|w": [null, null, 3.5, null], + "6.5|x": [null, null, 3, null], + "6.5|y": [null, null, "c", null], + "6.5|z": [null, null, true, null], + '6.5|"w" + "x"': [null, null, 6.5, null], + "8.5|w": [null, null, null, 4.5], + "8.5|x": [null, null, null, 4], + "8.5|y": [null, null, null, "d"], + "8.5|z": [null, null, null, false], + '8.5|"w" + "x"': [null, null, null, 8.5], + }); + view.delete(); + table.delete(); + }); + + test("Should be able to row + split by on an expression column.", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + group_by: ['"w" + "x"'], + split_by: ['"w" + "x"'], + expressions: { '"w" + "x"': '"w" + "x"' }, + }); + const result = await view.to_columns(); + expect(result).toEqual({ + __ROW_PATH__: [[], [2.5], [4.5], [6.5], [8.5]], + "2.5|w": [1.5, 1.5, null, null, null], + "2.5|x": [1, 1, null, null, null], + "2.5|y": [1, 1, null, null, null], + "2.5|z": [1, 1, null, null, null], + '2.5|"w" + "x"': [2.5, 2.5, null, null, null], + "4.5|w": [2.5, null, 2.5, null, null], + "4.5|x": [2, null, 2, null, null], + "4.5|y": [1, null, 1, null, null], + "4.5|z": [1, null, 1, null, null], + '4.5|"w" + "x"': [4.5, null, 4.5, null, null], + "6.5|w": [3.5, null, null, 3.5, null], + "6.5|x": [3, null, null, 3, null], + "6.5|y": [1, null, null, 1, null], + "6.5|z": [1, null, null, 1, null], + '6.5|"w" + "x"': [6.5, null, null, 6.5, null], + "8.5|w": [4.5, null, null, null, 4.5], + "8.5|x": [4, null, null, null, 4], + "8.5|y": [1, null, null, null, 1], + "8.5|z": [1, null, null, null, 1], + '8.5|"w" + "x"': [8.5, null, null, null, 8.5], + }); + view.delete(); + table.delete(); + }); + + test("Should be able to aggregate a numeric expression column.", async function () { + const table = await perspective.table({ + x: [1, 2, 3, 4], + y: [100, 200, 300, 400], + z: [1.5, 2.5, 3.5, 4.5], + }); + const view = await table.view({ + group_by: ['"x" + "z"'], + aggregates: { + '"x" + "z"': "median", + x: "median", + }, + expressions: { '"x" + "z"': '"x" + "z"' }, + }); + const result = await view.to_columns(); + expect(result).toEqual({ + __ROW_PATH__: [[], [2.5], [4.5], [6.5], [8.5]], + '"x" + "z"': [5.5, 2.5, 4.5, 6.5, 8.5], + x: [3, 1, 2, 3, 4], + y: [1000, 100, 200, 300, 400], + z: [12, 1.5, 2.5, 3.5, 4.5], + }); + view.delete(); + table.delete(); + }); + + test("Should be able to weighted mean aggregate a numeric expression column.", async function () { + const table = await perspective.table({ + x: [2, 2, 4, 4], + y: [100, 200, 300, 400], + z: [1.5, 2.5, 3.5, 4.5], + }); + const view = await table.view({ + group_by: ["y"], + columns: ['"x" + "z"'], + aggregates: { + '"x" + "z"': ["weighted mean", ["y"]], + }, + expressions: { '"x" + "z"': '"x" + "z"' }, + }); + const result = await view.to_columns(); + expect(result).toEqual({ + __ROW_PATH__: [[], [100], [200], [300], [400]], + '"x" + "z"': [6.9, 3.5, 4.5, 7.5, 8.5], + }); + view.delete(); + table.delete(); + }); + + test("Should be able to aggregate a numeric expression column that aliases a real column.", async function () { + const table = await perspective.table({ + x: [1, 2, 3, 4], + y: [100, 200, 300, 400], + z: [1.5, 2.5, 3.5, 4.5], + }); + const view = await table.view({ + group_by: ['"x"'], + aggregates: { + '"x"': "median", + x: "median", + }, + expressions: { '"x"': '"x"' }, + }); + const result = await view.to_columns(); + expect(result).toEqual({ + __ROW_PATH__: [[], [1], [2], [3], [4]], + '"x"': [3, 1, 2, 3, 4], + x: [3, 1, 2, 3, 4], + y: [1000, 100, 200, 300, 400], + z: [12, 1.5, 2.5, 3.5, 4.5], + }); + view.delete(); + table.delete(); + }); + + test("Should be able to aggregate a string expression column.", async function () { + const table = await perspective.table({ + x: ["a", "a", "c", "a"], + y: ["w", "w", "y", "w"], + }); + const view = await table.view({ + group_by: ['upper("x")'], + aggregates: { + 'upper("x")': "distinct count", + }, + expressions: { 'upper("x")': 'upper("x")' }, + }); + const result = await view.to_columns(); + expect(result).toEqual({ + __ROW_PATH__: [[], ["A"], ["C"]], + 'upper("x")': [2, 1, 1], + x: [4, 3, 1], + y: [4, 3, 1], + }); + view.delete(); + table.delete(); + }); + + test("Should be able to aggregate a date expression column.", async function () { + const table = await perspective.table({ + x: [ + new Date(2019, 0, 15), + new Date(2019, 0, 30), + new Date(2019, 1, 15), + ], + }); + const view = await table.view({ + group_by: [`bucket("x", 'M')`], + aggregates: { + "bucket(\"x\", 'M')": "distinct count", + }, + expressions: { "bucket(\"x\", 'M')": "bucket(\"x\", 'M')" }, + }); + const result = await view.to_columns(); + expect(result).toEqual({ + __ROW_PATH__: [[], [1546300800000], [1548979200000]], + "bucket(\"x\", 'M')": [2, 1, 1], + x: [3, 2, 1], + }); + view.delete(); + table.delete(); + }); + + test("Should be able to weighted mean on an expression column.", async function () { + const table = await perspective.table({ + x: [1, 2, 3, 4], + y: [100, 200, 300, 400], + z: [1.5, 2.5, 3.5, 4.5], + }); + const view = await table.view({ + group_by: ['"x" + "z"'], + aggregates: { + x: ["weighted mean", ['"x" + "z"']], + '"x" + "z"': ["weighted mean", ["y"]], + }, + expressions: { '"x" + "z"': '"x" + "z"' }, + }); + const result = await view.to_columns(); + expect(result).toEqual({ + __ROW_PATH__: [[], [2.5], [4.5], [6.5], [8.5]], + '"x" + "z"': [6.5, 2.5, 4.5, 6.5, 8.5], + x: [2.9545454545454546, 1, 2, 3, 4], + y: [1000, 100, 200, 300, 400], + z: [12, 1.5, 2.5, 3.5, 4.5], + }); + view.delete(); + table.delete(); + }); + + test("Should be able to filter on an expression column.", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + filter: [['"w" + "x"', ">", 6.5]], + expressions: { '"w" + "x"': '"w" + "x"' }, + }); + const result = await view.to_columns(); + expect(result).toEqual({ + '"w" + "x"': [8.5], + w: [4.5], + x: [4], + y: ["d"], + z: [false], + }); + view.delete(); + table.delete(); + }); + + test("Should be able to sort on an expression column.", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + sort: [['"w" + "x"', "desc"]], + expressions: { '"w" + "x"': '"w" + "x"' }, + }); + const result = await view.to_columns(); + expect(result).toEqual({ + '"w" + "x"': [8.5, 6.5, 4.5, 2.5], + w: [4.5, 3.5, 2.5, 1.5], + x: [4, 3, 2, 1], + y: ["d", "c", "b", "a"], + z: [false, true, false, true], + }); + view.delete(); + table.delete(); + }); + + test("Should be able to sort on a hidden expression column.", async function () { + const table = await perspective.table( + expressions_common.int_float_data, + ); + const view = await table.view({ + columns: ["w"], + sort: [['"w" + "x"', "desc"]], + expressions: { '"w" + "x"': '"w" + "x"' }, + }); + const result = await view.to_columns(); + expect(result).toEqual({ + w: [4.5, 3.5, 2.5, 1.5], + }); + view.delete(); + table.delete(); + }); + + test.describe("Validation using table expression schema", function () { + test("Should show correct expression column types in table expression schema.", async function () { + const table = await perspective.table({ + a: [1, 2, 3, 4], + b: [new Date(), new Date(), new Date(), new Date()], + c: ["a", "b", "c", "d"], + }); + const expressions = [ + '"a" ^ 2', + "sqrt(144)", + "0 and 1", + "0 or 1", + '-"a"', + 'upper("c")', + `bucket("b", 'M')`, + `bucket("b", 's')`, + ]; + const results = await table.validate_expressions(expressions); + expect(results.expression_schema).toEqual({ + '"a" ^ 2': "float", + "sqrt(144)": "float", + '-"a"': "integer", + "0 and 1": "boolean", + "0 or 1": "boolean", + 'upper("c")': "string", + "bucket(\"b\", 'M')": "date", + "bucket(\"b\", 's')": "datetime", + }); + table.delete(); + }); + + test("Should skip expressions that try to overwrite table columns", async function () { + const table = await perspective.table({ + a: [1, 2, 3, 4], + b: [new Date(), new Date(), new Date(), new Date()], + c: ["a", "b", "c", "d"], + d: [1.5, 2.5, 3.5, 4.5], + }); + const expressions = { + abc: " 123 + 345", //valid + a: " sqrt(144)", // invalid + b: " upper('abc')", // invalid + c: " concat('a', 'b')", // invalid + d: " today()", // invalid + }; + const results = await table.validate_expressions(expressions); + expect(results.expression_schema).toEqual({ + abc: "float", + }); + table.delete(); + }); + + test("Should not skip expressions that try to overwrite expressions with different types", async function () { + const table = await perspective.table({ + a: [1, 2, 3, 4], + b: [new Date(), new Date(), new Date(), new Date()], + c: ["a", "b", "c", "d"], + d: [1.5, 2.5, 3.5, 4.5], + }); + + const view = await table.view({ + expressions: { + abc: " 123 + 345", + def: " lower('ABC')", + }, + }); + + const results = await table.validate_expressions({ + abc: " upper('abc')", + def: " 1 + 2", + }); + expect(results.expression_schema).toEqual({ + abc: "string", + def: "float", + }); + + view.delete(); + table.delete(); + }); + + test("Should skip invalid columns due to type error, with alias", async function () { + const table = await perspective.table({ + a: [1, 2, 3, 4], + b: [new Date(), new Date(), new Date(), new Date()], + c: ["a", "b", "c", "d"], + d: [1.5, 2.5, 3.5, 4.5], + }); + const expressions = { + abc: '"a" + "d"', // valid + def: '"c" + "b"', // invalid + '"c"': '"c"', // valid + "new column": "bucket(\"b\", 'M')", // valid + "bucket(\"b\", 'abcde')": "bucket(\"b\", 'abcde')", // invalid + 'concat("c", "a")': 'concat("c", "a")', // invalid + "more columns": "concat(\"c\", ' ', \"c\", 'abc')", // valid + 'upper("c")': 'upper("c")', // valid + 'lower("a")': 'lower("a")', // invalid, + 'min("a", "c")': 'min("a", "c")', // invalid, + 'max(100, "a")': 'max(100, "a")', // valid + }; + const results = await table.validate_expressions(expressions); + expect(results.expression_schema).toEqual({ + abc: "float", + '"c"': "string", + "new column": "date", + 'upper("c")': "string", + 'max(100, "a")': "float", + "more columns": "string", + }); + + for (const alias of Object.keys(expressions)) { + expect(results.expression_alias[alias]).toEqual( + expressions[alias], + ); + } + table.delete(); + }); + + test("Should skip invalid columns due to type error", async function () { + const table = await perspective.table({ + a: [1, 2, 3, 4], + b: [new Date(), new Date(), new Date(), new Date()], + c: ["a", "b", "c", "d"], + d: [1.5, 2.5, 3.5, 4.5], + }); + const expressions = [ + '"a" + "d"', // valid + '"c" + "b"', // invalid + '"c"', // valid + "bucket(\"b\", 'M')", // valid + "bucket(\"b\", 'abcde')", // invalid + 'concat("c", "a")', // invalid + "concat(\"c\", ' ', \"c\", 'abc')", // valid + 'upper("c")', // valid + 'lower("a")', // invalid, + 'min("a", "c")', // invalid, + 'max(100, "a")', // valid + ]; + const results = await table.validate_expressions(expressions); + + expect(results.expression_schema).toEqual({ + '"a" + "d"': "float", + '"c"': "string", + "bucket(\"b\", 'M')": "date", + 'upper("c")': "string", + 'max(100, "a")': "float", + "concat(\"c\", ' ', \"c\", 'abc')": "string", + }); + + for (const expr of expressions) { + expect(results.expression_alias[expr]).toEqual(expr); + } + + table.delete(); + }); + + test("Should skip a invalid column due to invalid column name", async function () { + const table = await perspective.table({ + a: [1, 2, 3, 4], + b: [new Date(), new Date(), new Date(), new Date()], + c: ["a", "b", "c", "d"], + d: [1.5, 2.5, 3.5, 4.5], + }); + const expressions = [ + '"a" + "d"', // valid + '"abxbasd" + "sdfadsf"', // invalid + '"c"', // valid + "bucket(\"b\", 'M')", // valid + "bucket(\"basdsa\", 'Y')", // invalid + 'upper("c")', // valid + 'lower("sdfadsj")', // invalid + ]; + const results = await table.validate_expressions(expressions); + expect(results.expression_schema).toEqual({ + '"a" + "d"': "float", + '"c"': "string", + "bucket(\"b\", 'M')": "date", + 'upper("c")': "string", + }); + + for (const expr of expressions) { + expect(results.expression_alias[expr]).toEqual(expr); + } + + table.delete(); + }); + + test("Should skip a invalid column due to parse error", async function () { + const table = await perspective.table({ + a: [1, 2, 3, 4], + b: [new Date(), new Date(), new Date(), new Date()], + c: ["a", "b", "c", "d"], + d: [1.5, 2.5, 3.5, 4.5], + }); + const expressions = [ + '"a" + "d"', // valid + "{", // invalid + "if (\"c\" == 'a') 123; else 0;", // valid + "if (", // invalid + ]; + const results = await table.validate_expressions(expressions); + expect(results.expression_schema).toEqual({ + '"a" + "d"': "float", + "if (\"c\" == 'a') 123; else 0;": "float", + }); + table.delete(); + }); + }); + + test.describe("View expression schema", function () { + test("Column types in view schema on 0-sided view.", async function () { + const table = await perspective.table({ + a: [1, 2, 3, 4], + b: [new Date(), new Date(), new Date(), new Date()], + c: ["a", "b", "c", "d"], + }); + const view = await table.view({ + expressions: [ + '"a" ^ 2', + "sqrt(144)", + "0 and 1", + "0 or 1", + '-"a"', + 'upper("c")', + `bucket("b", 'M')`, + `bucket("b", 's')`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + const schema = await view.schema(); + expect(schema).toEqual({ + a: "integer", + b: "datetime", + c: "string", + '"a" ^ 2': "float", + "sqrt(144)": "float", + '-"a"': "integer", + "0 and 1": "boolean", + "0 or 1": "boolean", + 'upper("c")': "string", + "bucket(\"b\", 'M')": "date", + "bucket(\"b\", 's')": "datetime", + }); + view.delete(); + table.delete(); + }); + + test("Column types in view expression schema on 0-sided view.", async function () { + const table = await perspective.table({ + a: [1, 2, 3, 4], + b: [new Date(), new Date(), new Date(), new Date()], + c: ["a", "b", "c", "d"], + }); + const view = await table.view({ + expressions: [ + '"a" ^ 2', + "sqrt(144)", + "0 and 1", + "0 or 1", + '-"a"', + 'upper("c")', + `bucket("b", 'M')`, + `bucket("b", 's')`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + const schema = await view.expression_schema(); + expect(schema).toEqual({ + '"a" ^ 2': "float", + "sqrt(144)": "float", + '-"a"': "integer", + "0 and 1": "boolean", + "0 or 1": "boolean", + 'upper("c")': "string", + "bucket(\"b\", 'M')": "date", + "bucket(\"b\", 's')": "datetime", + }); + view.delete(); + table.delete(); + }); + + test("Should show ALL columns in view expression schema regardless of custom columns.", async function () { + const table = await perspective.table({ + a: [1, 2, 3, 4], + b: [new Date(), new Date(), new Date(), new Date()], + c: ["a", "b", "c", "d"], + }); + const view = await table.view({ + columns: ["a"], + expressions: [ + '"a" ^ 2', + "sqrt(144)", + "0 and 1", + "0 or 1", + '-"a"', + 'upper("c")', + `bucket("b", 'M')`, + `bucket("b", 's')`, + `bucket("b",'M')`, + `bucket("b",'s')`, + `bucket("b", 'M')`, + `bucket("b", 's')`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + const schema = await view.expression_schema(); + expect(schema).toEqual({ + '"a" ^ 2': "float", + "sqrt(144)": "float", + '-"a"': "integer", + "0 and 1": "boolean", + "0 or 1": "boolean", + 'upper("c")': "string", + "bucket(\"b\", 'M')": "date", + "bucket(\"b\", 's')": "datetime", + "bucket(\"b\",'M')": "date", + "bucket(\"b\",'s')": "datetime", + "bucket(\"b\", 'M')": "date", + "bucket(\"b\", 's')": "datetime", + }); + view.delete(); + table.delete(); + }); + + test("Aggregated types in view expression schema on 1-sided view.", async function () { + const table = await perspective.table({ + a: [1, 2, 3, 4], + b: [new Date(), new Date(), new Date(), new Date()], + c: ["a", "b", "c", "d"], + }); + const view = await table.view({ + group_by: ["c"], + expressions: [ + '"a" ^ 2', + "sqrt(144)", + "0 and 1", + "0 or 1", + '-"a"', + 'upper("c")', + `bucket("b", 'M')`, + `bucket("b", 's')`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + const schema = await view.expression_schema(); + expect(schema).toEqual({ + '"a" ^ 2': "float", + "sqrt(144)": "float", + '-"a"': "integer", + "0 and 1": "integer", + "0 or 1": "integer", + 'upper("c")': "integer", + "bucket(\"b\", 'M')": "integer", + "bucket(\"b\", 's')": "integer", + }); + view.delete(); + table.delete(); + }); + + test("Aggregated types in view expression schema on 1-sided view with custom aggregates.", async function () { + const table = await perspective.table({ + a: [1, 2, 3, 4], + b: [new Date(), new Date(), new Date(), new Date()], + c: ["a", "b", "c", "d"], + }); + const view = await table.view({ + group_by: ["c"], + aggregates: { + "0 and 1": "any", + "0 or 1": "any", + "bucket(\"b\", 's')": "last", + }, + expressions: [ + '"a" ^ 2', + "sqrt(144)", + "0 and 1", + "0 or 1", + '-"a"', + 'upper("c")', + `bucket("b", 'M')`, + `bucket("b", 's')`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + const schema = await view.expression_schema(); + expect(schema).toEqual({ + '"a" ^ 2': "float", + "sqrt(144)": "float", + '-"a"': "integer", + "0 and 1": "boolean", + "0 or 1": "boolean", + 'upper("c")': "integer", + "bucket(\"b\", 'M')": "integer", + "bucket(\"b\", 's')": "datetime", + }); + view.delete(); + table.delete(); + }); + + test("Aggregated types in view expression schema on 2-sided view.", async function () { + const table = await perspective.table({ + a: [1, 2, 3, 4], + b: [new Date(), new Date(), new Date(), new Date()], + c: ["a", "b", "c", "d"], + }); + const view = await table.view({ + group_by: ["c"], + split_by: ["a"], + expressions: [ + '"a" ^ 2', + "sqrt(144)", + "0 and 1", + "0 or 1", + '-"a"', + 'upper("c")', + `bucket("b", 'M')`, + `bucket("b", 's')`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + const schema = await view.expression_schema(); + expect(schema).toEqual({ + '"a" ^ 2': "float", + "sqrt(144)": "float", + '-"a"': "integer", + "0 and 1": "integer", // aggregated bool = count + "0 or 1": "integer", + 'upper("c")': "integer", + "bucket(\"b\", 'M')": "integer", + "bucket(\"b\", 's')": "integer", + }); + view.delete(); + table.delete(); + }); + + test("Aggregated types in view expression schema on 2-sided column-only view.", async function () { + const table = await perspective.table({ + a: [1, 2, 3, 4], + b: [new Date(), new Date(), new Date(), new Date()], + c: ["a", "b", "c", "d"], + }); + const view = await table.view({ + group_by: ["c"], + split_by: ["a"], + aggregates: { + "0 and 1": "any", + "0 or 1": "any", + "bucket(\"b\", 's')": "last", + }, + expressions: [ + '"a" ^ 2', + "sqrt(144)", + "0 and 1", + "0 or 1", + '-"a"', + 'upper("c")', + `bucket("b", 'M')`, + `bucket("b", 's')`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + const schema = await view.expression_schema(); + expect(schema).toEqual({ + '"a" ^ 2': "float", + "sqrt(144)": "float", + '-"a"': "integer", + "0 and 1": "boolean", + "0 or 1": "boolean", + 'upper("c")': "integer", + "bucket(\"b\", 'M')": "integer", + "bucket(\"b\", 's')": "datetime", + }); + view.delete(); + table.delete(); + }); + + test("Vlookup should access the correct row", async function () { + const table = await perspective.table({ + a: [1, 2, 3, 4], + b: ["a", "b", "c", "d"], + }); + const expr = "vlookup('b', integer(0))"; + const view = await table.view({ + expressions: [expr].reduce( + (x, y) => Object.assign(x, { [y]: y }), + {}, + ), + }); + const result = await view.to_columns(); + expect(result).toEqual({ + a: [1, 2, 3, 4], + b: ["a", "b", "c", "d"], + [expr]: ["a", "a", "a", "a"], + }); + view.delete(); + table.delete(); + }); + + test("Col should behave the same as direct column access", async function () { + const table = await perspective.table({ + a: [1, 2, 3, 4], + b: ["a", "b", "c", "d"], + }); + const view = await table.view({ + expressions: { "col('b')": "col('b')" }, + }); + const result = await view.to_columns(); + expect(result).toEqual({ + a: [1, 2, 3, 4], + b: ["a", "b", "c", "d"], + "col('b')": ["a", "b", "c", "d"], + }); + view.delete(); + table.delete(); + }); + + test("index() should return the correct row number", async function () { + const table = await perspective.table({ + a: [1, 2, 3, 4], + }); + const view = await table.view({ + expressions: { "index()": "index()" }, + }); + const result = await view.to_columns(); + expect(result).toEqual({ + a: [1, 2, 3, 4], + "index()": [0, 1, 2, 3], + }); + view.delete(); + table.delete(); + }); + + test("index() should work with custom a index", async function () { + const table = await perspective.table( + { + a: [1, 2, 3, 4], + }, + { index: "a" }, + ); + const view = await table.view({ + expressions: { "index()": "index()" }, + }); + const result = await view.to_columns(); + expect(result).toEqual({ + a: [1, 2, 3, 4], + "index()": [1, 2, 3, 4], + }); + view.delete(); + table.delete(); + }); + + test("index() should work with an update", async function () { + const table = await perspective.table( + { + a: [1, 2, 3, 4], + }, + { index: "a" }, + ); + + const view = await table.view({ + expressions: { "index()": "index()" }, + }); + + let sentinel = false; + view.on_update(() => { + sentinel = true; + }); + + await table.update({ a: [5, 6] }); + const result = await view.to_columns(); + + expect(sentinel).toBeTruthy(); + expect(result).toEqual({ + a: [1, 2, 3, 4, 5, 6], + "index()": [1, 2, 3, 4, 5, 6], + }); + + view.delete(); + table.delete(); + }); + + test("Validation works with both input formats", async function () { + const table = await perspective.table({ + a: [1, 2, 3, 4], + b: [1, 2, 3, 4], + }); + + const str_exprs = { + x: '"a"', + y: '"b" * 0.5', + c: "'abcdefg'", + d: "true and false", + e: 'float("a") > 2 ? null : 1', + f: "today()", + g: "now()", + h: "length(123)", + }; + + const dict_exprs = { + x: '"a"', + y: '"b" * 0.5', + c: "'abcdefg'", + d: "true and false", + e: 'float("a") > 2 ? null : 1', + f: "today()", + g: "now()", + h: "length(123)", + }; + + let vstrs = await table.validate_expressions(str_exprs); + let vdicts = await table.validate_expressions(dict_exprs); + expect(vstrs.errors).toEqual(vdicts.errors); + expect(vstrs.expression_schema).toEqual( + vdicts.expression_schema, + ); + + for (let [key, val] of Object.entries(vstrs.expression_alias)) { + let dict_val = vdicts.expression_alias[key]; + // let parsed_val = `${dict_val}`; + expect(val).toEqual(dict_val); + } + }); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/expressions/invariant.js b/rust/perspective-js/test/js/expressions/invariant.js new file mode 100644 index 0000000..ed176b4 --- /dev/null +++ b/rust/perspective-js/test/js/expressions/invariant.js @@ -0,0 +1,385 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client"; + +const jsc = require("jsverify"); + +const replicate = (n, g) => jsc.tuple(new Array(n).fill(g)); + +const array_equals = function (a, b) { + return JSON.stringify(a) === JSON.stringify(b); +}; + +const generator = function (length = 100, has_zero = true) { + const min = has_zero ? 0 : 1; + return jsc.record({ + a: replicate(length, jsc.number(min, 1000000)), + b: replicate(length, jsc.number(min, 1000000)), + c: replicate(length, jsc.string), + d: replicate(length, jsc.datetime), + e: replicate(length, jsc.bool), + }); +}; + +/** + * Uses invariant testing to assert base correctness of computed columns. + */ +((perspective) => { + test.describe("Invariant testing", function () { + test.describe("Inverse expressions should be invariant", function () { + jsc.property("(x - y) + x == y", generator(), async (data) => { + const table = await perspective.table(data); + + const view = await table.view({ + expressions: { '("a" - "b") + "b"': '("a" - "b") + "b"' }, + }); + const result = await view.to_columns(); + const expected = array_equals( + result['("a" - "b") + "b"'], + data["a"], + ); + view.delete(); + table.delete(); + return expected; + }); + + jsc.property("(x + y) - x - y == 0", generator(), async (data) => { + const table = await perspective.table(data); + + const view = await table.view({ + expressions: { + '("a" + "b") - "a" - "b"': '("a" + "b") - "a" - "b"', + }, + }); + const result = await view.to_columns(); + const expected = array_equals( + result['("a" + "b") - "a" - "b"'], + Array(data["a"].length).fill(0), + ); + view.delete(); + table.delete(); + return expected; + }); + + jsc.property( + "(x + y) - (x + y) == 0", + generator(), + async (data) => { + const table = await perspective.table(data); + + const view = await table.view({ + expressions: { + '("a" + "b") - ("a" + "b")': + '("a" + "b") - ("a" + "b")', + }, + }); + const result = await view.to_columns(); + const expected = array_equals( + result['("a" + "b") - ("a" + "b")'], + Array(data["a"].length).fill(0), + ); + view.delete(); + table.delete(); + return expected; + }, + ); + + jsc.property("(x - x) == 0", generator(), async (data) => { + const table = await perspective.table(data); + + const view = await table.view({ + expressions: { '"a" - "a"': '"a" - "a"' }, + }); + const result = await view.to_columns(); + const expected = array_equals( + result['"a" - "a"'], + Array(data["a"].length).fill(0), + ); + view.delete(); + table.delete(); + return expected; + }); + + jsc.property("(x / x) == 1", generator(), async (data) => { + const table = await perspective.table(data); + + const view = await table.view({ + expressions: { '"a" / "a"': '"a" / "a"' }, + }); + const result = await view.to_columns(); + const expected = array_equals( + result['"a" / "a"'], + Array(data["a"].length).fill(1), + ); + view.delete(); + table.delete(); + return expected; + }); + + jsc.property("(x + x) - x - x == 0", generator(), async (data) => { + const table = await perspective.table(data); + + const view = await table.view({ + expressions: { + '("a" + "a") - "a" - "a"': '("a" + "a") - "a" - "a"', + }, + }); + const result = await view.to_columns(); + const expected = array_equals( + result['("a" + "a") - "a" - "a"'], + Array(data["a"].length).fill(0), + ); + view.delete(); + table.delete(); + return expected; + }); + + jsc.property( + "sqrt(x ^ 2) == x", + generator(100, false), + async (data) => { + const table = await perspective.table(data); + + const view = await table.view({ + expressions: { + 'sqrt(pow("a", 2))': 'sqrt(pow("a", 2))', + }, + }); + const result = await view.to_columns(); + const expected = array_equals( + result['sqrt(pow("a", 2))'], + data["a"], + ); + view.delete(); + table.delete(); + return expected; + }, + ); + + // jsc.property( + // "x ^ 2 == (x * x)", + // generator(100, false), + // async (data) => { + // const table = await perspective.table(data); + + // const view = await table.view({ + // expressions: ['pow("a", 2)', '"a" * "a"'].reduce((x, y) => Object.assign(x, {[y]: y}), {}), + // }); + // const result = await view.to_columns(); + // const expected = array_equals( + // result['pow("a", 2)'], + // result['"a" * "a"'] + // ); + // view.delete(); + // table.delete(); + // return expected; + // } + // ); + + jsc.property( + "x % x == 100", + generator(100, false), + async (data) => { + const table = await perspective.table(data); + + const view = await table.view({ + expressions: { + 'percent_of("a", "a")': 'percent_of("a", "a")', + }, + }); + const result = await view.to_columns(); + const expected = array_equals( + result['percent_of("a", "a")'], + Array(data["a"].length).fill(100), + ); + view.delete(); + table.delete(); + return expected; + }, + ); + + jsc.property( + "abs(x) ? x > 0 == x", + generator(100, false), + async (data) => { + const table = await perspective.table(data); + + const view = await table.view({ + expressions: { 'abs("a")': 'abs("a")' }, + }); + const result = await view.to_columns(); + const expected = array_equals( + result['abs("a")'], + data["a"], + ); + view.delete(); + table.delete(); + return expected; + }, + ); + }); + + test.describe("Comparison operations should be pure with the same inputs.", function () { + jsc.property( + "== should always be true with the same input column", + generator(), + async (data) => { + const table = await perspective.table(data); + + const view = await table.view({ + expressions: { '"a" == "a"': '"a" == "a"' }, + }); + + const result = await view.to_columns(); + const expected = array_equals( + result['"a" == "a"'], + Array(data["a"].length).fill(true), + ); + view.delete(); + table.delete(); + return expected; + }, + ); + + jsc.property( + "> should always be false with the same input column", + generator(), + async (data) => { + const table = await perspective.table(data); + + const view = await table.view({ + expressions: { '"a" > "a"': '"a" > "a"' }, + }); + + const result = await view.to_columns(); + const expected = array_equals( + result['"a" > "a"'], + Array(data["a"].length).fill(false), + ); + view.delete(); + table.delete(); + return expected; + }, + ); + + jsc.property( + "< should always be false with the same input column", + generator(), + async (data) => { + const table = await perspective.table(data); + + const view = await table.view({ + expressions: { '"a" < "a"': '"a" < "a"' }, + }); + + const result = await view.to_columns(); + const expected = array_equals( + result['"a" < "a"'], + Array(data["a"].length).fill(false), + ); + view.delete(); + table.delete(); + return expected; + }, + ); + + jsc.property( + "String == should always be true with the same input column", + generator(), + async (data) => { + const table = await perspective.table({ + a: "float", + b: "float", + c: "string", + d: "datetime", + e: "boolean", + }); + + await table.update(data); + + const view = await table.view({ + expressions: { '"c" == "c"': '"c" == "c"' }, + }); + + const result = await view.to_columns(); + const expected = array_equals( + result['"c" == "c"'], + Array(data["c"].length).fill(true), + ); + view.delete(); + table.delete(); + return expected; + }, + ); + + jsc.property( + "Datetime == should always be true with the same input column", + generator(), + async (data) => { + const table = await perspective.table({ + a: "float", + b: "float", + c: "string", + d: "datetime", + e: "boolean", + }); + + await table.update(data); + + const view = await table.view({ + expressions: { '"d" == "d"': '"d" == "d"' }, + }); + + const result = await view.to_columns(); + const expected = array_equals( + result['"d" == "d"'], + Array(data["d"].length).fill(true), + ); + view.delete(); + table.delete(); + return expected; + }, + ); + + jsc.property( + "Date == should always be true with the same input column", + generator(), + async (data) => { + const table = await perspective.table({ + a: "float", + b: "float", + c: "string", + d: "date", + e: "boolean", + }); + + await table.update(data); + + const view = await table.view({ + expressions: { '"d" == "d"': '"d" == "d"' }, + }); + + const result = await view.to_columns(); + const expected = array_equals( + result['"d" == "d"'], + Array(data["d"].length).fill(true), + ); + view.delete(); + table.delete(); + return expected; + }, + ); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/expressions/multiple_views.spec.js b/rust/perspective-js/test/js/expressions/multiple_views.spec.js new file mode 100644 index 0000000..729d8af --- /dev/null +++ b/rust/perspective-js/test/js/expressions/multiple_views.spec.js @@ -0,0 +1,1472 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client"; + +import * as expressions_common from "./common.js"; + +/** + * Tests the functionality of `View`-based expressions, specifically that + * existing column/view semantics (pivots, aggregates, columns, sorts, filters) + * continue to be functional on expressions. + */ +((perspective) => { + const validate_delta = async (colname, delta, expected) => { + const t = await perspective.table(delta.slice()); + const v = await t.view(); + const data = await v.to_columns(); + expect(data[colname]).toEqual(expected); + await v.delete(); + await t.delete(); + }; + + test.describe("Expressions with multiple views", () => { + test.describe("Schema then update", () => { + test("Appends", async () => { + const table = await perspective.table({ + x: "integer", + y: "string", + z: "boolean", + }); + + const v1 = await table.view({ + expressions: { column: `"x" + 10` }, + }); + const v2 = await table.view({ + expressions: { column: `upper("y")` }, + }); + + let result = await v1.to_columns(); + let result2 = await v2.to_columns(); + + expect(result).toEqual({ column: [], x: [], y: [], z: [] }); + expect(result2).toEqual({ column: [], x: [], y: [], z: [] }); + + await table.update(expressions_common.data); + + result = await v1.to_columns(); + result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([11, 12, 13, 14]); + expect(result2["column"]).toEqual(["A", "B", "C", "D"]); + + await v2.delete(); + await v1.delete(); + await table.delete(); + }); + + test("Append in sequence", async () => { + const table = await perspective.table({ + x: "integer", + y: "string", + z: "boolean", + }); + + const v1 = await table.view({ + expressions: { column: `"x" * 2` }, + }); + + const v2 = await table.view({ + expressions: { column: `upper(concat("y", 'bcd'))` }, + }); + + let result = await v1.to_columns(); + let result2 = await v2.to_columns(); + + expect(result).toEqual({ column: [], x: [], y: [], z: [] }); + expect(result2).toEqual({ column: [], x: [], y: [], z: [] }); + + const expected = []; + const expected2 = []; + + for (let i = 1; i < 10; i++) { + await table.update({ + x: [i], + y: [`${i}`], + }); + + result = await v1.to_columns(); + result2 = await v2.to_columns(); + + expected.push(i * 2); + expected2.push(`${i}BCD`); + + expect(result["column"]).toEqual(expected); + expect(result2["column"]).toEqual(expected2); + } + + await v2.delete(); + await v1.delete(); + await table.delete(); + }); + + test("Append in sequence, filtered", async () => { + const table = await perspective.table({ + x: "integer", + y: "string", + z: "boolean", + }); + + const v1 = await table.view({ + expressions: { column: `"x" * 2` }, + filter: [["column", ">", 5]], + }); + + const v2 = await table.view({ + expressions: { column: `upper(concat("y", 'bcd'))` }, + filter: [["column", "contains", "A"]], + }); + + let result = await v1.to_columns(); + let result2 = await v2.to_columns(); + + expect(result).toEqual({ column: [], x: [], y: [], z: [] }); + expect(result2).toEqual({ column: [], x: [], y: [], z: [] }); + + const expected = []; + const expected2 = []; + + for (let i = 1; i < 10; i++) { + await table.update({ + x: [i], + y: [`A${i}`], + }); + + result = await v1.to_columns(); + result2 = await v2.to_columns(); + + if (i * 2 > 5) { + expected.push(i * 2); + expect(result["column"]).toEqual(expected); + } else { + expect(result).toEqual({ + column: [], + x: [], + y: [], + z: [], + }); + } + + expected2.push(`A${i}BCD`); + expect(result2["column"]).toEqual(expected2); + } + + await v2.delete(); + await v1.delete(); + await table.delete(); + }); + + test("Append in sequence, sorted", async () => { + const table = await perspective.table({ + x: "integer", + y: "string", + z: "boolean", + }); + + const v1 = await table.view({ + expressions: { column: `"x" * 2` }, + sort: [["column", "desc"]], + }); + + const v2 = await table.view({ + expressions: { column: `upper(concat("y", 'bcd'))` }, + sort: [["column", "asc"]], + }); + + let result = await v1.to_columns(); + let result2 = await v2.to_columns(); + + expect(result).toEqual({ column: [], x: [], y: [], z: [] }); + expect(result2).toEqual({ column: [], x: [], y: [], z: [] }); + + const expected = []; + const expected2 = []; + + let idx = 0; + for (let i = 5; i < 9; i++) { + await table.update({ + x: [i], + y: [`${i}`], + }); + + result = await v1.to_columns(); + result2 = await v2.to_columns(); + + expected.unshift(i * 2); + expected2.splice(idx, 0, `${i}BCD`); + + expect(result["column"]).toEqual(expected); + expect(result2["column"]).toEqual(expected2); + idx++; + } + + await v2.delete(); + await v1.delete(); + await table.delete(); + }); + + test("Partial update", async () => { + const table = await perspective.table( + { + x: "integer", + y: "string", + z: "boolean", + }, + { index: "x" }, + ); + + const v1 = await table.view({ + expressions: { column: `"x" * 2` }, + }); + const v2 = await table.view({ + expressions: { column: `upper("y")` }, + }); + + let result = await v1.to_columns(); + let result2 = await v2.to_columns(); + + expect(result).toEqual({ column: [], x: [], y: [], z: [] }); + expect(result2).toEqual({ column: [], x: [], y: [], z: [] }); + + await table.update({ + x: [2, 4, 3, 10, null], + y: ["X", "Y", "Z", "ABC", "DEF"], + }); + + result = await v1.to_columns(); + result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([null, 4, 6, 8, 20]); + expect(result2["column"]).toEqual([ + "DEF", + "X", + "Z", + "Y", + "ABC", + ]); + + await v2.delete(); + await v1.delete(); + await table.delete(); + }); + + test("Partial update, filtered", async () => { + const table = await perspective.table( + { + x: "integer", + y: "string", + z: "boolean", + }, + { index: "x" }, + ); + + const v1 = await table.view({ + expressions: { column: `"x" * 2` }, + filter: [["column", "==", 8]], + }); + const v2 = await table.view({ + expressions: { column: `upper("y")` }, + filter: [["column", "==", "Z"]], + }); + + let result = await v1.to_columns(); + let result2 = await v2.to_columns(); + + expect(result).toEqual({ column: [], x: [], y: [], z: [] }); + expect(result2).toEqual({ column: [], x: [], y: [], z: [] }); + + await table.update({ + x: [2, 4, 3, 10, null], + y: ["X", "Y", "Z", "ABC", "DEF"], + }); + + result = await v1.to_columns(); + result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([8]); + expect(result2["column"]).toEqual(["Z"]); + + await v2.delete(); + await v1.delete(); + await table.delete(); + }); + + test("Partial update, sorted", async () => { + const table = await perspective.table( + { + x: "integer", + y: "string", + z: "boolean", + }, + { index: "x" }, + ); + + const v1 = await table.view({ + expressions: { column: `"x" * 2` }, + sort: [["column", "desc"]], + }); + const v2 = await table.view({ + expressions: { column: `upper("y")` }, + sort: [["column", "desc"]], + }); + + let result = await v1.to_columns(); + let result2 = await v2.to_columns(); + + expect(result).toEqual({ column: [], x: [], y: [], z: [] }); + expect(result2).toEqual({ column: [], x: [], y: [], z: [] }); + + await table.update({ + x: [2, 4, 3, 10, null], + y: ["X", "Y", "Z", "ABC", "DEF"], + }); + + result = await v1.to_columns(); + result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([20, 8, 6, 4, null]); + expect(result2["column"]).toEqual([ + "Z", + "Y", + "X", + "DEF", + "ABC", + ]); + + await table.update({ + x: [2, 10], + y: ["XYZ", "DEF"], + }); + + result = await v1.to_columns(); + result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([20, 8, 6, 4, null]); + expect(result2["x"]).toEqual([3, 4, 2, null, 10]); + expect(result2["column"]).toEqual([ + "Z", + "Y", + "XYZ", + "DEF", + "DEF", + ]); + + await v2.delete(); + await v1.delete(); + await table.delete(); + }); + + test("Limit", async () => { + const table = await perspective.table( + { + x: "integer", + y: "string", + z: "boolean", + }, + { limit: 2 }, + ); + + const v1 = await table.view({ + expressions: { column: `"x" * 2` }, + }); + const v2 = await table.view({ + expressions: { column: `upper("y")` }, + }); + + let result = await v1.to_columns(); + let result2 = await v2.to_columns(); + + expect(result).toEqual({ column: [], x: [], y: [], z: [] }); + expect(result2).toEqual({ column: [], x: [], y: [], z: [] }); + + await table.update({ + x: [2, 4, 3, 10, null], + y: ["X", "Y", "Z", "ABC", "DEF"], + }); + + result = await v1.to_columns(); + result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([20, null]); + expect(result2["column"]).toEqual(["ABC", "DEF"]); + + expect(await table.size()).toEqual(2); + + await v2.delete(); + await v1.delete(); + await table.delete(); + }); + }); + + test.describe("Data then update", () => { + test("Appends", async () => { + const table = await perspective.table(expressions_common.data); + + const v1 = await table.view({ + expressions: { column: `"x" + 10` }, + }); + const v2 = await table.view({ + expressions: { column: `upper("y")` }, + }); + + let result = await v1.to_columns(); + let result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([11, 12, 13, 14]); + expect(result2["column"]).toEqual(["A", "B", "C", "D"]); + + await table.update(expressions_common.data); + + result = await v1.to_columns(); + result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([ + 11, 12, 13, 14, 11, 12, 13, 14, + ]); + expect(result2["column"]).toEqual([ + "A", + "B", + "C", + "D", + "A", + "B", + "C", + "D", + ]); + + await v2.delete(); + await v1.delete(); + await table.delete(); + }); + + test("Append in sequence", async () => { + const table = await perspective.table(expressions_common.data); + + const v1 = await table.view({ + expressions: { column: `"x" * 2` }, + }); + + const v2 = await table.view({ + expressions: { column: `upper(concat("y", 'bcd'))` }, + }); + + let result = await v1.to_columns(); + let result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([2, 4, 6, 8]); + expect(result2["column"]).toEqual([ + "ABCD", + "BBCD", + "CBCD", + "DBCD", + ]); + + const expected = [2, 4, 6, 8]; + const expected2 = ["ABCD", "BBCD", "CBCD", "DBCD"]; + + for (let i = 0; i < 10; i++) { + await table.update({ + x: [i + 4], + y: [`${i + 4}`], + }); + + result = await v1.to_columns(); + result2 = await v2.to_columns(); + + expected.push((i + 4) * 2); + expected2.push(`${i + 4}BCD`); + + expect(result["column"]).toEqual(expected); + expect(result2["column"]).toEqual(expected2); + } + + await v2.delete(); + await v1.delete(); + await table.delete(); + }); + + test("Append in sequence, filtered", async () => { + const table = await perspective.table(expressions_common.data); + + const v1 = await table.view({ + expressions: { column: `"x" * 2` }, + filter: [["column", ">", 5]], + }); + + const v2 = await table.view({ + expressions: { column: `upper(concat("y", 'bcd'))` }, + filter: [["column", "contains", "A"]], + }); + + let result = await v1.to_columns(); + let result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([6, 8]); + expect(result2["column"]).toEqual(["ABCD"]); + + const expected = [6, 8]; + + for (let i = 0; i < 10; i++) { + await table.update({ + x: [i + 4], + y: [`${i + 4}`], + }); + + result = await v1.to_columns(); + result2 = await v2.to_columns(); + + expected.push((i + 4) * 2); + + expect(result["column"]).toEqual(expected); + expect(result2["column"]).toEqual(["ABCD"]); + } + + await v2.delete(); + await v1.delete(); + await table.delete(); + }); + + test("Append in sequence, sorted", async () => { + const table = await perspective.table(expressions_common.data); + + const v1 = await table.view({ + expressions: { column: `"x" * 2` }, + sort: [["column", "desc"]], + }); + + const v2 = await table.view({ + expressions: { column: `upper(concat("y", 'bcd'))` }, + sort: [["column", "asc"]], + }); + + let result = await v1.to_columns(); + let result2 = await v2.to_columns(); + + const expected = [8, 6, 4, 2]; + const expected2 = ["ABCD", "BBCD", "CBCD", "DBCD"]; + + expect(result["column"]).toEqual(expected); + expect(result2["column"]).toEqual(expected2); + + let idx = 0; + for (let i = 5; i < 9; i++) { + await table.update({ + x: [i], + y: [`${i}`], + }); + + result = await v1.to_columns(); + result2 = await v2.to_columns(); + + expected.unshift(i * 2); + expected2.splice(idx, 0, `${i}BCD`); + + expect(result["column"]).toEqual(expected); + expect(result2["column"]).toEqual(expected2); + idx++; + } + + await v2.delete(); + await v1.delete(); + await table.delete(); + }); + + test("Partial update", async () => { + const table = await perspective.table(expressions_common.data, { + index: "x", + }); + + const v1 = await table.view({ + expressions: { column: `"x" * 2` }, + }); + const v2 = await table.view({ + expressions: { column: `upper("y")` }, + }); + + let result = await v1.to_columns(); + let result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([2, 4, 6, 8]); + expect(result2["column"]).toEqual(["A", "B", "C", "D"]); + + await table.update({ + x: [2, 4, 3, 10, null], + y: ["X", "Y", "Z", "ABC", "DEF"], + }); + + result = await v1.to_columns(); + result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([null, 2, 4, 6, 8, 20]); + expect(result2["column"]).toEqual([ + "DEF", + "A", + "X", + "Z", + "Y", + "ABC", + ]); + + await v2.delete(); + await v1.delete(); + await table.delete(); + }); + + test("Partial update, filtered", async () => { + const table = await perspective.table(expressions_common.data, { + index: "x", + }); + + const v1 = await table.view({ + expressions: { column: `"x" * 2` }, + filter: [["column", "==", 8]], + }); + const v2 = await table.view({ + expressions: { column: `upper("y")` }, + filter: [["column", "==", "B"]], + }); + + let result = await v1.to_columns(); + let result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([8]); + expect(result2["column"]).toEqual(["B"]); + + await table.update({ + x: [2, 4, 3, 10, null], + y: ["X", "Y", "Z", "ABC", "DEF"], + }); + + result = await v1.to_columns(); + result2 = await v2.to_columns(); + + console.log(result, result2); + + expect(result["column"]).toEqual([8]); + expect(result2).toEqual({ column: [], x: [], y: [], z: [] }); + + await v2.delete(); + await v1.delete(); + await table.delete(); + }); + + test("Partial update, sorted", async () => { + const table = await perspective.table(expressions_common.data, { + index: "x", + }); + + const v1 = await table.view({ + expressions: { column: `"x" * 2` }, + sort: [["column", "desc"]], + }); + const v2 = await table.view({ + expressions: { column: `upper("y")` }, + sort: [["column", "desc"]], + }); + + let result = await v1.to_columns(); + let result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([8, 6, 4, 2]); + expect(result2["column"]).toEqual(["D", "C", "B", "A"]); + + await table.update({ + x: [2, 4, 3, 10, null], + y: ["X", "Y", "Z", "ABC", "DEF"], + }); + + result = await v1.to_columns(); + result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([20, 8, 6, 4, 2, null]); + expect(result2["column"]).toEqual([ + "Z", + "Y", + "X", + "DEF", + "ABC", + "A", + ]); + + await table.update({ + x: [2, 10], + y: ["XYZ", "DEF"], + }); + + result = await v1.to_columns(); + result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([20, 8, 6, 4, 2, null]); + expect(result2["x"]).toEqual([3, 4, 2, null, 10, 1]); + expect(result2["column"]).toEqual([ + "Z", + "Y", + "XYZ", + "DEF", + "DEF", + "A", + ]); + + await v2.delete(); + await v1.delete(); + await table.delete(); + }); + + test("Limit", async () => { + const table = await perspective.table(expressions_common.data, { + limit: 2, + }); + + const v1 = await table.view({ + expressions: { column: `"x" * 2` }, + }); + const v2 = await table.view({ + expressions: { column: `upper("y")` }, + }); + + let result = await v1.to_columns(); + let result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([6, 8]); + expect(result2["column"]).toEqual(["C", "D"]); + expect(result["y"]).toEqual(["c", "d"]); + + await table.update({ + x: [2, 4, 3, 10, null], + y: ["X", "Y", "Z", "ABC", "DEF"], + }); + + result = await v1.to_columns(); + result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([20, null]); + expect(result2["column"]).toEqual(["ABC", "DEF"]); + + await v2.delete(); + await v1.delete(); + await table.delete(); + }); + }); + + test.describe("Deltas", () => { + test("Appends delta", async () => { + expect.assertions(6); + + const table = await perspective.table(expressions_common.data); + + const v1 = await table.view({ + expressions: { column: `"x" + 10` }, + }); + + const v2 = await table.view({ + expressions: { column: `upper("y")` }, + }); + + const result = await v1.to_columns(); + const result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([11, 12, 13, 14]); + expect(result2["column"]).toEqual(["A", "B", "C", "D"]); + + v1.on_update( + async (updated) => { + await validate_delta( + "column", + updated.delta, + [11, 12, 13, 14], + ); + const result = await v1.to_columns(); + expect(result["column"]).toEqual([ + 11, 12, 13, 14, 11, 12, 13, 14, + ]); + }, + { mode: "row" }, + ); + + let done; + let result3 = new Promise((x) => { + done = x; + }); + v2.on_update( + async (updated) => { + await validate_delta("column", updated.delta, [ + "A", + "B", + "C", + "D", + ]); + const result2 = await v2.to_columns(); + expect(result2["column"]).toEqual([ + "A", + "B", + "C", + "D", + "A", + "B", + "C", + "D", + ]); + await v2.delete(); + await v1.delete(); + await table.delete(); + done(); + }, + { mode: "row" }, + ); + + await table.update(expressions_common.data); + await result3; + }); + + test("Partial update delta", async () => { + expect.assertions(6); + + const table = await perspective.table(expressions_common.data, { + index: "x", + }); + + const v1 = await table.view({ + expressions: { column: `"x" * 2` }, + }); + + const v2 = await table.view({ + expressions: { column: `upper("y")` }, + }); + + const result = await v1.to_columns(); + const result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([2, 4, 6, 8]); + expect(result2["column"]).toEqual(["A", "B", "C", "D"]); + + v1.on_update( + async (updated) => { + await validate_delta("column", updated.delta, [ + null, + 4, + 6, + 8, + 20, + ]); + const result = await v1.to_columns(); + expect(result["column"]).toEqual([ + null, + 2, + 4, + 6, + 8, + 20, + ]); + }, + { mode: "row" }, + ); + + let done; + let result3 = new Promise((x) => { + done = x; + }); + v2.on_update( + async (updated) => { + await validate_delta("column", updated.delta, [ + "DEF", + "X", + "Z", + "Y", + "ABC", + ]); + const result = await v2.to_columns(); + expect(result["column"]).toEqual([ + "DEF", + "A", + "X", + "Z", + "Y", + "ABC", + ]); + await v2.delete(); + await v1.delete(); + await table.delete(); + done(); + }, + { mode: "row" }, + ); + + await table.update({ + x: [2, 4, 3, 10, null], + y: ["X", "Y", "Z", "ABC", "DEF"], + }); + await result3; + }); + + test("Partial update, sorted delta", async () => { + expect.assertions(6); + const table = await perspective.table(expressions_common.data, { + index: "x", + }); + + const v1 = await table.view({ + expressions: { column: `"x" * 2` }, + sort: [["column", "desc"]], + }); + const v2 = await table.view({ + expressions: { column: `upper("y")` }, + sort: [["column", "desc"]], + }); + + const result = await v1.to_columns(); + const result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([8, 6, 4, 2]); + expect(result2["column"]).toEqual(["D", "C", "B", "A"]); + + v1.on_update( + async (updated) => { + await validate_delta("column", updated.delta, [ + 20, + 8, + 6, + 4, + null, + ]); + const result = await v1.to_columns(); + expect(result["column"]).toEqual([ + 20, + 8, + 6, + 4, + 2, + null, + ]); + }, + { mode: "row" }, + ); + + let done; + let result3 = new Promise((x) => { + done = x; + }); + v2.on_update( + async (updated) => { + await validate_delta("column", updated.delta, [ + "Z", + "Y", + "X", + "DEF", + "ABC", + ]); + const result2 = await v2.to_columns(); + expect(result2["column"]).toEqual([ + "Z", + "Y", + "X", + "DEF", + "ABC", + "A", + ]); + await v2.delete(); + await v1.delete(); + await table.delete(); + done(); + }, + { mode: "row" }, + ); + + await table.update({ + x: [2, 4, 3, 10, null], + y: ["X", "Y", "Z", "ABC", "DEF"], + }); + await result3; + }); + }); + + test.describe("Clear/Replace", () => { + test("Rows should clear() when called", async () => { + const now = new Date(); + const bucketed = new Date(now.getUTCFullYear(), 0, 1).getTime(); + const table = await perspective.table({ + x: [1, 2, 3, 4], + y: ["a", "b", "c", "d"], + z: [now, now, now, now], + }); + + const v1 = await table.view({ + columns: ["column", "column2"], + expressions: { + [`column`]: `"x" + 10`, + [`column2`]: `concat('a', 'b', 'c')`, + }, + }); + const v2 = await table.view({ + columns: ["column2", "column"], + expressions: { + [`column`]: `upper("y")`, + [`column2`]: `bucket("z", 'Y')`, + }, + }); + + expect(await v1.expression_schema()).toEqual({ + column: "float", + column2: "string", + }); + + expect(await v2.expression_schema()).toEqual({ + column: "string", + column2: "date", + }); + + let result = await v1.to_columns(); + let result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([11, 12, 13, 14]); + expect(result["column2"]).toEqual(Array(4).fill("abc")); + expect(result2["column"]).toEqual(["A", "B", "C", "D"]); + expect(result2["column2"]).toEqual(Array(4).fill(bucketed)); + + await table.clear(); + + expect(await v1.num_rows()).toEqual(0); + expect(await v2.num_rows()).toEqual(0); + + result = await v1.to_columns(); + result2 = await v2.to_columns(); + + expect(result).toEqual({ column: [], column2: [] }); + expect(result2).toEqual({ column: [], column2: [] }); + + await v2.delete(); + await v1.delete(); + await table.delete(); + }); + + test("Rows should replace() when called", async () => { + const now = new Date(); + const bucketed = new Date(now.getUTCFullYear(), 0, 1).getTime(); + const table = await perspective.table({ + x: [1, 2, 3, 4], + y: ["a", "b", "c", "d"], + z: [now, now, now, now], + }); + + const v1 = await table.view({ + columns: ["column", "column2"], + expressions: { + [`column`]: `"x" + 10`, + [`column2`]: `concat('a', 'b', 'c')`, + }, + }); + const v2 = await table.view({ + columns: ["column2", "column"], + expressions: { + [`column`]: `upper("y")`, + [`column2`]: `bucket("z", 'Y')`, + }, + }); + + expect(await v1.expression_schema()).toEqual({ + column: "float", + column2: "string", + }); + + expect(await v2.expression_schema()).toEqual({ + column: "string", + column2: "date", + }); + + let result = await v1.to_columns(); + let result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([11, 12, 13, 14]); + expect(result["column2"]).toEqual(Array(4).fill("abc")); + expect(result2["column"]).toEqual(["A", "B", "C", "D"]); + expect(result2["column2"]).toEqual(Array(4).fill(bucketed)); + + await table.replace({ + x: [100, 300], + y: ["x", "y"], + z: [now, now], + }); + + expect(await v1.num_rows()).toEqual(2); + expect(await v2.num_rows()).toEqual(2); + + result = await v1.to_columns(); + result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([110, 310]); + expect(result["column2"]).toEqual(["abc", "abc"]); + expect(result2["column"]).toEqual(["X", "Y"]); + expect(result2["column2"]).toEqual([bucketed, bucketed]); + + await v2.delete(); + await v1.delete(); + await table.delete(); + }); + }); + + test.describe("Remove", () => { + test("Rows should remove() when called", async () => { + const now = new Date(); + const bucketed = new Date(now.getUTCFullYear(), 0, 1).getTime(); + const table = await perspective.table( + { + x: [1, 2, 3, 4], + y: ["a", "b", "c", "d"], + z: [now, now, now, now], + }, + { index: "x" }, + ); + + const v1 = await table.view({ + columns: ["column", "column2"], + expressions: { + [`column`]: `"x" + 10`, + [`column2`]: `concat('a', 'b', 'c')`, + }, + }); + const v2 = await table.view({ + columns: ["column2", "column"], + expressions: { + [`column`]: `upper("y")`, + [`column2`]: `bucket("z", 'Y')`, + }, + }); + + expect(await v1.expression_schema()).toEqual({ + column: "float", + column2: "string", + }); + + expect(await v2.expression_schema()).toEqual({ + column: "string", + column2: "date", + }); + + let result = await v1.to_columns(); + let result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([11, 12, 13, 14]); + expect(result["column2"]).toEqual(Array(4).fill("abc")); + expect(result2["column"]).toEqual(["A", "B", "C", "D"]); + expect(result2["column2"]).toEqual(Array(4).fill(bucketed)); + + await table.remove([2, 3]); + + // FIXME: size() and num_rows() do not respond correctly to + // remove() calls. + // expect(await v1.num_rows()).toEqual(2); + // expect(await v2.num_rows()).toEqual(2); + + result = await v1.to_columns(); + result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([11, 14]); + expect(result["column2"]).toEqual(["abc", "abc"]); + expect(result2["column"]).toEqual(["A", "D"]); + expect(result2["column2"]).toEqual([bucketed, bucketed]); + + await v2.delete(); + await v1.delete(); + await table.delete(); + }); + }); + + test("Multiple views with the same expression alias should not conflict", async () => { + const now = new Date(); + const bucketed = new Date(now.getUTCFullYear(), 0, 1).getTime(); + const table = await perspective.table({ + x: [1, 2, 3, 4], + y: ["a", "b", "c", "d"], + z: [now, now, now, now], + }); + + const v1 = await table.view({ + columns: ["column", "column2"], + expressions: { + [`column`]: `"x" + 10`, + [`column2`]: `concat('a', 'b', 'c')`, + }, + }); + const v2 = await table.view({ + columns: ["column2", "column"], + expressions: { + [`column`]: `upper("y")`, + [`column2`]: `bucket("z", 'Y')`, + }, + }); + + expect(await v1.expression_schema()).toEqual({ + column: "float", + column2: "string", + }); + + expect(await v2.expression_schema()).toEqual({ + column: "string", + column2: "date", + }); + + const result = await v1.to_columns(); + const result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([11, 12, 13, 14]); + expect(result["column2"]).toEqual(Array(4).fill("abc")); + expect(result2["column"]).toEqual(["A", "B", "C", "D"]); + expect(result2["column2"]).toEqual(Array(4).fill(bucketed)); + + await v2.delete(); + await v1.delete(); + await table.delete(); + }); + + test("Multiple views with the same expression alias should not conflict, to_arrow", async () => { + const table = await perspective.table(expressions_common.data); + + const v1 = await table.view({ + expressions: { column: `"x" + 10` }, + }); + const v2 = await table.view({ + expressions: { column: `upper("y")` }, + }); + + let result = await v1.to_columns(); + let result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([11, 12, 13, 14]); + expect(result2["column"]).toEqual(["A", "B", "C", "D"]); + + const t1 = await perspective.table(await v1.to_arrow()); + const t2 = await perspective.table(await v2.to_arrow()); + + const new_v1 = await t1.view(); + const new_v2 = await t2.view(); + + result = await new_v1.to_columns(); + result2 = await new_v2.to_columns(); + + expect(result["column"]).toEqual([11, 12, 13, 14]); + expect(result2["column"]).toEqual(["A", "B", "C", "D"]); + + await new_v2.delete(); + await new_v1.delete(); + await t2.delete(); + await t1.delete(); + await v2.delete(); + await v1.delete(); + await table.delete(); + }); + + test("Multiple views with the same expression alias should not conflict, filtered", async () => { + const table = await perspective.table(expressions_common.data); + + const v1 = await table.view({ + expressions: { column: `"x" + 10` }, + filter: [["column", "==", 12]], + }); + const v2 = await table.view({ + expressions: { column: `upper("y")` }, + filter: [["column", "==", "D"]], + }); + + const result = await v1.to_columns(); + const result2 = await v2.to_columns(); + + expect(result["column"]).toEqual([12]); + expect(result2["column"]).toEqual(["D"]); + + await v2.delete(); + await v1.delete(); + await table.delete(); + }); + + test("Multiple pivoted views with the same expression alias should not conflict", async () => { + const table = await perspective.table(expressions_common.data); + + const v1 = await table.view({ + group_by: ["y"], + split_by: ["x"], + expressions: { column: `"x" + 10` }, + }); + const v2 = await table.view({ + group_by: ["x"], + split_by: ["y"], + expressions: { column: `upper("y")` }, + aggregates: { + column: "last", + }, + }); + + const result = await v1.to_columns(); + const result2 = await v2.to_columns(); + + expect(result["1|column"]).toEqual([11, 11, null, null, null]); + expect(result["2|column"]).toEqual([12, null, 12, null, null]); + expect(result["3|column"]).toEqual([13, null, null, 13, null]); + expect(result["4|column"]).toEqual([14, null, null, null, 14]); + + expect(result2["a|column"]).toEqual(["A", "A", null, null, null]); + expect(result2["b|column"]).toEqual(["B", null, "B", null, null]); + expect(result2["c|column"]).toEqual(["C", null, null, "C", null]); + expect(result2["d|column"]).toEqual(["D", null, null, null, "D"]); + + await v2.delete(); + await v1.delete(); + await table.delete(); + }); + + test("Multiple pivoted views with the same expression alias and different aggregates should not conflict", async () => { + const table = await perspective.table({ + x: [10, 10, 20, 20], + y: ["A", "B", "C", "D"], + z: [1.5, 2.5, 3.5, 4.5], + }); + + const v1 = await table.view({ + group_by: ["x"], + expressions: { column: `"z" + 10` }, + aggregates: { + column: "avg", + }, + }); + + const v2 = await table.view({ + group_by: ["x"], + expressions: { column: `upper("y")` }, + aggregates: { + column: "last", + }, + }); + + const v3 = await table.view({ + group_by: ["x"], + expressions: { column: `2 * "z"` }, + aggregates: { + column: ["weighted mean", ["z"]], + }, + }); + + const result = await v1.to_columns(); + const result2 = await v2.to_columns(); + const result3 = await v3.to_columns(); + + expect(result["column"]).toEqual([13, 12, 14]); + expect(result2["column"]).toEqual(["D", "B", "D"]); + expect(result3["column"]).toEqual([6.833333333333333, 4.25, 8.125]); + + await v3.delete(); + await v2.delete(); + await v1.delete(); + await table.delete(); + }); + + test("Multiple 2-sided pivoted views with the same expression alias and different aggregates should not conflict", async () => { + const table = await perspective.table({ + x: [10, 10, 20, 20], + y: ["A", "B", "C", "D"], + z: [1.5, 2.5, 3.5, 4.5], + }); + + const v1 = await table.view({ + group_by: ["x"], + split_by: ["y"], + expressions: { column: `"z" + 10` }, + aggregates: { + column: "avg", + }, + }); + + const v2 = await table.view({ + group_by: ["x"], + split_by: ["y"], + expressions: { column: `upper("y")` }, + aggregates: { + column: "last", + }, + }); + + const v3 = await table.view({ + group_by: ["x"], + split_by: ["y"], + expressions: { column: `2 * "z"` }, + aggregates: { + column: ["weighted mean", ["z"]], + }, + }); + + const result = await v1.to_columns(); + const result2 = await v2.to_columns(); + const result3 = await v3.to_columns(); + + expect(result).toEqual({ + __ROW_PATH__: [[], [10], [20]], + "A|x": [10, 10, null], + "A|y": [1, 1, null], + "A|z": [1.5, 1.5, null], + "A|column": [11.5, 11.5, null], + "B|x": [10, 10, null], + "B|y": [1, 1, null], + "B|z": [2.5, 2.5, null], + "B|column": [12.5, 12.5, null], + "C|x": [20, null, 20], + "C|y": [1, null, 1], + "C|z": [3.5, null, 3.5], + "C|column": [13.5, null, 13.5], + "D|x": [20, null, 20], + "D|y": [1, null, 1], + "D|z": [4.5, null, 4.5], + "D|column": [14.5, null, 14.5], + }); + expect(result2).toEqual({ + __ROW_PATH__: [[], [10], [20]], + "A|x": [10, 10, null], + "A|y": [1, 1, null], + "A|z": [1.5, 1.5, null], + "A|column": ["A", "A", null], + "B|x": [10, 10, null], + "B|y": [1, 1, null], + "B|z": [2.5, 2.5, null], + "B|column": ["B", "B", null], + "C|x": [20, null, 20], + "C|y": [1, null, 1], + "C|z": [3.5, null, 3.5], + "C|column": ["C", null, "C"], + "D|x": [20, null, 20], + "D|y": [1, null, 1], + "D|z": [4.5, null, 4.5], + "D|column": ["D", null, "D"], + }); + expect(result3).toEqual({ + __ROW_PATH__: [[], [10], [20]], + "A|x": [10, 10, null], + "A|y": [1, 1, null], + "A|z": [1.5, 1.5, null], + "A|column": [3, 3, null], + "B|x": [10, 10, null], + "B|y": [1, 1, null], + "B|z": [2.5, 2.5, null], + "B|column": [5, 5, null], + "C|x": [20, null, 20], + "C|y": [1, null, 1], + "C|z": [3.5, null, 3.5], + "C|column": [7, null, 7], + "D|x": [20, null, 20], + "D|y": [1, null, 1], + "D|z": [4.5, null, 4.5], + "D|column": [9, null, 9], + }); + + await v3.delete(); + await v2.delete(); + await v1.delete(); + await table.delete(); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/expressions/numeric.spec.js b/rust/perspective-js/test/js/expressions/numeric.spec.js new file mode 100644 index 0000000..34895cc --- /dev/null +++ b/rust/perspective-js/test/js/expressions/numeric.spec.js @@ -0,0 +1,1862 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client"; +import * as common from "./common.js"; + +const NUMERIC_TYPES = [ + "i8", + "ui8", + "i16", + "ui16", + "i32", + "ui32", + "i64", + "ui64", + "f32", + "f64", +]; + +/** + * Return all possible permutations of unary operators for all numeric types. + */ +function generate_unary_operations(operator) { + const expressions = []; + for (const expr of NUMERIC_TYPES) { + expressions.push(`${operator}"${expr}"`); + } + return expressions; +} + +/** + * Return all possible permutations of binary operators for all numeric types. + */ +function generate_binary_operations(operator) { + const expressions = []; + for (const expr of NUMERIC_TYPES) { + for (let i = 0; i < NUMERIC_TYPES.length; i++) { + expressions.push(`"${expr}" ${operator} "${NUMERIC_TYPES[i]}"`); + } + } + return expressions; +} + +function calc_unary(operator, value) { + switch (operator) { + case "+": + return +value; + case "-": + return -value; + default: + throw new Error("Unknown operator"); + } +} + +function calc_binary(operator, left, right) { + switch (operator) { + case "+": + return left + right; + case "-": + return left - right; + case "*": + return left * right; + case "/": + return left / right; + case "%": + return left % right; + case "^": + return Math.pow(left, right); + case "==": + return left == right ? 1 : 0; + case "!=": + return left != right ? 1 : 0; + case ">": + return left > right ? 1 : 0; + case "<": + return left < right ? 1 : 0; + case ">=": + return left >= right ? 1 : 0; + case "<=": + return left <= right ? 1 : 0; + default: + throw new Error("Unknown operator"); + } +} + +/** + * Validate the results of operations against all numeric types. + * + * @param {*} operator + */ +function validate_unary_operations(output, expressions, operator) { + for (const expr of expressions) { + const output_col = output[expr]; + const input = expr.substr(2, expr.length - 3); + expect({ + expr, + result: output_col, + }).toEqual({ + expr, + result: output[input].map((v) => calc_unary(operator, v)), + }); + } +} + +/** + * Validate the results of operations against all numeric types. + * + * @param {*} operator + */ +function validate_binary_operations(output, expressions, operator) { + for (const expr of expressions) { + const output_col = output[expr]; + const inputs = expr.split(` ${operator} `); + const left = inputs[0].substr(1, inputs[0].length - 2); + const right = inputs[1].substr(1, inputs[1].length - 2); + expect({ + expr, + result: output_col, + }).toEqual({ + expr, + result: output[left].map((v, idx) => + calc_binary(operator, v, output[right][idx]), + ), + }); + } +} + +/** + * Tests the correctness of expressions allowed by exprtk, using as many + * of the expression structures as we can, such as conditionals, loops, etc. + */ +((perspective) => { + test.describe("Numeric operations", function () { + test.describe("All numeric types", function () { + test.describe("aggregates", function () { + test( + "integer", + async function () { + const table = await perspective.table({ + a: "integer", + }); + + const view = await table.view({ + expressions: { '"a"': '"a"' }, + }); + + await table.update({ + a: [10, 15, 20, 30], + }); + + const result = await view.to_columns(); + expect(result['"a"']).toEqual([10, 15, 20, 30]); + expect(result["a"]).toEqual([10, 15, 20, 30]); + await view.delete(); + await table.delete(); + }, + + test("expression integer column sum", async function () { + const table = await perspective.table({ + a: [10, 15, 20, 30], + b: [1, 2, 3, 4], + }); + + const view = await table.view({ + group_by: ["b"], + aggregates: { + a: "sum", + b: "sum", + }, + expressions: { '"a"': '"a"' }, + }); + + await table.update({ + a: [40, 50, 60, 70], + }); + + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[], [null], [1], [2], [3], [4]], + a: [295, 220, 10, 15, 20, 30], + b: [10, 0, 1, 2, 3, 4], + '"a"': [295, 220, 10, 15, 20, 30], + }); + await view.delete(); + await table.delete(); + }), + ); + }); + + test.describe("unary", function () { + test("negative", async function () { + const table = await perspective.table( + common.all_types_arrow.slice(), + ); + const expressions = generate_unary_operations("+"); + const view = await table.view({ + expressions, + }); + + const col_names = await view.column_paths(); + + for (const expr of expressions) { + expect(col_names.includes(expr)).toBe(true); + } + + const result = await view.to_columns(); + validate_unary_operations(result, expressions, "+"); + await view.delete(); + await table.delete(); + }); + }); + + test.describe("binary", function () { + test("add", async function () { + const table = await perspective.table( + common.all_types_arrow.slice(), + ); + const expressions = generate_binary_operations("+"); + const view = await table.view({ + expressions, + }); + + const col_names = await view.column_paths(); + + for (const expr of expressions) { + expect(col_names.includes(expr)).toBe(true); + } + + const result = await view.to_columns(); + validate_binary_operations(result, expressions, "+"); + await view.delete(); + await table.delete(); + }); + + test("subtract", async function () { + const table = await perspective.table( + common.all_types_arrow.slice(), + ); + const expressions = generate_binary_operations("-"); + const view = await table.view({ + expressions, + }); + + const col_names = await view.column_paths(); + + for (const expr of expressions) { + expect(col_names.includes(expr)).toBe(true); + } + + const result = await view.to_columns(); + validate_binary_operations(result, expressions, "-"); + await view.delete(); + await table.delete(); + }); + + test("multiply", async function () { + const table = await perspective.table( + common.all_types_arrow.slice(), + ); + const expressions = generate_binary_operations("*"); + const view = await table.view({ + expressions, + }); + + const col_names = await view.column_paths(); + + for (const expr of expressions) { + expect(col_names.includes(expr)).toBe(true); + } + + const result = await view.to_columns(); + validate_binary_operations(result, expressions, "*"); + await view.delete(); + await table.delete(); + }); + + test("divide", async function () { + const table = await perspective.table( + common.all_types_arrow.slice(), + ); + const expressions = generate_binary_operations("/"); + + const view = await table.view({ + expressions, + }); + + const col_names = await view.column_paths(); + + for (const expr of expressions) { + expect(col_names.includes(expr)).toBe(true); + } + + const result = await view.to_columns(); + validate_binary_operations(result, expressions, "/"); + await view.delete(); + await table.delete(); + }); + + test("modulus", async function () { + const table = await perspective.table( + common.all_types_arrow.slice(), + ); + const expressions = generate_binary_operations("%"); + + const view = await table.view({ + expressions, + }); + + const col_names = await view.column_paths(); + + for (const expr of expressions) { + expect(col_names.includes(expr)).toBe(true); + } + + const result = await view.to_columns(); + validate_binary_operations(result, expressions, "%"); + await view.delete(); + await table.delete(); + }); + + test("power", async function () { + const table = await perspective.table(common.arrow.slice()); + const expressions = generate_binary_operations("^"); + const view = await table.view({ + expressions, + }); + + const col_names = await view.column_paths(); + + for (const expr of expressions) { + expect(col_names.includes(expr)).toBe(true); + } + + const result = await view.to_columns(); + validate_binary_operations(result, expressions, "^"); + }); + + test("==", async function () { + const table = await perspective.table( + common.all_types_multi_arrow.slice(), + ); + const expressions = []; + + // comparisons only work when the two types are the same + for (const expr of NUMERIC_TYPES) { + expressions.push(`"${expr}" == "${expr} 2"`); + } + + const view = await table.view({ + expressions, + }); + + const col_names = await view.column_paths(); + + for (const expr of expressions) { + expect(col_names.includes(expr)).toBe(true); + } + + const result = await view.to_columns(); + + for (const expr of expressions) { + const output_col = result[expr]; + const inputs = expr.split(` == `); + const left = inputs[0].substr(1, inputs[0].length - 2); + const right = inputs[1].substr(1, inputs[1].length - 2); + expect(output_col).toEqual( + result[left].map( + (v, idx) => v == result[right][idx], + ), + ); + } + }); + + test("!=", async function () { + const table = await perspective.table( + common.all_types_multi_arrow.slice(), + ); + const expressions = []; + + // comparisons only work when the two types are the same + for (const expr of NUMERIC_TYPES) { + expressions.push(`"${expr}" != "${expr} 2"`); + } + + const view = await table.view({ + expressions, + }); + + const col_names = await view.column_paths(); + + for (const expr of expressions) { + expect(col_names.includes(expr)).toBe(true); + } + + const result = await view.to_columns(); + + for (const expr of expressions) { + const output_col = result[expr]; + const inputs = expr.split(` != `); + const left = inputs[0].substr(1, inputs[0].length - 2); + const right = inputs[1].substr(1, inputs[1].length - 2); + expect(output_col).toEqual( + result[left].map( + (v, idx) => v != result[right][idx], + ), + ); + } + }); + + test(">", async function () { + const table = await perspective.table( + common.all_types_multi_arrow.slice(), + ); + const expressions = []; + + // comparisons only work when the two types are the same + for (const expr of NUMERIC_TYPES) { + expressions.push(`"${expr}" > "${expr} 2"`); + } + + const view = await table.view({ + expressions, + }); + + const col_names = await view.column_paths(); + + for (const expr of expressions) { + expect(col_names.includes(expr)).toBe(true); + } + + const result = await view.to_columns(); + + for (const expr of expressions) { + const output_col = result[expr]; + const inputs = expr.split(` > `); + const left = inputs[0].substr(1, inputs[0].length - 2); + const right = inputs[1].substr(1, inputs[1].length - 2); + expect(output_col).toEqual( + result[left].map( + (v, idx) => v > result[right][idx], + ), + ); + } + }); + + test("<", async function () { + const table = await perspective.table( + common.all_types_multi_arrow.slice(), + ); + const expressions = []; + + // comparisons only work when the two types are the same + for (const expr of NUMERIC_TYPES) { + expressions.push(`"${expr}" < "${expr} 2"`); + } + + const view = await table.view({ + expressions, + }); + + const col_names = await view.column_paths(); + + for (const expr of expressions) { + expect(col_names.includes(expr)).toBe(true); + } + + const result = await view.to_columns(); + + for (const expr of expressions) { + const output_col = result[expr]; + const inputs = expr.split(` < `); + const left = inputs[0].substr(1, inputs[0].length - 2); + const right = inputs[1].substr(1, inputs[1].length - 2); + expect(output_col).toEqual( + result[left].map( + (v, idx) => v < result[right][idx], + ), + ); + } + }); + + test(">=", async function () { + const table = await perspective.table( + common.all_types_multi_arrow.slice(), + ); + const expressions = []; + + // comparisons only work when the two types are the same + for (const expr of NUMERIC_TYPES) { + expressions.push(`"${expr}" >= "${expr} 2"`); + } + + const view = await table.view({ + expressions, + }); + + const col_names = await view.column_paths(); + + for (const expr of expressions) { + expect(col_names.includes(expr)).toBe(true); + } + + const result = await view.to_columns(); + + for (const expr of expressions) { + const output_col = result[expr]; + const inputs = expr.split(` >= `); + const left = inputs[0].substr(1, inputs[0].length - 2); + const right = inputs[1].substr(1, inputs[1].length - 2); + expect(output_col).toEqual( + result[left].map( + (v, idx) => v >= result[right][idx], + ), + ); + } + }); + + test("<=", async function () { + const table = await perspective.table( + common.all_types_multi_arrow.slice(), + ); + const expressions = []; + + // comparisons only work when the two types are the same + for (const expr of NUMERIC_TYPES) { + expressions.push(`"${expr}" <= "${expr} 2"`); + } + + const view = await table.view({ + expressions, + }); + + const col_names = await view.column_paths(); + + for (const expr of expressions) { + expect(col_names.includes(expr)).toBe(true); + } + + const result = await view.to_columns(); + + for (const expr of expressions) { + const output_col = result[expr]; + const inputs = expr.split(` <= `); + const left = inputs[0].substr(1, inputs[0].length - 2); + const right = inputs[1].substr(1, inputs[1].length - 2); + expect(output_col).toEqual( + result[left].map( + (v, idx) => v <= result[right][idx], + ), + ); + } + }); + + test("Numeric comparisons should be false for different types", async function () { + const table = await perspective.table({ + a: "integer", + b: "float", + c: "integer", + d: "float", + }); + const view = await table.view({ + expressions: { + '"a" == "b"': '"a" == "b"', + '"a" != "b"': '"a" != "b"', + }, + }); + await table.update({ + a: [1, 2, 3, 4], + b: [1.0, 2.0, 3.0, 4.0], + c: [1, 0, 1, 0], + d: [1.0, 0, 3.0, 0.0], + }); + const result = await view.to_columns(); + expect(result['"a" == "b"']).toEqual([ + false, + false, + false, + false, + ]); + expect(result['"a" != "b"']).toEqual([ + true, + true, + true, + true, + ]); + await view.delete(); + await table.delete(); + }); + + test("Numeric comparisons should be true for same types", async function () { + const table = await perspective.table({ + a: "integer", + b: "float", + c: "integer", + d: "float", + }); + const view = await table.view({ + expressions: [ + '"a" == "c"', + '"a" != "c"', + '"b" == "d"', + '"b" != "d"', + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + await table.update({ + a: [1, 2, 3, 4], + b: [1.0, 2.0, 3.0, 4.0], + c: [1, 0, 1, 0], + d: [1.0, 0, 3.0, 0.0], + }); + const result = await view.to_columns(); + expect(result['"a" == "c"']).toEqual([ + true, + false, + false, + false, + ]); + expect(result['"a" != "c"']).toEqual([ + false, + true, + true, + true, + ]); + expect(result['"b" == "d"']).toEqual([ + true, + false, + true, + false, + ]); + expect(result['"b" != "d"']).toEqual([ + false, + true, + false, + true, + ]); + await view.delete(); + await table.delete(); + }); + + test("Should not divide by 0", async function () { + const table = await perspective.table({ + a: [1, 2, 3, 4], + b: [0, 0, 0, 0], + c: [1.5, 2.123, 3.125, 4.123809], + }); + + const view = await table.view({ + expressions: { + '"a" / "b"': '"a" / "b"', + '"c" / "b"': '"c" / "b"', + }, + }); + + const result = await view.to_columns(); + expect(result['"a" / "b"']).toEqual([ + null, + null, + null, + null, + ]); + expect(result['"c" / "b"']).toEqual([ + null, + null, + null, + null, + ]); + await view.delete(); + await table.delete(); + }); + + test("Should not modulo by 0", async function () { + const table = await perspective.table({ + a: [1, 2, 3, 4], + b: [0, 0, 0, 0], + c: [1.5, 2.123, 3.125, 4.123809], + }); + + const view = await table.view({ + expressions: { + '"a" % "b"': '"a" % "b"', + '"c" % "b"': '"c" % "b"', + }, + }); + + const result = await view.to_columns(); + expect(result['"a" % "b"']).toEqual([ + null, + null, + null, + null, + ]); + expect(result['"c" % "b"']).toEqual([ + null, + null, + null, + null, + ]); + await view.delete(); + await table.delete(); + }); + }); + }); + + test.describe("Integers", function () { + test("Integer outputs with negative values dont overflow", async function () { + var data = [ + { x: -1, y: "a", z: true }, + { x: -2, y: "b", z: false }, + { x: -3, y: "c", z: true }, + { x: -4, y: "d", z: false }, + ]; + + var table = await perspective.table({ + x: "integer", + y: "string", + z: "boolean", + }); + + await table.update(data); + + var view = await table.view({ + group_by: ["z"], + columns: ["x", "w"], + expressions: { + w: 'integer("x")', + }, + }); + + var answer = [ + { __ROW_PATH__: [], x: -10, w: -10 }, + { __ROW_PATH__: [false], x: -6, w: -6 }, + { __ROW_PATH__: [true], x: -4, w: -4 }, + ]; + + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + }); + + test.describe("Functions", function () { + test("min", async function () { + const table = await perspective.table({ + a: "integer", + b: "float", + }); + + const expressions = [ + "min(1)", + 'min("a")', + 'min("a", -10, -10.001)', + 'min("b")', + 'min("b", 0.00000000001, -10, -100, -100.1)', + 'min("a", "b")', + ]; + + const view = await table.view({ + expressions, + }); + + await table.update({ + a: [1, 2, 3, 4], + b: [1.5, 2.5, 3.5, 4.5], + }); + + const schema = await view.expression_schema(); + for (const expr of expressions) { + expect(schema[expr]).toEqual("float"); + } + + const result = await view.to_columns(); + expect(result["min(1)"]).toEqual([1, 1, 1, 1]); + expect(result['min("a")']).toEqual([1, 2, 3, 4]); + expect(result['min("a", -10, -10.001)']).toEqual([ + -10.001, -10.001, -10.001, -10.001, + ]); + expect(result['min("b")']).toEqual([1.5, 2.5, 3.5, 4.5]); + expect( + result['min("b", 0.00000000001, -10, -100, -100.1)'], + ).toEqual([-100.1, -100.1, -100.1, -100.1]); + expect(result['min("a", "b")']).toEqual([1, 2, 3, 4]); + await view.delete(); + await table.delete(); + }); + + test("max", async function () { + const table = await perspective.table({ + a: "integer", + b: "float", + }); + + const expressions = [ + "max(1)", + "max(2000000000000.11, 2000000000000.1)", + 'max("a")', + 'max("a", -1.00001)', + 'max("a", "b")', + 'max("b")', + 'max("a", 10, 20, 0.1, 0.00000001)', + 'max("b", -1, -100, 100)', + ]; + + const view = await table.view({ + expressions, + }); + + await table.update({ + a: [1, 2, 3, 4], + b: [1.5, 2.5, 3.5, 4.5], + }); + + const schema = await view.expression_schema(); + for (const expr of expressions) { + expect(schema[expr]).toEqual("float"); + } + + const result = await view.to_columns(); + expect(result["max(1)"]).toEqual([1, 1, 1, 1]); + expect( + result["max(2000000000000.11, 2000000000000.1)"], + ).toEqual([ + 2000000000000.11, 2000000000000.11, 2000000000000.11, + 2000000000000.11, + ]); + expect(result['max("a")']).toEqual([1, 2, 3, 4]); + expect(result['max("a", "b")']).toEqual([1.5, 2.5, 3.5, 4.5]); + expect(result['max("b")']).toEqual([1.5, 2.5, 3.5, 4.5]); + expect(result['max("a", 10, 20, 0.1, 0.00000001)']).toEqual([ + 20, 20, 20, 20, + ]); + expect(result['max("b", -1, -100, 100)']).toEqual([ + 100, 100, 100, 100, + ]); + + await view.delete(); + await table.delete(); + }); + + test("inrange", async function () { + const table = await perspective.table({ + a: "integer", + b: "float", + }); + + // works for floats only + const view = await table.view({ + expressions: { + 'inrange(9, "b", 20)': 'inrange(9, "b", 20)', + }, + }); + + await table.update({ + a: [10, 15, 20, 30], + b: [10.5, 15.5, 20.5, 30.5], + }); + + const result = await view.to_columns(); + expect(result['inrange(9, "b", 20)']).toEqual([ + true, + true, + false, + false, + ]); + await view.delete(); + await table.delete(); + }); + + test("iclamp", async function () { + const table = await perspective.table({ + a: "integer", + b: "float", + }); + + const view = await table.view({ + expressions: { + 'iclamp(10, "a", 20)': 'iclamp(10, "a", 20)', + 'iclamp(10, "b", 20)': 'iclamp(10, "b", 20)', + }, + }); + + await table.update({ + a: [10, 15, 20, 30], + b: [10.5, 15.5, 20.5, 30.5], + }); + + const result = await view.to_columns(); + expect(result['iclamp(10, "a", 20)']).toEqual([10, 15, 20, 30]); + expect(result['iclamp(10, "b", 20)']).toEqual([ + 10, 20, 20.5, 30.5, + ]); + await view.delete(); + await table.delete(); + }); + + test("pow", async function () { + const table = await perspective.table({ + a: "integer", + b: "float", + }); + + const view = await table.view({ + expressions: { + 'pow("a", 1)': 'pow("a", 1)', + 'pow("b", 3)': 'pow("b", 3)', + }, + }); + + await table.update({ + a: [1, 2, 3, 4], + b: [1.5, 2.5, 3.5, 4.5], + }); + + const result = await view.to_columns(); + expect(result['pow("a", 1)']).toEqual([1, 2, 3, 4]); + expect(result['pow("b", 3)']).toEqual( + [1.5, 2.5, 3.5, 4.5].map((x) => Math.pow(x, 3)), + ); + await view.delete(); + await table.delete(); + }); + + test.skip("logn", async function () { + const table = await perspective.table({ + a: "integer", + b: "float", + }); + + const view = await table.view({ + expressions: { + 'logn("a", 5)': 'logn("a", 5)', + 'logn("b", 3)': 'logn("b", 3)', + }, + }); + + await table.update({ + a: [100, 200, 300, 400], + b: [100.5, 200.5, 300.5, 400.5], + }); + + const result = await view.to_columns(); + expect(result['logn("a", 5)']).toEqual( + [100, 200, 300, 400].map((x) => Math.log(x) / Math.log(5)), + ); + expect(result['logn("b", 3)']).toEqual( + [100.5, 200.5, 300.5, 400.5].map( + (x) => Math.log(x) / Math.log(3), + ), + ); + + await view.delete(); + await table.delete(); + }); + + test("root", async function () { + const table = await perspective.table({ + a: "integer", + b: "float", + }); + + const view = await table.view({ + expressions: { + 'root("a", 5)': 'root("a", 5)', + 'root("b", 3)': 'root("b", 3)', + }, + }); + + await table.update({ + a: [100, 200, 300, 400], + b: [100.5, 200.5, 300.5, 400.5], + }); + + const result = await view.to_columns(); + expect( + result['root("a", 5)'].map((x) => Number(x.toFixed(5))), + ).toEqual( + [100, 200, 300, 400].map((x) => + Number(Math.pow(x, 1 / 5).toFixed(5)), + ), + ); + expect( + result['root("b", 3)'].map((x) => Number(x.toFixed(5))), + ).toEqual( + [100.5, 200.5, 300.5, 400.5].map((x) => + Number(Math.pow(x, 1 / 3).toFixed(5)), + ), + ); + await view.delete(); + await table.delete(); + }); + + test("avg", async function () { + const table = await perspective.table({ + a: "integer", + b: "float", + }); + + const view = await table.view({ + expressions: [ + 'avg("a", 10, 20, 30, 40, "a")', + 'avg("b", 3, 4, 5, "b")', + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + await table.update({ + a: [1, 2, 3, 4], + b: [1.5, 2.5, 3.5, 4.5], + }); + + const result = await view.to_columns(); + expect(result['avg("a", 10, 20, 30, 40, "a")']).toEqual( + [1, 2, 3, 4].map((x) => (x + x + 10 + 20 + 30 + 40) / 6), + ); + expect(result['avg("b", 3, 4, 5, "b")']).toEqual( + [1.5, 2.5, 3.5, 4.5].map((x) => (x + x + 3 + 4 + 5) / 5), + ); + await view.delete(); + await table.delete(); + }); + + test("sum", async function () { + const table = await perspective.table({ + a: "integer", + b: "float", + }); + + const view = await table.view({ + expressions: [ + 'sum("a", 10, 20, 30, 40, "a")', + 'sum("b", 3, 4, 5, "b")', + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + await table.update({ + a: [1, 2, 3, 4], + b: [1.5, 2.5, 3.5, 4.5], + }); + + const result = await view.to_columns(); + expect(result['sum("a", 10, 20, 30, 40, "a")']).toEqual( + [1, 2, 3, 4].map((x) => x + x + 10 + 20 + 30 + 40), + ); + expect(result['sum("b", 3, 4, 5, "b")']).toEqual( + [1.5, 2.5, 3.5, 4.5].map((x) => x + x + 3 + 4 + 5), + ); + await view.delete(); + await table.delete(); + }); + + test("trunc", async function () { + const table = await perspective.table({ + a: "integer", + b: "float", + }); + + const view = await table.view({ + expressions: { + 'trunc("a")': 'trunc("a")', + 'trunc("b")': 'trunc("b")', + }, + }); + + await table.update({ + a: [1, 2, 3, 4], + b: [1.5, 2.5, 3.5, 4.5], + }); + + const result = await view.to_columns(); + expect(result['trunc("a")']).toEqual([1, 2, 3, 4]); + expect(result['trunc("b")']).toEqual([1, 2, 3, 4]); + await view.delete(); + await table.delete(); + }); + + test("d2r", async function () { + const table = await perspective.table({ + a: "integer", + b: "float", + }); + + const view = await table.view({ + expressions: { + 'deg2rad("a")': 'deg2rad("a")', + 'deg2rad("b")': 'deg2rad("b")', + }, + }); + + await table.update({ + a: [30, 60, 90, 120], + b: [25.5, 45.5, 88.721282, 91.12983], + }); + + const result = await view.to_columns(); + expect(result['deg2rad("a")']).toEqual( + [30, 60, 90, 120].map((x) => x * (Math.PI / 180)), + ); + expect(result['deg2rad("b")']).toEqual( + [25.5, 45.5, 88.721282, 91.12983].map( + (x) => x * (Math.PI / 180), + ), + ); + await view.delete(); + await table.delete(); + }); + + test.skip("OG - r2d", async function () { + const table = await perspective.table({ + a: "integer", + b: "float", + }); + + const view = await table.view({ + expressions: { + 'rad2deg("a")': 'rad2deg("a")', + 'rad2deg("b")': 'rad2deg("b")', + }, + }); + + await table.update({ + a: [1, 2, 3, 4], + b: [25.5, 45.5, 88.721282, 91.12983].map( + (x) => x * (Math.PI / 180), + ), + }); + + const result = await view.to_columns(); + expect(result['rad2deg("a")']).toEqual( + [1, 2, 3, 4].map((x) => x * (180 / Math.PI)), + ); + expect(result['rad2deg("b")']).toEqual([ + 25.5, 45.5, 88.721282, 91.12983, + ]); + await view.delete(); + await table.delete(); + }); + + test("r2d", async function () { + const table = await perspective.table({ + a: "integer", + b: "float", + }); + + const view = await table.view({ + expressions: { + 'rad2deg("a")': 'rad2deg("a")', + 'rad2deg("b")': 'rad2deg("b")', + }, + }); + + await table.update({ + a: [1, 2, 3, 4], + b: [25.5, 45.5, 88.721282, 91.12983].map( + (x) => x * (Math.PI / 180), + ), + }); + + const result = await view.to_columns(); + expect(result['rad2deg("a")']).toEqual( + [1, 2, 3, 4].map((x) => x * (180 / Math.PI)), + ); + const expected = [25.5, 45.5, 88.721282, 91.12983]; + expect(result['rad2deg("b")'].length).toEqual(expected.length); + result['rad2deg("b")'].forEach((x, idx) => + expect(x).toBeCloseTo(expected[idx], 5), + ); + await view.delete(); + await table.delete(); + }); + + test("is_null", async function () { + const table = await perspective.table({ + a: "integer", + b: "float", + }); + + const view = await table.view({ + expressions: [ + 'is_null("a")', + 'is_null("b")', + 'if(is_null("a")) 100; else 0;', + 'if(is_null("b")) 100; else 0;', + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + await table.update({ + a: [1, null, null, 4], + b: [null, 2.5, null, 4.5], + }); + + const result = await view.to_columns(); + expect(result['is_null("a")']).toEqual([ + false, + true, + true, + false, + ]); + expect(result['is_null("b")']).toEqual([ + true, + false, + true, + false, + ]); + expect(result['if(is_null("a")) 100; else 0;']).toEqual([ + 0, 100, 100, 0, + ]); + expect(result['if(is_null("b")) 100; else 0;']).toEqual([ + 100, 0, 100, 0, + ]); + await view.delete(); + await table.delete(); + }); + + test("is_not_null", async function () { + const table = await perspective.table({ + a: "integer", + b: "float", + }); + + const view = await table.view({ + expressions: [ + 'is_not_null("a")', + 'is_not_null("b")', + 'if(is_not_null("a")) 100; else 0;', + 'if(is_not_null("b")) 100; else 0;', + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + await table.update({ + a: [1, null, null, 4], + b: [null, 2.5, null, 4.5], + }); + + const result = await view.to_columns(); + expect(result['is_not_null("a")']).toEqual([ + true, + false, + false, + true, + ]); + expect(result['is_not_null("b")']).toEqual([ + false, + true, + false, + true, + ]); + expect(result['if(is_not_null("a")) 100; else 0;']).toEqual([ + 100, 0, 0, 100, + ]); + expect(result['if(is_not_null("b")) 100; else 0;']).toEqual([ + 0, 100, 0, 100, + ]); + await view.delete(); + await table.delete(); + }); + + test("coalesce returns first non-null arg", async function () { + const table = await perspective.table({ + a: "integer", + b: "integer", + }); + + const view = await table.view({ + expressions: { + coalesce_ab: 'coalesce("a", "b")', + coalesce_ab_default: 'coalesce("a", "b", 99)', + }, + }); + + await table.update({ + a: [1, null, null, 4], + b: [10, 20, null, 40], + }); + + const result = await view.to_columns(); + expect(result["coalesce_ab"]).toEqual([1, 20, null, 4]); + expect(result["coalesce_ab_default"]).toEqual([1, 20, 99, 4]); + await view.delete(); + await table.delete(); + }); + + test("coalesce promotes mixed numeric inputs to float", async function () { + const table = await perspective.table({ + i: "integer", + f: "float", + }); + + const view = await table.view({ + expressions: { + coalesce_if: 'coalesce("i", "f")', + coalesce_if_default: 'coalesce("i", "f", 0.5)', + }, + }); + + await table.update({ + i: [1, null, null, 4], + f: [null, 2.5, null, 4.5], + }); + + const result = await view.to_columns(); + const schema = await view.expression_schema(); + expect(schema["coalesce_if"]).toEqual("float"); + expect(schema["coalesce_if_default"]).toEqual("float"); + expect(result["coalesce_if"]).toEqual([1, 2.5, null, 4]); + expect(result["coalesce_if_default"]).toEqual([1, 2.5, 0.5, 4]); + + await view.delete(); + await table.delete(); + }); + + test("coalesce with all-null inputs returns null", async function () { + const table = await perspective.table({ + a: "integer", + b: "integer", + }); + + const view = await table.view({ + expressions: { coalesce_nulls: 'coalesce("a", "b")' }, + }); + + await table.update({ + a: [null, null, null], + b: [null, null, null], + }); + + const result = await view.to_columns(); + expect(result["coalesce_nulls"]).toEqual([null, null, null]); + await view.delete(); + await table.delete(); + }); + + test("coalesce fails validation for incompatible types", async function () { + const table = await perspective.table({ + a: "integer", + b: "string", + }); + + const validated = await table.validate_expressions([ + 'coalesce("a", "b")', + "coalesce(\"a\", 'fallback')", + ]); + + expect(validated.expression_schema).toEqual({}); + expect(validated.errors['coalesce("a", "b")']).toEqual({ + column: 0, + line: 0, + error_message: + "Type Error - inputs do not resolve to a valid expression.", + }); + + expect(validated.errors["coalesce(\"a\", 'fallback')"]).toEqual( + { + column: 0, + line: 0, + error_message: + "Type Error - inputs do not resolve to a valid expression.", + }, + ); + + await table.delete(); + }); + + test("null", async function () { + const table = await perspective.table({ + a: "integer", + b: "float", + }); + + const view = await table.view({ + expressions: [ + "float(null)", + "string(null)", + "integer(null)", + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + await table.update({ + a: [1, null, null, 4], + b: [null, 2.5, null, 4.5], + }); + + const result = await view.to_columns(); + expect(result["float(null)"]).toEqual([null, null, null, null]); + expect(result["string(null)"]).toEqual([ + null, + null, + null, + null, + ]); + + expect(result["integer(null)"]).toEqual([ + null, + null, + null, + null, + ]); + + await view.delete(); + await table.delete(); + }); + + test("percent_of", async function () { + const table = await perspective.table({ + a: "integer", + b: "float", + }); + + const view = await table.view({ + expressions: [ + 'percent_of("a", 500)', + 'percent_of("a", "b")', + "percent_of(1, 3)", + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + await table.update({ + a: [100, 200, 300, 400], + b: [100.5, 200.5, 300.5, 400.5], + }); + + const result = await view.to_columns(); + expect(result['percent_of("a", 500)']).toEqual( + [100, 200, 300, 400].map((x) => (x / 500) * 100), + ); + expect(result['percent_of("a", "b")']).toEqual( + [100, 200, 300, 400].map( + (x, idx) => (x / result["b"][idx]) * 100, + ), + ); + expect(result["percent_of(1, 3)"]).toEqual([ + 33.33333333333333, 33.33333333333333, 33.33333333333333, + 33.33333333333333, + ]); + + await view.delete(); + await table.delete(); + }); + + test("bucket", async function () { + const table = await perspective.table({ + a: "integer", + b: "float", + }); + + const view = await table.view({ + expressions: [ + 'bucket("a", 5)', + 'bucket("b", 2.5)', + `bucket("b", 10)`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + await table.update({ + a: [15, 15, 35, 40, 1250, 1255], + b: [2.25, 2, 3.5, 16.5, 28, 8], + }); + + const result = await view.to_columns(); + expect(result['bucket("a", 5)']).toEqual([ + 15, 15, 35, 40, 1250, 1255, + ]); + expect(result['bucket("b", 2.5)']).toEqual([ + 0, 0, 2.5, 15, 27.5, 7.5, + ]); + expect(result['bucket("b", 10)']).toEqual([0, 0, 0, 10, 20, 0]); + await view.delete(); + await table.delete(); + }); + + test("random", async function () { + const table = await perspective.table({ + x: "float", + }); + + const data = []; + for (let i = 0; i < 1000; i++) { + data.push(i); + } + await table.update({ x: data }); + const view = await table.view({ + expressions: { "random()": "random()" }, + }); + const schema = await view.expression_schema(); + expect(schema).toEqual({ "random()": "float" }); + const result = await view.to_columns(); + + for (let i = 0; i < 1000; i++) { + const res = result["random()"][i]; + expect(res >= 0 && res <= 1).toBe(true); + } + + await view.delete(); + await table.delete(); + }); + }); + + test.describe("Booleans", function () { + test("AND", async function () { + const table = await perspective.table(common.comparison_data); + const view = await table.view({ + expressions: [ + '"u" and "u"', + '"u" and "z"', + '"z" and "z"', + "0 and 0", + "1 and 1", + "1 and 100", + "true and false", + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + const result = await view.to_columns(); + expect(result['"u" and "u"']).toEqual([ + false, + true, + false, + true, + ]); + expect(result['"u" and "z"']).toEqual([ + false, + false, + false, + false, + ]); + expect(result['"u" and "z"']).toEqual([ + false, + false, + false, + false, + ]); + expect(result["0 and 0"]).toEqual([false, false, false, false]); + expect(result["1 and 1"]).toEqual([true, true, true, true]); + expect(result["1 and 100"]).toEqual([true, true, true, true]); + expect(result["true and false"]).toEqual([ + false, + false, + false, + false, + ]); + await view.delete(); + await table.delete(); + }); + + test("mand", async function () { + const table = await perspective.table(common.comparison_data); + const view = await table.view({ + expressions: [ + 'mand("u" and "u", "u" and "z", "z" and "z")', + "mand(true, true, true, true)", + "mand(is_null(null), is_not_null(null))", + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + const result = await view.to_columns(); + + expect( + result['mand("u" and "u", "u" and "z", "z" and "z")'], + ).toEqual(Array(4).fill(false)); + + expect( + result["mand(is_null(null), is_not_null(null))"], + ).toEqual(Array(4).fill(false)); + + expect(result["mand(true, true, true, true)"]).toEqual( + Array(4).fill(true), + ); + + await view.delete(); + await table.delete(); + }); + + test("mand should fail validation for non boolean inputs", async function () { + const table = await perspective.table(common.comparison_data); + const validated = await table.validate_expressions([ + 'mand("w", "x", "y")', + "mand(100, 200, 300)", + "mand('a', 'b', 'cdef')", + ]); + + expect(validated.expression_schema).toEqual({}); + expect(validated.errors['mand("w", "x", "y")']).toEqual({ + column: 0, + line: 0, + error_message: + "Type Error - inputs do not resolve to a valid expression.", + }); + expect(validated.errors["mand(100, 200, 300)"]).toEqual({ + column: 0, + line: 0, + error_message: + "Type Error - inputs do not resolve to a valid expression.", + }); + expect(validated.errors["mand('a', 'b', 'cdef')"]).toEqual({ + column: 0, + line: 0, + error_message: + "Type Error - inputs do not resolve to a valid expression.", + }); + + await table.delete(); + }); + + test("OR", async function () { + const table = await perspective.table({ + a: [0, 1, 0, 1], + b: [0, 1, 0, 1], + c: [0, 0, 0, 0], + d: [1, 1, 1, 1], + e: [false, false, false, false], + f: [true, true, true, true], + }); + const view = await table.view({ + expressions: { + '"a" or "b"': '"a" or "b"', + '"c" or "d"': '"c" or "d"', + '"e" or "f"': '"e" or "f"', + "0 or 1": "0 or 1", + "true or false": "true or false", + "false or false": "false or false", + filtered: '"a" > 0.5 or "d" < 0.5', + }, + }); + const result = await view.to_columns(); + expect(result['"a" or "b"']).toEqual([ + false, + true, + false, + true, + ]); + expect(result['"c" or "d"']).toEqual([true, true, true, true]); + expect(result['"e" or "f"']).toEqual([true, true, true, true]); + expect(result["0 or 1"]).toEqual([true, true, true, true]); + expect(result["true or false"]).toEqual([ + true, + true, + true, + true, + ]); + expect(result["false or false"]).toEqual([ + false, + false, + false, + false, + ]); + expect(result["filtered"]).toEqual([true, true, true, true]); + await view.delete(); + await table.delete(); + }); + + test("mor", async function () { + const table = await perspective.table(common.comparison_data); + const view = await table.view({ + expressions: [ + 'mor("u" and "u", "u" and "z", "z" and "z")', + "mor(false, false, false)", + "mor(false, true, false)", + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + const result = await view.to_columns(); + expect( + result['mor("u" and "u", "u" and "z", "z" and "z")'], + ).toEqual([true, true, true, true]); + + // The boolean false is a false + expect(result["mor(false, false, false)"]).toEqual([ + false, + false, + false, + false, + ]); + + expect(result["mor(false, true, false)"]).toEqual([ + true, + true, + true, + true, + ]); + + await view.delete(); + await table.delete(); + }); + + test("mor should fail validation for non boolean inputs", async function () { + const table = await perspective.table(common.comparison_data); + const validated = await table.validate_expressions([ + 'mor("w", "x", "y")', + "mor(100, 200, 300)", + "mor('a', 'b', 'cdef')", + ]); + + expect(validated.expression_schema).toEqual({}); + expect(validated.errors['mor("w", "x", "y")']).toEqual({ + column: 0, + line: 0, + error_message: + "Type Error - inputs do not resolve to a valid expression.", + }); + expect(validated.errors["mor(100, 200, 300)"]).toEqual({ + column: 0, + line: 0, + error_message: + "Type Error - inputs do not resolve to a valid expression.", + }); + expect(validated.errors["mor('a', 'b', 'cdef')"]).toEqual({ + column: 0, + line: 0, + error_message: + "Type Error - inputs do not resolve to a valid expression.", + }); + + await table.delete(); + }); + + test("NAND", async function () { + const table = await perspective.table(common.comparison_data); + const view = await table.view({ + expressions: [ + '"u" nand "u"', + '"u" nand "z"', + '"z" nand "z"', + "0 nand 0", + "1 nand 1", + "1 nand 100", + "true nand true", + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + const result = await view.to_columns(); + expect(result['"u" nand "u"']).toEqual([ + true, + false, + true, + false, + ]); + expect(result['"u" nand "z"']).toEqual([ + true, + true, + true, + true, + ]); + expect(result['"u" nand "z"']).toEqual([ + true, + true, + true, + true, + ]); + expect(result["0 nand 0"]).toEqual([true, true, true, true]); + expect(result["1 nand 1"]).toEqual([ + false, + false, + false, + false, + ]); + expect(result["1 nand 100"]).toEqual([ + false, + false, + false, + false, + ]); + expect(result["true nand true"]).toEqual([ + false, + false, + false, + false, + ]); + await view.delete(); + await table.delete(); + }); + + test("NOR", async function () { + const table = await perspective.table({ + a: [0, 1, 0, 1], + b: [0, 1, 0, 1], + c: [0, 0, 0, 0], + d: [1, 1, 1, 1], + e: [false, false, false, false], + f: [true, true, true, true], + }); + const view = await table.view({ + expressions: [ + '"a" nor "b"', + '"c" nor "d"', + '"e" nor "f"', + "0 nor 1", + "false nor false", + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + const result = await view.to_columns(); + expect(result['"a" nor "b"']).toEqual([ + true, + false, + true, + false, + ]); + expect(result['"c" nor "d"']).toEqual([ + false, + false, + false, + false, + ]); + expect(result['"e" nor "f"']).toEqual([ + false, + false, + false, + false, + ]); + expect(result["0 nor 1"]).toEqual([false, false, false, false]); + expect(result["false nor false"]).toEqual([ + true, + true, + true, + true, + ]); + await view.delete(); + await table.delete(); + }); + + test("XOR", async function () { + const table = await perspective.table({ + a: [0, 1, 0, 1], + b: [0, 1, 0, 1], + c: [0, 0, 0, 0], + d: [1, 1, 1, 1], + e: [false, false, false, false], + f: [true, true, true, true], + }); + const view = await table.view({ + expressions: [ + '"a" xor "b"', + '"c" xor "d"', + '"e" xor "f"', + "0 xor 1", + "false xor false", + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + const result = await view.to_columns(); + expect(result['"a" xor "b"']).toEqual([ + false, + false, + false, + false, + ]); + expect(result['"c" xor "d"']).toEqual([true, true, true, true]); + expect(result['"e" xor "f"']).toEqual([true, true, true, true]); + expect(result["0 xor 1"]).toEqual([true, true, true, true]); + expect(result["false xor false"]).toEqual([ + false, + false, + false, + false, + ]); + await view.delete(); + await table.delete(); + }); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/expressions/parsing.spec.js b/rust/perspective-js/test/js/expressions/parsing.spec.js new file mode 100644 index 0000000..9b6bfc9 --- /dev/null +++ b/rust/perspective-js/test/js/expressions/parsing.spec.js @@ -0,0 +1,71 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client"; + +import * as common from "./common.js"; + +/** + * Tests the correctness of each datetime computation function in various + * environments and parameters - different types, nulls, undefined, etc. + */ +((perspective) => { + test.describe("bucket() function", function () { + test.describe("parses arguments separated by arbitrary whitespace", function () { + for (const [title, expression] of [ + ["space before", `bucket( "a",'Y')`], + ["space after", `bucket("a",'Y' )`], + ["space between", `bucket("a", 'Y')`], + ["two spaces before", `bucket( "a",'Y')`], + ["two spaces after", `bucket("a",'Y' )`], + ["two spaces between", `bucket("a", 'Y')`], + ["space before between and after", `bucket( "a", 'Y' )`], + [ + "two spaces before between and after", + `bucket( "a", 'Y' )`, + ], + ]) { + test(title, async function () { + const table = await perspective.table({ + a: "datetime", + }); + + const view = await table.view({ + expressions: [expression].reduce( + (x, y) => Object.assign(x, { [y]: y }), + {}, + ), + }); + + await table.update({ + a: [ + new Date(2020, 0, 12), + new Date(2020, 0, 15), + new Date(2021, 11, 17), + new Date(2019, 2, 18), + new Date(2019, 2, 29), + ], + }); + + let result = await view.to_columns(); + expect( + result[expression].map((x) => (x ? new Date(x) : null)), + ).toEqual(result.a.map((x) => common.year_bucket(x))); + + view.delete(); + table.delete(); + }); + } + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/expressions/string.spec.js b/rust/perspective-js/test/js/expressions/string.spec.js new file mode 100644 index 0000000..0575f5a --- /dev/null +++ b/rust/perspective-js/test/js/expressions/string.spec.js @@ -0,0 +1,2545 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client"; + +const CHARS = ` !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_\`abcdefghijklmnopqrstuvwxyz{|}~`; +const ALPHA = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; +const NUMERIC = "0123456789"; +const ALPHANUMERIC = ALPHA + NUMERIC; +const randint = (min, max) => + Math.floor(Math.random() * (Math.floor(max) - Math.ceil(min) + 1)) + + Math.ceil(min); +const randchoice = (collection) => + collection[randint(0, collection.length - 1)]; +const random_string = ( + max_length = 100, + is_null = false, + input_values = CHARS, +) => { + if (is_null && Math.random() > 0.5) return null; + const length = randint(1, max_length); + const output = []; + + for (let i = 0; i < length; i++) { + output.push(randchoice(input_values)); + } + + return output.join(""); +}; + +/** + * Tests the correctness of each string computation function in various + * environments and parameters - different types, nulls, undefined, etc. + * + * TODO: add tests for shared vocab after clear() + */ +((perspective) => { + test.describe("String functions", function () { + test("Comparisons", async function () { + const table = await perspective.table({ + x: ["abc", "deeeeef", "fg", "hhs", "abcdefghijk"], + y: ["ABC", "DEF", "EfG", "HIjK", "lMNoP"], + }); + const view = await table.view({ + expressions: { + [`a`]: `'abcdefghijklmnopqrstuvwxyz' == 'abcdefghijklmnopqrstuvwxyz'`, + [`b`]: `"x" == lower("y")`, + [`c`]: `if("x" == 'abc', 100, 0)`, + [`d`]: `if("x" != 'abc', 'new string 1', 'new string 2')`, + [`e`]: `'d' > 'a'`, + [`f`]: `'efz' > 'efy'`, // lexicographic + }, + }); + + let result = await view.to_columns(); + + expect(result["a"]).toEqual(Array(5).fill(true)); + expect(result["b"]).toEqual([true, false, false, false, false]); + expect(result["c"]).toEqual([100, 0, 0, 0, 0]); + expect(result["d"]).toEqual([ + "new string 2", + "new string 1", + "new string 1", + "new string 1", + "new string 1", + ]); + expect(result["e"]).toEqual(Array(5).fill(true)); + expect(result["f"]).toEqual(Array(5).fill(true)); + + view.delete(); + table.delete(); + }); + + test("Pivoted", async function () { + const table = await perspective.table({ + a: ["abc", "deeeeef", "fg", "hhs", "abcdefghijk"], + b: ["ABC", "DEF", "EfG", "HIjK", "lMNoP"], + c: [2, 2, 4, 4, 5], + }); + const view = await table.view({ + aggregates: { column: "last" }, + group_by: ["column"], + expressions: { + [`column`]: `concat("a", ', ', 'here is a long string, ', "b")`, + }, + }); + let result = await view.to_columns(); + + expect(result["column"]).toEqual([ + "hhs, here is a long string, HIjK", + "abc, here is a long string, ABC", + "abcdefghijk, here is a long string, lMNoP", + "deeeeef, here is a long string, DEF", + "fg, here is a long string, EfG", + "hhs, here is a long string, HIjK", + ]); + + view.delete(); + table.delete(); + }); + + test("Filtered", async function () { + const table = await perspective.table({ + a: ["abc", "deeeeef", "fg", "hhs", "abcdefghijk"], + b: ["ABC", "DEF", "EfG", "HIjK", "lMNoP"], + c: [2, 2, 4, 4, 5], + }); + const view = await table.view({ + filter: [["column", "==", "hhs, here is a long string, HIjK"]], + expressions: { + [`column`]: `concat("a", ', ', 'here is a long string, ', "b")`, + }, + }); + let result = await view.to_columns(); + expect(result["column"]).toEqual([ + "hhs, here is a long string, HIjK", + ]); + view.delete(); + table.delete(); + }); + + test("Length", async function () { + const table = await perspective.table({ + a: ["abc", "deeeeef", "fg", "hhs", "abcdefghijk"], + }); + const view = await table.view({ + expressions: { 'length("a")': 'length("a")' }, + }); + let result = await view.to_columns(); + expect(result['length("a")']).toEqual( + result.a.map((x) => x.length), + ); + view.delete(); + table.delete(); + }); + + test("Length with null", async function () { + const table = await perspective.table({ + a: ["abc", "deeeeef", null, undefined, "abcdefghijk"], + }); + const view = await table.view({ + expressions: { 'length("a")': 'length("a")' }, + }); + let result = await view.to_columns(); + expect(result['length("a")']).toEqual( + result.a.map((x) => (x ? x.length : null)), + ); + view.delete(); + table.delete(); + }); + + test("Order", async function () { + const table = await perspective.table({ + a: ["abc", "deeeeef", "fg", "hhs", "abcdefghijk"], + }); + + const validate = await table.validate_expressions({ + [`col`]: `order("a", 'deeeeef', 'fg', 'abcdefghijk', 'hhs', 'abc')`, + }); + + expect(validate.expression_schema).toEqual({ + col: "float", + }); + + const view = await table.view({ + expressions: [ + `order("a", 'deeeeef', 'fg', 'abcdefghijk', 'hhs', 'abc')`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + let result = await view.to_columns(); + expect( + result[ + `order("a", 'deeeeef', 'fg', 'abcdefghijk', 'hhs', 'abc')` + ], + ).toEqual([4, 0, 1, 3, 2]); + + view.delete(); + table.delete(); + }); + + test("Order type validates", async function () { + const table = await perspective.table({ + a: ["abc", "deeeeef", "fg", "hhs", "abcdefghijk"], + b: [1, 2, 3, 4, 5], + }); + + const validate = await table.validate_expressions({ + [`col`]: `order("a", 'deeeeef', 'fg', 'abcdefghijk', 'hhs', 'abc')`, + [`col2`]: `order('a', 'b', today())`, + [`col3`]: `order("b")`, + [`col4`]: `order()`, + }); + + expect(validate.expression_schema).toEqual({ + col: "float", + }); + + expect(validate.errors).toEqual({ + col2: { + column: 0, + error_message: + "Type Error - inputs do not resolve to a valid expression.", + line: 0, + }, + col3: { + column: 0, + error_message: + "Type Error - inputs do not resolve to a valid expression.", + line: 0, + }, + col4: { + column: 7, + error_message: + "Zero parameter call to generic function: order not allowed", + line: 0, + }, + }); + + await table.delete(); + }); + + test("Order with partial specification", async function () { + const table = await perspective.table({ + a: ["abc", "deeeeef", "fg", "hhs", "abcdefghijk"], + }); + const view = await table.view({ + expressions: { + "order(\"a\", 'deeeeef', 'fg')": + "order(\"a\", 'deeeeef', 'fg')", + }, + }); + let result = await view.to_columns(); + expect(result[`order("a", 'deeeeef', 'fg')`]).toEqual([ + 2, 0, 1, 2, 2, + ]); + view.delete(); + table.delete(); + }); + + test("Order with null", async function () { + const table = await perspective.table({ + a: ["abc", "deeeeef", null, undefined, "abcdefghijk"], + }); + const view = await table.view({ + expressions: { + [`order("a", 'deeeeef', 'abcdefghijk', 'abc')`]: `order("a", 'deeeeef', 'abcdefghijk', 'abc')`, + }, + }); + let result = await view.to_columns(); + expect( + result[`order("a", 'deeeeef', 'abcdefghijk', 'abc')`], + ).toEqual([2, 0, null, null, 1]); + view.delete(); + table.delete(); + }); + + test("Upper", async function () { + const table = await perspective.table({ + a: ["abc", "deeeeef", "fg", "hhs", "abcdefghijk"], + }); + const view = await table.view({ + expressions: { 'upper("a")': 'upper("a")' }, + }); + let result = await view.to_columns(); + expect(result['upper("a")']).toEqual( + result.a.map((x) => x.toUpperCase()), + ); + view.delete(); + table.delete(); + }); + + test("Uppercase with null", async function () { + const table = await perspective.table({ + a: ["abc", "deeeeef", null, undefined, "abcdefghijk"], + }); + const view = await table.view({ + expressions: { 'upper("a")': 'upper("a")' }, + }); + let result = await view.to_columns(); + expect(result['upper("a")']).toEqual( + result.a.map((x) => (x ? x.toUpperCase() : null)), + ); + view.delete(); + table.delete(); + }); + + test.skip("Uppercase, non-utf8", async function () { + const table = await perspective.table({ + a: ["𝕙ḗľᶅở щṏᵲɭⅾ", "𝓊⋁ẅ⤫𝛾𝓏", null], + b: ["𝕙ḗľᶅở щṏᵲɭⅾ", "𝑢ⱴⱳẍ𝘺𝘇ӑṣᶑᵴ", "EfG"], + }); + const view = await table.view({ + expressions: { + 'upper("a")': 'upper("a")', + 'upper("b")': 'upper("b")', + }, + }); + let result = await view.to_columns(); + expect(result['upper("a")']).toEqual( + result.a.map((x) => (x ? x.toUpperCase() : null)), + ); + expect(result['upper("b")']).toEqual( + result.b.map((x) => (x ? x.toUpperCase() : null)), + ); + view.delete(); + table.delete(); + }); + + test("Lowercase", async function () { + const table = await perspective.table({ + a: ["ABC", "DEF", "EfG", "HIjK", "lMNoP"], + }); + const view = await table.view({ + expressions: { 'lower("a")': 'lower("a")' }, + }); + let result = await view.to_columns(); + expect(result['lower("a")']).toEqual( + result.a.map((x) => x.toLowerCase()), + ); + view.delete(); + table.delete(); + }); + + test("Lowercase with null", async function () { + const table = await perspective.table({ + a: ["ABC", "DEF", null, undefined, "lMNoP"], + }); + const view = await table.view({ + expressions: { 'lower("a")': 'lower("a")' }, + }); + let result = await view.to_columns(); + expect(result['lower("a")']).toEqual( + result.a.map((x) => (x ? x.toLowerCase() : null)), + ); + view.delete(); + table.delete(); + }); + + test("Lowercase, non-utf8", async function () { + const table = await perspective.table({ + a: ["𝕙ḗľᶅở щṏᵲɭⅾ", "𝓊⋁ẅ⤫𝛾𝓏", null], + b: ["𝕙ḗľᶅở щṏᵲɭⅾ", "𝑢ⱴⱳẍ𝘺𝘇ӑṣᶑᵴ", "EfG"], + }); + + const view = await table.view({ + expressions: { + 'lower("a")': 'lower("a")', + 'lower("b")': 'lower("b")', + }, + }); + + let result = await view.to_columns(); + expect(result['lower("a")']).toEqual( + result.a.map((x) => (x ? x.toLowerCase() : null)), + ); + expect(result['lower("b")']).toEqual( + result.b.map((x) => (x ? x.toLowerCase() : null)), + ); + view.delete(); + table.delete(); + }); + + test("Coalesce strings", async function () { + const table = await perspective.table({ + a: ["ABC", null, null, "HIjK", null], + b: ["xyz", "DEF", null, "stu", null], + }); + const view = await table.view({ + expressions: { + coalesce_str: 'coalesce("a", "b", \'N/A\')', + }, + }); + const result = await view.to_columns(); + const schema = await view.expression_schema(); + expect(schema["coalesce_str"]).toEqual("string"); + expect(result["coalesce_str"]).toEqual([ + "ABC", + "DEF", + "N/A", + "HIjK", + "N/A", + ]); + view.delete(); + table.delete(); + }); + + test("Concat", async function () { + const table = await perspective.table({ + a: ["abc", "deeeeef", "fg", "hhs", "abcdefghijk"], + b: ["ABC", "DEF", "EfG", "HIjK", "lMNoP"], + }); + const view = await table.view({ + expressions: [ + `concat("a", ', ', 'here is a long string, ', "b")`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + let result = await view.to_columns(); + expect( + result[`concat("a", ', ', 'here is a long string, ', "b")`], + ).toEqual( + result.a.map( + (x, idx) => x + ", here is a long string, " + result.b[idx], + ), + ); + view.delete(); + table.delete(); + }); + + test("Concats, nulls", async function () { + const table = await perspective.table({ + a: ["ABC", "DEF", null, "HIjK", "lMNoP"], + b: ["ABC", undefined, "EfG", "HIjK", "lMNoP"], + }); + const view = await table.view({ + expressions: [ + `concat("a", ', ', 'here is a long string, ', "b")`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + let result = await view.to_columns(); + let expected = result.a.map( + (x, idx) => x + ", here is a long string, " + result.b[idx], + ); + expected[1] = null; + expected[2] = null; + expect( + result[`concat("a", ', ', 'here is a long string, ', "b")`], + ).toEqual(expected); + view.delete(); + table.delete(); + }); + + test("Concats, extra long", async function () { + const table = await perspective.table({ + a: [ + "ABC".repeat(10), + "DEF".repeat(10), + null, + "HIjK".repeat(10), + "lMNoP".repeat(10), + ], + b: ["ABC", undefined, "EfG", "HIjK", "lMNoP"], + }); + const view = await table.view({ + expressions: [ + `concat("a", ', ', 'here is a long string, ', "b")`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + let result = await view.to_columns(); + let expected = result.a.map( + (x, idx) => x + ", here is a long string, " + result.b[idx], + ); + expected[1] = null; + expected[2] = null; + expect( + result[`concat("a", ', ', 'here is a long string, ', "b")`], + ).toEqual(expected); + view.delete(); + table.delete(); + }); + + test("Concats, non-utf8", async function () { + const table = await perspective.table({ + a: ["𝕙ḗľᶅở щṏᵲɭⅾ", "𝓊⋁ẅ⤫𝛾𝓏", null], + b: ["𝕙ḗľᶅở щṏᵲɭⅾ", "𝑢ⱴⱳẍ𝘺𝘇ӑṣᶑᵴ", "EfG"], + }); + const view = await table.view({ + expressions: [ + `concat("a", ', ', 'here is a long string, ', "b")`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + let result = await view.to_columns(); + let expected = result.a.map( + (x, idx) => x + ", here is a long string, " + result.b[idx], + ); + expected[2] = null; + expect( + result[`concat("a", ', ', 'here is a long string, ', "b")`], + ).toEqual(expected); + view.delete(); + table.delete(); + }); + + test("Upper concats", async function () { + const table = await perspective.table({ + a: [ + "hello world", + "abakshdaskjhlgkjasdiukjqewlkjesaljhgdaskd", + null, + ], + b: ["asjdhlkhfdshafiywhjklsjfaksdgjadkjlv", "abc", "EfG"], + }); + const view = await table.view({ + expressions: [ + `upper(concat("a", ', ', 'here is a long string, ', "b"))`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + let result = await view.to_columns(); + let expected = result[ + `upper(concat("a", ', ', 'here is a long string, ', "b"))` + ].map((x) => (x ? x.toUpperCase() : null)); + expected[2] = null; + expect( + result[ + `upper(concat("a", ', ', 'here is a long string, ', "b"))` + ], + ).toEqual(expected); + view.delete(); + table.delete(); + }); + + test("Lower concats", async function () { + const table = await perspective.table({ + a: [ + "HELLO WORLD SADJKHFUOIWNS:AJKSKJDJBCL", + "KJBSJHDBGASHJDB ASCBAKISJHDKJSAHNDKASJ SJKHDJKAS", + null, + ], + b: [ + "LDJSA:KJFGHJAKLSoijSJDM:ALKJDAS)oewqSAPDOD", + "ASdhnlsaadkjhASJKDSAHIUEHYWIUDSHDNBKJSAD", + "EfG", + ], + }); + const view = await table.view({ + expressions: [ + `lower(concat("a", ', ', 'HERE is a long string, ', "b"))`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + let result = await view.to_columns(); + let expected = result[ + `lower(concat("a", ', ', 'HERE is a long string, ', "b"))` + ].map((x) => (x ? x.toLowerCase() : null)); + expected[2] = null; + expect( + result[ + `lower(concat("a", ', ', 'HERE is a long string, ', "b"))` + ], + ).toEqual(expected); + view.delete(); + table.delete(); + }); + + test("Order lower concats", async function () { + const table = await perspective.table({ + a: ["HELLO WORLD", "VERY LONG STRING HERE", null], + b: ["ALSO HELLO WORLD", "ANOTHER LONG STRING IS HERE", "EfG"], + }); + const view = await table.view({ + expressions: [ + `order(lower(concat("a", ', ', 'HERE is a long string, ', "b")), 'very long string here, here is a long string, another long string is here')`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + let result = await view.to_columns(); + expect( + result[ + `order(lower(concat("a", ', ', 'HERE is a long string, ', "b")), 'very long string here, here is a long string, another long string is here')` + ], + ).toEqual([1, 0, null]); + view.delete(); + table.delete(); + }); + + test.skip("Upper concats, non-utf8", async function () { + const table = await perspective.table({ + a: ["𝕙ḗľᶅở щṏᵲɭⅾ", "𝓊⋁ẅ⤫𝛾𝓏", null], + b: ["𝕙ḗľᶅở щṏᵲɭⅾ", "𝑢ⱴⱳẍ𝘺𝘇ӑṣᶑᵴ", "EfG"], + }); + const view = await table.view({ + expressions: [ + `upper(concat("a", ', ', 'here is a long string, ', "b"))`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + let result = await view.to_columns(); + let expected = result[ + `upper(concat("a", ', ', 'here is a long string, ', "b"))` + ].map((x) => (x ? x.toUpperCase() : null)); + expected[2] = null; + expect( + result[ + `upper(concat("a", ', ', 'here is a long string, ', "b"))` + ], + ).toEqual(expected); + view.delete(); + table.delete(); + }); + + test("Lower concats, non-utf8", async function () { + const table = await perspective.table({ + a: ["𝕙ḗľᶅở щṏᵲɭⅾ", "𝓊⋁ẅ⤫𝛾𝓏", null], + b: ["𝕙ḗľᶅở щṏᵲɭⅾ", "𝑢ⱴⱳẍ𝘺𝘇ӑṣᶑᵴ", "EfG"], + }); + const view = await table.view({ + expressions: [ + `lower(concat("a", ', ', 'HERE is a long string, ', "b"))`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + let result = await view.to_columns(); + let expected = result[ + `lower(concat("a", ', ', 'HERE is a long string, ', "b"))` + ].map((x) => (x ? x.toLowerCase() : null)); + expect( + result[ + `lower(concat("a", ', ', 'HERE is a long string, ', "b"))` + ], + ).toEqual(expected); + view.delete(); + table.delete(); + }); + + test.skip("Length concats, non-utf8", async function () { + const table = await perspective.table({ + a: ["𝕙ḗľᶅở щṏᵲɭⅾ", "𝓊⋁ẅ⤫𝛾𝓏", null], + b: ["𝕙ḗľᶅở щṏᵲɭⅾ", "𝑢ⱴⱳẍ𝘺𝘇ӑṣᶑᵴ", "EfG"], + }); + const view = await table.view({ + expressions: [ + `length(concat("a", ', ', 'here is a long string, ', "b"))`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + let result = await view.to_columns(); + let expected = result.a.map( + (x, idx) => + (x + ", here is a long string, " + result.b[idx]).length, + ); + expected[2] = null; + expect( + result[ + `length(concat("a", ', ', 'here is a long string, ', "b"))` + ], + ).toEqual(expected); + view.delete(); + table.delete(); + }); + + test.skip("Order concats, non-utf8", async function () { + const table = await perspective.table({ + a: ["𝕙ḗľᶅở щṏᵲɭⅾ", "𝓊⋁ẅ⤫𝛾𝓏", null], + b: ["𝕙ḗľᶅở щṏᵲɭⅾ", "𝑢ⱴⱳẍ𝘺𝘇ӑṣᶑᵴ", "EfG"], + }); + const view = await table.view({ + expressions: [ + `var x := concat("a", ', ', 'here is a long string, ', "b"); order(x, '𝓊⋁ẅ⤫𝛾𝓏, here is a long string, 𝑢ⱴⱳẍ𝘺𝘇ӑṣᶑᵴ', '𝕙ḗľᶅở щṏᵲɭⅾ, here is a long string, 𝕙ḗľᶅở щṏᵲɭⅾ')`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + let result = await view.to_columns(); + expect( + result[ + `var x := concat("a", ', ', 'here is a long string, ', "b"); order(x, '𝓊⋁ẅ⤫𝛾𝓏, here is a long string, 𝑢ⱴⱳẍ𝘺𝘇ӑṣᶑᵴ', '𝕙ḗľᶅở щṏᵲɭⅾ, here is a long string, 𝕙ḗľᶅở щṏᵲɭⅾ')` + ], + ).toEqual([1, 0, 2]); + + view.delete(); + table.delete(); + }); + }); + + test.describe("String comparison", function () { + test("==", async function () { + const table = await perspective.table({ + a: ["ABC", "DEF", null, "HIjK", "lMNoP"], + b: ["ABC", undefined, null, "HIjK", "lMNoP"], + }); + + let view = await table.view({ + expressions: { '"a" == "b"': '"a" == "b"' }, + }); + + let result = await view.to_columns(); + + // null == null is true here + expect(result['"a" == "b"']).toEqual([ + true, + false, + true, + true, + true, + ]); + view.delete(); + table.delete(); + }); + + test("== on expression output", async function () { + const table = await perspective.table({ + a: ["ABC", "DEF", "cba", "HIjK", "lMNoP"], + b: ["ABC", "ad", "asudfh", "HIjK", "lMNoP"], + }); + + let view = await table.view({ + expressions: [ + `concat("a", ', ', "b") == concat("a", ', ', "b")`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + let result = await view.to_columns(); + expect( + result[`concat("a", ', ', "b") == concat("a", ', ', "b")`], + ).toEqual([true, true, true, true, true]); + view.delete(); + table.delete(); + }); + + test("==, nulls", async function () { + const table = await perspective.table({ + a: ["ABC", "DEF", undefined, null, null], + b: ["ABC", "not", "EfG", "HIjK", null], + }); + + let view = await table.view({ + expressions: { '"a" == "b"': '"a" == "b"' }, + }); + + let result = await view.to_columns(); + expect(result['"a" == "b"']).toEqual([ + true, + false, + false, + false, + true, + ]); + view.delete(); + table.delete(); + }); + + test("==, extra long", async function () { + const table = await perspective.table({ + a: [ + "ABC".repeat(10), + "DEF".repeat(10), + null, + "HIjK".repeat(10), + "lMNoP", + ], + b: [ + "ABC".repeat(10), + "DEF".repeat(10), + undefined, + "HIjK", + "lMNoP", + ], + }); + + let view = await table.view({ + expressions: { '"a" == "b"': '"a" == "b"' }, + }); + + let result = await view.to_columns(); + expect(result['"a" == "b"']).toEqual([ + true, + true, + true, + false, + true, + ]); + view.delete(); + table.delete(); + }); + + test("==, short", async function () { + const table = await perspective.table({ + a: ["A", "E", null, "h", "l"], + b: ["a", "E", undefined, "h", "l"], + }); + + let view = await table.view({ + expressions: { '"a" == "b"': '"a" == "b"' }, + }); + + let result = await view.to_columns(); + expect(result['"a" == "b"']).toEqual([ + false, + true, + true, + true, + true, + ]); + view.delete(); + table.delete(); + }); + + test("==, mixed length", async function () { + const table = await perspective.table({ + a: [ + "ABC".repeat(100), + "DEF".repeat(10), + null, + "hijk".repeat(10), + "lm", + ], + b: [ + "arc".repeat(50), + "DEf".repeat(10), + undefined, + "HIjK", + "lMNoP", + ], + }); + + let view = await table.view({ + expressions: { '"a" == "b"': '"a" == "b"' }, + }); + + let result = await view.to_columns(); + expect(result['"a" == "b"']).toEqual([ + false, + false, + true, + false, + false, + ]); + view.delete(); + table.delete(); + }); + + test("==, UTF-8", async function () { + const table = await perspective.table({ + a: [ + ">ﺐ{׆Meڱ㒕宾ⷭ̽쉱L𞔚Ո拏۴ګPظǭPۋV|팺㺞㷾墁鴦򒲹|ۿ򧊊䭪񪩛𬦢񺣠񦋳򵾳蛲񖑐iM񊪝񆷯", + "灙𬡍瀳։󷿙񅈕ǐ-kʂiJ!P񙺍󵝳̃੝w𬾐򕕉耨󉋦o򰵏詂3򒤹J<ꑭ񃕱Ӏ𛤦4u򉠚UPf􂢳P##Q񪂈", + "ĈᔞZ񇌖Qఋ?x?#$12ボլ㕢ﺧ𷛘󽙮[񲸧I񟭝򋨰魏ճכ󽺴ۏ󫨫䆐'㓔ǃ[ְ੬䎕寽𤩚ߨ袧򲕊򓰷|%", + "ęԛ򓍯󍩁𨞟㰢œ󇂣õ􌁇΍Ԥ⥯۷˝㿙צּ񬆩򤿭顂ݦۍ式+=ԋ帋񃴕譋ⴏ0l􅏎߳cί򇈊iȞڈU򆐹񍖮򷡦̥𩮏DZ", + "0ой3֝󻙋򑨮꾪߫0󏜬󆑝w󊭟񑓫򾷄𶳿o󏉃纊ʫ􅋶聍𾋊ô򓨼쀨ˆ퍨׽ȿKOŕ􅽾󙸹Ѩ󶭆j񽪌򸢐p򊘏׷򿣂dgD쩖", + ], + b: [ + ">ﺐ{׆Meڱ㒕宾ⷭ̽쉱L𞔚Ո拏۴ګPظǭPۋV|팺㺞㷾墁鴦򒲹|ۿ򧊊䭪񪩛𬦢񺣠񦋳򵾳蛲񖑐iM񊪝񆷯", + "灙𬡍瀳։󷿙񅈕ǐ-kʂiJ!P񙺍󵝳̃੝w𬾐򕕉耨󉋦o򰵏詂3򒤹J<ꑭ񃕱Ӏ𛤦4u򉠚UPf􂢳P##Q񪂈", + "ĈᔞZ񇌖Qఋ?x?#$12ボլ㕢ﺧ𷛘󽙮[񲸧I񟭝򋨰魏ճכ󽺴ۏ󫨫䆐'㓔ǃ[ְ੬䎕寽𤩚ߨ袧򲕊򓰷|%", + "ęԛ򓍯󍩁𨞟㰢œ󇂣õ􌁇΍Ԥ⥯۷˝㿙צּ񬆩򤿭顂ݦۍ式+=ԋ帋񃴕譋ⴏ0l􅏎߳cί򇈊iȞڈU򆐹񍖮򷡦̥𩮏DZ", + "0ой3֝󻙋򑨮꾪߫0󏜬󆑝w󊭟񑓫򾷄𶳿o󏉃纊ʫ􅋶聍𾋊ô򓨼쀨ˆ퍨׽ȿKOŕ􅽾󙸹Ѩ󶭆j񽪌򸢐p򊘏׷򿣂dgD쩖2", + ], + }); + let view = await table.view({ + expressions: { '"a" == "b"': '"a" == "b"' }, + }); + let result = await view.to_columns(); + expect(result['"a" == "b"']).toEqual([ + true, + true, + true, + true, + false, + ]); + view.delete(); + table.delete(); + }); + + test("==, UTF-8 converted to Unicode", async function () { + const table = await perspective.table({ + a: [ + ">{MeLPPV||iM", + "-kiJ!Pwo3J<4uUPfP##Q", + "ZQ?x?#$12[I'[|%", + "ܦf+=0lciU", + "030wo􎼨KOjpdD", + ], + b: [ + ">{MeLPPV||iM", + "-kiJ!Pwo3J<4uUPfP##Q", + "ZQ?x?#$12[I'[|%", + "ܦf+=0lciU", + "030wo􎼨KOjpdD2", + ], + }); + let view = await table.view({ + expressions: { '"a" == "b"': '"a" == "b"' }, + }); + let result = await view.to_columns(); + expect(result['"a" == "b"']).toEqual([ + true, + true, + true, + true, + false, + ]); + view.delete(); + table.delete(); + }); + }); + + test.describe("Regular Expressions", () => { + test("Match string with string", async () => { + const table = await perspective.table({ + a: "string", + b: "string", + c: "string", + }); + + await table.update({ + a: ["ABC", "DEF", "cbA", "HIjK", "lMNoP"], + b: ["ABC", "ad", "asudfh", "HIjK", "lMNoP"], + c: [ + "1234567890", + "123x4567", + "abcdefg123", + "4567123", + "1?2?3?", + ], + }); + + const expressions = [ + `match("a", 'ABC')`, + "match('aBc', '[aAbBcC]{3}')", + `match("a", 'A')`, + `match("c", '[0-9]{3}')`, + `match("c", '4567')`, + ]; + + const view = await table.view({ + expressions, + }); + + const schema = await view.expression_schema(); + + for (const expr of expressions) { + expect(schema[expr]).toEqual("boolean"); + } + + const results = await view.to_columns(); + expect(results[expressions[0]]).toEqual([ + true, + false, + false, + false, + false, + ]); + expect(results[expressions[1]]).toEqual(Array(5).fill(true)); + expect(results[expressions[2]]).toEqual([ + true, + false, + true, + false, + false, + ]); + expect(results[expressions[3]]).toEqual([ + true, + true, + true, + true, + false, + ]); + expect(results[expressions[4]]).toEqual([ + true, + true, + false, + true, + false, + ]); + + await view.delete(); + await table.delete(); + }); + + test("match_all string with string", async () => { + const table = await perspective.table({ + a: "string", + b: "string", + c: "string", + }); + + await table.update({ + a: ["ABC", "DEF", "cbA", "HIjK", "lMNoP"], + b: ["ABC", "ad", "asudfh", "HIjK", "lMNoP"], + c: ["1234567890", "123x4567", "abcdefg123", "4567123", "123"], + }); + + const expressions = [ + `match_all("a", 'ABC')`, + "match_all('aBc', '[aAbBcC]{3}')", + `match_all("a", 'A')`, + `match_all("c", '[0-9]{3}')`, + `match_all("c", '4567')`, + `match_all("c", '4567123')`, + `match_all("c", '[0-9]+')`, + ]; + + const view = await table.view({ + expressions, + }); + + const schema = await view.expression_schema(); + + for (const expr of expressions) { + expect(schema[expr]).toEqual("boolean"); + } + + const results = await view.to_columns(); + expect(results[expressions[0]]).toEqual([ + true, + false, + false, + false, + false, + ]); + expect(results[expressions[1]]).toEqual(Array(5).fill(true)); + expect(results[expressions[2]]).toEqual(Array(5).fill(false)); + expect(results[expressions[3]]).toEqual([ + false, + false, + false, + false, + true, + ]); + expect(results[expressions[4]]).toEqual(Array(5).fill(false)); + expect(results[expressions[5]]).toEqual([ + false, + false, + false, + true, + false, + ]); + expect(results[expressions[6]]).toEqual([ + true, + false, + false, + true, + true, + ]); + + await view.delete(); + await table.delete(); + }); + + test("Match string with bad regex should fail type-checking", async () => { + const table = await perspective.table({ + a: ["ABC", "DEF", "cbA", "HIjK", "lMNoP"], + b: ["ABC", "ad", "asudfh", "HIjK", "lMNoP"], + }); + + const expressions = [ + `match("a", '*.')`, + "match('abc', '(?=a)')", + `match("a", '?')`, + ]; + + const validated = await table.validate_expressions(expressions); + expect(validated.expression_schema).toEqual({}); + + for (const expr of expressions) { + expect(validated.expression_schema[expr]).toBeUndefined(); + expect(validated.errors[expr]).toEqual({ + line: 0, + column: 0, + error_message: + "Type Error - inputs do not resolve to a valid expression.", + }); + } + + // Because a bad regex does not raise a parse error, it is still + // valid to create a view from them. + await expect( + table.view({ + expressions, + }), + ).rejects.toThrow(); + + await table.delete(); + }); + + // I don't know what makes a parse error unique in the internal comment, + // but `validate()` and `expression` shoul dbe identical in behavior. + test.skip("OG - Match string with bad regex should fail type-checking", async () => { + const table = await perspective.table({ + a: ["ABC", "DEF", "cbA", "HIjK", "lMNoP"], + b: ["ABC", "ad", "asudfh", "HIjK", "lMNoP"], + }); + + const expressions = [ + `match("a", '*.')`, + "match('abc', '(?=a)')", + `match("a", '?')`, + ]; + + const validated = await table.validate_expressions(expressions); + expect(validated.expression_schema).toEqual({}); + + for (const expr of expressions) { + expect(validated.expression_schema[expr]).toBeUndefined(); + expect(validated.errors[expr]).toEqual({ + line: 0, + column: 0, + error_message: + "Type Error - inputs do not resolve to a valid expression.", + }); + } + + // Because a bad regex does not raise a parse error, it is still + // valid to create a view from them. + const view = await table.view({ + expressions, + }); + + const schema = await view.expression_schema(); + const results = await view.to_columns(); + + for (const expr of expressions) { + expect(schema[expr]).toEqual("boolean"); + expect(results[expr]).toEqual(Array(5).fill(null)); + } + + await view.delete(); + await table.delete(); + }); + + test("match_all string with bad regex should fail type-checking", async () => { + const table = await perspective.table({ + a: ["ABC", "DEF", "cbA", "HIjK", "lMNoP"], + b: ["ABC", "ad", "asudfh", "HIjK", "lMNoP"], + }); + + const expressions = [ + `match_all("a", '*.')`, + "match_all('abc', '(?=a)')", + `match_all("a", '?')`, + `match_all("a", '+.')`, + ]; + + const validated = await table.validate_expressions(expressions); + expect(validated.expression_schema).toEqual({}); + + for (const expr of expressions) { + expect(validated.expression_schema[expr]).toBeUndefined(); + expect(validated.errors[expr]).toEqual({ + line: 0, + column: 0, + error_message: + "Type Error - inputs do not resolve to a valid expression.", + }); + } + + await expect( + table.view({ + expressions, + }), + ).rejects.toThrow(); + + await table.delete(); + }); + + test("Match should only work on strings", async () => { + const table = await perspective.table({ + a: ["ABC", "DEF", "cbA", "HIjK", "lMNoP"], + b: ["abc123", "abc567", "abc56", "1234567", "aaa000"], + }); + + const expressions = [ + `match("a", "b")`, + `match("a", 123)`, + `match(today(), '[a-z]{3}[0-9]{3}')`, + `match(False, '[0-9]{7}')`, + ]; + + const validated = await table.validate_expressions(expressions); + + for (const expr of expressions) { + expect(validated.expression_schema[expr]).toBeUndefined(); + } + + expect(validated.errors[expressions[0]].error_message).toEqual( + "Failed parameter type check for function 'match', Expected 'TS' call set: 'TT'", + ); + + expect(validated.errors[expressions[1]].error_message).toEqual( + "Failed parameter type check for function 'match', Expected 'TS' call set: 'TT'", + ); + + expect(validated.errors[expressions[2]]).toEqual({ + line: 0, + column: 0, + error_message: + "Type Error - inputs do not resolve to a valid expression.", + }); + + expect(validated.errors[expressions[3]]).toEqual({ + line: 0, + column: 0, + error_message: + "Type Error - inputs do not resolve to a valid expression.", + }); + + await table.delete(); + }); + + test("match_all should only work on strings", async () => { + const table = await perspective.table({ + a: ["ABC", "DEF", "cbA", "HIjK", "lMNoP"], + b: ["abc123", "abc567", "abc56", "1234567", "aaa000"], + }); + + const expressions = [ + `match_all("a", "b")`, + `match_all("a", 123)`, + `match_all(today(), '[a-z]{3}[0-9]{3}')`, + `match_all(False, '[0-9]{7}')`, + ]; + + const validated = await table.validate_expressions(expressions); + + for (const expr of expressions) { + expect(validated.expression_schema[expr]).toBeUndefined(); + } + + expect(validated.errors[expressions[0]].error_message).toEqual( + "Failed parameter type check for function 'match_all', Expected 'TS' call set: 'TT'", + ); + + expect(validated.errors[expressions[1]].error_message).toEqual( + "Failed parameter type check for function 'match_all', Expected 'TS' call set: 'TT'", + ); + + expect(validated.errors[expressions[2]]).toEqual({ + line: 0, + column: 0, + error_message: + "Type Error - inputs do not resolve to a valid expression.", + }); + + expect(validated.errors[expressions[3]]).toEqual({ + line: 0, + column: 0, + error_message: + "Type Error - inputs do not resolve to a valid expression.", + }); + + await table.delete(); + }); + + test("Match string with regex", async () => { + const table = await perspective.table({ + a: ["ABC", "DEF", "cbA", "HIjK", "lMNoP"], + b: ["abc123", "abc567", "abc56", "1234567", "aaa000"], + }); + + const expressions = [ + `match("a", '.*')`, + `match("b", '[a-z]{3}[0-9]{3}')`, + `match("b", '[0-9]{7}')`, + ]; + + const view = await table.view({ + expressions, + }); + + const schema = await view.expression_schema(); + + for (const expr of expressions) { + expect(schema[expr]).toEqual("boolean"); + } + + const results = await view.to_columns(); + expect(results[expressions[0]]).toEqual(Array(5).fill(true)); + expect(results[expressions[1]]).toEqual([ + true, + true, + false, + false, + true, + ]); + expect(results[expressions[2]]).toEqual([ + false, + false, + false, + true, + false, + ]); + + await view.delete(); + await table.delete(); + }); + + test("match_all string with regex", async () => { + const table = await perspective.table({ + a: ["ABC", "DEF", "cbA", "HIjK", "lMNoP"], + b: ["abc123", "abc567", "abc56", "1234567", "aaa0001234"], + }); + + const expressions = [ + `match_all("a", '.*')`, + `match_all("b", '[a-z]{3}[0-9]{3}')`, + `match_all("b", '[0-9]{7}')`, + ]; + + const view = await table.view({ + expressions, + }); + + const schema = await view.expression_schema(); + + for (const expr of expressions) { + expect(schema[expr]).toEqual("boolean"); + } + + const results = await view.to_columns(); + expect(results[expressions[0]]).toEqual(Array(5).fill(true)); + expect(results[expressions[1]]).toEqual([ + true, + true, + false, + false, + false, + ]); + expect(results[expressions[2]]).toEqual([ + false, + false, + false, + true, + false, + ]); + + await view.delete(); + await table.delete(); + }); + + test("Match string with regex, randomized", async () => { + const data = { a: [] }; + for (let i = 0; i < 500; i++) { + data.a.push(random_string(100, true)); + } + + const table = await perspective.table(data); + const expressions = [ + `match("a", '.*')`, // should match everything + `match("a", '.{100}')`, // should match strings the size of max + ]; + + const view = await table.view({ + expressions, + }); + + const schema = await view.expression_schema(); + for (const expr of expressions) { + expect(schema[expr]).toEqual("boolean"); + } + + const results = await view.to_columns(); + for (let i = 0; i < 500; i++) { + const a = results[expressions[0]][i]; + const b = results[expressions[1]][i]; + const source = data.a[i]; + if (source === null) { + expect(a).toEqual(null); + expect(b).toEqual(null); + } else { + expect(a).toEqual(true); + expect(b).toEqual(source.length === 100); + } + } + + await view.delete(); + await table.delete(); + }); + + test("match_all string with regex, randomized", async () => { + const data = { a: [] }; + + for (let i = 0; i < 500; i++) { + data.a.push(random_string(100, true)); + } + + const table = await perspective.table(data); + + const expressions = [ + `match_all("a", '.*')`, // should match everything + `match_all("a", '.{100}')`, // should match strings the size of max + ]; + + const view = await table.view({ + expressions, + }); + + const schema = await view.expression_schema(); + + for (const expr of expressions) { + expect(schema[expr]).toEqual("boolean"); + } + + const results = await view.to_columns(); + + for (let i = 0; i < 500; i++) { + const a = results[expressions[0]][i]; + const b = results[expressions[1]][i]; + const source = data.a[i]; + + if (source === null) { + expect(a).toEqual(null); + expect(b).toEqual(null); + } else { + expect(a).toEqual(true); + expect(b).toEqual(source.length === 100); + } + } + + await view.delete(); + await table.delete(); + }); + + test("Match string and null with regex", async () => { + const table = await perspective.table({ + a: ["ABC", "abc", null, "AbC", "12345"], + }); + const expressions = [ + `match("a", '.*')`, + `match("a", '[aAbBcC]{3}')`, + `match("a", '[0-9]{5}')`, + ]; + + const view = await table.view({ + expressions, + }); + + const schema = await view.expression_schema(); + + for (const expr of expressions) { + expect(schema[expr]).toEqual("boolean"); + } + + const results = await view.to_columns(); + expect(results[expressions[0]]).toEqual([ + true, + true, + null, + true, + true, + ]); + expect(results[expressions[1]]).toEqual([ + true, + true, + null, + true, + false, + ]); + expect(results[expressions[2]]).toEqual([ + false, + false, + null, + false, + true, + ]); + + await view.delete(); + await table.delete(); + }); + + test("match_all string and null with regex", async () => { + const table = await perspective.table({ + a: ["ABC", "abc", null, "AbC", "12345", "123456"], + }); + + const expressions = [ + `match_all("a", '.*')`, + `match_all("a", '[aAbBcC]{3}')`, + `match_all("a", '[0-9]{5}')`, + ]; + + const view = await table.view({ + expressions, + }); + + const schema = await view.expression_schema(); + + for (const expr of expressions) { + expect(schema[expr]).toEqual("boolean"); + } + + const results = await view.to_columns(); + expect(results[expressions[0]]).toEqual([ + true, + true, + null, + true, + true, + true, + ]); + expect(results[expressions[1]]).toEqual([ + true, + true, + null, + true, + false, + false, + ]); + expect(results[expressions[2]]).toEqual([ + false, + false, + null, + false, + true, + false, + ]); + + await view.delete(); + await table.delete(); + }); + + test("Search with string", async () => { + const table = await perspective.table({ + a: ["ABC", "DEF", "cbA", "HIjK", "lMNoP"], + b: ["abc123", "abc567", "DEF56", "1234567", "AAA000"], + }); + + const expressions = [ + `search("a", '(ABC)')`, + `search("b", '(.*)')`, + `search("a", '([A-Za-z]{3})')`, + `search("b", '([A-Z]{3})')`, + `search("b", '([0-9]{7})')`, + `search("b", '([A-Za-z]{3})')`, + ]; + + const view = await table.view({ expressions }); + + const schema = await view.expression_schema(); + + for (const expr of expressions) { + expect(schema[expr]).toEqual("string"); + } + + const results = await view.to_columns(); + + expect(results[expressions[0]]).toEqual([ + "ABC", + null, + null, + null, + null, + ]); + expect(results[expressions[1]]).toEqual([ + "abc123", + "abc567", + "DEF56", + "1234567", + "AAA000", + ]); + expect(results[expressions[2]]).toEqual([ + "ABC", + "DEF", + "cbA", + "HIj", + "lMN", + ]); + expect(results[expressions[3]]).toEqual([ + null, + null, + "DEF", + null, + "AAA", + ]); + expect(results[expressions[4]]).toEqual([ + null, + null, + null, + "1234567", + null, + ]); + expect(results[expressions[5]]).toEqual([ + "abc", + "abc", + "DEF", + null, + "AAA", + ]); + + await view.delete(); + await table.delete(); + }); + + test("Search - extract email", async () => { + const endings = ["com", "net", "org", "co.uk", "ie", "me", "io"]; + const valid_address_chars = ALPHANUMERIC + "._-"; + const get_data = (num_rows) => { + const data = []; + + for (let i = 0; i < num_rows; i++) { + const email = `${random_string( + 30, + false, + valid_address_chars, + )}@${random_string(10, false, ALPHA)}.${randchoice( + endings, + )}`; + data.push(email); + } + + return data; + }; + + const table = await perspective.table({ a: get_data(100) }); + const expressions = [ + `// address + search("a", '^([a-zA-Z0-9._-]+)@');`, + `// domain + search("a", '@([a-zA-Z.]+)$')`, + ]; + + // Make the same regex 10x - make sure it's ok to cache the regex + for (let i = 0; i < 10; i++) { + const view = await table.view({ expressions }); + const schema = await view.expression_schema(); + expect(schema).toEqual({ + address: "string", + domain: "string", + }); + + const result = await view.to_columns(); + + for (let i = 0; i < 100; i++) { + const source = result["a"][i]; + const expected_address = + source.match(/^([a-zA-Z0-9._-]+)@/)[1]; + const expected_domain = source.match(/@([a-zA-Z.]+)$/)[1]; + expect(result["address"][i]).toEqual(expected_address); + expect(result["domain"][i]).toEqual(expected_domain); + } + + await view.delete(); + } + + await table.delete(); + }); + + test("Search - extract card number", async () => { + const digits = () => { + const output = []; + for (let i = 0; i < 4; i++) { + output.push(randchoice(NUMERIC)); + } + return output.join(""); + }; + + const get_data = (num_rows) => { + const data = []; + + for (let i = 0; i < num_rows; i++) { + const separator = Math.random() > 0.5 ? " " : "-"; + const num = `${digits()}${separator}${digits()}${separator}${digits()}${separator}${digits()}`; + data.push(num); + } + + return data; + }; + + const table = await perspective.table({ a: get_data(1000) }); + const parsed = `var parts[4]; + parts[0] := search("a", '^([0-9]{4})[ -][0-9]{4}[ -][0-9]{4}[ -][0-9]{4}'); + parts[1] := search("a", '^[0-9]{4}[ -]([0-9]{4})[ -][0-9]{4}[ -][0-9]{4}'); + parts[2] := search("a", '^[0-9]{4}[ -][0-9]{4}[ -]([0-9]{4})[ -][0-9]{4}'); + parts[3] := search("a", '^[0-9]{4}[ -][0-9]{4}[ -][0-9]{4}[ -]([0-9]{4})'); + concat(parts[0], parts[1], parts[2], parts[3])`; + + const view = await table.view({ + expressions: { parsed }, + }); + const schema = await view.expression_schema(); + expect(schema).toEqual({ parsed: "string" }); + const result = await view.to_columns(); + + for (let i = 0; i < 100; i++) { + const source = result["a"][i]; + const expected = source.replace(/[ -]/g, ""); + expect(result.parsed[i]).toEqual(expected); + } + + await view.delete(); + await table.delete(); + }); + + test("Search local var", async () => { + const digits = () => { + const output = []; + for (let i = 0; i < 4; i++) { + output.push(randchoice(NUMERIC)); + } + return output.join(""); + }; + + const get_data = (num_rows) => { + const data = []; + + for (let i = 0; i < num_rows; i++) { + const separator = Math.random() > 0.5 ? " " : "-"; + const num = `${digits()}${separator}${digits()}${separator}${digits()}${separator}${digits()}`; + data.push(num); + } + + return data; + }; + + const table = await perspective.table({ a: get_data(1000) }); + const expression = `var parts[4]; + parts[0] := search("a", '^([0-9]{4})[ -][0-9]{4}[ -][0-9]{4}[ -][0-9]{4}'); + parts[1] := search("a", '^[0-9]{4}[ -]([0-9]{4})[ -][0-9]{4}[ -][0-9]{4}'); + parts[2] := search("a", '^[0-9]{4}[ -][0-9]{4}[ -]([0-9]{4})[ -][0-9]{4}'); + parts[3] := search("a", '^[0-9]{4}[ -][0-9]{4}[ -][0-9]{4}[ -]([0-9]{4})'); + var z := parts[2]; + z`; + + const view = await table.view({ + expressions: { parsed: expression }, + }); + const schema = await view.expression_schema(); + expect(schema).toEqual({ parsed: "string" }); + const result = await view.to_columns(); + + for (let i = 0; i < 100; i++) { + const source = result["a"][i]; + const expected = source.replace(/[ -]/g, ""); + expect(result.parsed[i]).toEqual(expected.substring(8, 12)); + } + + await view.delete(); + await table.delete(); + }); + + test("non strings should not work", async () => { + const table = await perspective.table({ + x: [1, 2, 3], + y: [100.5, 200.5, 300.5], + z: [new Date(), new Date(), null], + }); + + const expressions = []; + const cols = ["x", "y", "z"]; + const pattern = "(.*)"; + const fns = [ + "match", + "match_all", + "search", + "indexof", + "substring", + ]; + + for (const fn of fns) { + for (const col of cols) { + let expr; + + switch (fn) { + case "indexof": + { + expr = `var x[2]; indexof("${col}", '${pattern}', x);`; + } + break; + case "substring": + { + expr = `substring("${col}", 0)`; + } + break; + default: + { + expr = `${fn}("${col}", '${pattern}')`; + } + break; + } + + expressions.push(expr); + } + } + + const validate = await table.validate_expressions(expressions); + expect(validate.expression_schema).toEqual({}); + + for (const expr of expressions) { + expect(validate.errors[expr]).toEqual({ + column: 0, + error_message: + "Type Error - inputs do not resolve to a valid expression.", + line: 0, + }); + } + + await table.delete(); + }); + + test("string literals should work", async () => { + const table = await perspective.table({ + x: [1, 2, 3], + }); + + const expressions = []; + const str = "abcdefghijklmn"; + const pattern = "(.*)"; + const fns = [ + "match", + "match_all", + "search", + "indexof", + "substring", + ]; + + const expected_schema = {}; + + for (const fn of fns) { + let expr; + + switch (fn) { + case "indexof": + { + expr = `var x[2]; indexof('${str}', '${pattern}', x);`; + expected_schema[expr] = "boolean"; + } + break; + case "substring": + { + expr = `substring('${str}', 0)`; + expected_schema[expr] = "string"; + } + break; + default: + { + expr = `${fn}('${str}', '${pattern}')`; + fn === "search" + ? (expected_schema[expr] = "string") + : (expected_schema[expr] = "boolean"); + } + break; + } + + expressions.push(expr); + } + + const validate = await table.validate_expressions(expressions); + expect(validate.expression_schema).toEqual(expected_schema); + + await table.delete(); + }); + + test("Search - newlines", async () => { + const table = await perspective.table({ + a: [ + "abc\ndef", + "\n\n\n\nabc\ndef", + "abc\n\n\n\n\n\nabc\ndef\n\n\n\n", + null, + "def", + ], + b: [ + "hello\tworld", + "\n\n\n\n\nhello\n\n\n\n\n\tworld", + "\tworld", + "world", + null, + ], + }); + const view = await table.view({ + expressions: { + c1: "search(\"a\", '(\ndef)')", + c2: "search(\"b\", '(\tworld)')", + }, + }); + + const schema = await view.expression_schema(); + expect(schema).toEqual({ + c1: "string", + c2: "string", + }); + + const result = await view.to_columns(); + expect(result["c1"]).toEqual([ + "\ndef", + "\ndef", + "\ndef", + null, + null, + ]); + expect(result["c2"]).toEqual([ + "\tworld", + "\tworld", + "\tworld", + null, + null, + ]); + + await view.delete(); + await table.delete(); + }); + + test("indexof", async () => { + const table = await perspective.table({ + x: "string", + y: "string", + }); + + await table.update({ + x: ["15 02 1997", "31 11 2021", "01 01 2020", "31 12 2029"], + y: ["$300", "$123.58", "$0.99", "$1.99"], + }); + + const expressions = { + "parsed date": `var year_vec[2]; + indexof("x", '([0-9]{4})$', year_vec); + year_vec[1] - year_vec[0]; + `, + "parsed date2": `var year_vec[2]; + indexof("x", '([0-9]{4})$', year_vec); + `, + "parsed dollars": + "var dollar_vec[2];indexof(\"y\", '^[$]([0-9.]+)', dollar_vec); dollar_vec[0] + dollar_vec[1];", + }; + + const view = await table.view({ expressions }); + const schema = await view.expression_schema(); + expect(schema).toEqual({ + "parsed date": "float", + "parsed date2": "boolean", + "parsed dollars": "float", + }); + + const result = await view.to_columns(); + expect(result["parsed date"]).toEqual(Array(4).fill(3)); + expect(result["parsed date2"]).toEqual(Array(4).fill(true)); + expect(result["parsed dollars"]).toEqual([4, 7, 5, 5]); + + await view.delete(); + await table.delete(); + }); + + test.skip("OG - indexof various vectors", async () => { + const table = await perspective.table({ + x: "string", + y: "string", + }); + + await table.update({ + x: ["15 02 1997", "31 11 2021", "01 01 2020", "31 12 2029"], + y: ["$300", "$123.58", "$0.99", "$1.99"], + }); + + const expressions = { + "parsed date": `var year_vec[1]; // vector too small + indexof("x", '([0-9]{4})$', year_vec); + `, + "parsed date2": `var year_vec[2]; + indexof("x", '([a-z])', year_vec); // should not match + `, + "parsed dollars": + "var dollar_vec[2] := {100, 200};indexof(\"y\", '^[$]([0-9.]+)', dollar_vec); dollar_vec[0] + dollar_vec[1];", + "parsed dollars2": `var dollar_vec[2] := {100, 200}; + indexof("y", '^([a-z])', dollar_vec); // should not match + dollar_vec[0] + dollar_vec[1]; // should not overwrite vector + `, + }; + + const view = await table.view({ expressions }); + const schema = await view.expression_schema(); + expect(schema).toEqual({ + "parsed date": "boolean", + "parsed date2": "boolean", + "parsed dollars": "float", + "parsed dollars2": "float", + }); + + const result = await view.to_columns(); + expect(result["parsed date"]).toEqual(Array(4).fill(null)); + expect(result["parsed date2"]).toEqual(Array(4).fill(false)); + expect(result["parsed dollars"]).toEqual([4, 7, 5, 5]); + expect(result["parsed dollars2"]).toEqual(Array(4).fill(300)); + + await view.delete(); + await table.delete(); + }); + + test("indexof various vectors", async () => { + const table = await perspective.table({ + x: "string", + y: "string", + }); + + await table.update({ + x: ["15 02 1997", "31 11 2021", "01 01 2020", "31 12 2029"], + y: ["$300", "$123.58", "$0.99", "$1.99"], + }); + + const expressions = { + "parsed date": `var year_vec[1]; // vector too small + indexof("x", '([0-9]{4})$', year_vec); + `, + "parsed date2": `var year_vec[2]; + indexof("x", '([a-z])', year_vec); // should not match + `, + "parsed dollars": + "var dollar_vec[2] := {100, 200};indexof(\"y\", '^[$]([0-9.]+)', dollar_vec); dollar_vec[0] + dollar_vec[1];", + "parsed dollars2": `var dollar_vec[2] := {100, 200}; + indexof("y", '^([a-z])', dollar_vec); // should not match + dollar_vec[0] + dollar_vec[1]; // should not overwrite vector + `, + }; + + try { + await table.view({ expressions }); + } catch (e) { + const expected = `Abort(): Type Error - inputs do not resolve to a valid expression.`; + expect(e.message.slice(0, expected.length)).toEqual(expected); + } + delete expressions["parsed date"]; + const view = await table.view({ expressions }); + const schema = await view.expression_schema(); + expect(schema).toEqual({ + "parsed date2": "boolean", + "parsed dollars": "float", + "parsed dollars2": "float", + }); + + const result = await view.to_columns(); + expect(result["parsed date2"]).toEqual(Array(4).fill(false)); + expect(result["parsed dollars"]).toEqual([4, 7, 5, 5]); + expect(result["parsed dollars2"]).toEqual(Array(4).fill(300)); + + await view.delete(); + await table.delete(); + }); + + test("indexof and substr", async () => { + const table = await perspective.table({ + x: "string", + y: "string", + }); + + await table.update({ + x: ["15 02 1997", "31 11 2021", "01 01 2020", "31 12 2029"], + y: ["$300", "$123.58", "$0.99", "$1.99"], + }); + + const expressions = [ + `// year + var year_vec[2]; + indexof("x", '([0-9]{4})$', year_vec); + var start := year_vec[0]; + var len := year_vec[1] - start + 1; + substring("x", start, len);`, + `// year int + var year_vec[2]; + indexof("x", '([0-9]{4})$', year_vec); + var start := year_vec[0]; + var len := year_vec[1] - start + 1; + integer(substring("x", start, len));`, + `// dollars + var out[2]; + indexof("y", '^[$]([0-9.]+)', out); + substring("y", out[0], out[1] - out[0] + 1)`, + `// dollars float + var out[2]; + indexof("y", '^[$]([0-9.]+)', out); + float(substring("y", out[0], out[1] - out[0] + 1))`, + ]; + + const view = await table.view({ expressions }); + const schema = await view.expression_schema(); + expect(schema).toEqual({ + year: "string", + "year int": "integer", + dollars: "string", + "dollars float": "float", + }); + + const result = await view.to_columns(); + + expect(result.year).toEqual(["1997", "2021", "2020", "2029"]); + expect(result["year int"]).toEqual([1997, 2021, 2020, 2029]); + expect(result.dollars).toEqual(["300", "123.58", "0.99", "1.99"]); + expect(result["dollars float"]).toEqual([300, 123.58, 0.99, 1.99]); + + await view.delete(); + await table.delete(); + }); + + test("substr with invalid indices", async () => { + const table = await perspective.table({ + x: ["abc, def, efg", "abcdef", null, "", "12345678"], + y: ["abc", "abc", "abc", "abc", "abc"], + }); + const view = await table.view({ + expressions: [ + "substring('abcdef', 0)", + "substring('abcdef', 3)", + 'substring("x", 3)', + 'substring("x", -1)', + 'substring("x", 1, 10000)', + 'substring("x", 10000)', + 'substring("x", 5, 4)', + 'substring("y", 0, 3)', + 'substring("y", 0)', + 'substring("y", 1, 2)', + 'substring("y", 2, 2)', + 'substring("y", 1, 1)', + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + const results = await view.to_columns(); + expect(results["substring('abcdef', 0)"]).toEqual( + Array(5).fill("abcdef"), + ); + expect(results["substring('abcdef', 3)"]).toEqual( + Array(5).fill("def"), + ); + expect(results['substring("x", 3)']).toEqual([ + ", def, efg", + "def", + null, + null, + "45678", + ]); + expect(results['substring("y", 0, 3)']).toEqual( + Array(5).fill("abc"), + ); + expect(results['substring("y", 1, 2)']).toEqual( + Array(5).fill("bc"), + ); + expect(results['substring("y", 0)']).toEqual(results["y"]); + expect(results['substring("y", 2, 2)']).toEqual( + Array(5).fill(null), + ); + expect(results['substring("y", 1, 1)']).toEqual(Array(5).fill("b")); + expect(results['substring("x", -1)']).toEqual(Array(5).fill(null)); + expect(results['substring("x", 1, 10000)']).toEqual( + Array(5).fill(null), + ); + expect(results['substring("x", 10000)']).toEqual( + Array(5).fill(null), + ); + expect(results['substring("x", 5, 4)']).toEqual([ + "def,", + null, + null, + null, + null, + ]); + + // substring() with invalid indices will pass type checking but + // will return null. + const validate = await table.validate_expressions([ + 'substring("x", -1)', + 'substring("x", 1, 10000)', + 'substring("x", 10000)', + 'substring("x", 5, 4)', + ]); + + expect(validate.expression_schema).toEqual({ + 'substring("x", -1)': "string", + 'substring("x", 1, 10000)': "string", + 'substring("x", 10000)': "string", + 'substring("x", 5, 4)': "string", + }); + + expect(validate.errors).toEqual({}); + + await view.delete(); + await table.delete(); + }); + + test("substr with partial update", async () => { + const table = await perspective.table( + { + x: ["abc, def, efg", "abcdef", null, "", "12345678"], + y: [1, 2, 3, 4, 5], + }, + { index: "y" }, + ); + + const view = await table.view({ + expressions: { + 'substring("x", 3)': 'substring("x", 3)', + 'substring("x", 1, 2)': 'substring("x", 1, 2)', + }, + }); + + let results = await view.to_columns(); + expect(results['substring("x", 3)']).toEqual([ + ", def, efg", + "def", + null, + null, + "45678", + ]); + + expect(results['substring("x", 1, 2)']).toEqual([ + "bc", + "bc", + null, + null, + "23", + ]); + + await table.update({ + x: ["new string", null, "abc"], + y: [3, 1, 5], + }); + + results = await view.to_columns(); + + expect(results['substring("x", 3)']).toEqual([ + null, + "def", + " string", + null, + null, + ]); + expect(results['substring("x", 1, 2)']).toEqual([ + null, + "bc", + "ew", + null, + "bc", + ]); + + await table.update({ + x: ["1234", "1234", "1234", "1234", "1234"], + y: [5, 4, 3, 2, 1], + }); + + results = await view.to_columns(); + + expect(results['substring("x", 3)']).toEqual([ + "4", + "4", + "4", + "4", + "4", + ]); + + expect(results['substring("x", 1, 2)']).toEqual([ + "23", + "23", + "23", + "23", + "23", + ]); + + await view.delete(); + await table.delete(); + }); + + test("substr with clear", async () => { + const table = await perspective.table({ + x: ["abc, def, efg", "abcdef", null, "", "12345678"], + }); + + const view = await table.view({ + expressions: { + 'substring("x", 3)': 'substring("x", 3)', + 'substring("x", 1, 2)': 'substring("x", 1, 2)', + }, + }); + + let results = await view.to_columns(); + expect(results['substring("x", 3)']).toEqual([ + ", def, efg", + "def", + null, + null, + "45678", + ]); + + expect(results['substring("x", 1, 2)']).toEqual([ + "bc", + "bc", + null, + null, + "23", + ]); + + await view.delete(); + await table.clear(); + + expect(await table.size()).toEqual(0); + + await table.update({ + x: ["new string", null, "abc"], + }); + + const view2 = await table.view({ + expressions: { + 'substring("x", 3)': 'substring("x", 3)', + 'substring("x", 1, 2)': 'substring("x", 1, 2)', + }, + }); + results = await view2.to_columns(); + + expect(results['substring("x", 3)']).toEqual([ + " string", + null, + null, + ]); + + await view2.delete(); + await table.delete(); + }); + + test("substr more invalid indices", async () => { + const table = await perspective.table( + { + x: ["abc, def, efg", "abcdef", null, "", "12345678"], + y: [1, 2, 3, 4, 5], + }, + { index: "y" }, + ); + + const view = await table.view({ + expressions: [ + 'substring("x", 100)', + 'substring("x", 1, 300)', + 'substring("x", -100)', + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + let results = await view.to_columns(); + + expect(results['substring("x", 100)']).toEqual(Array(5).fill(null)); + expect(results['substring("x", 1, 300)']).toEqual( + Array(5).fill(null), + ); + expect(results['substring("x", -100)']).toEqual( + Array(5).fill(null), + ); + + await table.remove([1, 2, 3, 4, 5]); + + const str = Array(100).fill("abcde").join(""); + await table.update({ + x: [str], + y: [1], + }); + + results = await view.to_columns(); + expect(results['substring("x", 100)']).toEqual([ + str.substring(100), + ]); + expect(results['substring("x", 1, 300)']).toEqual([ + str.substr(1, 300), + ]); + expect(results['substring("x", -100)']).toEqual( + Array(1).fill(null), + ); + + await view.delete(); + await table.delete(); + }); + + test("substring - extract email", async () => { + const endings = ["com", "net", "org", "co.uk", "ie", "me", "io"]; + const valid_address_chars = ALPHANUMERIC + "._-"; + const get_data = (num_rows) => { + const data = []; + + for (let i = 0; i < num_rows; i++) { + const email = `${random_string( + 30, + false, + valid_address_chars, + )}@${random_string(10, false, ALPHA)}.${randchoice( + endings, + )}`; + data.push(email); + } + + return data; + }; + + const table = await perspective.table({ a: get_data(100) }); + const expressions = [ + `// ending + var domain := search(\"a\", '@([a-zA-Z.]+)$'); + var len := length(domain); + if (len > 0 and is_not_null(domain)) { + search(domain, '[.](.*)$'); + } else { + 'not found'; + }`, + ]; + + // Make the same regex 10x - make sure it's ok to cache the regex + for (let i = 0; i < 10; i++) { + const view = await table.view({ expressions }); + const schema = await view.expression_schema(); + expect(schema).toEqual({ + ending: "string", + }); + + const result = await view.to_columns(); + + for (let i = 0; i < 100; i++) { + const source = result["a"][i]; + const domain = source.match(/@([a-zA-Z.]+)$/)[1]; + const ending = domain.match(/[.](.*)$/)[1]; + expect(result["ending"][i]).toEqual(ending); + } + + await view.delete(); + } + + await table.delete(); + }); + + test("replace", async () => { + const digits = () => { + const output = []; + for (let i = 0; i < 4; i++) { + output.push(randchoice(NUMERIC)); + } + return output.join(""); + }; + + const data = []; + const index = []; + + for (let i = 0; i < 1000; i++) { + const separator = Math.random() > 0.5 ? " " : "-"; + const num = `${digits()}${separator}${digits()}${separator}${digits()}${separator}${digits()}`; + data.push(num); + index.push(i); + } + + const table = await perspective.table({ a: "string", b: "string" }); + await table.update({ a: data, b: index }); + const expressions = { + [`w`]: `replace('abc-def-hijk', '-', '')`, + [`x`]: `replace("a", '[0-9]{4}$', "b")`, + [`y`]: `replace("a", '[a-z]{4}$', "b")`, + [`z`]: `var x := 'long string, very cool!'; replace("a", '^[0-9]{4}', x)`, + }; + const validate = await table.validate_expressions(expressions); + expect(validate.expression_schema).toEqual({ + w: "string", + x: "string", + y: "string", + z: "string", + }); + + const view = await table.view({ expressions }); + const result = await view.to_columns(); + + for (let i = 0; i < 100; i++) { + const source = result["a"][i]; + const idx = result["b"][i]; + expect(result["w"][i]).toEqual("abcdef-hijk"); + expect(result["x"][i]).toEqual( + source.replace(/[0-9]{4}$/, idx), + ); + expect(result["y"][i]).toEqual(source); + expect(result["z"][i]).toEqual( + source.replace(/^[0-9]{4}/, "long string, very cool!"), + ); + } + + await view.delete(); + await table.delete(); + }); + + test("replace invalid", async () => { + const table = await perspective.table({ a: "string", b: "string" }); + + const expressions = { + [`v`]: `replace('abc-def-hijk', '-', 123)`, + [`w`]: `replace('', '-', today())`, + [`x`]: `replace("a", '[0-9]{4}$', today())`, + [`y`]: `replace("a", '[a-z]{4}$', null)`, + [`z`]: `var x := 123; replace("a", '^[0-9]{4}', x)`, + }; + const validate = await table.validate_expressions(expressions); + expect(validate.expression_schema).toEqual({}); + + await table.delete(); + }); + + test("replace all", async () => { + const digits = () => { + const output = []; + for (let i = 0; i < 4; i++) { + output.push(randchoice(NUMERIC)); + } + return output.join(""); + }; + + const data = []; + const index = []; + + for (let i = 0; i < 1000; i++) { + const separator = Math.random() > 0.5 ? " " : "-"; + const num = `${digits()}${separator}${digits()}${separator}${digits()}${separator}${digits()}`; + data.push(num); + index.push(i); + } + + const table = await perspective.table({ a: "string", b: "string" }); + await table.update({ a: data, b: index }); + const expressions = { + [`w`]: `replace_all('abc-def-hijk', '-', '')`, + [`x`]: `replace_all("a", '[0-9]{4}', "b")`, + [`y`]: `replace_all("a", '[a-z]{4}', "b")`, + [`z`]: `var x := 'long string, very cool!'; replace_all("a", '[0-9]{4}', x)`, + }; + const validate = await table.validate_expressions(expressions); + expect(validate.expression_schema).toEqual({ + w: "string", + x: "string", + y: "string", + z: "string", + }); + + const view = await table.view({ expressions }); + const result = await view.to_columns(); + + for (let i = 0; i < 100; i++) { + const source = result["a"][i]; + const idx = result["b"][i]; + expect(result["w"][i]).toEqual("abcdefhijk"); + expect(result["x"][i]).toEqual( + source.replace(/[0-9]{4}/g, () => idx), + ); + expect(result["y"][i]).toEqual(source); + expect(result["z"][i]).toEqual( + source.replace( + /[0-9]{4}/g, + () => "long string, very cool!", + ), + ); + } + + await view.delete(); + await table.delete(); + }); + + test("replace all invalid", async () => { + const table = await perspective.table({ a: "string", b: "string" }); + + const expressions = { + [`v`]: `replace_all('abc-def-hijk', '-', 123)`, + [`w`]: `replace_all('', '-', today())`, + [`x`]: `replace_all("a", '[0-9]{4}$', today())`, + [`y`]: `replace_all("a", '[a-z]{4}$', null)`, + [`z`]: `var x := 123; replace_all("a", '^[0-9]{4}', x)`, + }; + const validate = await table.validate_expressions(expressions); + expect(validate.expression_schema).toEqual({}); + + await table.delete(); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/expressions/string_slices.spec.js b/rust/perspective-js/test/js/expressions/string_slices.spec.js new file mode 100644 index 0000000..d94c244 --- /dev/null +++ b/rust/perspective-js/test/js/expressions/string_slices.spec.js @@ -0,0 +1,143 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client"; + +// Concrete use cases from issue #1527 ("Better string functions in +// expressions"). The original issue body sketched these in a hypothetical +// dialect; the tests below port them to the dialect Perspective actually +// implements. Differences from the issue's pseudocode: +// +// - `find(str, substr) -> int` does not exist. The closest function is +// `indexof(col, regex, out_vec) -> bool`, which performs a *regex* search, +// writes [start, end] of the first capturing group into `out_vec`, and +// requires the regex to have at least one capturing group (else it +// returns STATUS_CLEAR). The tests therefore wrap the literal char in a +// capturing group: `' '` -> `'( )'`, `','` -> `'(,)'`, `$` -> `'([$])'`. +// - `null()` is not a function call; `null` is a literal. +// - `strlen(s)` -> `length(s)`. +// - `substring(s, start, count)` takes a *count*, not an end index, and +// returns null if `start + count > length(s)`. +// - String literals pass through ExprTK's `cleanup_escapes`, which drops +// unrecognized escape characters (`\s` -> `s`, `\.` -> `.`). + +((perspective) => { + test.describe("Issue 1527 use cases", function () { + test("contains literal substring", async function () { + const table = await perspective.table({ + a: ["abcdef", "xyz", "abXabY", null, "abc"], + }); + + const view = await table.view({ + expressions: { + has_ab: "contains(\"a\", 'ab')", + }, + }); + + const result = await view.to_columns(); + const schema = await view.expression_schema(); + expect(schema["has_ab"]).toEqual("boolean"); + expect(result["has_ab"]).toEqual([true, false, true, null, true]); + view.delete(); + table.delete(); + }); + + // Parse "USD $1000"-style strings into Currency (string) and Value + // (float) columns, tolerant of malformed rows. + test("split currency/value string column", async function () { + const table = await perspective.table({ + "Bad Column": [ + "USD $1000", + "EUR $250", + "malformed", + null, + "GBP $42", + ], + }); + const view = await table.view({ + expressions: { + Currency: `var v[2]; +if (indexof("Bad Column", '( )', v)) { substring("Bad Column", 0, v[0]) } else { null }`, + Value: `var v[2]; +if (indexof("Bad Column", '([$])', v)) { float(substring("Bad Column", v[0] + 1)) } else { null }`, + }, + }); + const result = await view.to_columns(); + const schema = await view.expression_schema(); + expect(schema["Currency"]).toEqual("string"); + expect(schema["Value"]).toEqual("float"); + expect(result["Currency"]).toEqual([ + "USD", + "EUR", + null, + null, + "GBP", + ]); + expect(result["Value"]).toEqual([1000, 250, null, null, 42]); + view.delete(); + table.delete(); + }); + + // Parse "(123, 456)"-style strings into Longitude and Latitude + // float columns. + test("split longitude/latitude string column", async function () { + const table = await perspective.table({ + Coords: [ + "(123, 456)", + "(1.5, -2.25)", + "broken", + null, + "(0, 0)", + ], + }); + const view = await table.view({ + expressions: { + Longitude: `var v[2]; +if (indexof("Coords", '(,)', v)) { float(substring("Coords", 1, v[0] - 1)) } else { null }`, + Latitude: `var v[2]; +if (indexof("Coords", '(,)', v)) { float(substring("Coords", v[0] + 1, length("Coords") - v[0] - 2)) } else { null }`, + }, + }); + const result = await view.to_columns(); + const schema = await view.expression_schema(); + expect(schema["Longitude"]).toEqual("float"); + expect(schema["Latitude"]).toEqual("float"); + expect(result["Longitude"]).toEqual([123, 1.5, null, null, 0]); + expect(result["Latitude"]).toEqual([456, -2.25, null, null, 0]); + view.delete(); + table.delete(); + }); + + // Normalize spelling variants by stripping dots and whitespace. + test("replace_all regex strips dots/whitespace", async function () { + const table = await perspective.table({ + State: ["NC", "N.C.", "N. C.", "N .C.", "VA"], + }); + const view = await table.view({ + expressions: { + Normalized: `replace_all("State", '[. ]', '')`, + }, + }); + const result = await view.to_columns(); + expect(result["Normalized"]).toEqual([ + "NC", + "NC", + "NC", + "NC", + "VA", + ]); + view.delete(); + table.delete(); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/expressions/updates.spec.js b/rust/perspective-js/test/js/expressions/updates.spec.js new file mode 100644 index 0000000..f549533 --- /dev/null +++ b/rust/perspective-js/test/js/expressions/updates.spec.js @@ -0,0 +1,1470 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client"; + +import * as common from "./common.js"; + +const data = [ + { x: 1, y: 2 }, + { x: 2, y: 4 }, + { x: 3, y: 6 }, + { x: 4, y: 8 }, +]; + +const pivot_data = [ + { int: 1, float: 2.25 }, + { int: 2, float: 3.5 }, + { int: 3, float: 4.75 }, + { int: 4, float: 5.25 }, +]; + +/** + * Tests the correctness of updates on Tables with expression columns created + * through `View`, including partial updates, appends, and removes. + */ +((perspective) => { + test.describe("Expression updates", function () { + test("Scalar-only expressions should respond to appends", async function () { + const table = await perspective.table({ + x: [1.5, 2.5, 3.5, 4.5], + y: ["A", "B", "C", "D"], + }); + const view = await table.view({ + expressions: [ + "10 + 20", + "lower('ABC')", + "concat('hello', ' ', 'world', ', ', 'here is a long, long, long string with lots of characters')", + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + const before = await view.to_columns(); + expect(before["10 + 20"]).toEqual([30, 30, 30, 30]); + expect(before["lower('ABC')"]).toEqual([ + "abc", + "abc", + "abc", + "abc", + ]); + expect( + before[ + "concat('hello', ' ', 'world', ', ', 'here is a long, long, long string with lots of characters')" + ], + ).toEqual([ + "hello world, here is a long, long, long string with lots of characters", + "hello world, here is a long, long, long string with lots of characters", + "hello world, here is a long, long, long string with lots of characters", + "hello world, here is a long, long, long string with lots of characters", + ]); + await table.update({ x: [5, 6, 7] }); + + const after = await view.to_columns(); + expect(after["10 + 20"]).toEqual([30, 30, 30, 30, 30, 30, 30]); + expect(after["lower('ABC')"]).toEqual([ + "abc", + "abc", + "abc", + "abc", + "abc", + "abc", + "abc", + ]); + expect( + after[ + "concat('hello', ' ', 'world', ', ', 'here is a long, long, long string with lots of characters')" + ], + ).toEqual([ + "hello world, here is a long, long, long string with lots of characters", + "hello world, here is a long, long, long string with lots of characters", + "hello world, here is a long, long, long string with lots of characters", + "hello world, here is a long, long, long string with lots of characters", + "hello world, here is a long, long, long string with lots of characters", + "hello world, here is a long, long, long string with lots of characters", + "hello world, here is a long, long, long string with lots of characters", + ]); + view.delete(); + table.delete(); + }); + + test("Conditional expressions should respond to appends", async function () { + const table = await perspective.table({ + x: [1.5, 2.5, 3.5, 4.5], + y: ["A", "B", "C", "D"], + }); + const view = await table.view({ + // conditional here must be float, as int-float comparisons + // don't work as of yet. + expressions: [ + 'if ("x" > 4) 10; else 100', + `"y" == 'A' ? true : false`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + const before = await view.to_columns(); + expect(before['if ("x" > 4) 10; else 100']).toEqual([ + 100, 100, 100, 10, + ]); + expect(before[`"y" == 'A' ? true : false`]).toEqual([ + true, + false, + false, + false, + ]); + + await table.update({ x: [5, 6, 7], y: ["A", "A", "B"] }); + + const after = await view.to_columns(); + expect(after['if ("x" > 4) 10; else 100']).toEqual([ + 100, 100, 100, 10, 10, 10, 10, + ]); + expect(after[`"y" == 'A' ? true : false`]).toEqual([ + true, + false, + false, + false, + true, + true, + false, + ]); + + view.delete(); + table.delete(); + }); + + test("Scalar-only expressions should respond to partial updates", async function () { + const table = await perspective.table( + { + x: [1.5, 2.5, 3.5, 4.5], + y: ["A", "B", "C", "D"], + }, + { index: "y" }, + ); + const view = await table.view({ + expressions: [ + "10 + 20", + "lower('ABC')", + "concat('hello', ' ', 'world', ', ', 'here is a long, long, long string with lots of characters')", + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + + const before = await view.to_columns(); + expect(before["10 + 20"]).toEqual([30, 30, 30, 30]); + expect(before["lower('ABC')"]).toEqual([ + "abc", + "abc", + "abc", + "abc", + ]); + expect( + before[ + "concat('hello', ' ', 'world', ', ', 'here is a long, long, long string with lots of characters')" + ], + ).toEqual([ + "hello world, here is a long, long, long string with lots of characters", + "hello world, here is a long, long, long string with lots of characters", + "hello world, here is a long, long, long string with lots of characters", + "hello world, here is a long, long, long string with lots of characters", + ]); + + await table.update({ x: [5, 6, 7], y: ["A", "B", "D"] }); + + const after = await view.to_columns(); + expect(after["10 + 20"]).toEqual([30, 30, 30, 30]); + expect(after["lower('ABC')"]).toEqual(["abc", "abc", "abc", "abc"]); + expect( + after[ + "concat('hello', ' ', 'world', ', ', 'here is a long, long, long string with lots of characters')" + ], + ).toEqual([ + "hello world, here is a long, long, long string with lots of characters", + "hello world, here is a long, long, long string with lots of characters", + "hello world, here is a long, long, long string with lots of characters", + "hello world, here is a long, long, long string with lots of characters", + ]); + + view.delete(); + table.delete(); + }); + + test("Expressions should respond to multiple streaming updates", async function () { + const table = await perspective.table({ + x: [1.5, 2.5, 3.5, 4.5], + y: ["A", "B", "C", "D"], + }); + const view = await table.view({ + expressions: { + 123: "123", + c0: '10 + "x"', + c1: 'lower("y")', + c2: `concat("y", ' ', 'abcd')`, + }, + }); + + const before = await view.to_columns(); + expect(before["123"]).toEqual(Array(4).fill(123)); + expect(before["c0"]).toEqual([11.5, 12.5, 13.5, 14.5]); + expect(before["c1"]).toEqual(["a", "b", "c", "d"]); + expect(before["c2"]).toEqual([ + "A abcd", + "B abcd", + "C abcd", + "D abcd", + ]); + + for (let i = 0; i < 5; i++) { + await table.update({ + x: [1.5, 2.5, 3.5, 4.5], + y: ["A", "B", "C", "D"], + }); + } + + const after = await view.to_columns(); + expect(await view.num_rows()).toEqual(24); + console.log(after); + expect(after["123"]).toEqual(Array(24).fill(123)); + expect(after["c0"]).toEqual([ + 11.5, 12.5, 13.5, 14.5, 11.5, 12.5, 13.5, 14.5, 11.5, 12.5, + 13.5, 14.5, 11.5, 12.5, 13.5, 14.5, 11.5, 12.5, 13.5, 14.5, + 11.5, 12.5, 13.5, 14.5, + ]); + expect(after["c1"]).toEqual([ + "a", + "b", + "c", + "d", + "a", + "b", + "c", + "d", + "a", + "b", + "c", + "d", + "a", + "b", + "c", + "d", + "a", + "b", + "c", + "d", + "a", + "b", + "c", + "d", + ]); + expect(after["c2"]).toEqual([ + "A abcd", + "B abcd", + "C abcd", + "D abcd", + "A abcd", + "B abcd", + "C abcd", + "D abcd", + "A abcd", + "B abcd", + "C abcd", + "D abcd", + "A abcd", + "B abcd", + "C abcd", + "D abcd", + "A abcd", + "B abcd", + "C abcd", + "D abcd", + "A abcd", + "B abcd", + "C abcd", + "D abcd", + ]); + + view.delete(); + table.delete(); + }); + + test("Conditional expressions should respond to partial updates", async function () { + const table = await perspective.table( + { + x: [1.5, 2.5, 3.5, 4.5], + y: ["A", "B", "C", "D"], + z: ["a", "b", "c", "d"], + }, + { index: "y" }, + ); + const view = await table.view({ + expressions: [ + 'if ("x" > 4) 10; else 100', + `"z" == 'a' ? true : false`, + ].reduce((x, y) => Object.assign(x, { [y]: y }), {}), + }); + const before = await view.to_columns(); + expect(before['if ("x" > 4) 10; else 100']).toEqual([ + 100, 100, 100, 10, + ]); + expect(before[`"z" == 'a' ? true : false`]).toEqual([ + true, + false, + false, + false, + ]); + await table.update({ + x: [5, 6, 7], + y: ["A", "C", "D"], + z: ["a", "a", "a"], + }); + + const after = await view.to_columns(); + expect(after['if ("x" > 4) 10; else 100']).toEqual([ + 10, 100, 10, 10, + ]); + expect(after[`"z" == 'a' ? true : false`]).toEqual([ + true, + false, + true, + true, + ]); + view.delete(); + table.delete(); + }); + + test("Appends should notify expression column", async function () { + const table = await perspective.table({ + x: [1, 2, 3, 4], + y: ["A", "B", "C", "D"], + }); + const view = await table.view({ + expressions: { 'lower("y")': 'lower("y")' }, + }); + + const before = await view.to_columns(); + expect(before['lower("y")']).toEqual(["a", "b", "c", "d"]); + await table.update({ y: ["HELLO", "WORLD"] }); + + const after = await view.to_columns(); + expect(after['lower("y")']).toEqual([ + "a", + "b", + "c", + "d", + "hello", + "world", + ]); + view.delete(); + table.delete(); + }); + + test("Multiple appends should notify expression column", async function () { + const table = await perspective.table({ + x: [1, 2, 3, 4], + y: ["A", "B", "C", "D"], + }); + const view = await table.view({ + expressions: { 'lower("y")': 'lower("y")' }, + }); + + const before = await view.to_columns(); + expect(before['lower("y")']).toEqual(["a", "b", "c", "d"]); + await table.update({ y: ["HELLO", "WORLD"] }); + + const result = await view.to_columns(); + expect(result['lower("y")']).toEqual([ + "a", + "b", + "c", + "d", + "hello", + "world", + ]); + + await table.update({ x: [12, 34], y: ["XYZ", "ABCdEFg"] }); + + const result2 = await view.to_columns(); + expect(result2["x"]).toEqual([1, 2, 3, 4, null, null, 12, 34]); + expect(result2['lower("y")']).toEqual([ + "a", + "b", + "c", + "d", + "hello", + "world", + "xyz", + "abcdefg", + ]); + + view.delete(); + table.delete(); + }); + + test("Appends should notify expression column with multiple columns", async function () { + const table = await perspective.table({ + x: [1, 2, 3, 4], + y: [1.5, 2.5, 3.5, 4.5], + z: [2, 4, 6, 8], + }); + const view = await table.view({ + expressions: { + '"x" + ("y" + 5.5) / "z"': '"x" + ("y" + 5.5) / "z"', + }, + }); + + const before = await view.to_columns(); + expect(before['"x" + ("y" + 5.5) / "z"']).toEqual([ + 4.5, 4, 4.5, 5.25, + ]); + + await table.update({ + x: [5, 6, 7, 8], + y: [5.5, 6.5, 7.5, 8.5], + z: [10, 10, 10, 10], + }); + + const after = await view.to_columns(); + expect(after['"x" + ("y" + 5.5) / "z"']).toEqual([ + 4.5, 4, 4.5, 5.25, 6.1, 7.2, 8.3, 9.4, + ]); + view.delete(); + table.delete(); + }); + + test("Partial updates should notify expression column", async function () { + const table = await perspective.table( + { + x: [1, 2, 3, 4], + y: ["A", "B", "C", "D"], + }, + { index: "x" }, + ); + const view = await table.view({ + expressions: { 'lower("y")': 'lower("y")' }, + }); + + const before = await view.to_columns(); + expect(before['lower("y")']).toEqual(["a", "b", "c", "d"]); + await table.update({ x: [1, 3], y: ["HELLO", "WORLD"] }); + + const after = await view.to_columns(); + expect(after['lower("y")']).toEqual(["hello", "b", "world", "d"]); + view.delete(); + table.delete(); + }); + + test("Dependent column appends with missing columns should notify expression columns", async function () { + const table = await perspective.table({ + x: [1, 2, 3, 4], + y: [1.5, 2.5, 3.5, 4.5], + z: ["a", "b", "c", "d"], + }); + const view = await table.view({ + expressions: { 'upper("z")': 'upper("z")' }, + }); + const before = await view.to_columns(); + expect(before['upper("z")']).toEqual(["A", "B", "C", "D"]); + + // `z` is missing in the update, so it should render as null + await table.update({ x: [2, 4], y: [10.5, 12.5] }); + + const after = await view.to_columns(); + expect(after).toEqual({ + x: [1, 2, 3, 4, 2, 4], + y: [1.5, 2.5, 3.5, 4.5, 10.5, 12.5], + z: ["a", "b", "c", "d", null, null], + 'upper("z")': ["A", "B", "C", "D", null, null], + }); + view.delete(); + table.delete(); + }); + + test("Dependent column update appends should notify expression columns, pivoted arity 1", async function () { + const table = await perspective.table({ + x: [1, 2, 3, 4], + y: ["A", "B", "C", "C"], + }); + const view = await table.view({ + group_by: ['lower("y")'], + expressions: { 'lower("y")': 'lower("y")' }, + }); + + const before = await view.to_columns(); + expect(before).toEqual({ + __ROW_PATH__: [[], ["a"], ["b"], ["c"]], + 'lower("y")': [4, 1, 1, 2], + x: [10, 1, 2, 7], + y: [4, 1, 1, 2], + }); + + await table.update({ y: ["HELLO", "WORLD"] }); + + const after = await view.to_columns(); + expect(after).toEqual({ + __ROW_PATH__: [[], ["a"], ["b"], ["c"], ["hello"], ["world"]], + 'lower("y")': [6, 1, 1, 2, 1, 1], + x: [10, 1, 2, 7, 0, 0], + y: [6, 1, 1, 2, 1, 1], + }); + view.delete(); + table.delete(); + }); + + test("Dependent column appends should notify expression columns, arity 2", async function () { + const table = await perspective.table(common.int_float_data); + const view = await table.view({ + expressions: { '"w" + "x"': '"w" + "x"' }, + }); + const before = await view.to_columns(); + expect(before['"w" + "x"']).toEqual([2.5, 4.5, 6.5, 8.5]); + + await table.update({ x: [2, 4], w: [10.5, 12.5] }); + + const after = await view.to_columns(); + expect(after['"w" + "x"']).toEqual([ + 2.5, 4.5, 6.5, 8.5, 12.5, 16.5, + ]); + view.delete(); + table.delete(); + }); + + test("Dependent column updates on all column updates should notify expression columns, arity 2", async function () { + const table = await perspective.table(common.int_float_data, { + index: "x", + }); + const view = await table.view({ + expressions: { + '"w" + "x"': '"w" + "x"', + 'upper("y")': 'upper("y")', + }, + }); + let before = await view.to_columns(); + expect(before['"w" + "x"']).toEqual([2.5, 4.5, 6.5, 8.5]); + expect(before['upper("y")']).toEqual(["A", "B", "C", "D"]); + + await table.update({ x: [2, 4], w: [10.5, 12.5] }); + + const after = await view.to_columns(); + expect(after['"w" + "x"']).toEqual([2.5, 12.5, 6.5, 16.5]); + + await table.update({ x: [1, 3], y: ["hello", "world"] }); + + const after2 = await view.to_columns(); + expect(after2['upper("y")']).toEqual(["HELLO", "B", "WORLD", "D"]); + view.delete(); + table.delete(); + }); + + test("Dependent column appends should notify expression columns on different views, arity 2", async function () { + const table = await perspective.table(common.int_float_data); + const view = await table.view({ + expressions: { '"w" + "x"': '"w" + "x"' }, + }); + + const view2 = await table.view({ + expressions: { '"w" - "x"': '"w" - "x"' }, + }); + + const before = await view.to_columns(); + const before2 = await view2.to_columns(); + expect(before['"w" + "x"']).toEqual([2.5, 4.5, 6.5, 8.5]); + expect(before2['"w" - "x"']).toEqual([0.5, 0.5, 0.5, 0.5]); + + await table.update({ x: [2, 4], w: [10.5, 12.5] }); + + const after = await view.to_columns(); + const after2 = await view2.to_columns(); + expect(after['"w" + "x"']).toEqual([ + 2.5, 4.5, 6.5, 8.5, 12.5, 16.5, + ]); + expect(after2['"w" - "x"']).toEqual([0.5, 0.5, 0.5, 0.5, 8.5, 8.5]); + view2.delete(); + view.delete(); + table.delete(); + }); + + test("Dependent column appends should notify equivalent expression columns on different views, arity 2", async function () { + const table = await perspective.table(common.int_float_data); + const view = await table.view({ + expressions: { '"w" + "x"': '"w" + "x"' }, + }); + + const view2 = await table.view({ + expressions: { + '"w" + "x"': '"w" + "x"', + '"w" - "x"': '"w" - "x"', + }, + }); + + const before = await view.to_columns(); + const before2 = await view2.to_columns(); + expect(before['"w" + "x"']).toEqual([2.5, 4.5, 6.5, 8.5]); + expect(before2['"w" + "x"']).toEqual([2.5, 4.5, 6.5, 8.5]); + expect(before2['"w" - "x"']).toEqual([0.5, 0.5, 0.5, 0.5]); + + await table.update({ x: [2, 4], w: [10.5, 12.5] }); + + const after = await view.to_columns(); + const after2 = await view2.to_columns(); + expect(after['"w" + "x"']).toEqual([ + 2.5, 4.5, 6.5, 8.5, 12.5, 16.5, + ]); + expect(after2['"w" + "x"']).toEqual([ + 2.5, 4.5, 6.5, 8.5, 12.5, 16.5, + ]); + expect(after2['"w" - "x"']).toEqual([0.5, 0.5, 0.5, 0.5, 8.5, 8.5]); + view2.delete(); + view.delete(); + table.delete(); + }); + + test("Dependent column updates should notify expression columns on different views, arity 2.", async function () { + const table = await perspective.table(common.int_float_data, { + index: "x", + }); + const view = await table.view({ + expressions: { '"w" + "x"': '"w" + "x"' }, + }); + + const view2 = await table.view({ + expressions: { '"w" - "x"': '"w" - "x"' }, + }); + + const before = await view.to_columns(); + const before2 = await view2.to_columns(); + expect(before['"w" + "x"']).toEqual([2.5, 4.5, 6.5, 8.5]); + expect(before2['"w" - "x"']).toEqual([0.5, 0.5, 0.5, 0.5]); + + await table.update({ x: [2, 4], w: [10.5, 12.5] }); + + const after = await view.to_columns(); + const after2 = await view2.to_columns(); + expect(after['"w" + "x"']).toEqual([2.5, 12.5, 6.5, 16.5]); + expect(after2['"w" - "x"']).toEqual([0.5, 8.5, 0.5, 8.5]); + view2.delete(); + view.delete(); + table.delete(); + }); + + test("Dependent column updates should notify equivalent expression columns on different views, arity 2.", async function () { + const table = await perspective.table(common.int_float_data, { + index: "x", + }); + const view = await table.view({ + expressions: { '"w" + "x"': '"w" + "x"' }, + }); + + const view2 = await table.view({ + expressions: { + '"w" + "x"': '"w" + "x"', + '"w" - "x"': '"w" - "x"', + }, + }); + + const before = await view.to_columns(); + const before2 = await view2.to_columns(); + expect(before['"w" + "x"']).toEqual([2.5, 4.5, 6.5, 8.5]); + expect(before2['"w" + "x"']).toEqual([2.5, 4.5, 6.5, 8.5]); + expect(before2['"w" - "x"']).toEqual([0.5, 0.5, 0.5, 0.5]); + + await table.update({ x: [2, 4], w: [10.5, 12.5] }); + + const after = await view.to_columns(); + const after2 = await view2.to_columns(); + expect(after['"w" + "x"']).toEqual([2.5, 12.5, 6.5, 16.5]); + expect(after2['"w" + "x"']).toEqual([2.5, 12.5, 6.5, 16.5]); + expect(after2['"w" - "x"']).toEqual([0.5, 8.5, 0.5, 8.5]); + view2.delete(); + view.delete(); + table.delete(); + }); + + test("Dependent column update with `null` should notify expression columns on different views, arity 2.", async function () { + const table = await perspective.table(common.int_float_data, { + index: "x", + }); + const view = await table.view({ + expressions: { '"w" + "x"': '"w" + "x"' }, + }); + + const view2 = await table.view({ + expressions: { '"w" - "x"': '"w" - "x"' }, + }); + + const before = await view.to_columns(); + const before2 = await view2.to_columns(); + expect(before['"w" + "x"']).toEqual([2.5, 4.5, 6.5, 8.5]); + expect(before2['"w" - "x"']).toEqual([0.5, 0.5, 0.5, 0.5]); + + await table.update({ x: [2, 4], w: [null, 12.5] }); + + const after = await view.to_columns(); + const after2 = await view2.to_columns(); + expect(after['"w" + "x"']).toEqual([2.5, null, 6.5, 16.5]); + expect(after2['"w" - "x"']).toEqual([0.5, null, 0.5, 8.5]); + view2.delete(); + view.delete(); + table.delete(); + }); + + test("Dependent column update with `null` should notify equivalent expression columns on different views, arity 2.", async function () { + const table = await perspective.table(common.int_float_data, { + index: "x", + }); + const view = await table.view({ + expressions: { '"w" + "x"': '"w" + "x"' }, + }); + + const view2 = await table.view({ + expressions: { + '"w" + "x"': '"w" + "x"', + '"w" - "x"': '"w" - "x"', + }, + }); + + const before = await view.to_columns(); + const before2 = await view2.to_columns(); + expect(before['"w" + "x"']).toEqual([2.5, 4.5, 6.5, 8.5]); + expect(before2['"w" + "x"']).toEqual([2.5, 4.5, 6.5, 8.5]); + expect(before2['"w" - "x"']).toEqual([0.5, 0.5, 0.5, 0.5]); + + await table.update({ x: [2, 4], w: [null, 12.5] }); + + const after = await view.to_columns(); + const after2 = await view2.to_columns(); + expect(after['"w" + "x"']).toEqual([2.5, null, 6.5, 16.5]); + expect(after2['"w" + "x"']).toEqual([2.5, null, 6.5, 16.5]); + expect(after2['"w" - "x"']).toEqual([0.5, null, 0.5, 8.5]); + view2.delete(); + view.delete(); + table.delete(); + }); + + test("Updating with `null` should clear the output expression column.", async function () { + const table = await perspective.table( + { + w: [1.5, 2.5, 3.5, 4.5], + x: [1, 2, 3, 4], + y: [5, 6, 7, 8], + }, + { index: "x" }, + ); + + const view = await table.view({ + expressions: { '"w" + "y"': '"w" + "y"' }, + }); + + let before = await view.to_columns(); + expect(before['"w" + "y"']).toEqual([6.5, 8.5, 10.5, 12.5]); + await table.update({ x: [2, 4], w: [null, 12.5] }); + const after = await view.to_columns(); + expect(after['"w" + "y"']).toEqual([6.5, null, 10.5, 20.5]); + await table.update({ x: [2, 3], w: [20.5, null] }); + const after2 = await view.to_columns(); + expect(after2['"w" + "y"']).toEqual([6.5, 26.5, null, 20.5]); + view.delete(); + table.delete(); + }); + + test.skip("OG - Updating with `undefined` should not clear the output expression column.", async function () { + const table = await perspective.table( + { + w: [1.5, 2.5, 3.5, 4.5], + x: [1, 2, 3, 4], + y: [5, 6, 7, 8], + }, + { index: "x" }, + ); + const view = await table.view({ + expressions: { '"w" + "y"': '"w" + "y"' }, + }); + let before = await view.to_columns(); + expect(before['"w" + "y"']).toEqual([6.5, 8.5, 10.5, 12.5]); + + await table.update({ x: [2, 4], w: [undefined, 12.5] }); + + const after = await view.to_columns(); + expect(after['"w" + "y"']).toEqual([6.5, 8.5, 10.5, 20.5]); + + await table.update({ x: [2, 3], w: [20.5, undefined] }); + + const after2 = await view.to_columns(); + expect(after2['"w" + "y"']).toEqual([6.5, 26.5, 10.5, 20.5]); + view.delete(); + table.delete(); + }); + + test("Updating with `undefined` should not clear the output expression column.", async function () { + const table = await perspective.table( + { + w: [1.5, 2.5, 3.5, 4.5], + x: [1, 2, 3, 4], + y: [5, 6, 7, 8], + }, + { index: "x" }, + ); + const view = await table.view({ + expressions: { '"w" + "y"': '"w" + "y"' }, + }); + let before = await view.to_columns(); + expect(before['"w" + "y"']).toEqual([6.5, 8.5, 10.5, 12.5]); + + await table.update({ x: [2] }); + await table.update({ x: [4], w: [12.5] }); + + const after = await view.to_columns(); + expect(after['"w" + "y"']).toEqual([6.5, 8.5, 10.5, 20.5]); + + await table.update({ x: [2], w: [20.5] }); + await table.update({ x: [3] }); + + const after2 = await view.to_columns(); + expect(after2['"w" + "y"']).toEqual([6.5, 26.5, 10.5, 20.5]); + view.delete(); + table.delete(); + }); + + test("Updates on non-dependent columns should not change expression columns.", async function () { + var meta = { + w: "float", + x: "float", + y: "string", + z: "boolean", + }; + const table = await perspective.table(meta, { index: "y" }); + const view = await table.view({ + columns: ["y", '"w" / "x"'], + expressions: { '"w" / "x"': '"w" / "x"' }, + }); + + await table.update(common.int_float_data); + + let delta_upd = [ + { y: "a", z: false }, + { y: "b", z: true }, + { y: "c", z: false }, + { y: "d", z: true }, + ]; + await table.update(delta_upd); + let result = await view.to_json(); + let expected = [ + { y: "a", '"w" / "x"': 1.5 }, + { y: "b", '"w" / "x"': 1.25 }, + { y: "c", '"w" / "x"': 1.1666666666666667 }, + { y: "d", '"w" / "x"': 1.125 }, + ]; + expect(result).toEqual(expected); + view.delete(); + table.delete(); + }); + + test("Should recompute after partial update using `__INDEX__`", async function () { + const table = await perspective.table({ + x: "integer", + y: "integer", + }); + const view = await table.view({ + expressions: { '"x" * "y"': '"x" * "y"' }, + }); + + await table.update(data); + await table.update([ + { __INDEX__: 0, x: 10 }, + { __INDEX__: 2, x: 10 }, + ]); + + const json = await view.to_json(); + expect(json).toEqual([ + { x: 10, y: 2, '"x" * "y"': 20 }, + { x: 2, y: 4, '"x" * "y"': 8 }, + { x: 10, y: 6, '"x" * "y"': 60 }, + { x: 4, y: 8, '"x" * "y"': 32 }, + ]); + view.delete(); + table.delete(); + }); + + test("Partial update without a new value shouldn't change computed output", async function () { + const table = await perspective.table(data); + const view = await table.view({ + expressions: { '"x" * "y"': '"x" * "y"' }, + }); + + const json = await view.to_json(); + expect(json).toEqual([ + { x: 1, y: 2, '"x" * "y"': 2 }, + { x: 2, y: 4, '"x" * "y"': 8 }, + { x: 3, y: 6, '"x" * "y"': 18 }, + { x: 4, y: 8, '"x" * "y"': 32 }, + ]); + + await table.update([{ __INDEX__: 0, x: 1 }]); + const json2 = await view.to_json(); + expect(json2).toEqual(json); + view.delete(); + table.delete(); + }); + + test("partial update on single computed source column", async function () { + const table = await perspective.table(data); + const view = await table.view({ + expressions: { '"x" * "y"': '"x" * "y"' }, + }); + + await table.update([{ __INDEX__: 0, x: 10 }]); + const json = await view.to_json(); + expect(json).toEqual([ + { x: 10, y: 2, '"x" * "y"': 20 }, + { x: 2, y: 4, '"x" * "y"': 8 }, + { x: 3, y: 6, '"x" * "y"': 18 }, + { x: 4, y: 8, '"x" * "y"': 32 }, + ]); + view.delete(); + table.delete(); + }); + + test("partial update on non-contiguous computed source columns", async function () { + const table = await perspective.table(data); + const view = await table.view({ + expressions: { '"x" * "y"': '"x" * "y"' }, + }); + await table.update([ + { __INDEX__: 0, x: 1, y: 10 }, + { __INDEX__: 2, x: 3, y: 20 }, + ]); + let json = await view.to_json(); + expect(json).toEqual([ + { x: 1, y: 10, '"x" * "y"': 10 }, + { x: 2, y: 4, '"x" * "y"': 8 }, + { x: 3, y: 20, '"x" * "y"': 60 }, + { x: 4, y: 8, '"x" * "y"': 32 }, + ]); + view.delete(); + table.delete(); + }); + + test("partial update on non-contiguous computed source columns, indexed table", async function () { + const table = await perspective.table(data, { index: "x" }); + const view = await table.view({ + expressions: { '"x" * "y"': '"x" * "y"' }, + }); + await table.update([ + { x: 1, y: 10 }, + { x: 3, y: 20 }, + ]); + let json = await view.to_json(); + expect(json).toEqual([ + { x: 1, y: 10, '"x" * "y"': 10 }, + { x: 2, y: 4, '"x" * "y"': 8 }, + { x: 3, y: 20, '"x" * "y"': 60 }, + { x: 4, y: 8, '"x" * "y"': 32 }, + ]); + view.delete(); + table.delete(); + }); + + test("multiple updates change validity", async function () { + const table = await perspective.table( + { + a: "float", + b: "float", + c: "string", + index: "integer", + }, + { index: "index" }, + ); + + await table.update({ + a: [null, null, 1.5, null], + b: [null, 2.5, null, null], + c: ["a", "b", "b", "a"], + index: [1, 2, 3, 4], + }); + + const view = await table.view({ + expressions: { computed: '"a" + "b"' }, + }); + + let result = await view.to_columns(); + expect(result["computed"]).toEqual([null, null, null, null]); + + await table.update({ + index: [4], + a: [100], + }); + + result = await view.to_columns(); + expect(result["computed"]).toEqual([null, null, null, null]); + + await table.update({ + index: [4], + b: [100], + }); + + result = await view.to_columns(); + expect(result["computed"]).toEqual([null, null, null, 200]); + + await table.update({ + index: [3], + b: [100], + }); + + result = await view.to_columns(); + expect(result["computed"]).toEqual([null, null, 101.5, 200]); + + await view.delete(); + await table.delete(); + }); + + test("multiple partial update on single computed source column", async function () { + const table = await perspective.table(data); + const view = await table.view({ + expressions: { '"x" * "y"': '"x" * "y"' }, + }); + + await table.update([ + { __INDEX__: 0, x: 10 }, + { __INDEX__: 2, x: 10 }, + ]); + await table.update([ + { __INDEX__: 0, x: 20 }, + { __INDEX__: 2, x: 20 }, + ]); + await table.update([ + { __INDEX__: 0, x: 30 }, + { __INDEX__: 2, x: 30 }, + ]); + + let json = await view.to_json(); + expect(json).toEqual([ + { x: 30, y: 2, '"x" * "y"': 60 }, + { x: 2, y: 4, '"x" * "y"': 8 }, + { x: 30, y: 6, '"x" * "y"': 180 }, + { x: 4, y: 8, '"x" * "y"': 32 }, + ]); + view.delete(); + table.delete(); + }); + + test("multiple expression columns with updates on source columns", async function () { + const table = await perspective.table(data); + const view = await table.view({ + expressions: { '"x" * "y"': '"x" * "y"' }, + }); + const view2 = await table.view({ + expressions: { '"x" + "y"': '"x" + "y"' }, + }); + + await table.update([ + { __INDEX__: 0, x: 5 }, + { __INDEX__: 2, x: 10 }, + ]); + + let result = await view.to_columns(); + let result2 = await view2.to_columns(); + + expect(result).toEqual({ + x: [5, 2, 10, 4], + y: [2, 4, 6, 8], + '"x" * "y"': [10, 8, 60, 32], + }); + + expect(result2).toEqual({ + x: [5, 2, 10, 4], + y: [2, 4, 6, 8], + '"x" + "y"': [7, 6, 16, 12], + }); + + view2.delete(); + view.delete(); + table.delete(); + }); + + test("propagate updates to all expression columns", async function () { + const table = await perspective.table(data, { index: "x" }); + + const view = await table.view({ + expressions: { '"x" * "y"': '"x" * "y"' }, + }); + + const view2 = await table.view({ + expressions: { '"x" + "y"': '"x" + "y"' }, + }); + + const view3 = await table.view({ + expressions: { '"x" - "y"': '"x" - "y"' }, + }); + + await table.update({ x: [1, 2, 3, 4], y: [1, 2, 3, 4] }); + + let result = await view.to_columns(); + let result2 = await view2.to_columns(); + let result3 = await view3.to_columns(); + + expect(result).toEqual({ + x: [1, 2, 3, 4], + y: [1, 2, 3, 4], + '"x" * "y"': [1, 4, 9, 16], + }); + + expect(result2).toEqual({ + x: [1, 2, 3, 4], + y: [1, 2, 3, 4], + '"x" + "y"': [2, 4, 6, 8], + }); + + expect(result3).toEqual({ + x: [1, 2, 3, 4], + y: [1, 2, 3, 4], + '"x" - "y"': [0, 0, 0, 0], + }); + + view3.delete(); + view2.delete(); + view.delete(); + table.delete(); + }); + + test("propagate appends to all expression columns", async function () { + const table = await perspective.table(data); + + const view = await table.view({ + expressions: { '"x" * "y"': '"x" * "y"' }, + }); + + const view2 = await table.view({ + expressions: { '"x" + "y"': '"x" + "y"' }, + }); + + const view3 = await table.view({ + expressions: { '"x" - "y"': '"x" - "y"' }, + }); + + await table.update({ x: [1, 2, 3, 4], y: [1, 2, 3, 4] }); + + let result = await view.to_columns(); + let result2 = await view2.to_columns(); + let result3 = await view3.to_columns(); + + expect(result).toEqual({ + x: [1, 2, 3, 4, 1, 2, 3, 4], + y: [2, 4, 6, 8, 1, 2, 3, 4], + '"x" * "y"': [2, 8, 18, 32, 1, 4, 9, 16], + }); + + expect(result2).toEqual({ + x: [1, 2, 3, 4, 1, 2, 3, 4], + y: [2, 4, 6, 8, 1, 2, 3, 4], + '"x" + "y"': [3, 6, 9, 12, 2, 4, 6, 8], + }); + + expect(result3).toEqual({ + x: [1, 2, 3, 4, 1, 2, 3, 4], + y: [2, 4, 6, 8, 1, 2, 3, 4], + '"x" - "y"': [-1, -2, -3, -4, 0, 0, 0, 0], + }); + + view3.delete(); + view2.delete(); + view.delete(); + table.delete(); + }); + }); + + test.describe("Computed updates with group by", function () { + test("should update on dependent columns, add", async function () { + const table = await perspective.table(pivot_data); + const view = await table.view({ + columns: ['"int" + "float"', "int"], + aggregates: { + '"int" + "float"': "sum", + }, + group_by: ['"int" + "float"'], + expressions: { '"int" + "float"': '"int" + "float"' }, + }); + + await table.update({ int: [4], __INDEX__: [0] }); + + let results = await view.to_columns({ + index: true, + }); + + expect(results).toEqual({ + __ROW_PATH__: [[], [5.5], [6.25], [7.75], [9.25]], + int: [13, 2, 4, 3, 4], + '"int" + "float"': [28.75, 5.5, 6.25, 7.75, 9.25], + __INDEX__: [[0, 3, 2, 1], [1], [0], [2], [3]], + }); + + view.delete(); + table.delete(); + }); + + test("should update on dependent columns, subtract", async function () { + const table = await perspective.table(pivot_data); + const view = await table.view({ + columns: ['"int" - "float"', "int"], + group_by: ['"int" - "float"'], + expressions: { '"int" - "float"': '"int" - "float"' }, + }); + + await table.update([{ int: 4, __INDEX__: 0 }]); + + let json = await view.to_json({ + index: true, + }); + + expect(json).toEqual([ + { + __ROW_PATH__: [], + int: 13, + '"int" - "float"': -2.75, + __INDEX__: [0, 3, 1, 2], + }, + { + __ROW_PATH__: [-1.75], + int: 3, + '"int" - "float"': -1.75, + __INDEX__: [2], + }, + { + __ROW_PATH__: [-1.5], + int: 2, + '"int" - "float"': -1.5, + __INDEX__: [1], + }, + { + __ROW_PATH__: [-1.25], + int: 4, + '"int" - "float"': -1.25, + __INDEX__: [3], + }, + { + __ROW_PATH__: [1.75], + int: 4, + '"int" - "float"': 1.75, + __INDEX__: [0], + }, + ]); + + view.delete(); + table.delete(); + }); + + test("should update on dependent columns, multiply", async function () { + const table = await perspective.table(pivot_data); + + const view = await table.view({ + columns: ['"int" * "float"', "int"], + group_by: ['"int" * "float"'], + expressions: { '"int" * "float"': '"int" * "float"' }, + }); + + await table.update([{ int: 4, __INDEX__: 0 }]); + + let json = await view.to_json({ + index: true, + }); + + expect(json).toEqual([ + { + __ROW_PATH__: [], + int: 13, + '"int" * "float"': 51.25, + __INDEX__: [0, 3, 2, 1], + }, + { + __ROW_PATH__: [7], + int: 2, + '"int" * "float"': 7, + __INDEX__: [1], + }, + { + __ROW_PATH__: [9], + int: 4, + '"int" * "float"': 9, + __INDEX__: [0], + }, + { + __ROW_PATH__: [14.25], + int: 3, + '"int" * "float"': 14.25, + __INDEX__: [2], + }, + { + __ROW_PATH__: [21], + int: 4, + '"int" * "float"': 21, + __INDEX__: [3], + }, + ]); + + view.delete(); + table.delete(); + }); + + test("should update on dependent columns, divide", async function () { + const table = await perspective.table(pivot_data); + + const view = await table.view({ + columns: ['"int" / "float"', "int"], + group_by: ['"int" / "float"'], + expressions: { '"int" / "float"': '"int" / "float"' }, + }); + + await table.update([{ int: 4, __INDEX__: 0 }]); + + let json = await view.to_json({ + index: true, + }); + + expect(json).toEqual([ + { + __ROW_PATH__: [], + int: 13, + '"int" / "float"': 3.742690058479532, + __INDEX__: [0, 3, 2, 1], + }, + { + __ROW_PATH__: [0.5714285714285714], + int: 2, + '"int" / "float"': 0.5714285714285714, + __INDEX__: [1], + }, + { + __ROW_PATH__: [0.631578947368421], + int: 3, + '"int" / "float"': 0.631578947368421, + __INDEX__: [2], + }, + { + __ROW_PATH__: [0.7619047619047619], + int: 4, + '"int" / "float"': 0.7619047619047619, + __INDEX__: [3], + }, + { + __ROW_PATH__: [1.7777777777777777], + int: 4, + '"int" / "float"': 1.7777777777777777, + __INDEX__: [0], + }, + ]); + + view.delete(); + table.delete(); + }); + }); + + test.describe("Partial update with null", function () { + test("Update over null should recalculate", async function () { + const table = await perspective.table(pivot_data, { index: "int" }); + const view = await table.view({ + columns: ['"int" + "float"', "int", "float"], + expressions: { '"int" + "float"': '"int" + "float"' }, + }); + + await table.update([{ int: 2, float: 3.5 }]); + + let result = await view.to_columns(); + + expect(result).toEqual({ + '"int" + "float"': [3.25, 5.5, 7.75, 9.25], + int: [1, 2, 3, 4], + float: [2.25, 3.5, 4.75, 5.25], + }); + + view.delete(); + table.delete(); + }); + + test("Update with null should unset", async function () { + const table = await perspective.table(pivot_data, { index: "int" }); + + const view = await table.view({ + columns: ['"int" + "float"', "int", "float"], + expressions: { '"int" + "float"': '"int" + "float"' }, + }); + + await table.update([{ int: 2, float: null }]); + + let result = await view.to_columns(); + + expect(result).toEqual({ + '"int" + "float"': [3.25, null, 7.75, 9.25], + int: [1, 2, 3, 4], + float: [2.25, null, 4.75, 5.25], + }); + + view.delete(); + table.delete(); + }); + + test("Undefined should be a no-op on expression columns", async function () { + const table = await perspective.table( + [ + { int: 1, float: 2.25, string: "a", datetime: new Date() }, + { int: 2, float: 3.5, string: "b", datetime: new Date() }, + { int: 3, float: 4.75, string: "c", datetime: new Date() }, + { int: 4, float: 5.25, string: "d", datetime: new Date() }, + ], + { index: "int" }, + ); + const view = await table.view({ + columns: ['"int" + "float"', "int", "float"], + expressions: { '"int" + "float"': '"int" + "float"' }, + }); + + await table.update([{ int: 2, float: undefined }]); + + let result = await view.to_columns(); + + expect(result).toEqual({ + '"int" + "float"': [3.25, 5.5, 7.75, 9.25], + int: [1, 2, 3, 4], + float: [2.25, 3.5, 4.75, 5.25], + }); + + view.delete(); + table.delete(); + }); + }); + + test.describe("Removing expression columns", () => { + test("Removes should remove rows in expression columns", async () => { + const table = await perspective.table( + { + x: [1, 2, 3, 4], + y: ["A", "B", "C", "D"], + }, + { index: "x" }, + ); + + const view = await table.view({ + expressions: { 'lower("y")': 'lower("y")' }, + }); + + const before = await view.to_columns(); + expect(before['lower("y")']).toEqual(["a", "b", "c", "d"]); + await table.update({ y: ["HELLO", "WORLD"] }); + + const result = await view.to_columns(); + expect(result["x"]).toEqual([null, 1, 2, 3, 4]); + expect(result['lower("y")']).toEqual(["world", "a", "b", "c", "d"]); + + await table.remove([4, null, 1]); + + const result2 = await view.to_columns(); + expect(result2["x"]).toEqual([2, 3]); + expect(result2['lower("y")']).toEqual(["b", "c"]); + + view.delete(); + table.delete(); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/expressions/vectors.spec.js b/rust/perspective-js/test/js/expressions/vectors.spec.js new file mode 100644 index 0000000..3a50948 --- /dev/null +++ b/rust/perspective-js/test/js/expressions/vectors.spec.js @@ -0,0 +1,154 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client"; + +import * as common from "./common.js"; + +/** + * Tests the correctness of user-defined vectors inside expressions. + * + * @param {*} perspective + */ +((perspective) => { + test.describe("Vectors", () => { + test("Create vector and return value", async () => { + const table = await perspective.table(common.int_float_data); + const view = await table.view({ + expressions: { a: `var vec[3] := {1, "w", 2}; vec[1]` }, + }); + expect(await view.expression_schema()).toEqual({ + a: "float", + }); + const result = await view.to_columns(); + expect(result["a"]).toEqual(result["w"]); + await view.delete(); + await table.delete(); + }); + + test("Return from empty vector should be 0", async () => { + const table = await perspective.table(common.int_float_data); + const view = await table.view({ + expressions: { a: `var vec[3]; vec[1]` }, + }); + expect(await view.expression_schema()).toEqual({ + a: "float", + }); + const result = await view.to_columns(); + expect(result["a"]).toEqual(Array(4).fill(0)); + await view.delete(); + await table.delete(); + }); + + test("Dynamic return types from vector", async () => { + const table = await perspective.table(common.int_float_data); + const view = await table.view({ + expressions: { + [`a`]: `var vec[3] := {'abc', 123, today()}; vec[0]`, + [`b`]: `var vec[3] := {'abc', 123, today()}; vec[1]`, + [`c`]: `var vec[3] := {'abc', 123, date(2020, 5, 23)}; vec[2]`, + [`d`]: `var vec[3] := {'abc', 123, is_null(null)}; vec[2]`, + }, + }); + + expect(await view.expression_schema()).toEqual({ + a: "string", + b: "float", + c: "date", + d: "boolean", + }); + + const result = await view.to_columns(); + + expect(result["a"]).toEqual(Array(4).fill("abc")); + expect(result["b"]).toEqual(Array(4).fill(123)); + expect(result["c"]).toEqual( + Array(4).fill(new Date(2020, 4, 23).getTime()), + ); + expect(result["d"]).toEqual(Array(4).fill(true)); + await view.delete(); + await table.delete(); + }); + + test("Use vector items as inputs", async () => { + const table = await perspective.table(common.int_float_data); + const view = await table.view({ + expressions: { + [`a`]: `var vec[2] := {"w", "x"}; vec[0] * vec[1]`, + }, + }); + expect(await view.expression_schema()).toEqual({ + a: "float", + }); + const result = await view.to_columns(); + expect(result["a"]).toEqual( + result["w"].map((item, idx) => item * result["x"][idx]), + ); + await view.delete(); + await table.delete(); + }); + + test("Custom function takes vector item input", async () => { + const table = await perspective.table(common.int_float_data); + const view = await table.view({ + expressions: { + [`a`]: `var vec[2] := {"w", "x"}; max(vec[0], vec[1])`, + }, + }); + expect(await view.expression_schema()).toEqual({ + a: "float", + }); + const result = await view.to_columns(); + expect(result["a"]).toEqual( + result["w"].map((item, idx) => + Math.max(item, result["x"][idx]), + ), + ); + await view.delete(); + await table.delete(); + }); + + test("`norm3` with a vector shorter than 3 is rejected", async () => { + const table = await perspective.table(common.int_float_data); + await expect( + table.view({ + expressions: { a: `var v[2] := {1, 2}; norm3(v)` }, + }), + ).rejects.toThrow(); + await table.delete(); + }); + + test("`diff3` with vectors shorter than 3 is rejected", async () => { + const table = await perspective.table(common.int_float_data); + await expect( + table.view({ + expressions: { + a: `var x[2] := {1, 2}; var y[2] := {3, 4}; var o[2]; diff3(x, y, o)`, + }, + }), + ).rejects.toThrow(); + await table.delete(); + }); + + test("`norm3` with a 3-element vector still computes", async () => { + const table = await perspective.table(common.int_float_data); + const view = await table.view({ + expressions: { a: `var v[3] := {3, 4, 0}; norm3(v)` }, + }); + const result = await view.to_columns(); + expect(result["a"]).toEqual(Array(4).fill(5)); // sqrt(9+16+0) + await view.delete(); + await table.delete(); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/filters.spec.js b/rust/perspective-js/test/js/filters.spec.js new file mode 100644 index 0000000..613cd9a --- /dev/null +++ b/rust/perspective-js/test/js/filters.spec.js @@ -0,0 +1,993 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "./perspective_client"; +import * as arrows from "./test_arrows.js"; + +var yesterday = new Date(); +yesterday.setDate(yesterday.getDate() - 1); + +var now = new Date(); + +var data = [ + { w: now, x: 1, y: "a", z: true }, + { w: now, x: 2, y: "b", z: false }, + { w: now, x: 3, y: "c", z: true }, + { w: yesterday, x: 4, y: "d", z: false }, +]; + +var rdata = [ + { w: +now, x: 1, y: "a", z: true }, + { w: +now, x: 2, y: "b", z: false }, + { w: +now, x: 3, y: "c", z: true }, + { w: +yesterday, x: 4, y: "d", z: false }, +]; + +// starting from 09/01/2018 to 12/01/2018 +var date_range_data = [ + { w: new Date(1535778060000), x: 1, y: "a", z: true }, // Sat Sep 01 2018 01:01:00 GMT-0400 + { w: new Date(1538370060000), x: 2, y: "b", z: false }, // Mon Oct 01 2018 01:01:00 GMT-0400 + { w: new Date(1541048460000), x: 3, y: "c", z: true }, // Thu Nov 01 2018 01:01:00 GMT-0400 + { w: new Date(1543644060000), x: 4, y: "d", z: false }, // Sat Dec 01 2018 01:01:00 GMT-0500 +]; + +var r_date_range_data = [ + { w: +new Date(1535778060000), x: 1, y: "a", z: true }, + { w: +new Date(1538370060000), x: 2, y: "b", z: false }, + { w: +new Date(1541048460000), x: 3, y: "c", z: true }, + { w: +new Date(1543644060000), x: 4, y: "d", z: false }, +]; + +const datetime_data = [ + { x: new Date(2019, 1, 28, 23, 59, 59) }, // 2019/02/28 23:59:59 GMT-0500 + { x: new Date(2020, 1, 29, 0, 0, 1) }, // 2020/02/29 00:00:01 GMT-0500 + { x: new Date(2020, 2, 8, 1, 59, 59) }, // 2020/03/8 01:59:59 GMT-0400 + { x: new Date(2020, 2, 8, 2, 0, 1) }, // 2020/03/8 02:00:01 GMT-0500 + { x: new Date(2020, 9, 1, 15, 11, 55) }, // 2020/10/01 15:30:55 GMT-0400 + { x: new Date(2020, 10, 1, 19, 29, 55) }, // 2020/11/01 19:30:55 GMT-0400 + { x: new Date(2020, 11, 31, 7, 42, 55) }, // 2020/12/31 07:30:55 GMT-0500 +]; + +const datetime_data_local = [ + { x: new Date(2019, 1, 28, 23, 59, 59).toLocaleString() }, // 2019/02/28 23:59:59 GMT-0500 + { x: new Date(2020, 1, 29, 0, 0, 1).toLocaleString() }, // 2020/02/29 00:00:01 GMT-0500 + { x: new Date(2020, 2, 8, 1, 59, 59).toLocaleString() }, // 2020/03/8 01:59:59 GMT-0400 + { x: new Date(2020, 2, 8, 2, 0, 1).toLocaleString() }, // 2020/03/8 02:00:01 GMT-0500 + { x: new Date(2020, 9, 1, 15, 11, 55).toLocaleString() }, // 2020/10/01 15:30:55 GMT-0400 + { x: new Date(2020, 10, 1, 19, 29, 55).toLocaleString() }, // 2020/11/01 19:30:55 GMT-0400 + { x: new Date(2020, 11, 31, 7, 42, 55).toLocaleString() }, // 2020/12/31 07:30:55 GMT-0500 +]; + +((perspective) => { + test.describe("Filters", function () { + test.describe("GT & LT", function () { + test("filters on long strings", async function () { + var table = await perspective.table([ + { x: 1, y: "123456789012a", z: true }, + { x: 2, y: "123456789012a", z: false }, + { x: 3, y: "123456789012b", z: true }, + { x: 4, y: "123456789012b", z: false }, + ]); + var view = await table.view({ + filter: [["y", "contains", "123456789012a"]], + }); + let json = await view.to_json(); + expect(json.length).toEqual(2); + view.delete(); + table.delete(); + }); + + test("x > 2", async function () { + var table = await perspective.table(data); + var view = await table.view({ + filter: [["x", ">", 2.0]], + }); + let json = await view.to_json(); + expect(json).toEqual(rdata.slice(2)); + view.delete(); + table.delete(); + }); + + test("x < 3", async function () { + var table = await perspective.table(data); + var view = await table.view({ + filter: [["x", "<", 3.0]], + }); + let json = await view.to_json(); + expect(json).toEqual(rdata.slice(0, 2)); + view.delete(); + table.delete(); + }); + + test("x > 4", async function () { + var table = await perspective.table(data); + var view = await table.view({ + filter: [["x", ">", 4]], + }); + let json = await view.to_json(); + expect(json).toEqual([]); + view.delete(); + table.delete(); + }); + + test("x < 0", async function () { + var table = await perspective.table(data); + var view = await table.view({ + filter: [["x", ">", 4]], + }); + let json = await view.to_json(); + expect(json).toEqual([]); + view.delete(); + table.delete(); + }); + + test("w > datetime as string", async function () { + var table = await perspective.table(date_range_data); + var view = await table.view({ + filter: [["w", ">", "10/01/2018"]], + }); + let json = await view.to_json(); + expect(json).toEqual(r_date_range_data.slice(1, 4)); + view.delete(); + table.delete(); + }); + + test("w < datetime as string", async function () { + var table = await perspective.table(date_range_data); + var view = await table.view({ + filter: [["w", "<", "10/01/2018"]], + }); + let json = await view.to_json(); + expect(json).toEqual([r_date_range_data[0]]); + view.delete(); + table.delete(); + }); + + test.describe("filtering on date column", function () { + const schema = { + w: "date", + }; + + const date_results = [ + { w: +new Date(1535760000000) }, // Fri Aug 31 2018 20:00:00 GMT-0400 + { w: +new Date(1538352000000) }, // Sun Sep 30 2018 20:00:00 GMT-0400 + { w: +new Date(1541030400000) }, // Wed Oct 31 2018 20:00:00 GMT-0400 + { w: +new Date(1543622400000) }, // Fri Nov 30 2018 19:00:00 GMT-0500 + ]; + + test("w > date as string", async function () { + var table = await perspective.table(schema); + await table.update(date_results); + var view = await table.view({ + filter: [["w", ">", "10/02/2018"]], + }); + let json = await view.to_json(); + expect(json).toEqual(date_results.slice(2, 4)); + view.delete(); + table.delete(); + }); + + test("w > date as number", async function () { + var table = await perspective.table(schema); + await table.update(date_results); + var view = await table.view({ + filter: [["w", ">", 1538352000000]], + }); + let json = await view.to_json(); + expect(json).toEqual(date_results.slice(2, 4)); + view.delete(); + table.delete(); + }); + + test("w < date as string", async function () { + var table = await perspective.table(schema); + await table.update(date_results); + var view = await table.view({ + filter: [["w", "<", "10/02/2018"]], + }); + let json = await view.to_json(); + expect(json).toEqual(date_results.slice(0, 2)); + view.delete(); + table.delete(); + }); + }); + }); + + test.describe("EQ", function () { + test("x == 1", async function () { + var table = await perspective.table(data); + var view = await table.view({ + filter: [["x", "==", 1]], + }); + let json = await view.to_json(); + expect(json).toEqual(rdata.slice(0, 1)); + view.delete(); + table.delete(); + }); + + test("empty, pivoted", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["y"], + filter: [["x", "==", 100]], + }); + let json = await view.to_json(); + expect(json).toEqual([ + // empty sum + { __ROW_PATH__: [], w: 0, x: null, y: 0, z: 0 }, + ]); + view.delete(); + table.delete(); + }); + + test("x == 1, rolling updates", async function () { + var table = await perspective.table(data); + var view = await table.view({ + columns: ["x"], + filter: [["x", "==", 1]], + }); + let json = await view.to_json(); + expect(json).toEqual([{ x: 1 }]); + + for (let i = 0; i < 5; i++) { + await table.update([{ x: 1 }]); + } + + expect(await view.to_columns()).toEqual({ + x: [1, 1, 1, 1, 1, 1], + }); + + await table.update([{ x: 2 }]); + + expect(await view.to_columns()).toEqual({ + x: [1, 1, 1, 1, 1, 1], + }); + + view.delete(); + table.delete(); + }); + + test.skip("x == 1 pivoted, rolling updates", async function () { + var table = await perspective.table( + { + a: [1, 2, 3, 4], + b: ["a", "b", "c", "d"], + c: ["A", "B", "C", "D"], + }, + { + index: "c", + }, + ); + var view = await table.view({ + group_by: ["a"], + columns: ["b", "c"], + }); + + let out = await view.to_columns(); + expect(out["__ROW_PATH__"]).toEqual([[], [1], [2], [3], [4]]); + + for (let i = 0; i < 5; i++) { + await table.update([{ x: 1 }]); + } + + expect(await view.to_columns()).toEqual({ + x: [1, 1, 1, 1, 1, 1], + }); + + await table.update([{ x: 2 }]); + + expect(await view.to_columns()).toEqual({ + x: [1, 1, 1, 1, 1, 1], + }); + + view.delete(); + table.delete(); + }); + + test("x == 5", async function () { + var table = await perspective.table(data); + var view = await table.view({ + filter: [["x", "==", 5]], + }); + let json = await view.to_json(); + expect(json).toEqual([]); + view.delete(); + table.delete(); + }); + + test("y == 'a'", async function () { + var table = await perspective.table(data); + var view = await table.view({ + filter: [["y", "==", "a"]], + }); + let json = await view.to_json(); + expect(json).toEqual(rdata.slice(0, 1)); + view.delete(); + table.delete(); + }); + + test("y == 'e'", async function () { + var table = await perspective.table(data); + var view = await table.view({ + filter: [["y", "==", "e"]], + }); + let json = await view.to_json(); + expect(json).toEqual([]); + view.delete(); + table.delete(); + }); + + test("y == 'abcdefghijklmnopqrstuvwxyz' (interned)", async function () { + const data = [ + { x: 1, y: "a", z: true }, + { x: 2, y: "b", z: false }, + { x: 3, y: "c", z: true }, + { + x: 4, + y: "abcdefghijklmnopqrstuvwxyz", + z: false, + }, + ]; + + const table = await perspective.table(data); + const view = await table.view({ + filter: [["y", "==", "abcdefghijklmnopqrstuvwxyz"]], + }); + + const json = await view.to_json(); + expect(json).toEqual([data[3]]); + view.delete(); + table.delete(); + }); + + test("z == true", async function () { + var table = await perspective.table(data); + var view = await table.view({ + filter: [["z", "==", true]], + }); + let json = await view.to_json(); + expect(json).toEqual([rdata[0], rdata[2]]); + view.delete(); + table.delete(); + }); + + test("z == false", async function () { + var table = await perspective.table(data); + var view = await table.view({ + filter: [["z", "==", false]], + }); + let json = await view.to_json(); + expect(json).toEqual([rdata[1], rdata[3]]); + view.delete(); + table.delete(); + }); + + test("w == yesterday", async function () { + var table = await perspective.table(data); + var view = await table.view({ + filter: [["w", "==", yesterday]], + }); + let json = await view.to_json(); + expect(json).toEqual([rdata[3]]); + view.delete(); + table.delete(); + }); + + test("w != yesterday", async function () { + var table = await perspective.table(data); + var view = await table.view({ + filter: [["w", "!=", yesterday]], + }); + let json = await view.to_json(); + expect(json).toEqual(rdata.slice(0, 3)); + view.delete(); + table.delete(); + }); + + test("w == datetime as Date() object", async function () { + const table = await perspective.table({ x: "datetime" }); + await table.update(datetime_data); + expect(await table.schema()).toEqual({ + x: "datetime", + }); + const view = await table.view({ + filter: [["x", "==", datetime_data[0]["x"]]], + }); + expect(await view.num_rows()).toBe(1); + let data = await view.to_json(); + data = data.map((d) => { + d.x = new Date(d.x); + return d; + }); + expect(data).toEqual(datetime_data.slice(0, 1)); + await view.delete(); + await table.delete(); + }); + + test("w == datetime as Number", async function () { + const table = await perspective.table({ x: "datetime" }); + await table.update(datetime_data); + expect(await table.schema()).toEqual({ + x: "datetime", + }); + + const view = await table.view({ + filter: [["x", "==", +datetime_data[0]["x"]]], + }); + + expect(await view.num_rows()).toBe(1); + let data = await view.to_json(); + data = data.map((d) => { + d.x = new Date(d.x); + return d; + }); + + expect(data).toEqual(datetime_data.slice(0, 1)); + await view.delete(); + await table.delete(); + }); + + test("w == datetime as US locale string", async function () { + const table = await perspective.table({ x: "datetime" }); + await table.update(datetime_data); + + expect(await table.schema()).toEqual({ + x: "datetime", + }); + const view = await table.view({ + filter: [["x", "==", datetime_data_local[0]["x"]]], + }); + expect(await view.num_rows()).toBe(1); + let data = await view.to_json(); + data = data.map((d) => { + d.x = new Date(d.x); + return d; + }); + expect(data).toEqual(datetime_data.slice(0, 1)); + await view.delete(); + await table.delete(); + }); + }); + + test.describe("in", function () { + test("y in ['a', 'b']", async function () { + var table = await perspective.table(data); + var view = await table.view({ + filter: [["y", "in", ["a", "b"]]], + }); + let json = await view.to_json(); + expect(json).toEqual(rdata.slice(0, 2)); + view.delete(); + table.delete(); + }); + }); + + test.describe("not in", function () { + test("y not in ['d']", async function () { + var table = await perspective.table(data); + var view = await table.view({ + filter: [["y", "not in", ["d"]]], + }); + let json = await view.to_json(); + expect(json).toEqual(rdata.slice(0, 3)); + view.delete(); + table.delete(); + }); + }); + + test.describe("contains", function () { + test("y contains 'a'", async function () { + var table = await perspective.table(data); + var view = await table.view({ + filter: [["y", "contains", "a"]], + }); + let json = await view.to_json(); + expect(rdata.slice(0, 1)).toEqual(json); + view.delete(); + table.delete(); + }); + }); + + test.describe("Arrow types", function () { + // https://github.com/perspective-dev/perspective/issues/2881 + test("Arrow float32 filters", async function () { + const table = await perspective.table( + arrows.float32_arrow.slice(), + ); + + const view = await table.view({ filter: [["score", "<", 93]] }); + const result = await view.to_columns(); + expect(result).toEqual({ + id: [1, 2], + name: ["Alice", "Bob"], + score: [92.5, 87.30000305175781], + }); + + const cfg = await view.get_config(); + expect(cfg).toEqual({ + aggregates: {}, + columns: ["id", "name", "score"], + expressions: {}, + filter: [["score", "<", 93]], + group_by: [], + sort: [], + split_by: [], + group_rollup_mode: "rollup", + }); + + view.delete(); + table.delete(); + }); + }); + + test.describe("multiple", function () { + test("x > 1 & x < 4", async function () { + var table = await perspective.table(data); + var view = await table.view({ + filter: [ + ["x", ">", 1], + ["x", "<", 4], + ], + }); + let json = await view.to_json(); + expect(json).toEqual(rdata.slice(1, 3)); + view.delete(); + table.delete(); + }); + + test("y contains 'a' OR y contains 'b'", async function () { + var table = await perspective.table(data); + // when `filter_op` is provided, perspective returns data differently. In this case, returned data should satisfy either/or of the filter conditions. + var view = await table.view({ + filter_op: "or", + filter: [ + ["y", "contains", "a"], + ["y", "contains", "b"], + ], + }); + let json = await view.to_json(); + expect(json).toEqual(rdata.slice(0, 2)); + view.delete(); + table.delete(); + }); + }); + + test.describe("long strings", function () { + test("", async function () { + const data = [ + { + field: 100, + index: "1", + title: "s15", + ts: "2024-11-30T06:50:10.214Z", + }, + { + field: 100, + index: "2", + title: "sys_c_c 15min", + ts: "2024-11-30T07:06:00.763Z", + }, + { + field: 100, + index: "3", + title: "s15", + ts: "2024-12-01T06:50:15.596Z", + }, + { + field: 100, + index: "4", + title: "sys_c_c 15min", + ts: "2024-12-01T07:06:04.909Z", + }, + { + field: 100, + index: "5", + title: "s15", + ts: "2024-12-02T06:50:24.712Z", + }, + { + field: 100, + index: "6", + title: "sys_c_c 15min", + ts: "2024-12-02T07:06:15.339Z", + }, + { + field: 100, + index: "7", + title: "s15", + ts: "2024-12-03T06:50:22.144Z", + }, + { + field: 100, + index: "8", + title: "sys_c_c 15min", + ts: "2024-12-03T07:06:20.146Z", + }, + ]; + + const config = { + group_by: ["ts"], + split_by: [], + columns: ["field"], + filter: [ + ["title", "in", ["sys_c_c 15min"]], + ["ts", ">=", "2024-12-01T00:00:00.000Z"], + ["ts", "<=", "2024-12-02T23:59:59.999Z"], + ], + expressions: {}, + aggregates: {}, + }; + + const table = await perspective.table( + { + title: "string", + field: "float", + ts: "datetime", + index: "string", + }, + { index: "index" }, + ); + + let x, y; + let xx = new Promise((xxx) => { + x = xxx; + }); + let yy = new Promise((yyy) => { + y = yyy; + }); + + await table.view(config).then((view) => { + view.on_update(() => view.to_json().then(x)); + }); + + await table.view(config).then((view) => { + view.on_update(() => view.to_json().then(y)); + }); + + await table.update(data); + const [xxx, yyy] = await Promise.all([xx, yy]); + expect(xxx).toEqual(yyy); + }); + }); + + test.describe("is null", function () { + test("returns the correct null cells for string column", async function () { + const table = await perspective.table([ + { x: 1, y: null }, + { x: 2, y: null }, + { x: 3, y: "x" }, + { x: 4, y: "x" }, + { x: 1, y: "y" }, + { x: 2, y: "x" }, + { x: 3, y: "y" }, + ]); + const view = await table.view({ + filter: [["y", "is null"]], + }); + const answer = [ + { x: 1, y: null }, + { x: 2, y: null }, + ]; + const result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("returns the correct null cells for integer column", async function () { + const table = await perspective.table([ + { x: 1, y: null }, + { x: 2, y: null }, + { x: 3, y: 1 }, + { x: 4, y: 2 }, + { x: 1, y: 3 }, + { x: 2, y: 4 }, + { x: 3, y: 5 }, + ]); + const view = await table.view({ + filter: [["y", "is null"]], + }); + const answer = [ + { x: 1, y: null }, + { x: 2, y: null }, + ]; + const result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("returns the correct null cells for datetime column", async function () { + const table = await perspective.table([ + { x: 1, y: null }, + { x: 2, y: null }, + { x: 3, y: "1/1/2019" }, + { x: 4, y: "1/1/2019" }, + { x: 1, y: "1/1/2019" }, + { x: 2, y: "1/1/2019" }, + { x: 3, y: "1/1/2019" }, + ]); + const view = await table.view({ + filter: [["y", "is null"]], + }); + const answer = [ + { x: 1, y: null }, + { x: 2, y: null }, + ]; + const result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + }); + + test.describe("nulls", function () { + test("x > 2", async function () { + var table = await perspective.table([ + { x: 3, y: 1 }, + { x: 2, y: 1 }, + { x: null, y: 1 }, + { x: null, y: 1 }, + { x: 4, y: 2 }, + { x: null, y: 2 }, + ]); + var view = await table.view({ + filter: [["x", ">", 2]], + }); + var answer = [ + { x: 3, y: 1 }, + { x: 4, y: 2 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("x < 3", async function () { + var table = await perspective.table([ + { x: 3, y: 1 }, + { x: 2, y: 1 }, + { x: null, y: 1 }, + { x: null, y: 1 }, + { x: 4, y: 2 }, + { x: null, y: 2 }, + ]); + var view = await table.view({ + filter: [["x", "<", 3]], + }); + var answer = [{ x: 2, y: 1 }]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("x > 2.5", async function () { + var table = await perspective.table({ + x: "float", + y: "integer", + }); + await table.update([ + { x: 3.5, y: 1 }, + { x: 2.5, y: 1 }, + { x: null, y: 1 }, + { x: null, y: 1 }, + { x: 4.5, y: 2 }, + { x: null, y: 2 }, + ]); + var view = await table.view({ + filter: [["x", ">", 2.5]], + }); + var answer = [ + { x: 3.5, y: 1 }, + { x: 4.5, y: 2 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test.skip("x > null should be an invalid filter", async function () { + var table = await perspective.table({ + x: "float", + y: "integer", + }); + const dataSet = [ + { x: 3.5, y: 1 }, + { x: 2.5, y: 1 }, + { x: null, y: 1 }, + { x: null, y: 1 }, + { x: 4.5, y: 2 }, + { x: null, y: 2 }, + ]; + await table.update(dataSet); + var view = await table.view({ + filter: [["x", ">", null]], + }); + var answer = dataSet; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("x == 'a'", async function () { + var table = await perspective.table([ + { x: "b", y: 1 }, + { x: "a", y: 1 }, + { x: null, y: 1 }, + { x: null, y: 1 }, + { x: "a", y: 2 }, + { x: "b", y: 2 }, + { x: null, y: 2 }, + ]); + var view = await table.view({ + filter: [["x", "==", "a"]], + }); + var answer = [ + { x: "a", y: 1 }, + { x: "a", y: 2 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("x != 'a'", async function () { + var table = await perspective.table([ + { x: "b", y: 1 }, + { x: "a", y: 1 }, + { x: null, y: 1 }, + { x: null, y: 1 }, + { x: "a", y: 2 }, + { x: "b", y: 2 }, + { x: null, y: 2 }, + ]); + var view = await table.view({ + filter: [["x", "!=", "a"]], + }); + var answer = [ + { x: "b", y: 1 }, + { x: null, y: 1 }, + { x: null, y: 1 }, + { x: "b", y: 2 }, + { x: null, y: 2 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("x == 'b'", async function () { + var table = await perspective.table([ + { x: "b", y: 1 }, + { x: "a", y: 1 }, + { x: null, y: 1 }, + { x: null, y: 1 }, + { x: "a", y: 2 }, + { x: "b", y: 2 }, + { x: null, y: 2 }, + ]); + var view = await table.view({ + filter: [["x", "==", "b"]], + }); + var answer = [ + { x: "b", y: 1 }, + { x: "b", y: 2 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + }); + + // TODO `is_valid_filter` is not currently used and is soon to be replaced by `validate()` + test.describe.skip("is_valid_filter", function () { + test("x == 2", async function () { + let table = await perspective.table(data); + let isValid = await table.is_valid_filter(["x", "==", 2]); + expect(isValid).toBeTruthy(); + table.delete(); + }); + test("x < null", async function () { + let table = await perspective.table(data); + let isValid = await table.is_valid_filter(["x", "<", null]); + expect(isValid).toBeFalsy(); + table.delete(); + }); + test("x > undefined", async function () { + let table = await perspective.table(data); + let isValid = await table.is_valid_filter([ + "x", + ">", + undefined, + ]); + expect(isValid).toBeFalsy(); + table.delete(); + }); + test('x == ""', async function () { + let table = await perspective.table(data); + let isValid = await table.is_valid_filter(["x", "==", ""]); + expect(isValid).toBeTruthy(); + table.delete(); + }); + test("valid date", async function () { + const schema = { + x: "string", + y: "date", + }; + let table = await perspective.table(schema); + let isValid = await table.is_valid_filter([ + "y", + "==", + "01-01-1970", + ]); + expect(isValid).toBeTruthy(); + table.delete(); + }); + test("invalid date", async function () { + const schema = { + x: "string", + y: "date", + }; + let table = await perspective.table(schema); + let isValid = await table.is_valid_filter(["y", "<", "1234"]); + expect(isValid).toBeFalsy(); + table.delete(); + }); + test("valid datetime", async function () { + const schema = { + x: "string", + y: "datetime", + }; + let table = await perspective.table(schema); + let isValid = await table.is_valid_filter([ + "y", + "==", + "2019-11-02 11:11:11.111", + ]); + expect(isValid).toBeTruthy(); + table.delete(); + }); + test("invalid datetime", async function () { + const schema = { + x: "string", + y: "datetime", + }; + let table = await perspective.table(schema); + let isValid = await table.is_valid_filter([ + "y", + ">", + "2019-11-02 11:11:11:111", + ]); + expect(isValid).toBeFalsy(); + table.delete(); + }); + test("ignores schema check if column is not in schema", async function () { + let table = await perspective.table(data); + let isValid = await table.is_valid_filter([ + "not a valid column", + "==", + 2, + ]); + expect(isValid).toBeTruthy(); + table.delete(); + }); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/get_min_max.spec.js b/rust/perspective-js/test/js/get_min_max.spec.js new file mode 100644 index 0000000..a3bd9d4 --- /dev/null +++ b/rust/perspective-js/test/js/get_min_max.spec.js @@ -0,0 +1,197 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "./perspective_client"; + +const data = { + w: [ + 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, -1.5, -3.5, -1.5, -4.5, -9.5, + -5.5, -8.5, -7.5, + ], + x: [1, 2, 3, 4, 4, 3, 2, 1, 3, 4, 2, 1, 4, 3, 1, 2], + y: [ + "a", + "b", + "c", + "d", + "a", + "b", + "c", + "d", + "a", + "b", + "c", + "d", + "a", + "b", + "c", + "d", + ], + z: [ + true, + false, + true, + false, + true, + false, + true, + false, + true, + true, + true, + true, + false, + false, + false, + false, + ], +}; + +test.describe("get_min_max", function () { + test.describe("0 sided", function () { + test("float column", async function () { + var table = await perspective.table(data); + var view = await table.view({}); + const range = await view.get_min_max("w"); + expect(range).toEqual([-9.5, 8.5]); + view.delete(); + table.delete(); + }); + + test("int column", async function () { + var table = await perspective.table(data); + var view = await table.view({}); + const range = await view.get_min_max("x"); + expect(range).toEqual([1, 4]); + view.delete(); + table.delete(); + }); + + test("string column", async function () { + var table = await perspective.table(data); + var view = await table.view({}); + const range = await view.get_min_max("y"); + expect(range).toEqual(["a", "d"]); + view.delete(); + table.delete(); + }); + }); + + test.describe("1 sided", function () { + test("float col", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["y"], + }); + const cols = await view.get_min_max("w"); + expect(cols).toEqual([-4, 1]); + view.delete(); + table.delete(); + }); + + test("int col", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["y"], + }); + const cols = await view.get_min_max("x"); + expect(cols).toEqual([8, 12]); + view.delete(); + table.delete(); + }); + + test("string col", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["y"], + }); + const cols = await view.get_min_max("y"); + expect(cols).toEqual([4, 4]); + view.delete(); + table.delete(); + }); + }); + + test.describe("2 sided", function () { + test("float col", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["y"], + split_by: ["z"], + }); + const cols = await view.get_min_max("w"); + expect(cols).toEqual([-9.5, 9.5]); + view.delete(); + table.delete(); + }); + + test("int column", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["y"], + split_by: ["z"], + }); + const cols = await view.get_min_max("x"); + expect(cols).toEqual([1, 8]); + view.delete(); + table.delete(); + }); + + test("string column", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["y"], + split_by: ["z"], + }); + const cols = await view.get_min_max("y"); + expect(cols).toEqual([1, 3]); + view.delete(); + table.delete(); + }); + }); + + test.describe("column only", function () { + test("float col", async function () { + var table = await perspective.table(data); + var view = await table.view({ + split_by: ["z"], + }); + const cols = await view.get_min_max("w"); + expect(cols).toEqual([-9.5, 8.5]); + view.delete(); + table.delete(); + }); + + test("int col", async function () { + var table = await perspective.table(data); + var view = await table.view({ + split_by: ["z"], + }); + const cols = await view.get_min_max("x"); + expect(cols).toEqual([1, 4]); + view.delete(); + table.delete(); + }); + + test("string col", async function () { + var table = await perspective.table(data); + var view = await table.view({ + split_by: ["z"], + }); + const cols = await view.get_min_max("y"); + expect(cols).toEqual(["a", "d"]); + view.delete(); + table.delete(); + }); + }); +}); diff --git a/rust/perspective-js/test/js/group_by.spec.js b/rust/perspective-js/test/js/group_by.spec.js new file mode 100644 index 0000000..dad637e --- /dev/null +++ b/rust/perspective-js/test/js/group_by.spec.js @@ -0,0 +1,1205 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "./perspective_client"; + +var data = [ + { x: 1, y: "a", z: true }, + { x: 2, y: "b", z: false }, + { x: 3, y: "c", z: true }, + { x: 4, y: "d", z: false }, +]; + +var meta = { + x: "integer", + y: "string", + z: "boolean", +}; + +var data2 = [ + { x: 1, y: 1, z: true }, + { x: 2, y: 1, z: false }, + { x: 3, y: 2, z: true }, + { x: 4, y: 2, z: false }, +]; + +var data_7 = { + w: [1.5, 2.5, 3.5, 4.5], + x: [1, 2, 3, 4], + y: ["a", "b", "c", "d"], + z: [true, false, true, false], +}; + +var data_8 = { + w: [1.5, 2.5, 3.5, 4.5], + x: [1, 2, 3, 4], + y: ["a", "b", "c", "d"], + z: [ + new Date(1555126035065), + new Date(1555126035065), + new Date(1555026035065), + new Date(1555026035065), + ], +}; + +const float_data = { + x: [ + 0.99098243, 0.36677191, 0.58926465, 0.95701263, 0.96904283, 0.50398721, + 0.67424934, 0.32015379, 0.14877031, 0.16285932, 0.00597484, 0.90579728, + 0.01338397, 0.66893083, 0.79209796, 0.41033256, 0.92328448, 0.20791236, + 0.14874502, 0.1727802, + ], + y: [ + "a", + "b", + "c", + "d", + "e", + "a", + "b", + "c", + "d", + "e", + "a", + "b", + "c", + "d", + "e", + "a", + "b", + "c", + "d", + "e", + ], +}; + +const variance = (nums) => { + let k = 0, + m = 0, + s = 0; + + for (const num of nums) { + k++; + const next_m = m + (num - m) / k; + s += (num - m) * (num - next_m); + m = next_m; + } + + return s / nums.length; +}; + +const std = (nums) => { + return Math.sqrt(variance(nums)); +}; + +((perspective) => { + test.describe("Group by", function () { + test("['x']", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["x"], + aggregates: { y: "distinct count", z: "distinct count" }, + }); + var answer = [ + { __ROW_PATH__: [], x: 10, y: 4, z: 2 }, + { __ROW_PATH__: [1], x: 1, y: 1, z: 1 }, + { __ROW_PATH__: [2], x: 2, y: 1, z: 1 }, + { __ROW_PATH__: [3], x: 3, y: 1, z: 1 }, + { __ROW_PATH__: [4], x: 4, y: 1, z: 1 }, + ]; + let result2 = await view.to_json(); + expect(result2).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['x'] test update pkey column", async function () { + const schema = { + id: "integer", + name: "string", + chg: "float", + pos: "integer", + }; + const rec1 = [ + { id: 1, name: "John", pos: 100, chg: 1 }, + { id: 2, name: "Mary", pos: 200, chg: 2 }, + { id: 3, name: "Tom", pos: 300, chg: 3 }, + ]; + const table = await perspective.table(schema, { index: "id" }); + await table.update(rec1); + let view = await table.view({ + group_by: ["id"], + columns: ["pos"], + }); + let rec2 = [{ id: 1, chg: 3 }]; + await table.update(rec2); + let result2 = await view.to_json(); + var answer = [ + { __ROW_PATH__: [], pos: 600 }, + { __ROW_PATH__: [1], pos: 100 }, + { __ROW_PATH__: [2], pos: 200 }, + { __ROW_PATH__: [3], pos: 300 }, + ]; + expect(result2).toEqual(answer); + view.delete(); + table.delete(); + }); + + test.describe("pivoting on column containing null values", function () { + test("shows one pivot for the nulls on initial load", async function () { + const dataWithNulls = [ + { name: "Homer", value: 1 }, + { name: null, value: 1 }, + { name: null, value: 1 }, + { name: "Krusty", value: 1 }, + ]; + + var table = await perspective.table(dataWithNulls); + + var view = await table.view({ + group_by: ["name"], + aggregates: { name: "distinct count" }, + }); + + const answer = [ + { __ROW_PATH__: [], name: 3, value: 4 }, + { __ROW_PATH__: [null], name: 1, value: 2 }, + { __ROW_PATH__: ["Homer"], name: 1, value: 1 }, + { __ROW_PATH__: ["Krusty"], name: 1, value: 1 }, + ]; + + let results = await view.to_json(); + expect(results).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("shows one pivot for the nulls after updating with a null", async function () { + const dataWithNull1 = [ + { name: "Homer", value: 1 }, + { name: null, value: 1 }, + ]; + const dataWithNull2 = [ + { name: null, value: 1 }, + { name: "Krusty", value: 1 }, + ]; + + var table = await perspective.table(dataWithNull1); + await table.update(dataWithNull2); + + var view = await table.view({ + group_by: ["name"], + aggregates: { name: "distinct count" }, + }); + + const answer = [ + { __ROW_PATH__: [], name: 3, value: 4 }, + { __ROW_PATH__: [null], name: 1, value: 2 }, + { __ROW_PATH__: ["Homer"], name: 1, value: 1 }, + { __ROW_PATH__: ["Krusty"], name: 1, value: 1 }, + ]; + + let results = await view.to_json(); + expect(results).toEqual(answer); + view.delete(); + table.delete(); + }); + }); + + test("['x'] has a schema", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["x"], + }); + let result2 = await view.schema(); + expect(result2).toEqual({ + x: "integer", + y: "integer", + z: "integer", + }); + view.delete(); + table.delete(); + }); + + test("['x'] translates type `string` to `integer` when pivoted by row", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["x"], + columns: ["y"], + aggregates: { y: "distinct count" }, + }); + let result2 = await view.schema(); + expect(result2).toEqual({ y: "integer" }); + view.delete(); + table.delete(); + }); + + test("['x'] translates type `integer` to `float` when pivoted by row", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["y"], + columns: ["x"], + aggregates: { x: "avg" }, + }); + let result2 = await view.schema(); + expect(result2).toEqual({ x: "float" }); + view.delete(); + table.delete(); + }); + + test("['x'] does not translate type when only pivoted by column", async function () { + var table = await perspective.table(data); + var view = await table.view({ + split_by: ["y"], + columns: ["x"], + aggregates: { x: "avg" }, + }); + let result2 = await view.schema(); + expect(result2).toEqual({ x: "integer" }); + view.delete(); + table.delete(); + }); + + test("['x'] has the correct # of rows", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["x"], + }); + let result2 = await view.num_rows(); + expect(result2).toEqual(5); + view.delete(); + table.delete(); + }); + + test("['x'] has the correct # of columns", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["x"], + }); + let result2 = await view.num_columns(); + expect(result2).toEqual(3); + view.delete(); + table.delete(); + }); + + test("Group By by date column results in correct headers", async function () { + var table = await perspective.table({ + a: [ + new Date("2020/01/15"), + new Date("2020/02/15"), + new Date("2020/03/15"), + new Date("2020/04/15"), + new Date("2020/05/15"), + new Date("2020/06/15"), + new Date("2020/07/15"), + new Date("2020/08/15"), + new Date("2020/09/15"), + new Date("2020/10/15"), + new Date("2020/11/15"), + new Date("2020/12/15"), + ], + b: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + }); + var view = await table.view({ + group_by: ["a"], + }); + const results = await view.to_columns(); + expect(results).toEqual({ + __ROW_PATH__: [ + [], + [1579046400000], + [1581724800000], + [1584230400000], + [1586908800000], + [1589500800000], + [1592179200000], + [1594771200000], + [1597449600000], + [1600128000000], + [1602720000000], + [1605398400000], + [1607990400000], + ], + a: [12, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + b: [78, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + }); + + const dates = results["__ROW_PATH__"]; + let date_results = []; + const expected = [ + new Date("2020/01/15"), + new Date("2020/02/15"), + new Date("2020/03/15"), + new Date("2020/04/15"), + new Date("2020/05/15"), + new Date("2020/06/15"), + new Date("2020/07/15"), + new Date("2020/08/15"), + new Date("2020/09/15"), + new Date("2020/10/15"), + new Date("2020/11/15"), + new Date("2020/12/15"), + ]; + + for (const d of dates) { + if (d[0]) { + date_results.push(new Date(d[0])); + } + } + + expect(date_results).toEqual(expected); + + view.delete(); + table.delete(); + }); + + test("['z']", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["z"], + aggregates: { y: "distinct count", z: "distinct count" }, + }); + var answer = [ + { __ROW_PATH__: [], x: 10, y: 4, z: 2 }, + { __ROW_PATH__: [false], x: 6, y: 2, z: 1 }, + { __ROW_PATH__: [true], x: 4, y: 2, z: 1 }, + ]; + let result2 = await view.to_json(); + expect(result2).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['x', 'z']", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["x", "z"], + aggregates: { y: "distinct count", z: "distinct count" }, + }); + var answer = [ + { __ROW_PATH__: [], x: 10, y: 4, z: 2 }, + { __ROW_PATH__: [1], x: 1, y: 1, z: 1 }, + { __ROW_PATH__: [1, true], x: 1, y: 1, z: 1 }, + { __ROW_PATH__: [2], x: 2, y: 1, z: 1 }, + { __ROW_PATH__: [2, false], x: 2, y: 1, z: 1 }, + { __ROW_PATH__: [3], x: 3, y: 1, z: 1 }, + { __ROW_PATH__: [3, true], x: 3, y: 1, z: 1 }, + { __ROW_PATH__: [4], x: 4, y: 1, z: 1 }, + { __ROW_PATH__: [4, false], x: 4, y: 1, z: 1 }, + ]; + let result2 = await view.to_json(); + expect(result2).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['x', 'z'] windowed", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["x", "z"], + }); + var answer = [ + { __ROW_PATH__: [1, true], x: 1, y: 1, z: 1 }, + { __ROW_PATH__: [2], x: 2, y: 1, z: 1 }, + { __ROW_PATH__: [2, false], x: 2, y: 1, z: 1 }, + { __ROW_PATH__: [3], x: 3, y: 1, z: 1 }, + { __ROW_PATH__: [3, true], x: 3, y: 1, z: 1 }, + { __ROW_PATH__: [4], x: 4, y: 1, z: 1 }, + { __ROW_PATH__: [4, false], x: 4, y: 1, z: 1 }, + ]; + let result2 = await view.to_json({ start_row: 2 }); + expect(result2).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['x', 'z'], pivot_depth = 1", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["x", "z"], + group_by_depth: 1, + aggregates: { y: "distinct count", z: "distinct count" }, + }); + var answer = [ + { __ROW_PATH__: [], x: 10, y: 4, z: 2 }, + { __ROW_PATH__: [1], x: 1, y: 1, z: 1 }, + { __ROW_PATH__: [2], x: 2, y: 1, z: 1 }, + { __ROW_PATH__: [3], x: 3, y: 1, z: 1 }, + { __ROW_PATH__: [4], x: 4, y: 1, z: 1 }, + ]; + let result2 = await view.to_json(); + expect(result2).toEqual(answer); + view.delete(); + table.delete(); + }); + }); + + test.describe("Split by", function () { + test("['y'] only, schema", async function () { + var table = await perspective.table(data); + var view = await table.view({ + split_by: ["y"], + }); + let result2 = await view.schema(); + expect(result2).toEqual(meta); + view.delete(); + table.delete(); + }); + + test("['z'] only, datetime column", async function () { + var table = await perspective.table(data_8); + var view = await table.view({ + split_by: ["z"], + columns: ["x", "y"], + }); + let result2 = await view.to_columns(); + + expect(result2).toEqual({ + "2019-04-11 23:40:35.065|x": [null, null, 3, 4], + "2019-04-11 23:40:35.065|y": [null, null, "c", "d"], + "2019-04-13 03:27:15.065|x": [1, 2, null, null], + "2019-04-13 03:27:15.065|y": ["a", "b", null, null], + }); + + view.delete(); + table.delete(); + }); + + test("['x'] only, column-oriented input", async function () { + var table = await perspective.table(data_7); + var view = await table.view({ + split_by: ["z"], + }); + let result2 = await view.to_json(); + expect(result2).toEqual([ + { + "true|w": 1.5, + "true|x": 1, + "true|y": "a", + "true|z": true, + "false|w": null, + "false|x": null, + "false|y": null, + "false|z": null, + }, + { + "true|w": null, + "true|x": null, + "true|y": null, + "true|z": null, + "false|w": 2.5, + "false|x": 2, + "false|y": "b", + "false|z": false, + }, + { + "true|w": 3.5, + "true|x": 3, + "true|y": "c", + "true|z": true, + "false|w": null, + "false|x": null, + "false|y": null, + "false|z": null, + }, + { + "true|w": null, + "true|x": null, + "true|y": null, + "true|z": null, + "false|w": 4.5, + "false|x": 4, + "false|y": "d", + "false|z": false, + }, + ]); + view.delete(); + table.delete(); + }); + + test("['z'] only, column-oriented output", async function () { + var table = await perspective.table(data_7); + var view = await table.view({ + split_by: ["z"], + }); + let result2 = await view.to_columns(); + expect(result2).toEqual({ + "true|w": [1.5, null, 3.5, null], + "true|x": [1, null, 3, null], + "true|y": ["a", null, "c", null], + "true|z": [true, null, true, null], + "false|w": [null, 2.5, null, 4.5], + "false|x": [null, 2, null, 4], + "false|y": [null, "b", null, "d"], + "false|z": [null, false, null, false], + }); + view.delete(); + table.delete(); + }); + + test("['y'] only sorted by ['x'] desc", async function () { + var table = await perspective.table(data); + var view = await table.view({ + split_by: ["y"], + sort: [["x", "desc"]], + }); + var answer = [ + { + "a|x": null, + "a|y": null, + "a|z": null, + "b|x": null, + "b|y": null, + "b|z": null, + "c|x": null, + "c|y": null, + "c|z": null, + "d|x": 4, + "d|y": "d", + "d|z": false, + }, + { + "a|x": null, + "a|y": null, + "a|z": null, + "b|x": null, + "b|y": null, + "b|z": null, + "c|x": 3, + "c|y": "c", + "c|z": true, + "d|x": null, + "d|y": null, + "d|z": null, + }, + { + "a|x": null, + "a|y": null, + "a|z": null, + "b|x": 2, + "b|y": "b", + "b|z": false, + "c|x": null, + "c|y": null, + "c|z": null, + "d|x": null, + "d|y": null, + "d|z": null, + }, + { + "a|x": 1, + "a|y": "a", + "a|z": true, + "b|x": null, + "b|y": null, + "b|z": null, + "c|x": null, + "c|y": null, + "c|z": null, + "d|x": null, + "d|y": null, + "d|z": null, + }, + ]; + let result2 = await view.to_json(); + expect(result2).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['y'] only", async function () { + var table = await perspective.table(data); + var view = await table.view({ + split_by: ["y"], + }); + var answer = [ + { + "a|x": 1, + "a|y": "a", + "a|z": true, + "b|x": null, + "b|y": null, + "b|z": null, + "c|x": null, + "c|y": null, + "c|z": null, + "d|x": null, + "d|y": null, + "d|z": null, + }, + { + "a|x": null, + "a|y": null, + "a|z": null, + "b|x": 2, + "b|y": "b", + "b|z": false, + "c|x": null, + "c|y": null, + "c|z": null, + "d|x": null, + "d|y": null, + "d|z": null, + }, + { + "a|x": null, + "a|y": null, + "a|z": null, + "b|x": null, + "b|y": null, + "b|z": null, + "c|x": 3, + "c|y": "c", + "c|z": true, + "d|x": null, + "d|y": null, + "d|z": null, + }, + { + "a|x": null, + "a|y": null, + "a|z": null, + "b|x": null, + "b|y": null, + "b|z": null, + "c|x": null, + "c|y": null, + "c|z": null, + "d|x": 4, + "d|y": "d", + "d|z": false, + }, + ]; + let result2 = await view.to_json(); + expect(result2).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['x'] by ['y']", async function () { + var table = await perspective.table(data); + var view = await table.view({ + split_by: ["y"], + group_by: ["x"], + }); + var answer = [ + { + __ROW_PATH__: [], + "a|x": 1, + "a|y": 1, + "a|z": 1, + "b|x": 2, + "b|y": 1, + "b|z": 1, + "c|x": 3, + "c|y": 1, + "c|z": 1, + "d|x": 4, + "d|y": 1, + "d|z": 1, + }, + { + __ROW_PATH__: [1], + "a|x": 1, + "a|y": 1, + "a|z": 1, + "b|x": null, + "b|y": null, + "b|z": null, + "c|x": null, + "c|y": null, + "c|z": null, + "d|x": null, + "d|y": null, + "d|z": null, + }, + { + __ROW_PATH__: [2], + "a|x": null, + "a|y": null, + "a|z": null, + "b|x": 2, + "b|y": 1, + "b|z": 1, + "c|x": null, + "c|y": null, + "c|z": null, + "d|x": null, + "d|y": null, + "d|z": null, + }, + { + __ROW_PATH__: [3], + "a|x": null, + "a|y": null, + "a|z": null, + "b|x": null, + "b|y": null, + "b|z": null, + "c|x": 3, + "c|y": 1, + "c|z": 1, + "d|x": null, + "d|y": null, + "d|z": null, + }, + { + __ROW_PATH__: [4], + "a|x": null, + "a|y": null, + "a|z": null, + "b|x": null, + "b|y": null, + "b|z": null, + "c|x": null, + "c|y": null, + "c|z": null, + "d|x": 4, + "d|y": 1, + "d|z": 1, + }, + ]; + let result2 = await view.to_json(); + expect(result2).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['x', 'z']", async function () { + var table = await perspective.table(data); + var view = await table.view({ + split_by: ["x", "z"], + columns: ["y"], + }); + let result2 = await view.to_json(); + expect(result2).toEqual([ + { + "1|true|y": "a", + "2|false|y": null, + "3|true|y": null, + "4|false|y": null, + }, + { + "1|true|y": null, + "2|false|y": "b", + "3|true|y": null, + "4|false|y": null, + }, + { + "1|true|y": null, + "2|false|y": null, + "3|true|y": "c", + "4|false|y": null, + }, + { + "1|true|y": null, + "2|false|y": null, + "3|true|y": null, + "4|false|y": "d", + }, + ]); + view.delete(); + table.delete(); + }); + }); + + test.describe("Expand/Collapse", function () { + test("Collapse a row in a 2x2 pivot", async function () { + var table = await perspective.table([ + { x: 7, y: "A", z: true, a: "AA", b: "BB", c: "CC" }, + { x: 2, y: "A", z: false, a: "AA", b: "CC", c: "CC" }, + { x: 5, y: "A", z: true, a: "AA", b: "BB", c: "DD" }, + { x: 4, y: "A", z: false, a: "AA", b: "CC", c: "DD" }, + { x: 1, y: "B", z: true, a: "AA", b: "BB", c: "CC" }, + { x: 8, y: "B", z: false, a: "AA", b: "CC", c: "CC" }, + { x: 3, y: "B", z: true, a: "BB", b: "BB", c: "DD" }, + { x: 6, y: "B", z: false, a: "BB", b: "CC", c: "DD" }, + { x: 9, y: "C", z: true, a: "BB", b: "BB", c: "CC" }, + { x: 10, y: "C", z: false, a: "BB", b: "CC", c: "CC" }, + { x: 11, y: "C", z: true, a: "BB", b: "BB", c: "DD" }, + { x: 12, y: "C", z: false, a: "BB", b: "CC", c: "DD" }, + ]); + var view = await table.view({ + split_by: ["z", "b"], + group_by: ["y", "a"], + columns: ["x"], + aggregates: { x: "last" }, + }); + + let answer = [ + { __ROW_PATH__: [], "false|CC|x": 12, "true|BB|x": 11 }, + { __ROW_PATH__: ["A"], "false|CC|x": 4, "true|BB|x": 5 }, + { __ROW_PATH__: ["A", "AA"], "false|CC|x": 4, "true|BB|x": 5 }, + { __ROW_PATH__: ["B"], "false|CC|x": 6, "true|BB|x": 3 }, + { __ROW_PATH__: ["C"], "false|CC|x": 12, "true|BB|x": 11 }, + { + __ROW_PATH__: ["C", "BB"], + "false|CC|x": 12, + "true|BB|x": 11, + }, + ]; + view.collapse(3); + let result2 = await view.to_json(); + expect(result2).toEqual(answer); + view.delete(); + table.delete(); + }); + }); + + test.describe("Split by w/sort", function () { + test("['y'] by ['z'], sorted by 'x'", async function () { + var table = await perspective.table([ + { x: 7, y: "A", z: true }, + { x: 2, y: "A", z: false }, + { x: 5, y: "A", z: true }, + { x: 4, y: "A", z: false }, + { x: 1, y: "B", z: true }, + { x: 8, y: "B", z: false }, + { x: 3, y: "B", z: true }, + { x: 6, y: "B", z: false }, + { x: 9, y: "C", z: true }, + { x: 10, y: "C", z: false }, + { x: 11, y: "C", z: true }, + { x: 12, y: "C", z: false }, + ]); + var view = await table.view({ + split_by: ["z"], + group_by: ["y"], + sort: [["x", "desc"]], + aggregates: { y: "distinct count", z: "distinct count" }, + }); + + let answer = [ + { + __ROW_PATH__: [], + "false|x": 42, + "false|y": 3, + "false|z": 1, + "true|x": 36, + "true|y": 3, + "true|z": 1, + }, + { + __ROW_PATH__: ["C"], + "false|x": 22, + "false|y": 1, + "false|z": 1, + "true|x": 20, + "true|y": 1, + "true|z": 1, + }, + { + __ROW_PATH__: ["A"], + "false|x": 6, + "false|y": 1, + "false|z": 1, + "true|x": 12, + "true|y": 1, + "true|z": 1, + }, + { + __ROW_PATH__: ["B"], + "false|x": 14, + "false|y": 1, + "false|z": 1, + "true|x": 4, + "true|y": 1, + "true|z": 1, + }, + ]; + let result2 = await view.to_json(); + expect(result2).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("['z'] by ['y'], sorted by 'y'", async function () { + var table = await perspective.table([ + { x: 7, y: "A", z: true }, + { x: 2, y: "A", z: false }, + { x: 5, y: "A", z: true }, + { x: 4, y: "A", z: false }, + { x: 1, y: "B", z: true }, + { x: 8, y: "B", z: false }, + { x: 3, y: "B", z: true }, + { x: 6, y: "B", z: false }, + { x: 9, y: "C", z: true }, + { x: 10, y: "C", z: false }, + { x: 11, y: "C", z: true }, + { x: 12, y: "C", z: false }, + ]); + var view = await table.view({ + split_by: ["y"], + group_by: ["z"], + sort: [["y", "col desc"]], + columns: ["x", "y"], + aggregates: { x: "sum", y: "any" }, + }); + + let result2 = await view.to_columns(); + expect(Object.keys(result2)).toEqual([ + "__ROW_PATH__", + "C|x", + "C|y", + "B|x", + "B|y", + "A|x", + "A|y", + ]); + view.delete(); + table.delete(); + }); + + test("['y'] by ['x'] sorted by ['x'] desc has the correct # of columns", async function () { + var table = await perspective.table(data); + var view = await table.view({ + split_by: ["y"], + group_by: ["x"], + sort: [["x", "desc"]], + }); + let num_cols = await view.num_columns(); + expect(num_cols).toEqual(12); + view.delete(); + table.delete(); + }); + }); + + test.describe("Pivot table operations", function () { + test("Should not expand past number of group by", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["x"], + split_by: ["y"], + }); + var expanded_idx = await view.expand(2); + // invalid expands return the index + expect(expanded_idx).toEqual(2); + }); + }); + + test.describe("Column paths", function () { + test("Should return all columns, 0-sided view from schema", async function () { + const table = await perspective.table(meta); + const view = await table.view(); + const paths = await view.column_paths(); + expect(paths).toEqual(["x", "y", "z"]); + view.delete(); + table.delete(); + }); + + test("Should return all columns in specified order, 0-sided view from schema", async function () { + const table = await perspective.table(meta); + const view = await table.view({ + columns: ["z", "y", "x"], + }); + const paths = await view.column_paths(); + expect(paths).toEqual(["z", "y", "x"]); + view.delete(); + table.delete(); + }); + + test("Should return specified visible columns, 0-sided view from schema", async function () { + const table = await perspective.table(meta); + const view = await table.view({ + columns: ["x"], + }); + const paths = await view.column_paths(); + expect(paths).toEqual(["x"]); + view.delete(); + table.delete(); + }); + + test("Should return all columns, 0-sided view", async function () { + const table = await perspective.table(data); + const view = await table.view(); + const paths = await view.column_paths(); + expect(paths).toEqual(["x", "y", "z"]); + view.delete(); + table.delete(); + }); + + test("Should return all columns in specified order, 0-sided view", async function () { + const table = await perspective.table(data); + const view = await table.view({ + columns: ["z", "y", "x"], + }); + const paths = await view.column_paths(); + expect(paths).toEqual(["z", "y", "x"]); + view.delete(); + table.delete(); + }); + + test("Should return specified visible columns, 0-sided view", async function () { + const table = await perspective.table(data); + const view = await table.view({ + columns: ["x"], + }); + const paths = await view.column_paths(); + expect(paths).toEqual(["x"]); + view.delete(); + table.delete(); + }); + + test("Should return all columns with __ROW_PATH__, 1-sided view", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_by: ["x"], + }); + const paths = await view.column_paths(); + expect(paths).toEqual(["x", "y", "z"]); + view.delete(); + table.delete(); + }); + + test("Should return numerical column names in the correct order, 1-sided view", async function () { + const table = await perspective.table({ + 2345: [0, 1, 2, 3], + 1.23456789: [0, 1, 2, 3], + 1234: [1, 2, 3, 4], + x: [5, 6, 7, 8], + }); + + // Previously, we iterated through the aggregates map using the + // order given in Object.keys() which meant that column names that + // were parsable as numbers automatically ended up at the front of + // the map. This test makes sure that column orders are respected + // by the engine for all column names. + const view = await table.view({ + group_by: ["x"], + columns: ["2345", "1234", "x", "1.23456789"], + aggregates: { + x: "sum", + 1234: "sum", + }, + }); + const paths = await view.column_paths(); + expect(paths).toEqual(["2345", "1234", "x", "1.23456789"]); + view.delete(); + table.delete(); + }); + + test("Should return all columns in specified order, 1-sided view", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_by: ["x"], + columns: ["z", "y", "x"], + }); + const paths = await view.column_paths(); + expect(paths).toEqual(["z", "y", "x"]); + view.delete(); + table.delete(); + }); + + test("Should return specified visible columns with __ROW_PATH__, 1-sided view", async function () { + const table = await perspective.table(data); + const view = await table.view({ + columns: ["x"], + group_by: ["x"], + }); + const paths = await view.column_paths(); + expect(paths).toEqual(["x"]); + view.delete(); + table.delete(); + }); + + test("Should return all columns with __ROW_PATH__, 2-sided view", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_by: ["x"], + split_by: ["y"], + }); + const paths = await view.column_paths(); + expect(paths).toEqual([ + "a|x", + "a|y", + "a|z", + "b|x", + "b|y", + "b|z", + "c|x", + "c|y", + "c|z", + "d|x", + "d|y", + "d|z", + ]); + view.delete(); + table.delete(); + }); + + test("Should return specified visible columns with __ROW_PATH__, 2-sided view", async function () { + const table = await perspective.table(data); + const view = await table.view({ + columns: ["z", "y", "x"], + group_by: ["x"], + split_by: ["y"], + }); + const paths = await view.column_paths(); + expect(paths).toEqual([ + "a|z", + "a|y", + "a|x", + "b|z", + "b|y", + "b|x", + "c|z", + "c|y", + "c|x", + "d|z", + "d|y", + "d|x", + ]); + view.delete(); + table.delete(); + }); + + test("Should return specified visible columns with __ROW_PATH__, 2-sided view variant", async function () { + const table = await perspective.table(data); + const view = await table.view({ + columns: ["x"], + group_by: ["x"], + split_by: ["y"], + }); + const paths = await view.column_paths(); + expect(paths).toEqual(["a|x", "b|x", "c|x", "d|x"]); + view.delete(); + table.delete(); + }); + + test("Should format date columns in split_by", async function () { + const table = await perspective.table({ + w: "float", + x: "integer", + y: "string", + z: "date", + }); + + await table.update(data_8); + const view = await table.view({ group_by: ["y"], split_by: ["z"] }); + const paths = await view.column_paths(); + expect(paths).toEqual([ + "2019-04-11|w", + "2019-04-11|x", + "2019-04-11|y", + "2019-04-11|z", + "2019-04-13|w", + "2019-04-13|x", + "2019-04-13|y", + "2019-04-13|z", + ]); + + view.delete(); + table.delete(); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/group_rollup_mode.spec.js b/rust/perspective-js/test/js/group_rollup_mode.spec.js new file mode 100644 index 0000000..7b34ad0 --- /dev/null +++ b/rust/perspective-js/test/js/group_rollup_mode.spec.js @@ -0,0 +1,624 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "./perspective_client"; + +const data = { + w: [1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5], + x: [1, 2, 3, 4, 4, 3, 2, 1], + y: ["a", "b", "c", "d", "a", "b", "c", "d"], + z: [true, false, true, false, true, false, true, false], +}; + +((perspective) => { + test.describe("group_rollup_mode", function () { + test.describe("flat", function () { + test("only emits leaves", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_by: ["y"], + group_rollup_mode: "flat", + }); + const json = await view.to_json(); + expect(json).toStrictEqual([ + { __ROW_PATH__: ["a"], w: 7, x: 5, y: 2, z: 2 }, + { __ROW_PATH__: ["b"], w: 9, x: 5, y: 2, z: 2 }, + { __ROW_PATH__: ["c"], w: 11, x: 5, y: 2, z: 2 }, + { __ROW_PATH__: ["d"], w: 13, x: 5, y: 2, z: 2 }, + ]); + view.delete(); + table.delete(); + }); + + test("num_rows returns leaf count", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_by: ["y"], + group_rollup_mode: "flat", + }); + const num_rows = await view.num_rows(); + expect(num_rows).toEqual(4); + view.delete(); + table.delete(); + }); + + test("to_columns works", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_by: ["y"], + group_rollup_mode: "flat", + }); + const cols = await view.to_columns(); + expect(cols).toStrictEqual({ + __ROW_PATH__: [["a"], ["b"], ["c"], ["d"]], + w: [7, 9, 11, 13], + x: [5, 5, 5, 5], + y: [2, 2, 2, 2], + z: [2, 2, 2, 2], + }); + view.delete(); + table.delete(); + }); + + test("sort asc", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_by: ["y"], + group_rollup_mode: "flat", + sort: [["w", "asc"]], + }); + const cols = await view.to_columns(); + expect(cols).toStrictEqual({ + __ROW_PATH__: [["a"], ["b"], ["c"], ["d"]], + w: [7, 9, 11, 13], + x: [5, 5, 5, 5], + y: [2, 2, 2, 2], + z: [2, 2, 2, 2], + }); + view.delete(); + table.delete(); + }); + + test("sort desc", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_by: ["y"], + group_rollup_mode: "flat", + sort: [["w", "desc"]], + }); + const cols = await view.to_columns(); + expect(cols).toStrictEqual({ + __ROW_PATH__: [["d"], ["c"], ["b"], ["a"]], + w: [13, 11, 9, 7], + x: [5, 5, 5, 5], + y: [2, 2, 2, 2], + z: [2, 2, 2, 2], + }); + view.delete(); + table.delete(); + }); + + test("sort with hidden column", async function () { + const table = await perspective.table(data); + const view = await table.view({ + columns: ["y"], + group_by: ["y"], + group_rollup_mode: "flat", + sort: [["x", "desc"]], + }); + const cols = await view.to_columns(); + expect(cols).toStrictEqual({ + __ROW_PATH__: [["a"], ["b"], ["c"], ["d"]], + y: [2, 2, 2, 2], + }); + view.delete(); + table.delete(); + }); + + test("multi-level group_by", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_by: ["y", "z"], + group_rollup_mode: "flat", + }); + const json = await view.to_json(); + expect(json).toStrictEqual([ + { + __ROW_PATH__: ["a", true], + w: 7, + x: 5, + y: 2, + z: 2, + }, + { + __ROW_PATH__: ["b", false], + w: 9, + x: 5, + y: 2, + z: 2, + }, + { + __ROW_PATH__: ["c", true], + w: 11, + x: 5, + y: 2, + z: 2, + }, + { + __ROW_PATH__: ["d", false], + w: 13, + x: 5, + y: 2, + z: 2, + }, + ]); + view.delete(); + table.delete(); + }); + + test("multi-level group_by with sort matches expanded tree order", async function () { + const table = await perspective.table(data); + const flat_view = await table.view({ + group_by: ["y", "z"], + group_rollup_mode: "flat", + sort: [["w", "desc"]], + }); + const flat_cols = await flat_view.to_columns(); + expect(flat_cols).toStrictEqual({ + __ROW_PATH__: [ + ["d", false], + ["c", true], + ["b", false], + ["a", true], + ], + w: [13, 11, 9, 7], + x: [5, 5, 5, 5], + y: [2, 2, 2, 2], + z: [2, 2, 2, 2], + }); + flat_view.delete(); + table.delete(); + }); + + test("with split_by", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_by: ["y"], + split_by: ["z"], + group_rollup_mode: "flat", + }); + const json = await view.to_json(); + expect(json).toStrictEqual([ + { + __ROW_PATH__: ["a"], + "false|w": null, + "false|x": null, + "false|y": null, + "false|z": null, + "true|w": 7, + "true|x": 5, + "true|y": 2, + "true|z": 2, + }, + { + __ROW_PATH__: ["b"], + "false|w": 9, + "false|x": 5, + "false|y": 2, + "false|z": 2, + "true|w": null, + "true|x": null, + "true|y": null, + "true|z": null, + }, + { + __ROW_PATH__: ["c"], + "false|w": null, + "false|x": null, + "false|y": null, + "false|z": null, + "true|w": 11, + "true|x": 5, + "true|y": 2, + "true|z": 2, + }, + { + __ROW_PATH__: ["d"], + "false|w": 13, + "false|x": 5, + "false|y": 2, + "false|z": 2, + "true|w": null, + "true|x": null, + "true|y": null, + "true|z": null, + }, + ]); + view.delete(); + table.delete(); + }); + + test("split_by only", async function () { + const table = await perspective.table(data); + const view = await table.view({ + split_by: ["z"], + group_rollup_mode: "flat", + }); + const json = await view.to_json(); + expect(json).toStrictEqual([ + { + "false|w": null, + "false|x": null, + "false|y": null, + "false|z": null, + "true|w": 1.5, + "true|x": 1, + "true|y": "a", + "true|z": true, + }, + { + "false|w": 2.5, + "false|x": 2, + "false|y": "b", + "false|z": false, + "true|w": null, + "true|x": null, + "true|y": null, + "true|z": null, + }, + { + "false|w": null, + "false|x": null, + "false|y": null, + "false|z": null, + "true|w": 3.5, + "true|x": 3, + "true|y": "c", + "true|z": true, + }, + { + "false|w": 4.5, + "false|x": 4, + "false|y": "d", + "false|z": false, + "true|w": null, + "true|x": null, + "true|y": null, + "true|z": null, + }, + { + "false|w": null, + "false|x": null, + "false|y": null, + "false|z": null, + "true|w": 5.5, + "true|x": 4, + "true|y": "a", + "true|z": true, + }, + { + "false|w": 6.5, + "false|x": 3, + "false|y": "b", + "false|z": false, + "true|w": null, + "true|x": null, + "true|y": null, + "true|z": null, + }, + { + "false|w": null, + "false|x": null, + "false|y": null, + "false|z": null, + "true|w": 7.5, + "true|x": 2, + "true|y": "c", + "true|z": true, + }, + { + "false|w": 8.5, + "false|x": 1, + "false|y": "d", + "false|z": false, + "true|w": null, + "true|x": null, + "true|y": null, + "true|z": null, + }, + ]); + view.delete(); + table.delete(); + }); + + test("with split_by and sort", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_by: ["y"], + split_by: ["z"], + group_rollup_mode: "flat", + sort: [["w", "desc"]], + }); + const json = await view.to_json(); + expect(json).toStrictEqual([ + { + __ROW_PATH__: ["d"], + "false|w": 13, + "false|x": 5, + "false|y": 2, + "false|z": 2, + "true|w": null, + "true|x": null, + "true|y": null, + "true|z": null, + }, + { + __ROW_PATH__: ["c"], + "false|w": null, + "false|x": null, + "false|y": null, + "false|z": null, + "true|w": 11, + "true|x": 5, + "true|y": 2, + "true|z": 2, + }, + { + __ROW_PATH__: ["b"], + "false|w": 9, + "false|x": 5, + "false|y": 2, + "false|z": 2, + "true|w": null, + "true|x": null, + "true|y": null, + "true|z": null, + }, + { + __ROW_PATH__: ["a"], + "false|w": null, + "false|x": null, + "false|y": null, + "false|z": null, + "true|w": 7, + "true|x": 5, + "true|y": 2, + "true|z": 2, + }, + ]); + view.delete(); + table.delete(); + }); + + test("updates after table.update()", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_by: ["y"], + group_rollup_mode: "flat", + }); + const before = await view.to_json(); + expect(before).toStrictEqual([ + { __ROW_PATH__: ["a"], w: 7, x: 5, y: 2, z: 2 }, + { __ROW_PATH__: ["b"], w: 9, x: 5, y: 2, z: 2 }, + { __ROW_PATH__: ["c"], w: 11, x: 5, y: 2, z: 2 }, + { __ROW_PATH__: ["d"], w: 13, x: 5, y: 2, z: 2 }, + ]); + table.update([{ w: 9.5, x: 5, y: "e", z: true }]); + const after = await view.to_json(); + expect(after).toStrictEqual([ + { __ROW_PATH__: ["a"], w: 7, x: 5, y: 2, z: 2 }, + { __ROW_PATH__: ["b"], w: 9, x: 5, y: 2, z: 2 }, + { __ROW_PATH__: ["c"], w: 11, x: 5, y: 2, z: 2 }, + { __ROW_PATH__: ["d"], w: 13, x: 5, y: 2, z: 2 }, + { __ROW_PATH__: ["e"], w: 9.5, x: 5, y: 1, z: 1 }, + ]); + view.delete(); + table.delete(); + }); + + test("updates preserve sort order", async function () { + const table = await perspective.table(data); + const flat_view = await table.view({ + group_by: ["y"], + group_rollup_mode: "flat", + sort: [["w", "desc"]], + }); + table.update([{ w: 100, x: 5, y: "e", z: true }]); + const flat_cols = await flat_view.to_columns(); + expect(flat_cols).toStrictEqual({ + __ROW_PATH__: [["e"], ["d"], ["c"], ["b"], ["a"]], + w: [100, 13, 11, 9, 7], + x: [5, 5, 5, 5, 5], + y: [1, 2, 2, 2, 2], + z: [1, 2, 2, 2, 2], + }); + flat_view.delete(); + table.delete(); + }); + + test("viewport pagination", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_by: ["y"], + group_rollup_mode: "flat", + }); + const json = await view.to_json({ + start_row: 0, + end_row: 2, + }); + expect(json).toStrictEqual([ + { __ROW_PATH__: ["a"], w: 7, x: 5, y: 2, z: 2 }, + { __ROW_PATH__: ["b"], w: 9, x: 5, y: 2, z: 2 }, + ]); + view.delete(); + table.delete(); + }); + + test("viewport pagination with sort", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_by: ["y"], + group_rollup_mode: "flat", + sort: [["w", "desc"]], + }); + const json = await view.to_json({ + start_row: 0, + end_row: 2, + }); + expect(json).toStrictEqual([ + { __ROW_PATH__: ["d"], w: 13, x: 5, y: 2, z: 2 }, + { __ROW_PATH__: ["c"], w: 11, x: 5, y: 2, z: 2 }, + ]); + view.delete(); + table.delete(); + }); + }); + + test.describe("total", function () { + test.skip("returns only grand total with group_by", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_by: ["y"], + group_rollup_mode: "total", + }); + const json = await view.to_json(); + expect(json).toStrictEqual([ + { __ROW_PATH__: [], w: 40, x: 20, y: 8, z: 4 }, + ]); + view.delete(); + table.delete(); + }); + + test("returns only grand total schema", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_rollup_mode: "total", + }); + + const json = await view.schema(); + expect(json).toStrictEqual({ + w: "float", + x: "integer", + y: "integer", + z: "integer", + }); + + view.delete(); + table.delete(); + }); + + test("returns only grand total", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_rollup_mode: "total", + }); + + const json = await view.to_json(); + expect(json).toStrictEqual([ + { __ROW_PATH__: [], w: 40, x: 20, y: 8, z: 8 }, + ]); + + view.delete(); + table.delete(); + }); + + test("num_rows returns 1 without group_by", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_rollup_mode: "total", + }); + + const num_rows = await view.num_rows(); + expect(num_rows).toEqual(1); + view.delete(); + table.delete(); + }); + + test("to_columns works", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_rollup_mode: "total", + }); + + const cols = await view.to_columns(); + expect(cols).toStrictEqual({ + w: [40], + x: [20], + y: [8], + z: [8], + }); + + view.delete(); + table.delete(); + }); + + test("with split_by", async function () { + const table = await perspective.table(data); + const view = await table.view({ + split_by: ["z"], + group_rollup_mode: "total", + }); + const json = await view.to_json(); + expect(json).toStrictEqual([ + { + "false|w": 22, + "false|x": 10, + "false|y": 4, + "false|z": 4, + "true|w": 18, + "true|x": 10, + "true|y": 4, + "true|z": 4, + }, + ]); + view.delete(); + table.delete(); + }); + + test("with split_by schema", async function () { + const table = await perspective.table(data); + const view = await table.view({ + split_by: ["z"], + group_rollup_mode: "total", + }); + const schema = await view.schema(); + expect(schema).toStrictEqual({ + w: "float", + x: "integer", + y: "integer", + z: "integer", + }); + view.delete(); + table.delete(); + }); + + test("updates after table.update()", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_rollup_mode: "total", + }); + const before = await view.to_json(); + expect(before).toStrictEqual([ + { __ROW_PATH__: [], w: 40, x: 20, y: 8, z: 8 }, + ]); + table.update([{ w: 10, x: 5, y: "e", z: true }]); + const after = await view.to_json(); + expect(after).toStrictEqual([ + { __ROW_PATH__: [], w: 50, x: 25, y: 9, z: 9 }, + ]); + view.delete(); + table.delete(); + }); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/hosted_tables.spec.js b/rust/perspective-js/test/js/hosted_tables.spec.js new file mode 100644 index 0000000..6feca23 --- /dev/null +++ b/rust/perspective-js/test/js/hosted_tables.spec.js @@ -0,0 +1,59 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "./perspective_client"; +import { test_arrow } from "./test_arrows"; + +test("Can get hosted table name from worker", async () => { + const testTableName1 = Math.random().toString(); + const _table = await perspective.table(test_arrow, { + name: testTableName1, + }); + const names = await perspective.get_hosted_table_names(); + + expect(names).toContain(testTableName1); + + const testTableName2 = Math.random().toString(); + const _table2 = await perspective.table(test_arrow, { + name: testTableName2, + }); + const names2 = await perspective.get_hosted_table_names(); + + expect(names2).toContain(testTableName1); + expect(names2).toContain(testTableName2); + await _table.delete(); + await _table2.delete(); +}); + +test("Can subscribe to hosted table changes from worker", async () => { + let count = 0; + const id = perspective.on_hosted_tables_update(() => { + count += 1; + }); + + const testTableName1 = Math.random().toString(); + const _table = await perspective.table(test_arrow, { + name: testTableName1, + }); + expect(count).toEqual(1); + + const testTableName2 = Math.random().toString(); + const _table2 = await perspective.table(test_arrow, { + name: testTableName2, + }); + expect(count).toEqual(2); + await _table.delete(); + expect(count).toEqual(3); + await _table2.delete(); + expect(count).toEqual(4); +}); diff --git a/rust/perspective-js/test/js/internal.spec.js b/rust/perspective-js/test/js/internal.spec.js new file mode 100644 index 0000000..76638c8 --- /dev/null +++ b/rust/perspective-js/test/js/internal.spec.js @@ -0,0 +1,60 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "./perspective_client"; +import { test_null_arrow } from "./test_arrows.js"; + +const arrow_psp_internal_schema = [ + 9, 10, 1, 2, 3, 4, 11, 19, 19, 12, 12, 12, 2, +]; + +((perspective) => { + test.describe("Internal API", function () { + test.skip("is actually using the correct runtime", async function () { + // Get the internal module; + if (perspective.sync_module) { + perspective = await perspective.sync_module(); + } + + expect(perspective.__module__.wasmJSMethod).toEqual("native-wasm"); + }); + + test.skip("Arrow schema types are mapped correctly", async function () { + // This only works for non parallel + if (perspective.sync_module) { + perspective = await perspective.sync_module(); + } + + var table = await perspective.table(test_null_arrow.slice()); + let schema, stypes; + let types = []; + try { + schema = table._Table.get_schema(); + stypes = schema.types(); + + for (let i = 0; i < stypes.size(); i++) { + types.push(stypes.get(i).value); + } + expect(types).toEqual(arrow_psp_internal_schema); + } finally { + if (schema) { + schema.delete(); + } + if (stypes) { + stypes.delete(); + } + } + table.delete(); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/joins/inner_join.spec.ts b/rust/perspective-js/test/js/joins/inner_join.spec.ts new file mode 100644 index 0000000..b49b367 --- /dev/null +++ b/rust/perspective-js/test/js/joins/inner_join.spec.ts @@ -0,0 +1,276 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client.ts"; + +test.describe("Inner joins", function () { + test("inner joins two tables on a shared key", async function () { + const left = await perspective.table([ + { id: 1, x: 10 }, + { id: 2, x: 20 }, + { id: 3, x: 30 }, + ]); + + const right = await perspective.table([ + { id: 1, y: "a" }, + { id: 2, y: "b" }, + { id: 4, y: "d" }, + ]); + + const joined = await perspective.join(left, right, "id"); + const view = await joined.view(); + const json = await view.to_json(); + + expect(json).toHaveLength(2); + + await view.delete(); + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("joined table has correct schema", async function () { + const left = await perspective.table({ id: "integer", x: "float" }); + + const right = await perspective.table({ + id: "integer", + y: "string", + }); + + const joined = await perspective.join(left, right, "id"); + const schema = await joined.schema(); + + expect(schema).toEqual({ + id: "integer", + x: "float", + y: "string", + }); + + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("joined table reacts to left table updates", async function () { + const left = await perspective.table([ + { id: 1, x: 10 }, + { id: 2, x: 20 }, + ]); + + const right = await perspective.table([ + { id: 1, y: "a" }, + { id: 2, y: "b" }, + ]); + + const joined = await perspective.join(left, right, "id"); + const view = await joined.view(); + + let json = await view.to_json(); + expect(json).toHaveLength(2); + + await left.update([{ id: 1, x: 99 }]); + json = await view.to_json(); + + expect(json).toEqual([ + { id: 1, x: 10, y: "a" }, + { id: 2, x: 20, y: "b" }, + { id: 1, x: 99, y: "a" }, + ]); + + await view.delete(); + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("joined table reacts to right table updates", async function () { + const left = await perspective.table([ + { id: 1, x: 10 }, + { id: 2, x: 20 }, + ]); + + const right = await perspective.table([ + { id: 1, y: "a" }, + { id: 2, y: "b" }, + ]); + + const joined = await perspective.join(left, right, "id"); + const view = await joined.view(); + + await right.update([{ id: 1, y: "c" }]); + const json = await view.to_json(); + + // id=3 only exists in right, so inner join should not include it + expect(json).toHaveLength(3); + + expect(json).toEqual([ + { id: 1, x: 10, y: "a" }, + { id: 1, x: 10, y: "c" }, + { id: 2, x: 20, y: "b" }, + ]); + + await view.delete(); + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("joined table reacts to new matching rows", async function () { + const left = await perspective.table([{ id: 1, x: 10 }]); + + const right = await perspective.table([{ id: 2, y: "b" }]); + + const joined = await perspective.join(left, right, "id"); + const view = await joined.view(); + + let json = await view.to_json(); + expect(json).toHaveLength(0); + + // Add matching row to right + await right.update([{ id: 1, y: "a" }]); + json = await view.to_json(); + expect(json).toHaveLength(1); + expect(json).toEqual([{ id: 1, x: 10, y: "a" }]); + + await view.delete(); + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("joined table supports views with group_by", async function () { + const left = await perspective.table([ + { id: 1, category: "A", x: 10 }, + { id: 2, category: "A", x: 20 }, + { id: 3, category: "B", x: 30 }, + ]); + + const right = await perspective.table([ + { id: 1, y: 100 }, + { id: 2, y: 200 }, + { id: 3, y: 300 }, + ]); + + const joined = await perspective.join(left, right, "id"); + const view = await joined.view({ + group_by: ["category"], + columns: ["x", "y"], + }); + + const json = await view.to_columns(); + expect(json["x"]).toEqual([60, 30, 30]); + expect(json["y"]).toEqual([600, 300, 300]); + + await view.delete(); + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("inner joins two tables by name strings", async function () { + const left = await perspective.table( + [ + { id: 1, x: 10 }, + { id: 2, x: 20 }, + { id: 3, x: 30 }, + ], + { name: "left_named" }, + ); + + const right = await perspective.table( + [ + { id: 1, y: "a" }, + { id: 2, y: "b" }, + { id: 4, y: "d" }, + ], + { name: "right_named" }, + ); + + const joined = await perspective.join( + "left_named", + "right_named", + "id", + ); + const view = await joined.view(); + const json = await view.to_json(); + + expect(json).toHaveLength(2); + + await view.delete(); + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("inner joins with mixed Table and string args", async function () { + const left = await perspective.table([ + { id: 1, x: 10 }, + { id: 2, x: 20 }, + ]); + + const right = await perspective.table( + [ + { id: 1, y: "a" }, + { id: 2, y: "b" }, + ], + { name: "right_mixed" }, + ); + + const joined = await perspective.join(left, "right_mixed", "id"); + const view = await joined.view(); + const json = await view.to_json(); + + expect(json).toHaveLength(2); + + await view.delete(); + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("rejects column name conflicts", async function () { + const left = await perspective.table([{ id: 1, value: 10 }]); + const right = await perspective.table([{ id: 1, value: 20 }]); + + let error; + try { + await perspective.join(left, right, "id"); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + await right.delete(); + await left.delete(); + }); + + test("rejects updates on joined table", async function () { + const left = await perspective.table([{ id: 1, x: 10 }]); + const right = await perspective.table([{ id: 1, y: "a" }]); + + const joined = await perspective.join(left, right, "id"); + + let error; + try { + await joined.update([{ id: 1, x: 99, y: "z" }]); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + + await joined.delete(); + await right.delete(); + await left.delete(); + }); +}); diff --git a/rust/perspective-js/test/js/joins/inner_join_indexed.spec.ts b/rust/perspective-js/test/js/joins/inner_join_indexed.spec.ts new file mode 100644 index 0000000..f6e7eba --- /dev/null +++ b/rust/perspective-js/test/js/joins/inner_join_indexed.spec.ts @@ -0,0 +1,248 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client.ts"; + +((perspective) => { + test.describe("Inner joins, indexed tables", function () { + test("inner joins two tables on a shared key", async function () { + const left = await perspective.table( + [ + { id: 1, x: 10 }, + { id: 2, x: 20 }, + { id: 3, x: 30 }, + ], + { index: "id" }, + ); + + const right = await perspective.table( + [ + { id: 1, y: "a" }, + { id: 2, y: "b" }, + { id: 4, y: "d" }, + ], + { index: "id" }, + ); + + const joined = await perspective.join(left, right, "id"); + const view = await joined.view(); + const json = await view.to_json(); + + expect(json).toHaveLength(2); + + await view.delete(); + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("joined table has correct schema", async function () { + const left = await perspective.table( + { id: "integer", x: "float" }, + { index: "id" }, + ); + + const right = await perspective.table( + { id: "integer", y: "string" }, + { index: "id" }, + ); + + const joined = await perspective.join(left, right, "id"); + const schema = await joined.schema(); + + expect(schema).toEqual({ + id: "integer", + x: "float", + y: "string", + }); + + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("joined table reacts to left table updates", async function () { + const left = await perspective.table( + [ + { id: 1, x: 10 }, + { id: 2, x: 20 }, + ], + { index: "id" }, + ); + + const right = await perspective.table( + [ + { id: 1, y: "a" }, + { id: 2, y: "b" }, + ], + { index: "id" }, + ); + + const joined = await perspective.join(left, right, "id"); + const view = await joined.view(); + + let json = await view.to_json(); + expect(json).toHaveLength(2); + + await left.update([{ id: 1, x: 99 }]); + json = await view.to_json(); + expect(json).toEqual([ + { id: 1, x: 99, y: "a" }, + { id: 2, x: 20, y: "b" }, + ]); + + await view.delete(); + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("joined table reacts to right table updates", async function () { + const left = await perspective.table( + [ + { id: 1, x: 10 }, + { id: 2, x: 20 }, + ], + { index: "id" }, + ); + + const right = await perspective.table( + [ + { id: 1, y: "a" }, + { id: 2, y: "b" }, + ], + { index: "id" }, + ); + + const joined = await perspective.join(left, right, "id"); + const view = await joined.view(); + + await right.update([{ id: 3, y: "c" }]); + const json = await view.to_json(); + + // id=3 only exists in right, so inner join should not include it + expect(json).toHaveLength(2); + expect(json).toEqual([ + { id: 1, x: 10, y: "a" }, + { id: 2, x: 20, y: "b" }, + ]); + + await view.delete(); + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("joined table reacts to new matching rows", async function () { + const left = await perspective.table([{ id: 1, x: 10 }], { + index: "id", + }); + + const right = await perspective.table([{ id: 2, y: "b" }], { + index: "id", + }); + + const joined = await perspective.join(left, right, "id"); + const view = await joined.view(); + + let json = await view.to_json(); + expect(json).toHaveLength(0); + + // Add matching row to right + await right.update([{ id: 1, y: "a" }]); + json = await view.to_json(); + expect(json).toHaveLength(1); + expect(json).toEqual([{ id: 1, x: 10, y: "a" }]); + + await view.delete(); + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("joined table supports views with group_by", async function () { + const left = await perspective.table( + [ + { id: 1, category: "A", x: 10 }, + { id: 2, category: "A", x: 20 }, + { id: 3, category: "B", x: 30 }, + ], + { index: "id" }, + ); + + const right = await perspective.table( + [ + { id: 1, y: 100 }, + { id: 2, y: 200 }, + { id: 3, y: 300 }, + ], + { index: "id" }, + ); + + const joined = await perspective.join(left, right, "id"); + const view = await joined.view({ + group_by: ["category"], + columns: ["x", "y"], + }); + + const json = await view.to_columns(); + expect(json["x"]).toEqual([60, 30, 30]); + expect(json["y"]).toEqual([600, 300, 300]); + + await view.delete(); + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("rejects column name conflicts", async function () { + const left = await perspective.table([{ id: 1, value: 10 }]); + const right = await perspective.table([{ id: 1, value: 20 }]); + + let error; + try { + await perspective.join(left, right, "id"); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + await right.delete(); + await left.delete(); + }); + + test("rejects updates on joined table", async function () { + const left = await perspective.table([{ id: 1, x: 10 }], { + index: "id", + }); + const right = await perspective.table([{ id: 1, y: "a" }], { + index: "id", + }); + + const joined = await perspective.join(left, right, "id"); + + let error; + try { + await joined.update([{ id: 1, x: 99, y: "z" }]); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + + await joined.delete(); + await right.delete(); + await left.delete(); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/joins/left_join.spec.ts b/rust/perspective-js/test/js/joins/left_join.spec.ts new file mode 100644 index 0000000..4e9080b --- /dev/null +++ b/rust/perspective-js/test/js/joins/left_join.spec.ts @@ -0,0 +1,183 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client.ts"; + +((perspective) => { + test.describe("Left joins", function () { + test("left joins two tables on a shared key", async function () { + const left = await perspective.table([ + { id: 1, x: 10 }, + { id: 2, x: 20 }, + { id: 3, x: 30 }, + ]); + + const right = await perspective.table([ + { id: 1, y: "a" }, + { id: 2, y: "b" }, + { id: 4, y: "d" }, + ]); + + const joined = await perspective.join(left, right, "id", { + join_type: "left", + }); + const view = await joined.view(); + const json = await view.to_json(); + + // Left join: all left rows, matched right rows, id=3 has null y + expect(json).toHaveLength(3); + expect(json).toEqual([ + { id: 1, x: 10, y: "a" }, + { id: 2, x: 20, y: "b" }, + { id: 3, x: 30, y: null }, + ]); + + await view.delete(); + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("left join includes unmatched left rows with nulls", async function () { + const left = await perspective.table([ + { id: 1, x: 10 }, + { id: 2, x: 20 }, + ]); + + const right = await perspective.table([{ id: 1, y: "a" }]); + + const joined = await perspective.join(left, right, "id", { + join_type: "left", + }); + const view = await joined.view(); + const json = await view.to_json(); + + expect(json).toHaveLength(2); + expect(json).toEqual([ + { id: 1, x: 10, y: "a" }, + { id: 2, x: 20, y: null }, + ]); + + await view.delete(); + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("left join does not include unmatched right rows", async function () { + const left = await perspective.table([{ id: 1, x: 10 }]); + + const right = await perspective.table([ + { id: 1, y: "a" }, + { id: 2, y: "b" }, + ]); + + const joined = await perspective.join(left, right, "id", { + join_type: "left", + }); + const view = await joined.view(); + const json = await view.to_json(); + + expect(json).toHaveLength(1); + expect(json).toEqual([{ id: 1, x: 10, y: "a" }]); + + await view.delete(); + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("left join reacts to right table updates", async function () { + const left = await perspective.table([ + { id: 1, x: 10 }, + { id: 2, x: 20 }, + ]); + + const right = await perspective.table([{ id: 1, y: "a" }]); + + const joined = await perspective.join(left, right, "id", { + join_type: "left", + }); + const view = await joined.view(); + + // Add matching row for id=2 + await right.update([{ id: 2, y: "b" }]); + const json = await view.to_json(); + + // Now both rows match + expect(json).toHaveLength(2); + expect(json).toEqual([ + { id: 1, x: 10, y: "a" }, + { id: 2, x: 20, y: "b" }, + ]); + + await view.delete(); + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("left join with no matching rows", async function () { + const left = await perspective.table([ + { id: 1, x: 10 }, + { id: 2, x: 20 }, + ]); + + const right = await perspective.table([ + { id: 3, y: "c" }, + { id: 4, y: "d" }, + ]); + + const joined = await perspective.join(left, right, "id", { + join_type: "left", + }); + const view = await joined.view(); + const json = await view.to_json(); + + // All left rows present with null right columns + expect(json).toHaveLength(2); + expect(json).toEqual([ + { id: 1, x: 10, y: null }, + { id: 2, x: 20, y: null }, + ]); + + await view.delete(); + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("left join has correct schema", async function () { + const left = await perspective.table({ id: "integer", x: "float" }); + const right = await perspective.table({ + id: "integer", + y: "string", + }); + + const joined = await perspective.join(left, right, "id", { + join_type: "left", + }); + const schema = await joined.schema(); + + expect(schema).toEqual({ + id: "integer", + x: "float", + y: "string", + }); + + await joined.delete(); + await right.delete(); + await left.delete(); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/joins/outer_join.spec.ts b/rust/perspective-js/test/js/joins/outer_join.spec.ts new file mode 100644 index 0000000..60fa697 --- /dev/null +++ b/rust/perspective-js/test/js/joins/outer_join.spec.ts @@ -0,0 +1,208 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client.ts"; + +((perspective) => { + test.describe("Outer joins", function () { + test("outer joins two tables on a shared key", async function () { + const left = await perspective.table([ + { id: 1, x: 10 }, + { id: 2, x: 20 }, + { id: 3, x: 30 }, + ]); + + const right = await perspective.table([ + { id: 1, y: "a" }, + { id: 2, y: "b" }, + { id: 4, y: "d" }, + ]); + + const joined = await perspective.join(left, right, "id", { + join_type: "outer", + }); + const view = await joined.view(); + const json = await view.to_json(); + + // Outer join: all left + matched right + unmatched right + expect(json).toHaveLength(4); + expect(json).toEqual([ + { id: 1, x: 10, y: "a" }, + { id: 2, x: 20, y: "b" }, + { id: 3, x: 30, y: null }, + { id: 4, x: null, y: "d" }, + ]); + + await view.delete(); + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("outer join includes all rows when no keys match", async function () { + const left = await perspective.table([ + { id: 1, x: 10 }, + { id: 2, x: 20 }, + ]); + + const right = await perspective.table([ + { id: 3, y: "c" }, + { id: 4, y: "d" }, + ]); + + const joined = await perspective.join(left, right, "id", { + join_type: "outer", + }); + const view = await joined.view(); + const json = await view.to_json(); + + expect(json).toHaveLength(4); + expect(json).toEqual([ + { id: 1, x: 10, y: null }, + { id: 2, x: 20, y: null }, + { id: 3, x: null, y: "c" }, + { id: 4, x: null, y: "d" }, + ]); + + await view.delete(); + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("outer join with all keys matching is same as inner", async function () { + const left = await perspective.table([ + { id: 1, x: 10 }, + { id: 2, x: 20 }, + ]); + + const right = await perspective.table([ + { id: 1, y: "a" }, + { id: 2, y: "b" }, + ]); + + const joined = await perspective.join(left, right, "id", { + join_type: "outer", + }); + const view = await joined.view(); + const json = await view.to_json(); + + expect(json).toHaveLength(2); + expect(json).toEqual([ + { id: 1, x: 10, y: "a" }, + { id: 2, x: 20, y: "b" }, + ]); + + await view.delete(); + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("outer join reacts to left table updates", async function () { + const left = await perspective.table([{ id: 1, x: 10 }]); + + const right = await perspective.table([ + { id: 1, y: "a" }, + { id: 2, y: "b" }, + ]); + + const joined = await perspective.join(left, right, "id", { + join_type: "outer", + }); + const view = await joined.view(); + + let json = await view.to_json(); + expect(json).toHaveLength(2); + expect(json).toEqual([ + { id: 1, x: 10, y: "a" }, + { id: 2, x: null, y: "b" }, + ]); + + // Add matching row for id=2 + await left.update([{ id: 2, x: 20 }]); + json = await view.to_json(); + + // Both left rows now match right rows + expect(json).toHaveLength(2); + expect(json).toEqual([ + { id: 1, x: 10, y: "a" }, + { id: 2, x: 20, y: "b" }, + ]); + + await view.delete(); + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("outer join reacts to right table updates", async function () { + const left = await perspective.table([ + { id: 1, x: 10 }, + { id: 2, x: 20 }, + ]); + + const right = await perspective.table([{ id: 1, y: "a" }]); + + const joined = await perspective.join(left, right, "id", { + join_type: "outer", + }); + const view = await joined.view(); + + let json = await view.to_json(); + expect(json).toHaveLength(2); + expect(json).toEqual([ + { id: 1, x: 10, y: "a" }, + { id: 2, x: 20, y: null }, + ]); + + await right.update([{ id: 2, y: "b" }]); + json = await view.to_json(); + + // Now both match, but non-indexed tables append + expect(json).toHaveLength(2); + expect(json).toEqual([ + { id: 1, x: 10, y: "a" }, + { id: 2, x: 20, y: "b" }, + ]); + + await view.delete(); + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("outer join has correct schema", async function () { + const left = await perspective.table({ id: "integer", x: "float" }); + const right = await perspective.table({ + id: "integer", + y: "string", + }); + + const joined = await perspective.join(left, right, "id", { + join_type: "outer", + }); + const schema = await joined.schema(); + + expect(schema).toEqual({ + id: "integer", + x: "float", + y: "string", + }); + + await joined.delete(); + await right.delete(); + await left.delete(); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/joins/right_on.spec.ts b/rust/perspective-js/test/js/joins/right_on.spec.ts new file mode 100644 index 0000000..1fc1b7b --- /dev/null +++ b/rust/perspective-js/test/js/joins/right_on.spec.ts @@ -0,0 +1,235 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client.ts"; + +test.describe("right_on option", function () { + test("joins on differently-named columns", async function () { + const left = await perspective.table([ + { id: 1, x: 10 }, + { id: 2, x: 20 }, + { id: 3, x: 30 }, + ]); + + const right = await perspective.table([ + { user_id: 1, y: "a" }, + { user_id: 2, y: "b" }, + { user_id: 4, y: "d" }, + ]); + + const joined = await perspective.join(left, right, "id", { + right_on: "user_id", + }); + const view = await joined.view(); + const json = await view.to_json(); + + expect(json).toHaveLength(2); + expect(json).toEqual([ + { id: 1, x: 10, y: "a" }, + { id: 2, x: 20, y: "b" }, + ]); + + await view.delete(); + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("output schema uses left key column name", async function () { + const left = await perspective.table({ id: "integer", x: "float" }); + const right = await perspective.table({ + user_id: "integer", + y: "string", + }); + + const joined = await perspective.join(left, right, "id", { + right_on: "user_id", + }); + const schema = await joined.schema(); + + expect(schema).toEqual({ + id: "integer", + x: "float", + y: "string", + }); + + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("errors on type mismatch between on and right_on", async function () { + const left = await perspective.table({ id: "integer", x: "float" }); + const right = await perspective.table({ + user_id: "string", + y: "float", + }); + + let error; + try { + await perspective.join(left, right, "id", { + right_on: "user_id", + }); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + await right.delete(); + await left.delete(); + }); + + test("errors when right_on column not found", async function () { + const left = await perspective.table({ id: "integer", x: "float" }); + const right = await perspective.table({ + user_id: "integer", + y: "string", + }); + + let error; + try { + await perspective.join(left, right, "id", { + right_on: "nonexistent", + }); + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + await right.delete(); + await left.delete(); + }); + + test("right_on same as on behaves identically to omitting it", async function () { + const left = await perspective.table([ + { id: 1, x: 10 }, + { id: 2, x: 20 }, + ]); + + const right = await perspective.table([ + { id: 1, y: "a" }, + { id: 2, y: "b" }, + ]); + + const joined = await perspective.join(left, right, "id", { + right_on: "id", + }); + const view = await joined.view(); + const json = await view.to_json(); + + expect(json).toEqual([ + { id: 1, x: 10, y: "a" }, + { id: 2, x: 20, y: "b" }, + ]); + + await view.delete(); + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("reacts to right table updates with right_on", async function () { + const left = await perspective.table([ + { id: 1, x: 10 }, + { id: 2, x: 20 }, + ]); + + const right = await perspective.table([ + { user_id: 1, y: "a" }, + { user_id: 2, y: "b" }, + ]); + + const joined = await perspective.join(left, right, "id", { + right_on: "user_id", + }); + const view = await joined.view(); + + await right.update([{ user_id: 1, y: "c" }]); + const json = await view.to_json(); + + expect(json).toHaveLength(3); + expect(json).toEqual([ + { id: 1, x: 10, y: "a" }, + { id: 1, x: 10, y: "c" }, + { id: 2, x: 20, y: "b" }, + ]); + + await view.delete(); + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("left join with right_on", async function () { + const left = await perspective.table([ + { id: 1, x: 10 }, + { id: 2, x: 20 }, + { id: 3, x: 30 }, + ]); + + const right = await perspective.table([ + { user_id: 1, y: "a" }, + { user_id: 2, y: "b" }, + ]); + + const joined = await perspective.join(left, right, "id", { + join_type: "left", + right_on: "user_id", + }); + const view = await joined.view(); + const json = await view.to_json(); + + expect(json).toHaveLength(3); + expect(json).toEqual([ + { id: 1, x: 10, y: "a" }, + { id: 2, x: 20, y: "b" }, + { id: 3, x: 30, y: null }, + ]); + + await view.delete(); + await joined.delete(); + await right.delete(); + await left.delete(); + }); + + test("outer join with right_on", async function () { + const left = await perspective.table([ + { id: 1, x: 10 }, + { id: 2, x: 20 }, + ]); + + const right = await perspective.table([ + { user_id: 2, y: "b" }, + { user_id: 3, y: "c" }, + ]); + + const joined = await perspective.join(left, right, "id", { + join_type: "outer", + right_on: "user_id", + }); + const view = await joined.view(); + const json = await view.to_json(); + + expect(json).toHaveLength(3); + expect(json).toEqual([ + { id: 1, x: 10, y: null }, + { id: 2, x: 20, y: "b" }, + { id: 3, x: null, y: "c" }, + ]); + + await view.delete(); + await joined.delete(); + await right.delete(); + await left.delete(); + }); +}); diff --git a/rust/perspective-js/test/js/leaks.spec.js b/rust/perspective-js/test/js/leaks.spec.js new file mode 100644 index 0000000..5cfb244 --- /dev/null +++ b/rust/perspective-js/test/js/leaks.spec.js @@ -0,0 +1,317 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "./perspective_client"; +import * as fs from "node:fs"; + +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +const arr = fs.readFileSync( + require.resolve("superstore-arrow/superstore.lz4.arrow"), +).buffer; + +/** + * Run a function in a loop, comparing before-and-after wasm heap for leaks. + * Emscripten allocates in pages, and Perspective is hard coded to start at + * 16777216b, so rather than check `end - start`, we just test that any + * allocation ever occurs. + * @param {*} test + * @param {*} num_iterations + */ +async function leak_test(test, num_iterations = 10_000) { + // warmup + await test(); + await test(); + + // TODO Playwright uses the same host instance so this may have grown by + // the time the suite runs. Could fix with a nod eagent (and test in other + // browsers). + + const start = (await perspective.system_info()).heap_size; + const start_used = (await perspective.system_info()).used_size; + + for (var i = 0; i < num_iterations; i++) { + await test(); + } + + const final_used = (await perspective.system_info()).used_size; + expect((await perspective.system_info()).heap_size).toEqual(start); + expect(Number(final_used) / Number(start_used)).toBeGreaterThanOrEqual(0.8); + expect(Number(final_used) / Number(start_used)).toBeLessThanOrEqual(1.5); +} + +/** + * Given columns a (int), b (float), c (string) and d (datetime), + * generate expressions that use all columns and scalar values. + */ +function generate_expressions() { + const expressions = { + "concat('abcd', \"c\", 'efg')": "concat('abcd', \"c\", 'efg')", + }; + + for (const op of ["+", "-", "*", "/", "^", "%"]) { + expressions[`("a" ${op} "b") + ${Math.floor(Math.random() * 100)}`] = + `("a" ${op} "b") + ${Math.floor(Math.random() * 100)}`; + } + + for (const fn of ["sqrt", "log10", "deg2rad"]) { + expressions[`${fn}("b")`] = `${fn}("b")`; + } + + for (const fn of ["upper", "lower", "length"]) { + expressions[`${fn}("c")`] = `${fn}("c")`; + } + + for (const unit of ["m", "D"]) { + expressions[`bucket("d", '${unit}')`] = `bucket("d", '${unit}')`; + } + + const rand = + Object.keys(expressions)[ + Math.floor(Math.random() * Object.keys(expressions).length) + ]; + + return { [rand]: expressions[rand] }; +} + +test.describe("leaks", function () { + test.describe("view", function () { + test.describe("1-sided", function () { + test("to_json does not leak", async () => { + test.setTimeout(60000); + const table = await perspective.table(arr.slice()); + const view = await table.view({ group_by: ["State"] }); + await leak_test(async function () { + let json = await view.to_json(); + expect(json.length).toEqual(50); + }); + await view.delete(); + await table.delete(); + }); + + test.skip("OG - to_columns_string does not leak", async () => { + const table = await perspective.table(arr.slice()); + const view = await table.view({ group_by: ["State"] }); + await leak_test(async function () { + let json = await view.to_columns_string(); + expect(json.length).toEqual(6722); + }); + await view.delete(); + await table.delete(); + }); + + // The length of this string changed due to + // some of the trailing fractional digits in the floats. + test("to_columns_string does not leak", async () => { + const table = await perspective.table(arr.slice()); + const view = await table.view({ group_by: ["State"] }); + await leak_test(async function () { + let json = await view.to_columns_string(); + expect(json.length).toEqual(6669); + }); + await view.delete(); + await table.delete(); + }); + }); + }); + + test.describe("table", function () { + test("update does not leak", async () => { + const table = await perspective.table( + { x: "integer", y: "string" }, + { index: "x" }, + ); + let count = 0; + const view = await table.view(); + view.on_update(function () { + count += 1; + }); + + await table.update([{ x: 1, y: "TestTestTest" }]); + await leak_test(async function () { + table.update([{ x: 1, y: "TestTestTest" }]); + expect(await table.size()).toEqual(1); + }); + + // await table.update([{ x: 1, y: "TestTestTest" }]); + + expect(count).toBeGreaterThan(0); + await view.delete(); + await table.delete(); + }); + + test.skip("csv loading does not leak", async () => { + const table = await perspective.table(arr.slice()); + const view = await table.view(); + const csv = await view.to_csv({ end_row: 10 }); + await view.delete(); + await table.delete(); + await leak_test(async function () { + const table = await perspective.table(csv); + expect(await table.size()).toEqual(10); + await table.delete(); + }); + }); + }); + + test.describe("expression columns", function () { + test("0 sided does not leak", async () => { + test.setTimeout(60000); + const table = await perspective.table({ + a: [1, 2, 3, 4], + b: [1.5, 2.5, 3.5, 4.5], + c: ["a", "b", "c", "d"], + d: [new Date(), new Date(), new Date(), new Date()], + }); + + const expressions = generate_expressions(); + + await leak_test(async () => { + const view = await table.view({ + expressions, + }); + + const expression_schema = await view.expression_schema(); + expect(Object.keys(expression_schema).length).toEqual( + Object.keys(expressions).length, + ); + + await view.delete(); + }); + + await table.delete(); + }); + + /** + * Because the expression vocab and the regex cache is per-table and + * not per-view, we should be able to leak test the table creation + * and view creation. + */ + test.skip("0 sided regex does not leak", async () => { + const expressions = { + "match(\"a\", '.{1}')": "match(\"a\", '.{1}')", + "match_all(\"a\", '[a-z]{1}')": "match_all(\"a\", '[a-z]{1}')", + "search(\"a\", '.')": "search(\"a\", '.')", + }; + + await leak_test(async () => { + const table = await perspective.table({ + a: "abcdefghijklmnopqrstuvwxyz".split(""), + }); + + const view = await table.view({ + expressions, + }); + + const expression_schema = await view.expression_schema(); + expect(Object.keys(expression_schema).length).toEqual( + Object.keys(expressions).length, + ); + + await view.delete(); + await table.delete(); + }); + }); + + test.skip("0 sided string does not leak", async () => { + const table = await perspective.table({ + a: "abcdefghijklmnopqrstuvwxyz".split(""), + }); + + const expressions = { + "var x := 'abcdefghijklmnopqrstuvwxyz'; concat(\"a\", x, 'abc')": + "var x := 'abcdefghijklmnopqrstuvwxyz'; concat(\"a\", x, 'abc')", + "var x := 'abcdefghijklmnopqrstuvwxyz'; var y := 'defhijklmnopqrst'; concat(\"a\", x, 'abc', y)": + "var x := 'abcdefghijklmnopqrstuvwxyz'; var y := 'defhijklmnopqrst'; concat(\"a\", x, 'abc', y)", + }; + + await leak_test(async () => { + const view = await table.view({ + expressions, + }); + + const expression_schema = await view.expression_schema(); + expect(Object.keys(expression_schema).length).toEqual( + Object.keys(expressions).length, + ); + + await view.delete(); + }); + + await table.delete(); + }); + + test("1 sided does not leak", async () => { + const table = await perspective.table({ + a: [1, 2, 3, 4], + b: [1.5, 2.5, 3.5, 4.5], + c: ["a", "b", "c", "d"], + d: [new Date(), new Date(), new Date(), new Date()], + }); + + const columns = ["a", "b", "c", "d"]; + const expressions = generate_expressions(); + await leak_test(async () => { + const view = await table.view({ + group_by: [ + columns[Math.floor(Math.random() * columns.length)], + ], + expressions, + }); + + const expression_schema = await view.expression_schema(); + expect(Object.keys(expression_schema).length).toEqual( + Object.keys(expressions).length, + ); + + await view.delete(); + }, 3000); + + await table.delete(); + }); + + test("2 sided does not leak", async () => { + const table = await perspective.table({ + a: [1, 2, 3, 4], + b: [1.5, 2.5, 3.5, 4.5], + c: ["a", "b", "c", "d"], + d: [new Date(), new Date(), new Date(), new Date()], + }); + + const columns = ["a", "b", "c", "d"]; + const expressions = generate_expressions(); + await leak_test(async () => { + const view = await table.view({ + group_by: [ + columns[Math.floor(Math.random() * columns.length)], + ], + split_by: [ + columns[Math.floor(Math.random() * columns.length)], + ], + expressions, + }); + + const expression_schema = await view.expression_schema(); + expect(Object.keys(expression_schema).length).toEqual( + Object.keys(expressions).length, + ); + + await view.delete(); + }, 3000); + + await table.delete(); + }); + }); +}); diff --git a/rust/perspective-js/test/js/limit.spec.js b/rust/perspective-js/test/js/limit.spec.js new file mode 100644 index 0000000..8e28f40 --- /dev/null +++ b/rust/perspective-js/test/js/limit.spec.js @@ -0,0 +1,102 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "./perspective_client"; + +import moment from "moment"; +import * as arrows from "./test_arrows.js"; + +((perspective) => { + async function match_delta( + perspective, + delta, + expected, + formatter = "to_json", + ) { + const table = await perspective.table(delta); + const view = await table.view(); + const result = await view[formatter](); + expect(result).toEqual(expected); + await view.delete(); + await table.delete(); + } + + test.describe("Limits", () => { + test("Limiting rows should not limit the index space", async ({ + page, + }) => { + const table = await perspective.table( + { + x: "integer", + group: "integer", + }, + { limit: 10 }, + ); + + const data = []; + for (let i = 0; i < 15; i++) { + data.push({ x: i, group: 0 }); + } + + await table.update(data); + const lastByIdx = await ( + await table.view({ + group_by: ["group"], + aggregates: { x: "last by index" }, + }) + ).to_json(); + expect(lastByIdx[0]["x"]).toEqual(14); + + const view = await table.view(); + let viewData = await view.to_json(); + expect(viewData.length).toBe(10); + expect(viewData).toEqual(data.slice(-10)); + }); + + test("Append in sequence", async () => { + const table = await perspective.table( + { + x: "integer", + y: "string", + z: "boolean", + }, + { limit: 2 }, + ); + + const v1 = await table.view(); + const v2 = await table.view(); + + await table.update({ + x: [0], + y: ["0"], + }); + + await v1.to_columns(); + + await table.update({ + x: [1, 2], + y: ["1", "2"], + }); + + let result = await v1.to_columns(); + let result2 = await v2.to_columns(); + + expect(result["x"]).toEqual([1, 2]); + expect(result2["y"]).toEqual(["1", "2"]); + + await v2.delete(); + await v1.delete(); + await table.delete(); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/lz4.spec.js b/rust/perspective-js/test/js/lz4.spec.js new file mode 100644 index 0000000..9a2aaea --- /dev/null +++ b/rust/perspective-js/test/js/lz4.spec.js @@ -0,0 +1,54 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "./perspective_client"; + +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +import * as fs from "node:fs"; + +const superstore_uncompressed = fs.readFileSync( + require.resolve("superstore-arrow/superstore.arrow"), +).buffer; + +const superstore_lz4 = fs.readFileSync( + require.resolve("superstore-arrow/superstore.lz4.arrow"), +).buffer; + +test.describe("Arrow IPC", function () { + test.describe("to_arrow()", function () { + test("to_arrow() compressed is serializable roundtrip", async () => { + let table = await perspective.table( + superstore_uncompressed.slice(), + ); + + let view = await table.view(); + let arr = await view.to_arrow({ compression: "lz4" }); + expect(arr).toEqual(superstore_lz4); + expect(arr.byteLength).toBeLessThan( + superstore_uncompressed.byteLength, + ); + + view.delete(); + table.delete(); + table = await perspective.table(arr); + view = await table.view(); + let arr2 = await view.to_arrow({ compression: null }); + expect(arr2.byteLength).toBeGreaterThan(arr.byteLength); + view.delete(); + table.delete(); + }); + }); +}); diff --git a/rust/perspective-js/test/js/multi_server.spec.js b/rust/perspective-js/test/js/multi_server.spec.js new file mode 100644 index 0000000..31ac348 --- /dev/null +++ b/rust/perspective-js/test/js/multi_server.spec.js @@ -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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { test, expect } from "@perspective-dev/test"; +import perspective from "./perspective_client"; + +((perspective) => { + test.describe("Multiple Server instances", function () { + test("Construct Table from remote View", async function ({ page }) { + await page.goto("/rust/perspective-js/test/html/test.html"); + const [json0, json1] = await page.evaluate(async () => { + let perspective = await import( + "http://localhost:6598/node_modules/@perspective-dev/client/dist/esm/perspective.inline.js" + ); + + const worker0 = await perspective.worker(); + const worker1 = await perspective.worker(); + const table0 = await worker0.table("x,y\n1,2\n3,4"); + const view0 = await table0.view(); + const table1 = await worker1.table(view0); + const view1 = await table1.view(); + const json0 = await view0.to_json(); + const json1 = await view1.to_json(); + return [json0, json1]; + }); + + expect(json0).toEqual(json1); + }); + + test("View on_update transfers to new table", async function ({ + page, + }) { + await page.goto("/rust/perspective-js/test/html/test.html"); + const [json0, json1] = await page.evaluate(async () => { + let perspective = await import( + "http://localhost:6598/node_modules/@perspective-dev/client/dist/esm/perspective.inline.js" + ); + + const worker0 = await perspective.worker(); + const worker1 = await perspective.worker(); + const table0 = await worker0.table("x,y\n1,2\n3,4"); + const view0 = await table0.view(); + const table1 = await worker1.table(view0); + const view1 = await table1.view(); + const { promise, resolve } = Promise.withResolvers(); + await view1.on_update(() => resolve()); + await table0.update([{ x: 6 }]); + await promise; + const json0 = await view0.to_json(); + const json1 = await view1.to_json(); + return [json0, json1]; + }); + + expect(json0).toEqual(json1); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/multiple.spec.js b/rust/perspective-js/test/js/multiple.spec.js new file mode 100644 index 0000000..1184c5d --- /dev/null +++ b/rust/perspective-js/test/js/multiple.spec.js @@ -0,0 +1,122 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "./perspective_client"; +import _ from "lodash"; + +var arrow_result = [ + { + f32: 1.5, + f64: 1.5, + i64: 1, + i32: 1, + i16: 1, + i8: 1, + bool: true, + char: "a", + dict: "a", + datetime: +new Date("2018-01-25"), + }, + { + f32: 2.5, + f64: 2.5, + i64: 2, + i32: 2, + i16: 2, + i8: 2, + bool: false, + char: "b", + dict: "b", + datetime: +new Date("2018-01-26"), + }, + { + f32: 3.5, + f64: 3.5, + i64: 3, + i32: 3, + i16: 3, + i8: 3, + bool: true, + char: "c", + dict: "c", + datetime: +new Date("2018-01-27"), + }, + { + f32: 4.5, + f64: 4.5, + i64: 4, + i32: 4, + i16: 4, + i8: 4, + bool: false, + char: "d", + dict: "d", + datetime: +new Date("2018-01-28"), + }, + { + f32: 5.5, + f64: 5.5, + i64: 5, + i32: 5, + i16: 5, + i8: 5, + bool: true, + char: "d", + dict: "d", + datetime: +new Date("2018-01-29"), + }, +]; + +((perspective) => { + test.describe("Multiple Perspectives", function () { + test("Constructs table using data generated by to_arrow()", async function () { + let table = await perspective.table(arrow_result); + let view = await table.view(); + let result = await view.to_arrow(); + + let table2 = await perspective.table(result); + let view2 = await table2.view(); + let result2 = await view2.to_json(); + + expect(result2).toEqual(arrow_result); + + view.delete(); + view2.delete(); + table.delete(); + table2.delete(); + }); + + test("Constructs table using data in random column order generated by to_arrow()", async function () { + let table = await perspective.table(arrow_result); + let columns = _.shuffle(await table.columns()); + let view = await table.view({ + columns: columns, + }); + let result = await view.to_arrow(); + + let table2 = await perspective.table(result); + let columns2 = _.shuffle(await table2.columns()); + let view2 = await table2.view({ + columns: columns2, + }); + let result2 = await view2.to_json(); + + expect(result2).toEqual(arrow_result); + + view.delete(); + view2.delete(); + table.delete(); + table2.delete(); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/page_to_disk.spec.js b/rust/perspective-js/test/js/page_to_disk.spec.js new file mode 100644 index 0000000..8bb4e32 --- /dev/null +++ b/rust/perspective-js/test/js/page_to_disk.spec.js @@ -0,0 +1,136 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "./perspective_client"; + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const DISK_ROOT = path.join(os.tmpdir(), `perspective-${process.pid}`); + +function disk_file_count() { + const out = []; + const walk = (dir) => { + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, ent.name); + if (ent.isDirectory()) walk(p); + else out.push(p); + } + }; + try { + walk(DISK_ROOT); + } catch (e) { + /* root not created yet */ + } + return out.length; +} + +const data = { + x: [1, 2, 3, 4], + y: ["a", "b", "c", "d"], + z: [1.5, 2.5, 3.5, 4.5], +}; + +test.describe("page_to_disk", function () { + test("produces results identical to an in-memory table", async function () { + const mem = await perspective.table(data); + const disk = await perspective.table(data, { page_to_disk: true }); + const vm = await mem.view(); + const vd = await disk.view(); + expect(await vd.to_columns()).toEqual(await vm.to_columns()); + expect(await disk.schema()).toEqual(await mem.schema()); + expect(await disk.size()).toEqual(await mem.size()); + await vm.delete(); + await vd.delete(); + await mem.delete(); + await disk.delete(); + }); + + test("a small page_to_disk table stays in-heap (no files spilled)", async function () { + const before = disk_file_count(); + const disk = await perspective.table(data, { page_to_disk: true }); + const view = await disk.view(); + await view.to_columns(); + expect(disk_file_count()).toEqual(before); + await view.delete(); + await disk.delete(); + }); + + test("supports update + aggregates", async function () { + const disk = await perspective.table(data, { page_to_disk: true }); + await disk.update({ x: [5, 6], y: ["e", "f"], z: [5.5, 6.5] }); + const view = await disk.view({ + group_by: ["y"], + aggregates: { z: "sum" }, + columns: ["z"], + }); + const cols = await view.to_columns(); + expect(cols.z[0]).toEqual(1.5 + 2.5 + 3.5 + 4.5 + 5.5 + 6.5); + await view.delete(); + await disk.delete(); + }); + + test("supports expression columns", async function () { + const disk = await perspective.table(data, { page_to_disk: true }); + const view = await disk.view({ + columns: ["w"], + expressions: { w: `"x" + "z"` }, + }); + const cols = await view.to_columns(); + expect(cols.w).toEqual([2.5, 4.5, 6.5, 8.5]); + await view.delete(); + await disk.delete(); + }); + + // Forces actual eviction (table > 1gb resident) so the `node:fs` bridge + // round-trips: columns are flushed to disk on eviction and re-read on access. + test("evicts to disk over budget and restores correctly", async function () { + const ROWS = 100_000; + const COLS = 256; + const UPDATES = 5; // 25M rows total + const chunk = {}; + for (let c = 0; c < COLS; c++) { + const col = new Array(ROWS); + for (let i = 0; i < ROWS; i++) col[i] = c * 1000 + (i % 1000) + 0.5; + chunk["c" + c] = col; + } + + const before = disk_file_count(); + const table = await perspective.table(chunk, { page_to_disk: true }); + for (let u = 1; u < UPDATES; u++) await table.update(chunk); + + expect(await table.size()).toEqual(ROWS * UPDATES); + + // Eviction must have spilled column buffers to disk. + expect(disk_file_count()).toBeGreaterThan(before); + + // Reading after eviction restores evicted columns from disk. Verify the + // head of every column matches the source (a broken restore would read + // zeros). `c[c][i] === c*1000 + i + 0.5` for i < 1000. + const view = await table.view(); + const head = await view.to_columns({ start_row: 0, end_row: 4 }); + for (let c = 0; c < COLS; c++) { + expect(head["c" + c]).toEqual([ + c * 1000 + 0.5, + c * 1000 + 1.5, + c * 1000 + 2.5, + c * 1000 + 3.5, + ]); + } + + await view.delete(); + await table.delete(); + expect(disk_file_count()).toEqual(before); + }); +}); diff --git a/rust/perspective-js/test/js/perspective_client.ts b/rust/perspective-js/test/js/perspective_client.ts new file mode 100644 index 0000000..a3af964 --- /dev/null +++ b/rust/perspective-js/test/js/perspective_client.ts @@ -0,0 +1,19 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 from "@perspective-dev/client"; + +// // TO run the test suite against a different server: +// const client = await perspective.websocket("ws://localhost:8080/subscribe"); +// export default client; + +export default perspective; diff --git a/rust/perspective-js/test/js/pivot_nulls.spec.js b/rust/perspective-js/test/js/pivot_nulls.spec.js new file mode 100644 index 0000000..b4379b8 --- /dev/null +++ b/rust/perspective-js/test/js/pivot_nulls.spec.js @@ -0,0 +1,336 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "./perspective_client"; + +((perspective) => { + test.describe("Pivotting with nulls", function () { + test.describe("last aggregate", function () { + test("preserves null when it is the last element in a leaf", async function () { + const DATA = { + a: ["a", "a", "a", "b", "b", "b", "c", "c", "c"], + b: [1, 2, null, 3, 4, 5, null, null, null], + }; + var table = await perspective.table(DATA); + var view = await table.view({ + group_by: ["a"], + columns: ["b"], + aggregates: { b: "last" }, + }); + var answer = [ + { __ROW_PATH__: [], b: null }, + { __ROW_PATH__: ["a"], b: null }, + { __ROW_PATH__: ["b"], b: 5 }, + { __ROW_PATH__: ["c"], b: null }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("preserves null when it is the last element in a leaf under 2 levels", async function () { + const DATA = { + a: [ + "a", + "a", + "a", + "b", + "b", + "b", + "c", + "c", + "c", + "a", + "a", + "a", + "b", + "b", + "b", + "c", + "c", + "c", + ], + b: [ + 1, + 2, + null, + 3, + 4, + 5, + null, + null, + null, + 1, + 2, + null, + null, + null, + null, + 3, + 4, + 5, + ], + c: [ + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "y", + "y", + "y", + "y", + "y", + "y", + "y", + "y", + "y", + ], + }; + var table = await perspective.table(DATA); + var view = await table.view({ + group_by: ["c", "a"], + columns: ["b"], + aggregates: { b: "last" }, + }); + var answer = [ + { __ROW_PATH__: [], b: 5 }, + { __ROW_PATH__: ["x"], b: null }, + { __ROW_PATH__: ["x", "a"], b: null }, + { __ROW_PATH__: ["x", "b"], b: 5 }, + { __ROW_PATH__: ["x", "c"], b: null }, + { __ROW_PATH__: ["y"], b: 5 }, + { __ROW_PATH__: ["y", "a"], b: null }, + { __ROW_PATH__: ["y", "b"], b: null }, + { __ROW_PATH__: ["y", "c"], b: 5 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("preserves null when it is the last element in a leaf under 2 levels when grand total is null", async function () { + const DATA = { + a: [ + "a", + "a", + "a", + "b", + "b", + "b", + "c", + "c", + "c", + "a", + "a", + "a", + "b", + "b", + "b", + "c", + "c", + "c", + ], + b: [ + 1, + 2, + null, + null, + null, + null, + 3, + 4, + 5, + 1, + 2, + null, + 3, + 4, + 5, + null, + null, + null, + ], + c: [ + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "y", + "y", + "y", + "y", + "y", + "y", + "y", + "y", + "y", + ], + }; + var table = await perspective.table(DATA); + var view = await table.view({ + group_by: ["c", "a"], + columns: ["b"], + aggregates: { b: "last" }, + }); + var answer = [ + { __ROW_PATH__: [], b: null }, + { __ROW_PATH__: ["x"], b: 5 }, + { __ROW_PATH__: ["x", "a"], b: null }, + { __ROW_PATH__: ["x", "b"], b: null }, + { __ROW_PATH__: ["x", "c"], b: 5 }, + { __ROW_PATH__: ["y"], b: null }, + { __ROW_PATH__: ["y", "a"], b: null }, + { __ROW_PATH__: ["y", "b"], b: 5 }, + { __ROW_PATH__: ["y", "c"], b: null }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + }); + + test("shows one pivot for the nulls on initial load", async function () { + const dataWithNulls = [ + { name: "Homer", value: 1 }, + { name: null, value: 1 }, + { name: null, value: 1 }, + { name: "Krusty", value: 1 }, + ]; + + var table = await perspective.table(dataWithNulls); + + var view = await table.view({ + group_by: ["name"], + aggregates: { name: "distinct count" }, + }); + + const answer = [ + { __ROW_PATH__: [], name: 3, value: 4 }, + { __ROW_PATH__: [null], name: 1, value: 2 }, + { __ROW_PATH__: ["Homer"], name: 1, value: 1 }, + { __ROW_PATH__: ["Krusty"], name: 1, value: 1 }, + ]; + + let results = await view.to_json(); + expect(results).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("shows one pivot for the nulls after updating with a null", async function () { + const dataWithNull1 = [ + { name: "Homer", value: 1 }, + { name: null, value: 1 }, + ]; + const dataWithNull2 = [ + { name: null, value: 1 }, + { name: "Krusty", value: 1 }, + ]; + + var table = await perspective.table(dataWithNull1); + await table.update(dataWithNull2); + + var view = await table.view({ + group_by: ["name"], + aggregates: { name: "distinct count" }, + }); + + const answer = [ + { __ROW_PATH__: [], name: 3, value: 4 }, + { __ROW_PATH__: [null], name: 1, value: 2 }, + { __ROW_PATH__: ["Homer"], name: 1, value: 1 }, + { __ROW_PATH__: ["Krusty"], name: 1, value: 1 }, + ]; + + let results = await view.to_json(); + expect(results).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("aggregates that return NaN render correctly", async function () { + const dataWithNull1 = [ + { name: "Homer", value: 3 }, + { name: "Homer", value: 1 }, + { name: "Marge", value: null }, + { name: "Marge", value: null }, + ]; + + var table = await perspective.table(dataWithNull1); + + var view = await table.view({ + group_by: ["name"], + aggregates: { value: "avg" }, + }); + + const answer = [ + { __ROW_PATH__: [], name: 4, value: 2 }, + { __ROW_PATH__: ["Homer"], name: 2, value: 2 }, + { __ROW_PATH__: ["Marge"], name: 2, value: null }, + ]; + + let results = await view.to_json(); + expect(results).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("aggregates nulls correctly", async function () { + const data = [ + { x: "AAAAAAAAAAAAAA" }, + { x: "AAAAAAAAAAAAAA" }, + { x: "AAAAAAAAAAAAAA" }, + { x: null }, + { x: null }, + { x: "BBBBBBBBBBBBBB" }, + { x: "BBBBBBBBBBBBBB" }, + { x: "BBBBBBBBBBBBBB" }, + ]; + const tbl = await perspective.table(data); + const view = await tbl.view({ group_by: ["x"] }); + + const result = await view.to_json(); + expect(result).toEqual([ + { + __ROW_PATH__: [], + x: 8, + }, + { + __ROW_PATH__: [null], + x: 2, + }, + { + __ROW_PATH__: ["AAAAAAAAAAAAAA"], + x: 3, + }, + { + __ROW_PATH__: ["BBBBBBBBBBBBBB"], + x: 3, + }, + ]); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/ports.spec.js b/rust/perspective-js/test/js/ports.spec.js new file mode 100644 index 0000000..a5b40c0 --- /dev/null +++ b/rust/perspective-js/test/js/ports.spec.js @@ -0,0 +1,839 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "./perspective_client"; +import _ from "lodash"; + +const data = { + w: [1.5, 2.5, 3.5, 4.5], + x: [1, 2, 3, 4], + y: ["a", "b", "c", "d"], + z: [true, false, true, false], +}; + +const get_random_int = function (min, max) { + // liberally copied from stack + min = Math.ceil(min); + max = Math.floor(max); + return Math.floor(Math.random() * (max - min + 1)) + min; +}; + +function it_old_behavior(name, capture) { + test(name, async function () { + let done; + let result = new Promise((x) => { + done = x; + }); + + await capture(done); + await result; + }); +} + +((perspective) => { + test.describe("ports", function () { + test("Should create port IDs in incremental order", async function () { + const table = await perspective.table(data); + const port_ids = []; + for (let i = 0; i < 10; i++) { + port_ids.push(await table.make_port()); + } + expect(port_ids).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + table.delete(); + }); + + test("Should create port IDs in incremental order and allow updates on each port", async function () { + const table = await perspective.table(data); + const port_ids = []; + + for (let i = 0; i < 10; i++) { + port_ids.push(await table.make_port()); + } + + expect(port_ids).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + + for (const port_id of port_ids) { + await table.update( + { + w: [1.5], + x: [port_id], + y: ["d"], + z: [true], + }, + { port_id }, + ); + } + + const view = await table.view(); + const output = await view.to_columns(); + + expect(await table.size()).toEqual(14); + + const expected = { + w: [ + 1.5, 2.5, 3.5, 4.5, 1.5, 1.5, 1.5, 1.5, 1.5, 1.5, 1.5, 1.5, + 1.5, 1.5, + ], + x: [1, 2, 3, 4, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + y: [ + "a", + "b", + "c", + "d", + "d", + "d", + "d", + "d", + "d", + "d", + "d", + "d", + "d", + "d", + ], + z: [ + true, + false, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + ], + }; + + expect(output).toEqual(expected); + + view.delete(); + table.delete(); + }); + + test("Should create port IDs in incremental order and allow updates on each port, indexed", async function () { + const table = await perspective.table(data, { index: "w" }); + const port_ids = []; + + for (let i = 0; i < 10; i++) { + port_ids.push(await table.make_port()); + } + + expect(port_ids).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + + for (const port_id of port_ids) { + await table.update( + { + w: [1.5], + x: [1], + y: ["a"], + z: [true], + }, + { port_id }, + ); + } + + expect(await table.size()).toEqual(4); + + const view = await table.view(); + const output = await view.to_columns(); + expect(output).toEqual(data); + + view.delete(); + table.delete(); + }); + + test("Should create port IDs in incremental order and allow random arbitrary updates on each port, indexed", async function () { + const table = await perspective.table(data, { index: "w" }); + const port_ids = []; + + for (let i = 0; i < 10; i++) { + port_ids.push(await table.make_port()); + } + + expect(port_ids).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + + for (const port_id of port_ids) { + await table.update( + { + w: [1.5], + x: [1], + y: ["a"], + z: [true], + }, + { port_id }, + ); + } + + expect(await table.size()).toEqual(4); + + const view = await table.view(); + const output = await view.to_columns(); + expect(output).toEqual(data); + + // arbitarily update from a random port, force append with new pkey + const port = get_random_int(1, 9); + const update_data = { + w: [5.5], + x: [5], + y: ["e"], + z: [true], + }; + + await table.update(update_data, { port_id: 0 }); + + const output2 = await view.to_columns(); + expect(output2).toEqual({ + w: [1.5, 2.5, 3.5, 4.5, 5.5], + x: [1, 2, 3, 4, 5], + y: ["a", "b", "c", "d", "e"], + z: [true, false, true, false, true], + }); + + // and do it again but this time with null as pkey + let port2 = get_random_int(1, 9); + + while (port2 === port) { + port2 = get_random_int(1, 9); + } + + const update_data2 = { + w: [null], + x: [6], + y: ["f"], + z: [true], + }; + + await table.update(update_data2, { port_id: port2 }); + const output3 = await view.to_columns(); + expect(output3).toEqual({ + w: [null, 1.5, 2.5, 3.5, 4.5, 5.5], + x: [6, 1, 2, 3, 4, 5], + y: ["f", "a", "b", "c", "d", "e"], + z: [true, true, false, true, false, true], + }); + + view.delete(); + table.delete(); + }); + + test.describe("View notifications from different ports", function () { + test("All views should be notified by appends on all ports", async function () { + const table = await perspective.table(data); + const port_ids = []; + + for (let i = 0; i < 10; i++) { + port_ids.push(await table.make_port()); + } + + expect(port_ids).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + + const view = await table.view(); + + const view2 = await table.view({ + group_by: ["w"], + }); + + const view3 = await table.view({ + group_by: ["w"], + split_by: ["x"], + }); + + for (const port_id of port_ids) { + await table.update( + { + w: [1.5], + x: [port_id], + y: ["d"], + z: [true], + }, + { port_id }, + ); + } + + const output = await view.to_columns(); + + expect(await table.size()).toEqual(14); + + const expected = { + w: [ + 1.5, 2.5, 3.5, 4.5, 1.5, 1.5, 1.5, 1.5, 1.5, 1.5, 1.5, + 1.5, 1.5, 1.5, + ], + x: [1, 2, 3, 4, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + y: [ + "a", + "b", + "c", + "d", + "d", + "d", + "d", + "d", + "d", + "d", + "d", + "d", + "d", + "d", + ], + z: [ + true, + false, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + ], + }; + + expect(output).toEqual(expected); + + const output2 = await view2.to_columns(); + expect(output2).toEqual({ + __ROW_PATH__: [[], [1.5], [2.5], [3.5], [4.5]], + w: [27, 16.5, 2.5, 3.5, 4.5], + x: [65, 56, 2, 3, 4], + y: [14, 11, 1, 1, 1], + z: [14, 11, 1, 1, 1], + }); + + const output3 = await view3.to_columns(); + expect(output3).toEqual({ + __ROW_PATH__: [[], [1.5], [2.5], [3.5], [4.5]], + "1|w": [3, 3, null, null, null], + "1|x": [2, 2, null, null, null], + "1|y": [2, 2, null, null, null], + "1|z": [2, 2, null, null, null], + "2|w": [4, 1.5, 2.5, null, null], + "2|x": [4, 2, 2, null, null], + "2|y": [2, 1, 1, null, null], + "2|z": [2, 1, 1, null, null], + "3|w": [5, 1.5, null, 3.5, null], + "3|x": [6, 3, null, 3, null], + "3|y": [2, 1, null, 1, null], + "3|z": [2, 1, null, 1, null], + "4|w": [6, 1.5, null, null, 4.5], + "4|x": [8, 4, null, null, 4], + "4|y": [2, 1, null, null, 1], + "4|z": [2, 1, null, null, 1], + "5|w": [1.5, 1.5, null, null, null], + "5|x": [5, 5, null, null, null], + "5|y": [1, 1, null, null, null], + "5|z": [1, 1, null, null, null], + "6|w": [1.5, 1.5, null, null, null], + "6|x": [6, 6, null, null, null], + "6|y": [1, 1, null, null, null], + "6|z": [1, 1, null, null, null], + "7|w": [1.5, 1.5, null, null, null], + "7|x": [7, 7, null, null, null], + "7|y": [1, 1, null, null, null], + "7|z": [1, 1, null, null, null], + "8|w": [1.5, 1.5, null, null, null], + "8|x": [8, 8, null, null, null], + "8|y": [1, 1, null, null, null], + "8|z": [1, 1, null, null, null], + "9|w": [1.5, 1.5, null, null, null], + "9|x": [9, 9, null, null, null], + "9|y": [1, 1, null, null, null], + "9|z": [1, 1, null, null, null], + "10|w": [1.5, 1.5, null, null, null], + "10|x": [10, 10, null, null, null], + "10|y": [1, 1, null, null, null], + "10|z": [1, 1, null, null, null], + }); + + view3.delete(); + view2.delete(); + view.delete(); + table.delete(); + }); + + test("All views should be notified by partial updates on all ports", async function () { + const table = await perspective.table(data, { index: "w" }); + const port_ids = []; + + for (let i = 0; i < 10; i++) { + port_ids.push(await table.make_port()); + } + + expect(port_ids).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + + const view = await table.view(); + + const view2 = await table.view({ + group_by: ["w"], + }); + + const view3 = await table.view({ + group_by: ["w"], + split_by: ["x"], + }); + + for (const port_id of port_ids) { + await table.update( + { + w: [1.5], + x: [port_id], + y: ["d"], + z: [false], + }, + { port_id }, + ); + } + + const output = await view.to_columns(); + + expect(await table.size()).toEqual(4); + + const expected = { + w: [1.5, 2.5, 3.5, 4.5], + x: [10, 2, 3, 4], + y: ["d", "b", "c", "d"], + z: [false, false, true, false], + }; + + expect(output).toEqual(expected); + + const output2 = await view2.to_columns(); + expect(output2).toEqual({ + __ROW_PATH__: [[], [1.5], [2.5], [3.5], [4.5]], + w: [12, 1.5, 2.5, 3.5, 4.5], + x: [19, 10, 2, 3, 4], + y: [4, 1, 1, 1, 1], + z: [4, 1, 1, 1, 1], + }); + + const output3 = await view3.to_columns(); + expect(output3).toEqual({ + __ROW_PATH__: [[], [1.5], [2.5], [3.5], [4.5]], + "2|w": [2.5, null, 2.5, null, null], + "2|x": [2, null, 2, null, null], + "2|y": [1, null, 1, null, null], + "2|z": [1, null, 1, null, null], + "3|w": [3.5, null, null, 3.5, null], + "3|x": [3, null, null, 3, null], + "3|y": [1, null, null, 1, null], + "3|z": [1, null, null, 1, null], + "4|w": [4.5, null, null, null, 4.5], + "4|x": [4, null, null, null, 4], + "4|y": [1, null, null, null, 1], + "4|z": [1, null, null, null, 1], + "10|w": [1.5, 1.5, null, null, null], + "10|x": [10, 10, null, null, null], + "10|y": [1, 1, null, null, null], + "10|z": [1, 1, null, null, null], + }); + + view3.delete(); + view2.delete(); + view.delete(); + table.delete(); + }); + }); + + test.describe("on_update notifications from different ports", function () { + it_old_behavior( + "All calls to on_update should contain the port ID", + async function (done) { + const table = await perspective.table(data); + const port_ids = []; + + for (let i = 0; i < 10; i++) { + port_ids.push(await table.make_port()); + } + + expect(port_ids).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + + const view = await table.view(); + + // because no updates were called in port 0, start at 1 + let last_port_id = 1; + view.on_update(function (updated) { + expect(updated.port_id).toEqual(last_port_id); + if (last_port_id == 10) { + view.delete(); + table.delete(); + done(); + } else { + last_port_id++; + } + }); + + for (const port_id of port_ids) { + await table.update( + { + w: [1.5], + x: [1], + y: ["a"], + z: [true], + }, + { port_id }, + ); + } + }, + ); + + it_old_behavior( + "Only the port that was updated should be notified in on_update", + async function (done) { + const table = await perspective.table(data); + const port_ids = []; + + for (let i = 0; i < 10; i++) { + port_ids.push(await table.make_port()); + } + + expect(port_ids).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + + const view = await table.view(); + + const port_id = get_random_int(1, 9); + + view.on_update(function (updated) { + expect(updated.port_id).toEqual(port_id); + view.delete(); + table.delete(); + done(); + }); + + await table.update( + { + w: [1.5], + x: [1], + y: ["a"], + z: [true], + }, + { port_id }, + ); + }, + ); + + it_old_behavior( + "All ports should be notified in creation order, regardless of what order the update is called.", + async function (done) { + const table = await perspective.table(data); + const port_ids = []; + + for (let i = 0; i < 10; i++) { + port_ids.push(await table.make_port()); + } + + expect(port_ids).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + + const view = await table.view(); + + let last_port_id = 0; + let num_updates = 0; + + view.on_update(function (updated) { + expect(updated.port_id).toEqual(last_port_id); + if (num_updates === 10) { + view.delete(); + table.delete(); + done(); + } else { + num_updates++; + last_port_id++; + } + }); + + const update_order = _.shuffle([ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, + ]); + + for (const port_id of update_order) { + table.update( + { + w: [1.5], + x: [1], + y: ["a"], + z: [true], + }, + { port_id }, + ); + } + }, + ); + }); + + test.describe("deltas", function () { + it_old_behavior( + "Deltas should be unique to each port", + async function (done) { + const table = await perspective.table(data); + const port_ids = []; + + for (let i = 0; i < 10; i++) { + port_ids.push(await table.make_port()); + } + + expect(port_ids).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + + const view = await table.view(); + + let update_count = 0; + + view.on_update( + async function (updated) { + const _t = await perspective.table(updated.delta); + const _v = await _t.view(); + const result = await _v.to_columns(); + + if (updated.port_id == first_port) { + expect(result).toEqual({ + w: [100], + x: [first_port], + y: ["first port"], + z: [false], + }); + } else if (updated.port_id == second_port) { + expect(result).toEqual({ + w: [200], + x: [second_port], + y: ["second port"], + z: [true], + }); + } + + update_count++; + + if (update_count === 2) { + _v.delete(); + _t.delete(); + view.delete(); + table.delete(); + done(); + } + }, + { mode: "row" }, + ); + + const first_port = get_random_int(0, 10); + let second_port = get_random_int(0, 10); + + while (second_port === first_port) { + second_port = get_random_int(0, 10); + } + + await table.update( + { + w: [100], + x: [first_port], + y: ["first port"], + z: [false], + }, + { port_id: first_port }, + ); + + await table.update( + { + w: [200], + x: [second_port], + y: ["second port"], + z: [true], + }, + { port_id: second_port }, + ); + }, + ); + }); + + test.describe("Cross-table port operations", function () { + it_old_behavior( + "Should allow a client-server round trip without loops", + async function (done) { + const client_table = await perspective.table(data); + const server_table = await perspective.table(data); + const client_port = await client_table.make_port(); + + // "get rid" of a random number of ports by creating them + for (let i = 0; i < get_random_int(5, 20); i++) { + await server_table.make_port(); + } + + const server_port = await server_table.make_port(); + + expect(client_port).toBeLessThan(server_port); + + const client_view = await client_table.view(); + + let CLIENT_TO_SERVER_UPDATES = 0; + + client_view.on_update( + async (updated) => { + if (updated.port_id === client_port) { + await server_table.update(updated.delta, { + port_id: server_port, + }); + CLIENT_TO_SERVER_UPDATES++; + } else { + // Should not pass forward that update, and break + // the chain. + expect(CLIENT_TO_SERVER_UPDATES).toEqual(1); + } + }, + { mode: "row" }, + ); + + const server_view = await server_table.view(); + + server_view.on_update( + async (updated) => { + if (updated.port_id !== server_port) { + await client_table.update(updated.delta); + } else { + // update is trapped in this state - assert correct + // state and exit the test. + expect(await client_table.size()).toEqual(8); + expect(await server_table.size()).toEqual(8); + client_view.delete(); + server_view.delete(); + client_table.delete(); + server_table.delete(); + done(); + } + }, + { mode: "row" }, + ); + + // this should go to the server and round trip back to the + // client, but stop when it gets to the client again. + await client_table.update(data, { port_id: client_port }); + }, + ); + + it_old_behavior( + "Should allow multi client-server round trips without loops", + async function (done) { + const client_table = await perspective.table(data, { + index: "w", + }); + const client_table2 = await perspective.table(data, { + index: "w", + }); + const server_table = await perspective.table(data, { + index: "w", + }); + const client_port = await client_table.make_port(); + const client_port2 = await client_table2.make_port(); + + // "get rid" of a random number of ports by creating them + for (let i = 0; i < get_random_int(5, 20); i++) { + await server_table.make_port(); + } + + const server_port = await server_table.make_port(); + const server_port2 = await server_table.make_port(); + + expect(client_port).toBeLessThan(server_port); + expect(client_port2).toBeLessThan(server_port); + expect(client_port).toBeLessThan(server_port2); + expect(client_port2).toBeLessThan(server_port2); + + // port numbers can be equal between clients + expect(client_port2).toEqual(client_port); + + const client_view = await client_table.view(); + const client_view2 = await client_table2.view(); + + client_view.on_update( + async (updated) => { + // client1 and server handlers are as minimal as can be + if (updated.port_id === client_port) { + await server_table.update(updated.delta, { + port_id: server_port, + }); + } + }, + { mode: "row" }, + ); + + client_view2.on_update( + async (updated) => { + // client2 only reads updates from other clients + const results = await client_view2.to_columns(); + expect(results).toEqual({ + w: [1.5, 2.5, 3.5, 4.5], + x: [1, 2, 300, 4], + y: ["a", "b", "ccc", "d"], + z: [true, false, true, false], + }); + expect(await client_view.to_columns()).toEqual( + results, + ); + expect(await server_view.to_columns()).toEqual( + results, + ); + client_view.delete(); + client_view2.delete(); + server_view.delete(); + client_table.delete(); + client_table2.delete(); + server_table.delete(); + done(); + }, + { mode: "row" }, + ); + + const server_view = await server_table.view(); + + // Simulate multiple connections to the server + server_view.on_update( + async (updated) => { + if (updated.port_id !== server_port) { + await client_table.update(updated.delta); + } + }, + { mode: "row" }, + ); + + server_view.on_update( + async (updated) => { + if (updated.port_id !== server_port2) { + await client_table2.update(updated.delta); + } + }, + { mode: "row" }, + ); + + // this should go to the server and round trip back to the + // client, but stop when it gets to the client again. It should + // reflect on the client2 table though. + await client_table.update( + [ + { + w: 3.5, + x: 300, + y: "ccc", + }, + ], + { port_id: client_port }, + ); + }, + ); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/proxy_session.spec.js b/rust/perspective-js/test/js/proxy_session.spec.js new file mode 100644 index 0000000..b8e80bf --- /dev/null +++ b/rust/perspective-js/test/js/proxy_session.spec.js @@ -0,0 +1,102 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import { make_client, PerspectiveServer } from "@perspective-dev/client"; + +test("Proxy session tunnels requests through client", async () => { + const { client, server } = connectClientToServer(); + const { proxyClient } = connectProxyClient(client); + + // Verify that main client + proxy client observe the same server + const clientTables1 = await client.get_hosted_table_names(); + const proxyTables1 = await proxyClient.get_hosted_table_names(); + expect(proxyTables1).toStrictEqual([]); + expect(proxyTables1).toStrictEqual(clientTables1); + const name = "abc-" + Math.random(); + const _table = await client.table({ abc: [123] }, { name }); + const clientTables2 = await client.get_hosted_table_names(); + const proxyTables2 = await proxyClient.get_hosted_table_names(); + expect(proxyTables2).toStrictEqual([name]); + expect(proxyTables2).toStrictEqual(clientTables2); +}); + +test("Proxy session tunnels on_update callbacks through client", async () => { + // test.setTimeout(2000); + const { client } = connectClientToServer(); + const { proxyClient } = connectProxyClient(client); + const name = "abc-" + Math.random(); + const clientTable = await client.table({ abc: [123] }, { name }); + + // Add an on_update callback to the proxy client's view of the table + const proxyTable = await proxyClient.open_table(name); + const proxyView = await proxyTable.view(); + let resolveUpdate; + const onUpdateResp = new Promise((r) => (resolveUpdate = r)); + await proxyView.on_update( + (x) => { + resolveUpdate(x); + }, + { mode: "row" }, + ); + + // Enact table update through client's table handle, and assert that proxy + // client's on_update callback is called + await clientTable.update({ abc: [999] }); + const expectUpdate = expect.poll( + async () => { + const data = await onUpdateResp; + // TODO: construct table out of onUpdateResp, assert contents? + console.log("onUpdateResp, with data", data); + return data; + }, + { + message: "Ensure proxy view updates with table", + timeout: 10000, + }, + ); + + expect(await proxyView.to_columns()).toStrictEqual({ abc: [123, 999] }); + + await expectUpdate.toHaveProperty("delta"); + await expectUpdate.toHaveProperty("port_id"); +}); + +function connectClientToServer() { + const server = new PerspectiveServer(); + const session = server.make_session(async (msg) => { + await client.handle_response(msg); + }); + + const client = make_client(async (msg) => { + session.handle_request(msg); + }); + + return { + client, + server, + }; +} + +function connectProxyClient(client) { + const sess = client.new_proxy_session((res) => { + proxyClient.handle_response(res); + }); + const proxyClient = make_client(async (msg) => { + // Have to copy msg with slice() because the memory backing it will be + // deallocated before handle_request() is scheduled and executes + await sess.handle_request(msg.slice()); + }); + return { + proxyClient, + }; +} diff --git a/rust/perspective-js/test/js/remote.spec.js b/rust/perspective-js/test/js/remote.spec.js new file mode 100644 index 0000000..e3dd1e5 --- /dev/null +++ b/rust/perspective-js/test/js/remote.spec.js @@ -0,0 +1,250 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "@perspective-dev/client"; + +let server; +let port; + +test.describe("WebSocketManager", function () { + test.beforeEach(() => { + server = new perspective.WebSocketServer({ port: 0 }); + port = server._server.address().port; + }); + + test.afterEach(() => { + server.close(); + }); + + test("sends initial data client on subscribe", async () => { + const data = [{ x: 1 }]; + const table = await perspective.table(data, { name: "test" }); + const client = await perspective.websocket(`ws://localhost:${port}`); + const client_table = await client.open_table("test"); + const client_view = await client_table.view(); + const client_data = await client_view.to_json(); + expect(client_data).toEqual(data); + await client.terminate(); + + // // TODO Can't await delete here because `terminate()` doesn't + // // guarantee server-side cleanup happens immediately. So this test + // // leaks a small `Table` sometimes, as do several others in this + // // suite. + + await new Promise((x) => setTimeout(x, 10)); + await table.delete(); + }); + + test("Throws an exception when the server is detached", async () => { + const data = [{ x: 1 }]; + const _table = await perspective.table(data, { name: "test2" }); + const client = await perspective.websocket(`ws://localhost:${port}`); + const client_table = await client.open_table("test2"); + const client_view = await client_table.view(); + await server.close(); + const client_data = client_view.to_json(); + await expect(client_data).rejects.toThrow(); + }); + + test("passes back errors from server", async () => { + expect.assertions(2); + const data = [{ x: 1 }]; + const table = await perspective.table(data, { name: "test" }); + const client = await perspective.websocket(`ws://localhost:${port}`); + const client_table = await client.open_table("test"); + client_table.view({ columns: ["z"] }).catch((error) => { + expect(error.message).toContain( + "Abort(): Invalid column 'z' found in View columns.\n", + ); + }); + + const client_view = await client_table.view(); + const client_data = await client_view.to_json(); + expect(client_data).toEqual(data); + await client.terminate(); + + await new Promise((x) => setTimeout(x, 10)); + await table.delete(); + }); + + test("sends initial data multiple client on subscribe", async () => { + const data = [{ x: 1 }]; + const table = await perspective.table(data, { name: "test" }); + const client_1 = await perspective.websocket(`ws://localhost:${port}`); + const client_2 = await perspective.websocket(`ws://localhost:${port}`); + const client_1_table = await client_1.open_table("test"); + const client_2_table = await client_2.open_table("test"); + const client_1_view = await client_1_table.view(); + const client_2_view = await client_2_table.view(); + const client_1_data = await client_1_view.to_json(); + const client_2_data = await client_2_view.to_json(); + await client_1.terminate(); + await client_2.terminate(); + expect(client_1_data).toEqual(data); + expect(client_2_data).toEqual(data); + + await new Promise((x) => setTimeout(x, 10)); + await table.delete(); + }); + + test("passes back errors with multiple client on subscribe", async () => { + expect.assertions(3); + const data = [{ x: 1 }]; + const table = await perspective.table(data, { name: "test" }); + const client_1 = await perspective.websocket(`ws://localhost:${port}`); + const client_2 = await perspective.websocket(`ws://localhost:${port}`); + const client_1_table = await client_1.open_table("test"); + const client_2_table = await client_2.open_table("test"); + client_1_table.view({ columns: ["z"] }).catch((error) => { + expect(error.message).toContain( + "Abort(): Invalid column 'z' found in View columns.\n", + ); + }); + + const client_1_view = await client_1_table.view(); + const client_2_view = await client_2_table.view(); + const client_1_data = await client_1_view.to_json(); + const client_2_data = await client_2_view.to_json(); + await client_1.terminate(); + await client_2.terminate(); + expect(client_1_data).toEqual(data); + expect(client_2_data).toEqual(data); + + await new Promise((x) => setTimeout(x, 10)); + await table.delete(); + }); + + test("sends updates to client on subscribe", async () => { + const data = [{ x: 1 }]; + const table = await perspective.table(data, { name: "test" }); + const client = await perspective.websocket(`ws://localhost:${port}`); + const client_table = await client.open_table("test"); + const client_view = await client_table.view(); + let done; + let result = new Promise((x) => { + done = x; + }); + + const on_update = () => { + client_view.to_json().then(async (updated_data) => { + await client_view.delete(); + await table.delete(); + expect(updated_data).toEqual([{ x: 1 }, { x: 2 }]); + await client.terminate(); + setTimeout(done); + }); + }; + + client_view.on_update(on_update); + client_view.to_json().then((client_data) => { + expect(client_data).toEqual(data); + return table.update([{ x: 2 }]); + }); + + await result; + }); + + test("Calls `update` and sends arraybuffers using `binary_length`", async () => { + const data = [{ x: 1 }]; + const table = await perspective.table(data, { name: "test" }); + const view = await table.view(); + const arrow = await view.to_arrow(); + const client = await perspective.websocket(`ws://localhost:${port}`); + const client_table = await client.open_table("test"); + await client_table.update(arrow); + const client_view = await client_table.view(); + const client_data = await client_view.to_json(); + expect(client_data).toEqual([{ x: 1 }, { x: 1 }]); + await client_view.delete(); + await client.terminate(); + await view.delete(); + await table.delete(); + }); + + test("Calls `update` and sends `Uint8Array` using `binary_length` multiple times", async () => { + const data = [{ x: 1 }]; + const table = await perspective.table(data, { name: "test" }); + const view = await table.view(); + const arrow = await view.to_arrow(); + const client = await perspective.websocket(`ws://localhost:${port}`); + const client_table = await client.open_table("test"); + await client_table.update(arrow); + await client_table.update(arrow); + await client_table.update(arrow); + const client_view = await client_table.view(); + const client_data = await client_view.to_json(); + expect(client_data).toEqual([{ x: 1 }, { x: 1 }, { x: 1 }, { x: 1 }]); + await client_view.delete(); + await view.delete(); + await table.delete(); + }); + + test("Calls `update` and sends `Uint8Array` using `on_update`", async () => { + const data = [{ x: 1 }]; + const table = await perspective.table(data, { name: "test" }); + const view = await table.view(); + const arrow = await view.to_arrow(); + let update_port; + let done; + let result = new Promise((x) => { + done = x; + }); + + const updater = async (updated) => { + expect(updated.port_id).toEqual(update_port); + expect(updated.delta instanceof Uint8Array).toEqual(true); + expect(updated.delta.byteLength).toBeGreaterThan(0); + await view.delete(); + await table.delete(); + await client.terminate(); + + done(); + }; + + view.on_update(updater, { mode: "row" }); + const client = await perspective.websocket(`ws://localhost:${port}`); + const client_table = await client.open_table("test"); + for (let i = 0; i < 5; i++) { + // take up some ports on the remote table + await client_table.make_port(); + } + + update_port = await client_table.make_port(); + await client_table.update(arrow, { port_id: update_port }); + await result; + }); + + // TODO ping loop is not yet implemented + test.skip("disables ping loop on disconnect", async () => { + const data = [{ x: 1 }]; + const table = await perspective.table(data, { name: "test" }); + const client = await perspective.websocket(`ws://localhost:${port}`); + const _client_table = await client.open_table("test"); + expect(await _client_table.size()).toEqual(1); + expect(client._ping_loop).toBeDefined(); + await server.close(); + expect(client._ping_loop).toBeUndefined(); + await table.delete(); + }); + + test("Can get hosted table names", async () => { + const data = [{ x: 1 }]; + const host_table_name = "test"; + const table = await perspective.table(data, { name: host_table_name }); + const client = await perspective.websocket(`ws://localhost:${port}`); + const table_names = await client.get_hosted_table_names(); + expect(table_names).toContain(host_table_name); + await client.terminate(); + await table.delete(); + }); +}); diff --git a/rust/perspective-js/test/js/removes.spec.js b/rust/perspective-js/test/js/removes.spec.js new file mode 100644 index 0000000..6ed0419 --- /dev/null +++ b/rust/perspective-js/test/js/removes.spec.js @@ -0,0 +1,490 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "./perspective_client"; + +const _query = async (should_cache, table, config = {}, callback) => { + let cached_view; + if (should_cache) { + cached_view = await table.view(config); + } + + const data_getter = async () => { + if (should_cache) { + return await cached_view.to_columns(); + } else { + const view = await table.view(config); + const data = await view.to_columns(); + await view.delete(); + return data; + } + }; + + const v = await callback(data_getter); + if (cached_view) { + cached_view.delete(); + } + + return v; +}; + +const SCHEMA = { + str: "string", + int: "integer", + float: "float", +}; + +test.describe("Removes", () => { + for (const cond of [true, false]) { + const query = _query.bind(undefined, cond); + + for (const idx of ["str", "int"]) { + const string_pkey = idx === "str"; + + test.describe(`View cached? ${cond}, index: ${idx}`, () => { + test("Output consistent with filter", async () => { + const table = await perspective.table(SCHEMA, { + index: idx, + }); + const data = { + str: [1, 2, 3, 4, 5, 6].map((x) => x.toString()), + int: [1, 2, 3, 4, 5, 6], + float: [1, 2, 3, 4, 5, 6].map((x) => x * 0.5), + }; + + await table.update(data); + + await query( + table, + { + filter: [["str", "!=", "2"]], + }, + async (getter) => { + const pkey = 3; + await table.remove([ + string_pkey ? pkey.toString() : pkey, + ]); + + expect(await getter()).toEqual({ + str: ["1", "4", "5", "6"], + int: [1, 4, 5, 6], + float: [0.5, 2, 2.5, 3], + }); + }, + ); + + await table.delete(); + }); + + test("Output consistent with filter, empty string", async () => { + const table = await perspective.table(SCHEMA, { + index: idx, + }); + const data = { + str: [1, 2, 3, 4, 5, 6].map((x) => x.toString()), + int: [1, 2, 3, 4, 5, 6], + float: [1, 2, 3, 4, 5, 6].map((x) => x * 0.5), + }; + + data.str.push(""); + data.int.push(7); + data.float.push(3.5); + + await table.update(data); + + await query( + table, + { + filter: [["str", "!=", "2"]], + }, + async (getter) => { + const pkey = 3; + await table.remove([ + string_pkey ? pkey.toString() : pkey, + ]); + + if (string_pkey) { + expect(await getter()).toEqual({ + str: ["", "1", "4", "5", "6"], + int: [7, 1, 4, 5, 6], + float: [3.5, 0.5, 2, 2.5, 3], + }); + } else { + expect(await getter()).toEqual({ + str: ["1", "4", "5", "6", ""], + int: [1, 4, 5, 6, 7], + float: [0.5, 2, 2.5, 3, 3.5], + }); + } + }, + ); + + await table.delete(); + }); + + test("Output consistent with filter, null", async () => { + const table = await perspective.table(SCHEMA, { + index: idx, + }); + const data = { + str: [1, 2, 3, 4, 5, 6].map((x) => x.toString()), + int: [1, 2, 3, 4, 5, 6], + float: [1, 2, 3, 4, 5, 6].map((x) => x * 0.5), + }; + + data.str.push(null); + data.int.push(7); + data.float.push(3.5); + + await table.update(data); + + await query( + table, + { + filter: [["str", "!=", "2"]], + }, + async (getter) => { + const pkey = 3; + await table.remove([ + string_pkey ? pkey.toString() : pkey, + ]); + + if (string_pkey) { + expect(await getter()).toEqual({ + str: [null, "1", "4", "5", "6"], + int: [7, 1, 4, 5, 6], + float: [3.5, 0.5, 2, 2.5, 3], + }); + } else { + expect(await getter()).toEqual({ + str: ["1", "4", "5", "6", null], + int: [1, 4, 5, 6, 7], + float: [0.5, 2, 2.5, 3, 3.5], + }); + } + }, + ); + + await table.delete(); + }); + + test("Output consistent with filter, remove before view construction, sorted", async () => { + const table = await perspective.table(SCHEMA, { + index: idx, + }); + const data = { + str: [1, 2, 3, 4, 5, 6].map((x) => x.toString()), + int: [1, 2, 3, 4, 5, 6], + float: [1, 2, 3, 4, 5, 6].map((x) => x * 0.5), + }; + + await table.update(data); + await table.remove( + [1, 3, 5].map((x) => (string_pkey ? x.toString() : x)), + ); + + await query( + table, + { + sort: [["str", "desc"]], + }, + async (getter) => { + expect(await getter()).toEqual({ + str: ["6", "4", "2"], + int: [6, 4, 2], + float: [3, 2, 1], + }); + + await table.update({ + str: ["7", "9", "8"], + int: [7, 9, 8], + float: [3.5, 4.5, 4], + }); + + expect(await getter()).toEqual({ + str: ["9", "8", "7", "6", "4", "2"], + int: [9, 8, 7, 6, 4, 2], + float: [4.5, 4, 3.5, 3, 2, 1], + }); + }, + ); + + await table.delete(); + }); + + test("Output consistent with filter, remove and add identical", async () => { + const table = await perspective.table(SCHEMA, { + index: idx, + }); + const data = { + str: [1, 2, 3, 4, 5, 6].map((x) => x.toString()), + int: [1, 2, 3, 4, 5, 6], + float: [1, 2, 3, 4, 5, 6].map((x) => x * 0.5), + }; + + await table.update(data); + + await query( + table, + { + filter: [["str", "!=", "2"]], + }, + async (getter) => { + const pkey = 3; + await table.remove([ + string_pkey ? pkey.toString() : pkey, + ]); + + expect(await getter()).toEqual({ + str: ["1", "4", "5", "6"], + int: [1, 4, 5, 6], + float: [0.5, 2, 2.5, 3], + }); + + await table.update({ + str: ["7", "9", "8"], + int: [7, 9, 8], + float: [3.5, 4.5, 4], + }); + + expect(await getter()).toEqual({ + str: ["1", "4", "5", "6", "7", "8", "9"], + int: [1, 4, 5, 6, 7, 8, 9], + float: [0.5, 2, 2.5, 3, 3.5, 4, 4.5], + }); + }, + ); + + await table.delete(); + }); + + test("Output consistent with filter, add null and remove other", async () => { + const table = await perspective.table(SCHEMA, { + index: idx, + }); + const data = { + str: [1, 2, 3, 4, 5, 6].map((x) => x.toString()), + int: [1, 2, 3, 4, 5, 6], + float: [1, 2, 3, 4, 5, 6].map((x) => x * 0.5), + }; + + await table.update(data); + + await query(table, {}, async (getter) => { + expect(await getter()).toEqual(data); + + await table.update({ + str: [string_pkey ? null : "7"], + int: [string_pkey ? 7 : null], + float: [3.5], + }); + + if (string_pkey) { + expect(await getter()).toEqual({ + str: [null, 1, 2, 3, 4, 5, 6].map((x) => + x ? x.toString() : x, + ), + int: [7, 1, 2, 3, 4, 5, 6], + float: [7, 1, 2, 3, 4, 5, 6].map( + (x) => x * 0.5, + ), + }); + } else { + expect(await getter()).toEqual({ + str: [7, 1, 2, 3, 4, 5, 6].map((x) => + x.toString(), + ), + int: [null, 1, 2, 3, 4, 5, 6], + float: [7, 1, 2, 3, 4, 5, 6].map( + (x) => x * 0.5, + ), + }); + } + + await table.remove([string_pkey ? "3" : 3]); + + if (string_pkey) { + expect(await getter()).toEqual({ + str: [null, 1, 2, 4, 5, 6].map((x) => + x ? x.toString() : x, + ), + int: [7, 1, 2, 4, 5, 6], + float: [7, 1, 2, 4, 5, 6].map((x) => x * 0.5), + }); + } else { + expect(await getter()).toEqual({ + str: [7, 1, 2, 4, 5, 6].map((x) => + x.toString(), + ), + int: [null, 1, 2, 4, 5, 6], + float: [7, 1, 2, 4, 5, 6].map((x) => x * 0.5), + }); + } + }); + + await table.delete(); + }); + + test("Output consistent with filter, remove and add new dataset", async () => { + const table = await perspective.table(SCHEMA, { + index: idx, + }); + + const data = { + str: [1, 2, 3, 4, 5, 6].map((x) => x.toString()), + int: [1, 2, 3, 4, 5, 6], + float: [1, 2, 3, 4, 5, 6].map((x) => x * 0.5), + }; + + await table.update(data); + + await query( + table, + { + filter: [["int", "!=", 3]], + }, + async (getter) => { + await table.remove( + [1, 2, 3, 4, 5, 6].map((x) => + string_pkey ? x.toString() : x, + ), + ); + + expect(await getter()).toEqual({ + float: [], + int: [], + str: [], + }); + + await table.update({ + str: ["def", "abc", "deff"], + int: [1, 2, 4], + float: [100.5, 200.5, 300.5], + }); + + if (string_pkey) { + expect(await getter()).toEqual({ + str: ["abc", "def", "deff"], + int: [2, 1, 4], + float: [200.5, 100.5, 300.5], + }); + } else { + expect(await getter()).toEqual({ + str: ["def", "abc", "deff"], + int: [1, 2, 4], + float: [100.5, 200.5, 300.5], + }); + } + }, + ); + + await table.delete(); + }); + + test.skip("Output consistent with filter, remove and add null", async () => { + // FIXME + const table = await perspective.table(SCHEMA, { + index: idx, + }); + const data = { + str: [1, 2, 3, 4, 5, 6].map((x) => x.toString()), + int: [1, 2, 3, 4, 5, 6], + float: [1, 2, 3, 4, 5, 6].map((x) => x * 0.5), + }; + + const splice_idx = Math.floor(Math.random() * 6); + data.str.splice(splice_idx, 0, string_pkey ? null : "7"); + data.int.splice(splice_idx, 0, string_pkey ? 7 : null); + data.float.splice(splice_idx, 0, 3.5); + + await table.update(data); + + await query( + table, + { + filter: [["float", "==", 3.5]], + }, + async (getter) => { + await table.remove([null]); + const v = await table.view(); + console.log( + "str?", + string_pkey, + await v.to_columns(), + ); + await v.delete(); + + expect(await getter()).toEqual({}); + + await table.update({ + str: [string_pkey ? null : "7"], + int: [string_pkey ? 7 : null], + float: [3.5], + }); + + expect(await getter()).toEqual({ + str: [string_pkey ? null : "7"], + int: [string_pkey ? 7 : null], + float: [3.5], + }); + }, + ); + + await table.delete(); + }); + + test.skip("Output consistent with filter, update null", async () => { + const table = await perspective.table(SCHEMA, { + index: idx, + }); + const data = { + str: [1, 2, 3, 4, 5, 6].map((x) => x.toString()), + int: [1, 2, 3, 4, 5, 6], + float: [1, 2, 3, 4, 5, 6].map((x) => x * 0.5), + }; + + const splice_idx = Math.floor(Math.random() * 6); + data.str.splice(splice_idx, 0, string_pkey ? null : "7"); + data.int.splice(splice_idx, 0, string_pkey ? 7 : null); + data.float.splice(splice_idx, 0, 3.5); + + await table.update(data); + + await query(table, {}, async (getter) => { + const expected = { + str: data.str.slice(), + int: data.int.slice(), + float: data.float.slice(), + }; + + expect(await getter()).toEqual(expected); + + await table.update([ + { + str: string_pkey ? null : "7", + int: string_pkey ? 7 : null, + float: 4.5, + }, + ]); + + expected.float[splice_idx] = 4.5; + + expect(await getter()).toEqual(expected); + }); + + await table.delete(); + }); + }); + } + } +}); diff --git a/rust/perspective-js/test/js/sort.spec.js b/rust/perspective-js/test/js/sort.spec.js new file mode 100644 index 0000000..7010a6a --- /dev/null +++ b/rust/perspective-js/test/js/sort.spec.js @@ -0,0 +1,896 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "./perspective_client"; + +const data = { + w: [1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5], + x: [1, 2, 3, 4, 4, 3, 2, 1], + y: ["a", "b", "c", "d", "a", "b", "c", "d"], + z: [true, false, true, false, true, false, true, false], +}; + +const data2 = { + w: [3.5, 4.5, null, null, null, null, 1.5, 2.5], + x: [1, 2, 3, 4, 4, 3, 2, 1], + y: ["a", "b", "c", "d", "e", "f", "g", "h"], +}; + +const data3 = { + w: [3.5, 4.5, null, null, null, null, 1.5, 2.5], + x: [1, 2, 3, 4, 4, 3, 2, 1], + y: ["a", "a", "a", "a", "b", "b", "b", "b"], +}; + +((perspective) => { + test.describe("Sorts", function () { + test.describe("With nulls", () => { + test("asc", async function () { + var table = await perspective.table(data2); + var view = await table.view({ + columns: ["x", "w"], + sort: [["w", "asc"]], + }); + + const json = await view.to_columns(); + expect(json).toEqual({ + w: [null, null, null, null, 1.5, 2.5, 3.5, 4.5], + x: [3, 4, 4, 3, 2, 1, 1, 2], + }); + + view.delete(); + table.delete(); + }); + + test("desc", async function () { + var table = await perspective.table(data2); + var view = await table.view({ + columns: ["x", "w"], + sort: [["w", "desc"]], + }); + + const json = await view.to_columns(); + expect(json).toEqual({ + w: [4.5, 3.5, 2.5, 1.5, null, null, null, null], + x: [2, 1, 1, 2, 3, 4, 4, 3], + }); + + view.delete(); + table.delete(); + }); + + test("asc datetime", async function () { + var table = await perspective.table({ + w: [ + new Date(2020, 0, 1, 12, 30, 45), + new Date(2020, 0, 1), + null, + null, + null, + null, + new Date(2008, 0, 1, 12, 30, 45), + new Date(2020, 12, 1, 12, 30, 45), + ], + x: [1, 2, 3, 4, 4, 3, 2, 1], + y: ["a", "b", "c", "d", "e", "f", "g", "h"], + }); + + var view = await table.view({ + columns: ["x", "w"], + sort: [["w", "asc"]], + }); + + const json = await view.to_columns(); + expect(json).toEqual({ + w: [ + null, + null, + null, + null, + new Date(2008, 0, 1, 12, 30, 45).getTime(), + new Date(2020, 0, 1).getTime(), + new Date(2020, 0, 1, 12, 30, 45).getTime(), + new Date(2020, 12, 1, 12, 30, 45).getTime(), + ], + x: [3, 4, 4, 3, 2, 2, 1, 1], + }); + + view.delete(); + table.delete(); + }); + + test("desc datetime", async function () { + var table = await perspective.table({ + w: [ + new Date(2020, 0, 1, 12, 30, 45), + new Date(2020, 0, 1), + null, + null, + null, + null, + new Date(2008, 0, 1, 12, 30, 45), + new Date(2020, 12, 1, 12, 30, 45), + ], + x: [1, 2, 3, 4, 4, 3, 2, 1], + y: ["a", "b", "c", "d", "e", "f", "g", "h"], + }); + + var view = await table.view({ + columns: ["x", "w"], + sort: [["w", "desc"]], + }); + + const json = await view.to_columns(); + expect(json).toEqual({ + w: [ + new Date(2020, 12, 1, 12, 30, 45).getTime(), + new Date(2020, 0, 1, 12, 30, 45).getTime(), + new Date(2020, 0, 1).getTime(), + new Date(2008, 0, 1, 12, 30, 45).getTime(), + null, + null, + null, + null, + ], + x: [1, 1, 2, 2, 3, 4, 4, 3], + }); + + view.delete(); + table.delete(); + }); + }); + + test.describe("With aggregates", function () { + test.describe("aggregates, in a sorted column with nulls", function () { + test("sum", async function () { + var table = await perspective.table(data2); + var view = await table.view({ + columns: ["x", "w"], + group_by: ["y"], + aggregates: { + w: "sum", + x: "unique", + }, + sort: [["w", "asc"]], + }); + + const json = await view.to_columns(); + expect(json).toEqual({ + __ROW_PATH__: [ + [], + ["c"], + ["d"], + ["e"], + ["f"], + ["g"], + ["h"], + ["a"], + ["b"], + ], + w: [12, 0, 0, 0, 0, 1.5, 2.5, 3.5, 4.5], + x: [null, 3, 4, 4, 3, 2, 1, 1, 2], + }); + + view.delete(); + table.delete(); + }); + + test("sum of floats", async function () { + var table = await perspective.table({ + w: [3.25, 4.51, null, null, null, null, 1.57, 2.59], + x: [1, 2, 3, 4, 4, 3, 2, 1], + y: ["a", "b", "c", "d", "e", "f", "g", "h"], + }); + var view = await table.view({ + columns: ["x", "w"], + group_by: ["y"], + aggregates: { + w: "sum", + x: "unique", + }, + sort: [["w", "asc"]], + }); + + const json = await view.to_columns(); + expect(json).toEqual({ + __ROW_PATH__: [ + [], + ["c"], + ["d"], + ["e"], + ["f"], + ["g"], + ["h"], + ["a"], + ["b"], + ], + w: [11.92, 0, 0, 0, 0, 1.57, 2.59, 3.25, 4.51], + x: [null, 3, 4, 4, 3, 2, 1, 1, 2], + }); + + view.delete(); + table.delete(); + }); + + test("unique", async function () { + var table = await perspective.table(data2); + var view = await table.view({ + columns: ["x", "w"], + group_by: ["y"], + aggregates: { + w: "unique", + x: "unique", + }, + sort: [["w", "asc"]], + }); + + const json = await view.to_columns(); + expect(json).toEqual({ + __ROW_PATH__: [ + [], + ["c"], + ["d"], + ["e"], + ["f"], + ["g"], + ["h"], + ["a"], + ["b"], + ], + w: [null, null, null, null, null, 1.5, 2.5, 3.5, 4.5], + x: [null, 3, 4, 4, 3, 2, 1, 1, 2], + }); + + view.delete(); + table.delete(); + }); + + test("avg", async function () { + var table = await perspective.table(data2); + var view = await table.view({ + columns: ["x", "w"], + group_by: ["y"], + aggregates: { + w: "avg", + x: "unique", + }, + sort: [["w", "asc"]], + }); + + const json = await view.to_columns(); + expect(json).toEqual({ + __ROW_PATH__: [ + [], + ["c"], + ["d"], + ["e"], + ["f"], + ["g"], + ["h"], + ["a"], + ["b"], + ], + w: [3, null, null, null, null, 1.5, 2.5, 3.5, 4.5], + x: [null, 3, 4, 4, 3, 2, 1, 1, 2], + }); + + // Broken result: + // { + // __ROW_PATH__: [[], ["a"], ["b"], ["c"], ["d"], ["e"], ["f"], ["g"], ["h"]], + // w: [3, 3.5, 4.5, null, null, null, null, 1.5, 2.5], + // x: [null, 1, 2, 3, 4, 4, 3, 2, 1] + // }; + + view.delete(); + table.delete(); + }); + + test.describe("Multiple hidden sort", () => { + test("sum", async function () { + var table = await perspective.table(data3); + var view = await table.view({ + columns: ["x", "w"], + group_by: ["y"], + aggregates: { + w: "sum", + }, + sort: [ + ["x", "desc"], + ["w", "desc"], + ], + }); + + const json = await view.to_columns(); + expect(json).toEqual({ + __ROW_PATH__: [[], ["a"], ["b"]], + w: [12, 8, 4], + x: [20, 10, 10], + }); + + view.delete(); + table.delete(); + }); + + test("sum of floats", async function () { + var table = await perspective.table({ + w: [3.25, 4.51, null, null, null, null, 1.57, 2.59], + x: [1, 2, 3, 4, 4, 3, 2, 1], + y: ["a", "a", "a", "a", "b", "b", "b", "b"], + }); + var view = await table.view({ + columns: ["x", "w"], + group_by: ["y"], + aggregates: { + w: "sum", + }, + sort: [ + ["x", "desc"], + ["w", "desc"], + ], + }); + + const json = await view.to_columns(); + expect(json).toEqual({ + __ROW_PATH__: [[], ["a"], ["b"]], + w: [11.92, 7.76, 4.16], + x: [20, 10, 10], + }); + + view.delete(); + table.delete(); + }); + + test("unique", async function () { + var table = await perspective.table(data3); + var view = await table.view({ + columns: ["x", "w"], + group_by: ["y"], + aggregates: { + w: "unique", + }, + sort: [ + ["x", "desc"], + ["w", "desc"], + ], + }); + + const json = await view.to_columns(); + expect(json).toEqual({ + __ROW_PATH__: [[], ["a"], ["b"]], + w: [null, null, null], + x: [20, 10, 10], + }); + + view.delete(); + table.delete(); + }); + + test("avg", async function () { + var table = await perspective.table(data3); + var view = await table.view({ + columns: ["x", "w"], + group_by: ["y"], + aggregates: { + w: "avg", + }, + sort: [ + ["x", "desc"], + ["w", "desc"], + ], + }); + + const json = await view.to_columns(); + expect(json).toEqual({ + __ROW_PATH__: [[], ["a"], ["b"]], + // 4 and 2 are the avg of the non-null rows + w: [3, 4, 2], + x: [20, 10, 10], + }); + + view.delete(); + table.delete(); + }); + }); + }); + }); + + test.describe("On hidden columns", function () { + test("Column path should not emit hidden sorts", async function () { + var table = await perspective.table(data); + var view = await table.view({ + columns: ["w", "y"], + sort: [["x", "desc"]], + }); + const paths = await view.column_paths(); + expect(paths).toEqual(["w", "y"]); + view.delete(); + table.delete(); + }); + + test("Column path should not emit hidden column sorts", async function () { + var table = await perspective.table(data); + var view = await table.view({ + columns: ["w"], + group_by: ["y"], + split_by: ["z"], + sort: [["x", "col desc"]], + }); + const paths = await view.column_paths(); + expect(paths).toEqual(["false|w", "true|w"]); + view.delete(); + table.delete(); + }); + + test("Column path should not emit hidden regular and column sorts", async function () { + var table = await perspective.table(data); + var view = await table.view({ + columns: ["w"], + group_by: ["y"], + split_by: ["z"], + sort: [ + ["x", "col desc"], + ["y", "desc"], + ], + }); + const paths = await view.column_paths(); + expect(paths).toEqual(["false|w", "true|w"]); + view.delete(); + table.delete(); + }); + + test("unpivoted", async function () { + var table = await perspective.table(data); + var view = await table.view({ + columns: ["w", "y"], + sort: [["x", "desc"]], + }); + var answer = [ + { w: 4.5, y: "d" }, + { w: 5.5, y: "a" }, + { w: 3.5, y: "c" }, + { w: 6.5, y: "b" }, + { w: 2.5, y: "b" }, + { w: 7.5, y: "c" }, + { w: 1.5, y: "a" }, + { w: 8.5, y: "d" }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("group by ['y']", async function () { + var table = await perspective.table(data); + var view = await table.view({ + columns: ["w"], + group_by: ["y"], + sort: [["x", "desc"]], + }); + var answer = [ + { __ROW_PATH__: [], w: 40 }, + { __ROW_PATH__: ["a"], w: 7 }, + { __ROW_PATH__: ["b"], w: 9 }, + { __ROW_PATH__: ["c"], w: 11 }, + { __ROW_PATH__: ["d"], w: 13 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("group by and hidden sort ['y'] with aggregates specified", async function () { + const table = await perspective.table({ + x: [1, 2, 3, 4, 5], + y: ["a", "b", "b", "b", "c"], + }); + // Aggregate should be overriden if the sort column is hidden + // AND also in group by + const view = await table.view({ + aggregates: { + x: "sum", + y: "count", + }, + columns: ["x"], + group_by: ["y"], + sort: [["y", "desc"]], + }); + const answer = [ + { __ROW_PATH__: [], x: 15 }, + { __ROW_PATH__: ["c"], x: 5 }, + { __ROW_PATH__: ["b"], x: 9 }, + { __ROW_PATH__: ["a"], x: 1 }, + ]; + const result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("group by and hidden sort ['y'] without aggregates specified", async function () { + const table = await perspective.table({ + x: [1, 2, 3, 4, 5], + y: ["a", "b", "b", "b", "c"], + }); + const view = await table.view({ + columns: ["x"], + group_by: ["y"], + sort: [["y", "desc"]], + }); + const answer = [ + { __ROW_PATH__: [], x: 15 }, + { __ROW_PATH__: ["c"], x: 5 }, + { __ROW_PATH__: ["b"], x: 9 }, + { __ROW_PATH__: ["a"], x: 1 }, + ]; + const result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("group by with multiple hidden sorts emits all visible columns in to_columns()", async function () { + // https://github.com/perspective-dev/perspective/issues/3195 + const table = await perspective.table({ + v1: [1, 2, 3, 4], + v2: [10, 20, 30, 40], + v3: [100, 200, 300, 400], + h1: [1.5, 2.5, 3.5, 4.5], + h2: [4.5, 3.5, 2.5, 1.5], + g: ["a", "a", "b", "b"], + }); + const view = await table.view({ + columns: ["v1", "v2", "v3"], + group_by: ["g"], + sort: [ + ["h1", "desc"], + ["h2", "desc"], + ], + }); + const result = await view.to_columns(); + expect(result).toEqual({ + __ROW_PATH__: [[], ["b"], ["a"]], + v1: [10, 7, 3], + v2: [100, 70, 30], + v3: [1000, 700, 300], + }); + view.delete(); + table.delete(); + }); + + test("group by with more hidden sorts than visible columns does not emit hidden columns in to_columns()", async function () { + // https://github.com/perspective-dev/perspective/issues/3195 + const table = await perspective.table({ + v1: [1, 2, 3, 4], + h1: [1.5, 2.5, 3.5, 4.5], + h2: [4.5, 3.5, 2.5, 1.5], + g: ["a", "a", "b", "b"], + }); + const view = await table.view({ + columns: ["v1"], + group_by: ["g"], + sort: [ + ["h1", "desc"], + ["h2", "desc"], + ], + }); + const result = await view.to_columns(); + expect(result).toEqual({ + __ROW_PATH__: [[], ["b"], ["a"]], + v1: [10, 7, 3], + }); + view.delete(); + table.delete(); + }); + + test("split by ['y']", async function () { + const table = await perspective.table(data); + const view = await table.view({ + columns: ["w"], + split_by: ["y"], + sort: [["x", "desc"]], + }); + const paths = await view.column_paths(); + expect(paths).toEqual(["a|w", "b|w", "c|w", "d|w"]); + const answer = [ + { "a|w": null, "b|w": null, "c|w": null, "d|w": 4.5 }, + { "a|w": 5.5, "b|w": null, "c|w": null, "d|w": null }, + { "a|w": null, "b|w": null, "c|w": 3.5, "d|w": null }, + { "a|w": null, "b|w": 6.5, "c|w": null, "d|w": null }, + { "a|w": null, "b|w": 2.5, "c|w": null, "d|w": null }, + { "a|w": null, "b|w": null, "c|w": 7.5, "d|w": null }, + { "a|w": 1.5, "b|w": null, "c|w": null, "d|w": null }, + { "a|w": null, "b|w": null, "c|w": null, "d|w": 8.5 }, + ]; + const result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("split by ['y'], col desc sort", async function () { + const table = await perspective.table(data); + const view = await table.view({ + columns: ["w"], + split_by: ["y"], + sort: [["x", "col desc"]], + }); + const paths = await view.column_paths(); + expect(paths).toEqual(["d|w", "c|w", "b|w", "a|w"]); + const answer = { + "d|w": [null, null, null, 4.5, null, null, null, 8.5], + "c|w": [null, null, 3.5, null, null, null, 7.5, null], + "b|w": [null, 2.5, null, null, null, 6.5, null, null], + "a|w": [1.5, null, null, null, 5.5, null, null, null], + }; + const result = await view.to_columns(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("split by and hidden sort ['y']", async function () { + const table = await perspective.table({ + x: [1, 2, 3, 4], + y: ["a", "a", "a", "b"], + }); + const view = await table.view({ + columns: ["x"], + split_by: ["y"], + sort: [["y", "desc"]], + }); + + const paths = await view.column_paths(); + // regular non-col sort should not change order of column paths + expect(paths).toEqual(["a|x", "b|x"]); + + const result = await view.to_columns(); + expect(result).toEqual({ + "a|x": [null, 1, 2, 3], + "b|x": [4, null, null, null], + }); + view.delete(); + table.delete(); + }); + + test("split by and hidden col sort ['y']", async function () { + const table = await perspective.table({ + x: [1, 2, 3, 4], + y: ["a", "a", "a", "b"], + }); + const view = await table.view({ + columns: ["x"], + split_by: ["y"], + sort: [["y", "col desc"]], + }); + + const paths = await view.column_paths(); + expect(paths).toEqual(["b|x", "a|x"]); + + const result = await view.to_columns(); + expect(result).toEqual({ + "b|x": [null, null, null, 4], + "a|x": [1, 2, 3, null], + }); + view.delete(); + table.delete(); + }); + + test("split by and hidden sort ['y'] with aggregates specified", async function () { + const table = await perspective.table({ + x: [1, 2, 3, 4], + y: ["a", "a", "a", "b"], + }); + + // Aggregate for hidden sort should be ignored in column-only, + // so just make sure we stick to that. + const view = await table.view({ + columns: ["x"], + split_by: ["y"], + sort: [["y", "desc"]], + aggregates: { + y: "count", + }, + }); + + const paths = await view.column_paths(); + // regular non-col sort should not change order of column paths + expect(paths).toEqual(["a|x", "b|x"]); + + const result = await view.to_columns(); + expect(result).toEqual({ + "a|x": [null, 1, 2, 3], + "b|x": [4, null, null, null], + }); + view.delete(); + table.delete(); + }); + + test("split by and hidden col sort ['y'] with aggregates specified", async function () { + const table = await perspective.table({ + x: [1, 2, 3, 4], + y: ["a", "a", "a", "b"], + }); + + // Aggregate for hidden sort should be ignored in column-only, + // so just make sure we stick to that. + const view = await table.view({ + columns: ["x"], + split_by: ["y"], + sort: [["y", "col desc"]], + aggregates: { + y: "count", + }, + }); + + const paths = await view.column_paths(); + expect(paths).toEqual(["b|x", "a|x"]); + + const result = await view.to_columns(); + expect(result).toEqual({ + "b|x": [null, null, null, 4], + "a|x": [1, 2, 3, null], + }); + view.delete(); + table.delete(); + }); + + test("split by ['y'] with overridden aggregates", async function () { + const table = await perspective.table({ + x: [1, 2, 3, 4], + y: ["a", "a", "a", "b"], + }); + const view = await table.view({ + columns: ["x"], + split_by: ["y"], + aggregates: { y: "count" }, + sort: [["y", "col desc"]], + }); + + const paths = await view.column_paths(); + // Col sort will override aggregate + expect(paths).toEqual(["b|x", "a|x"]); + + let result = await view.to_columns(); + expect(result).toEqual({ + "b|x": [null, null, null, 4], + "a|x": [1, 2, 3, null], + }); + + view.delete(); + table.delete(); + }); + + test("split by ['y'] with extra aggregates", async function () { + var table = await perspective.table(data); + var view = await table.view({ + columns: ["w"], + split_by: ["y"], + aggregates: { w: "sum", z: "last" }, + sort: [["x", "desc"]], + }); + var answer = [ + { "a|w": null, "b|w": null, "c|w": null, "d|w": 4.5 }, + { "a|w": 5.5, "b|w": null, "c|w": null, "d|w": null }, + { "a|w": null, "b|w": null, "c|w": 3.5, "d|w": null }, + { "a|w": null, "b|w": 6.5, "c|w": null, "d|w": null }, + { "a|w": null, "b|w": 2.5, "c|w": null, "d|w": null }, + { "a|w": null, "b|w": null, "c|w": 7.5, "d|w": null }, + { "a|w": 1.5, "b|w": null, "c|w": null, "d|w": null }, + { "a|w": null, "b|w": null, "c|w": null, "d|w": 8.5 }, + ]; + let result = await view.to_json(); + expect(result).toEqual(answer); + view.delete(); + table.delete(); + }); + + test("group by ['x'], split by ['y'], both hidden and asc sorted", async function () { + const table = await perspective.table({ + x: ["a", "a", "b", "c"], + y: ["x", "x", "y", "x"], + z: [1, 2, 3, 4], + }); + const view = await table.view({ + columns: ["z"], + group_by: ["x"], + split_by: ["y"], + sort: [ + ["x", "asc"], + ["y", "col asc"], + ], + }); + const paths = await view.column_paths(); + expect(paths).toEqual(["x|z", "y|z"]); + const expected = { + __ROW_PATH__: [[], ["a"], ["b"], ["c"]], + "x|z": [7, 3, null, 4], + "y|z": [3, null, 3, null], + }; + const result = await view.to_columns(); + expect(result).toEqual(expected); + view.delete(); + table.delete(); + }); + + test("group by ['x'], split by ['y'], both hidden and desc sorted", async function () { + const table = await perspective.table({ + x: ["a", "a", "b", "c"], + y: ["x", "x", "y", "x"], + z: [1, 2, 3, 4], + }); + const view = await table.view({ + columns: ["z"], + group_by: ["x"], + split_by: ["y"], + sort: [ + ["x", "desc"], + ["y", "col desc"], + ], + }); + const paths = await view.column_paths(); + expect(paths).toEqual(["y|z", "x|z"]); + const expected = { + __ROW_PATH__: [[], ["c"], ["b"], ["a"]], + "y|z": [3, null, 3, null], + "x|z": [7, 4, null, 3], + }; + const result = await view.to_columns(); + expect(result).toEqual(expected); + view.delete(); + table.delete(); + }); + + test("group by ['x'], split by ['y'], hidden sorted on group by", async function () { + const table = await perspective.table({ + x: ["a", "a", "b", "c"], + y: ["x", "x", "y", "x"], + z: [1, 2, 3, 4], + }); + const view = await table.view({ + columns: ["z"], + group_by: ["y"], + split_by: ["x"], + sort: [["x", "desc"]], + }); + const paths = await view.column_paths(); + expect(paths).toEqual(["a|z", "b|z", "c|z"]); + const expected = { + __ROW_PATH__: [[], ["x"], ["y"]], + "a|z": [3, 3, null], + "b|z": [3, null, 3], + "c|z": [4, 4, null], + }; + const result = await view.to_columns(); + expect(result).toEqual(expected); + view.delete(); + table.delete(); + }); + + test("split by ['y'] has correct # of columns", async function () { + var table = await perspective.table(data); + var view = await table.view({ + columns: ["w"], + split_by: ["y"], + sort: [["x", "desc"]], + }); + let to_cols = await view.to_columns(); + let result = await view.num_columns(); + expect(result).toEqual(4); + view.delete(); + table.delete(); + }); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/sync_load.spec.js b/rust/perspective-js/test/js/sync_load.spec.js new file mode 100644 index 0000000..16236a7 --- /dev/null +++ b/rust/perspective-js/test/js/sync_load.spec.js @@ -0,0 +1,22 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; + +test.describe("perspective.js module", function () { + test("does not access the WASM module until it is ready", async () => { + const perspective = await import("@perspective-dev/client"); + const tbl = await perspective.table([{ x: 1 }]); + const size = await tbl.size(); + expect(size).toEqual(1); + }); +}); diff --git a/rust/perspective-js/test/js/test_arrows.js b/rust/perspective-js/test/js/test_arrows.js new file mode 100644 index 0000000..99db45f --- /dev/null +++ b/rust/perspective-js/test/js/test_arrows.js @@ -0,0 +1,127 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 "node:fs"; +import * as path from "node:path"; +import * as url from "node:url"; + +// globalThis.require = createRequire(import.meta.url); +const __filename = url.fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * Returns an `ArrayBuffer` containing the contents of a `.arrow` file + * located at `arrow_path`. + * + * Because `fs.readFileSync` shares its underlying buffer + * between calls to `readFileSync`, we need to get a slice + * of the `ArrayBuffer` specifically at its byte offset. + * + * See https://github.com/nodejs/node/issues/11132 for more details. + * + * @param arrow_path {String} a path to an arrow file. + * @returns {ArrayBuffer} an ArrayBuffer containing the arrow-serialized data. + */ +function load_arrow(arrow_path) { + const data = fs.readFileSync(arrow_path); + return data.buffer.slice( + data.byteOffset, + data.byteOffset + data.byteLength, + ); +} + +export const chunked_arrow = load_arrow( + path.join(__dirname, "..", "arrow", "chunked.arrow"), +); + +export const test_null_arrow = load_arrow( + path.join(__dirname, "..", "arrow", "test_null.arrow"), +); + +export const test_arrow = load_arrow( + path.join(__dirname, "..", "arrow", "test.arrow"), +); + +export const partial_arrow = load_arrow( + path.join(__dirname, "..", "arrow", "partial.arrow"), +); + +export const partial_missing_rows_arrow = load_arrow( + path.join(__dirname, "..", "arrow", "partial_missing_rows.arrow"), +); + +export const int_float_str_arrow = load_arrow( + path.join(__dirname, "..", "arrow", "int_float_str.arrow"), +); + +export const int_float_str_update_arrow = load_arrow( + path.join(__dirname, "..", "arrow", "int_float_str_update.arrow"), +); + +export const int_float_str_file_arrow = load_arrow( + path.join(__dirname, "..", "arrow", "int_float_str_file.arrow"), +); + +export const float32_arrow = load_arrow( + path.join(__dirname, "..", "arrow", "float32.arrow"), +); + +export const date32_arrow = load_arrow( + path.join(__dirname, "..", "arrow", "date32.arrow"), +); + +export const date64_arrow = load_arrow( + path.join(__dirname, "..", "arrow", "date64.arrow"), +); + +export const dict_arrow = load_arrow( + path.join(__dirname, "..", "arrow", "dict.arrow"), +); + +export const dict_update_arrow = load_arrow( + path.join(__dirname, "..", "arrow", "dict_update.arrow"), +); + +export const numbers_arrow = load_arrow( + path.join(__dirname, "..", "arrow", "number_types.arrow"), +); + +export const all_types_arrow = load_arrow( + path.join(__dirname, "..", "arrow", "all_types_small.arrow"), +); + +// uint8-64 x2, int8-64 x2, date, datetime, bool, string +export const all_types_multi_arrow = load_arrow( + path.join(__dirname, "..", "arrow", "all_types_small_multi.arrow"), +); + +export const lists_arrow = load_arrow( + path.join(__dirname, "..", "arrow", "lists.arrow"), +); + +// module.exports = { +// chunked_arrow, +// test_null_arrow, +// test_arrow, +// partial_arrow, +// partial_missing_rows_arrow, +// int_float_str_arrow, +// int_float_str_update_arrow, +// int_float_str_file_arrow, +// date32_arrow, +// date64_arrow, +// dict_arrow, +// dict_update_arrow, +// numbers_arrow, +// all_types_arrow, +// all_types_multi_arrow, +// }; diff --git a/rust/perspective-js/test/js/to_format/_shared.js b/rust/perspective-js/test/js/to_format/_shared.js new file mode 100644 index 0000000..7e4b24e --- /dev/null +++ b/rust/perspective-js/test/js/to_format/_shared.js @@ -0,0 +1,63 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +export const STD_DATE = new Date(); + +export const int_float_string_data = [ + { int: 1, float: 2.25, string: "a", datetime: STD_DATE }, + { int: 2, float: 3.5, string: "b", datetime: STD_DATE }, + { int: 3, float: 4.75, string: "c", datetime: STD_DATE }, + { int: 4, float: 5.25, string: "d", datetime: STD_DATE }, +]; + +export const pivoted_output = [ + { + __ROW_PATH__: [], + int: 10, + float: 15.75, + string: 4, + datetime: 4, + __INDEX__: [3, 2, 1, 0], + }, + { + __ROW_PATH__: [1], + int: 1, + float: 2.25, + string: 1, + datetime: 1, + __INDEX__: [0], + }, + { + __ROW_PATH__: [2], + int: 2, + float: 3.5, + string: 1, + datetime: 1, + __INDEX__: [1], + }, + { + __ROW_PATH__: [3], + int: 3, + float: 4.75, + string: 1, + datetime: 1, + __INDEX__: [2], + }, + { + __ROW_PATH__: [4], + int: 4, + float: 5.25, + string: 1, + datetime: 1, + __INDEX__: [3], + }, +]; diff --git a/rust/perspective-js/test/js/to_format/data_slice.spec.js b/rust/perspective-js/test/js/to_format/data_slice.spec.js new file mode 100644 index 0000000..351a42a --- /dev/null +++ b/rust/perspective-js/test/js/to_format/data_slice.spec.js @@ -0,0 +1,344 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client"; +import { int_float_string_data } from "./_shared"; + +((perspective) => { + test.describe("data slice", function () { + test("should filter out invalid start rows", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view(); + let json = await view.to_json({ + start_row: 5, + }); + expect(json).toEqual([]); + view.delete(); + table.delete(); + }); + + test("should filter out invalid start columns", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view(); + let json = await view.to_json({ + start_col: 5, + }); + expect(json).toEqual([]); + view.delete(); + table.delete(); + }); + + test("should filter out invalid start rows & columns", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view(); + let json = await view.to_json({ + start_row: 5, + start_col: 5, + }); + expect(json).toEqual([]); + view.delete(); + table.delete(); + }); + + test("should filter out invalid start rows based on view", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + filter: [["float", ">", 3.5]], + }); + + // valid on view() but not this filtered view + let json = await view.to_json({ + start_row: 3, + }); + + expect(json).toEqual([]); + + view.delete(); + table.delete(); + }); + + test("should filter out invalid start columns based on view", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + columns: ["float", "int"], + }); + + let json = await view.to_json({ + start_col: 2, + }); + + expect(json).toEqual([]); + view.delete(); + table.delete(); + }); + + test("should filter out invalid start rows & columns based on view", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + columns: ["float", "int"], + filter: [["float", ">", 3.5]], + }); + let json = await view.to_json({ + start_row: 5, + start_col: 5, + }); + expect(json).toEqual([]); + view.delete(); + table.delete(); + }); + + test("should respect start/end rows", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view(); + let json = await view.to_json({ + start_row: 2, + end_row: 3, + }); + let comparator = int_float_string_data[2]; + comparator.datetime = +comparator.datetime; + expect(json[0]).toEqual(comparator); + view.delete(); + table.delete(); + }); + + test("should respect end rows when larger than data size", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view(); + let json = await view.to_json({ + start_row: 2, + end_row: 6, + }); + expect(json).toEqual( + int_float_string_data.slice(2).map((x) => { + x.datetime = +x.datetime; + return x; + }), + ); + view.delete(); + table.delete(); + }); + + test("should respect start/end columns", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view(); + let json = await view.to_columns({ + start_col: 2, + end_col: 3, + }); + let comparator = { + string: int_float_string_data.map((d) => d.string), + }; + expect(json).toEqual(comparator); + view.delete(); + table.delete(); + }); + + test("should floor float start rows", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view(); + let json = await view.to_json({ + start_row: 1.5, + }); + expect(json).toEqual( + int_float_string_data.slice(1).map((x) => { + x.datetime = +x.datetime; + return x; + }), + ); + view.delete(); + table.delete(); + }); + + test("should ceil float end rows", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view(); + let json = await view.to_json({ + end_row: 1.5, + }); + expect(json).toEqual( + // deep copy + JSON.parse(JSON.stringify(int_float_string_data)) + .slice(0, 2) + .map((x) => { + x.datetime = +new Date(x.datetime); + return x; + }), + ); + view.delete(); + table.delete(); + }); + + test("should floor/ceil float start/end rows", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view(); + let json = await view.to_json({ + start_row: 2.9, + end_row: 2.4, + }); + let comparator = int_float_string_data[2]; + comparator.datetime = +comparator.datetime; + expect(json[0]).toEqual(comparator); + view.delete(); + table.delete(); + }); + + test("should ceil float end rows when larger than data size", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view(); + let json = await view.to_json({ + start_row: 2, + end_row: 5.5, + }); + expect(json).toEqual( + int_float_string_data.slice(2).map((x) => { + x.datetime = +x.datetime; + return x; + }), + ); + view.delete(); + table.delete(); + }); + + test("should floor/ceil float start/end columns", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view(); + let json = await view.to_columns({ + start_col: 2.6, + end_col: 2.4, + }); + let comparator = { + string: int_float_string_data.map((d) => d.string), + }; + expect(json).toEqual(comparator); + view.delete(); + table.delete(); + }); + + test("one-sided views should have row paths", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + group_by: ["int"], + }); + let json = await view.to_json(); + for (let d of json) { + expect(d.__ROW_PATH__).toBeDefined(); + } + view.delete(); + table.delete(); + }); + + test("one-sided views with start_col > 0 should have row paths", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + group_by: ["int"], + }); + let json = await view.to_json({ start_col: 1 }); + for (let d of json) { + expect(d.__ROW_PATH__).toBeDefined(); + } + view.delete(); + table.delete(); + }); + + test("one-sided column-only views should not have row paths", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + split_by: ["int"], + }); + let json = await view.to_json(); + for (let d of json) { + expect(d.__ROW_PATH__).toBeUndefined(); + } + view.delete(); + table.delete(); + }); + + test("column-only views should not have header rows", async function () { + let table = await perspective.table([ + { x: 1, y: "a" }, + { x: 2, y: "b" }, + ]); + let view = await table.view({ + split_by: ["x"], + }); + let json = await view.to_json(); + expect(json).toEqual([ + { "1|x": 1, "1|y": "a", "2|x": null, "2|y": null }, + { "1|x": null, "1|y": null, "2|x": 2, "2|y": "b" }, + ]); + view.delete(); + table.delete(); + }); + + test("column-only views should return correct windows of data", async function () { + let table = await perspective.table([ + { x: 1, y: "a" }, + { x: 2, y: "b" }, + ]); + let view = await table.view({ + split_by: ["x"], + }); + let json = await view.to_json({ + start_row: 1, + }); + expect(json).toEqual([ + { "1|x": null, "1|y": null, "2|x": 2, "2|y": "b" }, + ]); + view.delete(); + table.delete(); + }); + + test("two-sided views should have row paths", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + group_by: ["int"], + split_by: ["string"], + }); + let json = await view.to_json(); + for (let d of json) { + expect(d.__ROW_PATH__).toBeDefined(); + } + view.delete(); + table.delete(); + }); + + test("two-sided views with start_col > 0 should have row paths", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + group_by: ["int"], + split_by: ["string"], + }); + let json = await view.to_json({ start_col: 1 }); + for (let d of json) { + expect(d.__ROW_PATH__).toBeDefined(); + } + view.delete(); + table.delete(); + }); + + test("two-sided sorted views with start_col > 0 should have row paths", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + group_by: ["int"], + split_by: ["string"], + sort: [["string", "desc"]], + }); + let json = await view.to_json({ start_col: 1 }); + for (let d of json) { + expect(d.__ROW_PATH__).toBeDefined(); + } + view.delete(); + table.delete(); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/to_format/leaves_only.spec.js b/rust/perspective-js/test/js/to_format/leaves_only.spec.js new file mode 100644 index 0000000..3963a36 --- /dev/null +++ b/rust/perspective-js/test/js/to_format/leaves_only.spec.js @@ -0,0 +1,135 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client"; +import { STD_DATE, int_float_string_data } from "./_shared"; + +((perspective) => { + test.describe("leaves_only flag", function () { + test("only emits leaves when leaves_only is set", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + group_by: ["int"], + group_rollup_mode: "flat", + }); + let json = await view.to_json(); + expect(json).toEqual([ + { + __ROW_PATH__: [1], + datetime: 1, + float: 2.25, + int: 1, + string: 1, + }, + { + __ROW_PATH__: [2], + datetime: 1, + float: 3.5, + int: 2, + string: 1, + }, + { + __ROW_PATH__: [3], + datetime: 1, + float: 4.75, + int: 3, + string: 1, + }, + { + __ROW_PATH__: [4], + datetime: 1, + float: 5.25, + int: 4, + string: 1, + }, + ]); + view.delete(); + table.delete(); + }); + + test("num_rows returns correct leaf count", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + group_by: ["int"], + group_rollup_mode: "flat", + }); + let num_rows = await view.num_rows(); + expect(num_rows).toEqual(4); + view.delete(); + table.delete(); + }); + + test("viewport pagination returns exact page sizes", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + group_by: ["int"], + group_rollup_mode: "flat", + }); + let json = await view.to_json({ + start_row: 0, + end_row: 2, + }); + expect(json.length).toEqual(2); + expect(json[0].__ROW_PATH__).toEqual([1]); + expect(json[1].__ROW_PATH__).toEqual([2]); + view.delete(); + table.delete(); + }); + + test("to_columns works with leaves_only", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + group_by: ["int"], + group_rollup_mode: "flat", + }); + let cols = await view.to_columns(); + expect(cols["__ROW_PATH__"]).toEqual([[1], [2], [3], [4]]); + expect(cols["int"]).toEqual([1, 2, 3, 4]); + view.delete(); + table.delete(); + }); + + test("leaves_only with multi-level group_by", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + group_by: ["string", "int"], + group_rollup_mode: "flat", + }); + let num_rows = await view.num_rows(); + expect(num_rows).toEqual(4); + let json = await view.to_json(); + for (let row of json) { + expect(row.__ROW_PATH__.length).toEqual(2); + } + view.delete(); + table.delete(); + }); + + test("leaves_only updates after table.update()", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + group_by: ["int"], + group_rollup_mode: "flat", + }); + let num_rows = await view.num_rows(); + expect(num_rows).toEqual(4); + table.update([ + { int: 5, float: 6.0, string: "e", datetime: STD_DATE }, + ]); + let num_rows2 = await view.num_rows(); + expect(num_rows2).toEqual(5); + view.delete(); + table.delete(); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/to_format/to_arrow.spec.js b/rust/perspective-js/test/js/to_format/to_arrow.spec.js new file mode 100644 index 0000000..85c6b73 --- /dev/null +++ b/rust/perspective-js/test/js/to_format/to_arrow.spec.js @@ -0,0 +1,1132 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client"; +import { STD_DATE, int_float_string_data, pivoted_output } from "./_shared"; + +((perspective) => { + test.describe("to_arrow()", function () { + test("serializes boolean arrays correctly", async function () { + // prevent regression in boolean parsing + let table = await perspective.table({ + bool: [true, false, true, false, true, false, false], + }); + let view = await table.view(); + let arrow = await view.to_arrow(); + let json = await view.to_json(); + + expect(json).toEqual([ + { bool: true }, + { bool: false }, + { bool: true }, + { bool: false }, + { bool: true }, + { bool: false }, + { bool: false }, + ]); + + let table2 = await perspective.table(arrow); + let view2 = await table2.view(); + let json2 = await view2.to_json(); + expect(json2).toEqual(json); + + view2.delete(); + table2.delete(); + view.delete(); + table.delete(); + }); + + test("does not break when booleans are undefined", async function () { + let table = await perspective.table([ + { int: 1, bool: true }, + { int: 2, bool: false }, + { int: 3, bool: true }, + { int: 4, bool: undefined }, + ]); + let view = await table.view(); + let arrow = await view.to_arrow(); + let json = await view.to_json(); + + expect(json).toEqual([ + { int: 1, bool: true }, + { int: 2, bool: false }, + { int: 3, bool: true }, + { int: 4, bool: null }, + ]); + + let table2 = await perspective.table(arrow); + let view2 = await table2.view(); + let json2 = await view2.to_json(); + expect(json2).toEqual(json); + + view2.delete(); + table2.delete(); + view.delete(); + table.delete(); + }); + + test("arrow output respects start/end rows", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view(); + let arrow = await view.to_arrow({ + start_row: 1, + end_row: 2, + }); + let json2 = await view.to_json(); + //expect(arrow.byteLength).toEqual(1010); + + let table2 = await perspective.table(arrow); + let view2 = await table2.view(); + let json = await view2.to_json(); + expect(json).toEqual(json2.slice(1, 2)); + + view2.delete(); + table2.delete(); + view.delete(); + table.delete(); + }); + + test("Transitive arrow output 0-sided", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view(); + let arrow = await view.to_arrow(); + let json2 = await view.to_json(); + //expect(arrow.byteLength).toEqual(1010); + + let table2 = await perspective.table(arrow); + let view2 = await table2.view(); + let json = await view2.to_json(); + expect(json).toEqual(json2); + + view2.delete(); + table2.delete(); + view.delete(); + table.delete(); + }); + + test("Transitive arrow output 0-sided hidden sort", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + columns: ["float"], + sort: [["string", "desc"]], + }); + let arrow = await view.to_arrow(); + let json2 = await view.to_json(); + //expect(arrow.byteLength).toEqual(1010); + + let table2 = await perspective.table(arrow); + let view2 = await table2.view(); + let json = await view2.to_json(); + expect(json).toEqual(json2); + + view2.delete(); + table2.delete(); + view.delete(); + table.delete(); + }); + + test("Transitive arrow output 0-sided, with row range", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view(); + let arrow = await view.to_arrow({ start_row: 1, end_row: 3 }); + let json2 = await view.to_json({ start_row: 1, end_row: 3 }); + // expect(arrow.byteLength).toEqual(908); + + let table2 = await perspective.table(arrow); + let view2 = await table2.view(); + let json = await view2.to_json(); + expect(json).toEqual(json2); + expect(json.length).toEqual(2); + + view2.delete(); + table2.delete(); + view.delete(); + table.delete(); + }); + + test("Transitive arrow output 0-sided, with col range", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view(); + let arrow = await view.to_arrow({ start_col: 1, end_col: 3 }); + let json2 = await view.to_json({ start_col: 1, end_col: 3 }); + // expect(arrow.byteLength).toEqual(908); + + let table2 = await perspective.table(arrow); + let view2 = await table2.view(); + let json = await view2.to_json(); + expect(json).toEqual(json2); + expect(json.length).toEqual(4); + + view2.delete(); + table2.delete(); + view.delete(); + table.delete(); + }); + + test("Transitive arrow output 1-sided", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ group_by: ["string"] }); + let json = await view.to_json(); + let arrow = await view.to_arrow(); + let table2 = await perspective.table(arrow); + let view2 = await table2.view(); + let json2 = await view2.to_json(); + + expect(json).toEqual( + json2.map((x) => { + x.__ROW_PATH__ = [x["string (Group by 1)"]].filter( + (x) => x, + ); + delete x["string (Group by 1)"]; + return x; + }), + ); + + view2.delete(); + table2.delete(); + view.delete(); + table.delete(); + }); + + test("Arrow output 1-sided mean", async function () { + let table = await perspective.table({ + float: [1.25, 2.25, 3.25, 4.25], + string: ["a", "a", "b", "b"], + }); + let view = await table.view({ + group_by: ["string"], + aggregates: { float: "mean" }, + }); + let arrow = await view.to_arrow(); + let table2 = await perspective.table(arrow); + let view2 = await table2.view(); + let result = await view2.to_columns(); + + expect(result).toEqual({ + "string (Group by 1)": [null, "a", "b"], + float: [2.75, 1.75, 3.75], + string: [4, 2, 2], + }); + + await view2.delete(); + await table2.delete(); + await view.delete(); + await table.delete(); + }); + + test("Transitive arrow output 1-sided mean", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + group_by: ["string"], + aggregates: { float: "mean" }, + }); + let json = await view.to_json(); + let arrow = await view.to_arrow(); + let table2 = await perspective.table(arrow); + let view2 = await table2.view(); + let json2 = await view2.to_json(); + + expect(json).toEqual( + json2.map((x) => { + x.__ROW_PATH__ = [x["string (Group by 1)"]].filter( + (x) => x, + ); + delete x["string (Group by 1)"]; + return x; + }), + ); + + view2.delete(); + table2.delete(); + view.delete(); + table.delete(); + }); + + test("Transitive arrow output 1-sided sorted mean", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + group_by: ["string"], + aggregates: { float: "mean" }, + sort: [["string", "desc"]], + }); + let json = await view.to_json(); + let arrow = await view.to_arrow(); + let table2 = await perspective.table(arrow); + let view2 = await table2.view(); + let json2 = await view2.to_json(); + + expect(json).toEqual( + json2.map((x) => { + x.__ROW_PATH__ = [x["string (Group by 1)"]].filter( + (x) => x, + ); + delete x["string (Group by 1)"]; + return x; + }), + ); + + view2.delete(); + table2.delete(); + view.delete(); + table.delete(); + }); + test("Transitive arrow output 1-sided hidden sort mean", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + group_by: ["string"], + aggregates: { float: "mean" }, + columns: ["float", "int"], + sort: [["string", "desc"]], + }); + let json = await view.to_json(); + let arrow = await view.to_arrow(); + let table2 = await perspective.table(arrow); + let view2 = await table2.view(); + let json2 = await view2.to_json(); + + expect(json).toEqual( + json2.map((x) => { + x.__ROW_PATH__ = [x["string (Group by 1)"]].filter( + (x) => x, + ); + delete x["string (Group by 1)"]; + return x; + }), + ); + + view2.delete(); + table2.delete(); + view.delete(); + table.delete(); + }); + + test("Transitive arrow output 1-sided with row range", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ group_by: ["string"] }); + let json = await view.to_json({ start_row: 1, end_row: 3 }); + let arrow = await view.to_arrow({ start_row: 1, end_row: 3 }); + let table2 = await perspective.table(arrow); + let view2 = await table2.view(); + let json2 = await view2.to_json(); + + expect(json).toEqual( + json2.map((x) => { + x.__ROW_PATH__ = [x["string (Group by 1)"]].filter( + (x) => x, + ); + delete x["string (Group by 1)"]; + return x; + }), + ); + + view2.delete(); + table2.delete(); + view.delete(); + table.delete(); + }); + + test("Transitive arrow output 1-sided with col range", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ group_by: ["string"] }); + let json = await view.to_json({ start_col: 1, end_col: 3 }); + let arrow = await view.to_arrow({ start_col: 1, end_col: 3 }); + let table2 = await perspective.table(arrow); + let view2 = await table2.view(); + let json2 = await view2.to_json(); + + expect(json).toEqual( + json2.map((x) => { + x.__ROW_PATH__ = [x["string (Group by 1)"]].filter( + (x) => x, + ); + delete x["string (Group by 1)"]; + return x; + }), + ); + + view2.delete(); + table2.delete(); + view.delete(); + table.delete(); + }); + + test("Transitive arrow output 2-sided", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + group_by: ["string"], + split_by: ["int"], + }); + let json = await view.to_json(); + let arrow = await view.to_arrow(); + let table2 = await perspective.table(arrow); + let view2 = await table2.view(); + let json2 = await view2.to_json(); + + expect(json).toEqual( + json2.map((x) => { + x.__ROW_PATH__ = [x["string (Group by 1)"]].filter( + (x) => x, + ); + delete x["string (Group by 1)"]; + return x; + }), + ); + + view2.delete(); + table2.delete(); + view.delete(); + table.delete(); + }); + + test("Transitive arrow output 2-sided sorted", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + group_by: ["string"], + split_by: ["int"], + sort: [["int", "desc"]], + }); + let json = await view.to_json(); + let arrow = await view.to_arrow(); + let table2 = await perspective.table(arrow); + let view2 = await table2.view(); + let json2 = await view2.to_json(); + + expect(json).toEqual( + json2.map((x) => { + x.__ROW_PATH__ = [x["string (Group by 1)"]].filter( + (x) => x, + ); + delete x["string (Group by 1)"]; + return x; + }), + ); + + view2.delete(); + table2.delete(); + view.delete(); + table.delete(); + }); + + test("Transitive arrow output 2-sided with row range", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + group_by: ["string"], + split_by: ["int"], + }); + let json = await view.to_json({ start_row: 1, end_row: 3 }); + let arrow = await view.to_arrow({ start_row: 1, end_row: 3 }); + let table2 = await perspective.table(arrow); + let view2 = await table2.view(); + let json2 = await view2.to_json(); + + expect(json).toEqual( + json2.map((x) => { + x.__ROW_PATH__ = [x["string (Group by 1)"]].filter( + (x) => x, + ); + delete x["string (Group by 1)"]; + return x; + }), + ); + + view2.delete(); + table2.delete(); + view.delete(); + table.delete(); + }); + + test("Transitive arrow output 2-sided with col range", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + group_by: ["string"], + split_by: ["int"], + }); + let json = await view.to_json({ start_col: 1, end_col: 3 }); + let arrow = await view.to_arrow({ start_col: 1, end_col: 3 }); + let table2 = await perspective.table(arrow); + let view2 = await table2.view(); + let json2 = await view2.to_json(); + + expect(json).toEqual( + json2.map((x) => { + x.__ROW_PATH__ = [x["string (Group by 1)"]].filter( + (x) => x, + ); + delete x["string (Group by 1)"]; + return x; + }), + ); + + view2.delete(); + table2.delete(); + view.delete(); + table.delete(); + }); + + test("Transitive arrow output 2-sided column only", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ split_by: ["string"] }); + let json = await view.to_json(); + let arrow = await view.to_arrow(); + let table2 = await perspective.table(arrow); + let view2 = await table2.view(); + let json2 = await view2.to_json(); + + expect(json).toEqual(json2); + + view2.delete(); + table2.delete(); + view.delete(); + table.delete(); + }); + + test("Transitive arrow output 2-sided column only hidden sort", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + split_by: ["string"], + columns: ["float"], + sort: [["int", "desc"]], + }); + let json = await view.to_json(); + let arrow = await view.to_arrow(); + let table2 = await perspective.table(arrow); + let view2 = await table2.view(); + let json2 = await view2.to_json(); + + expect(json).toEqual(json2); + + view2.delete(); + table2.delete(); + view.delete(); + table.delete(); + }); + + test("Transitive arrow output 2-sided column only sorted", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + split_by: ["string"], + sort: [["int", "desc"]], + }); + let json = await view.to_json(); + let arrow = await view.to_arrow(); + let table2 = await perspective.table(arrow); + let view2 = await table2.view(); + let json2 = await view2.to_json(); + + expect(json).toEqual(json2); + + view2.delete(); + table2.delete(); + view.delete(); + table.delete(); + }); + + test("Transitive arrow output 2-sided column only row range", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ split_by: ["string"] }); + let json = await view.to_json({ start_row: 1, end_row: 3 }); + let arrow = await view.to_arrow({ start_row: 1, end_row: 3 }); + let table2 = await perspective.table(arrow); + let view2 = await table2.view(); + let json2 = await view2.to_json(); + + expect(json).toEqual(json2); + + view2.delete(); + table2.delete(); + view.delete(); + table.delete(); + }); + + test("Transitive arrow output 2-sided column only col range", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ split_by: ["string"] }); + let json = await view.to_json({ start_col: 1, end_col: 3 }); + let arrow = await view.to_arrow({ start_col: 1, end_col: 3 }); + let table2 = await perspective.table(arrow); + let view2 = await table2.view(); + let json2 = await view2.to_json(); + + expect(json).toEqual(json2); + + view2.delete(); + table2.delete(); + view.delete(); + table.delete(); + }); + + test.describe("to_format with index", function () { + test.describe("0-sided", function () { + test("should return correct pkey for unindexed table", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view(); + let json = await view.to_json({ + start_row: 0, + end_row: 1, + start_col: 1, + end_col: 2, + index: true, + }); + expect(json).toEqual([{ float: 2.25, __INDEX__: [0] }]); + view.delete(); + table.delete(); + }); + + test("should return correct pkey for float indexed table", async function () { + let table = await perspective.table(int_float_string_data, { + index: "float", + }); + let view = await table.view(); + let json = await view.to_json({ + start_row: 0, + end_row: 1, + start_col: 1, + end_col: 2, + index: true, + }); + expect(json).toEqual([{ float: 2.25, __INDEX__: [2.25] }]); + view.delete(); + table.delete(); + }); + + test("should return correct pkey for string indexed table", async function () { + let table = await perspective.table(int_float_string_data, { + index: "string", + }); + let view = await table.view(); + let json = await view.to_json({ + start_row: 0, + end_row: 1, + start_col: 1, + end_col: 2, + index: true, + }); + expect(json).toEqual([{ float: 2.25, __INDEX__: ["a"] }]); + view.delete(); + table.delete(); + }); + + test("should return correct pkey for date indexed table", async function () { + // default data generates the same datetime for each row, thus pkeys get collapsed + const data = [ + { int: 1, datetime: new Date() }, + { int: 2, datetime: new Date() }, + ]; + data[1].datetime.setDate(data[1].datetime.getDate() + 1); + let table = await perspective.table(data, { + index: "datetime", + }); + let view = await table.view(); + let json = await view.to_json({ + start_row: 1, + end_row: 2, + index: true, + }); + expect(json).toEqual([ + { + int: 2, + datetime: data[1].datetime.getTime(), + __INDEX__: [data[1].datetime.getTime()], + }, + ]); + view.delete(); + table.delete(); + }); + + test("should return correct pkey for all rows + columns on an unindexed table", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view(); + let json = await view.to_json({ + index: true, + }); + + for (let i = 0; i < json.length; i++) { + expect(json[i].__INDEX__).toEqual([i]); + } + view.delete(); + table.delete(); + }); + + test("should return correct pkey for all rows + columns on an indexed table", async function () { + let table = await perspective.table(int_float_string_data, { + index: "string", + }); + let view = await table.view(); + let json = await view.to_json({ + index: true, + }); + + let pkeys = ["a", "b", "c", "d"]; + for (let i = 0; i < json.length; i++) { + expect(json[i].__INDEX__).toEqual([pkeys[i]]); + } + view.delete(); + table.delete(); + }); + }); + }); + + test.describe("0-sided column subset", function () { + test("should return correct pkey for unindexed table", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + columns: ["int", "datetime"], + }); + let json = await view.to_json({ + start_row: 0, + end_row: 1, + start_col: 1, + end_col: 2, + index: true, + }); + expect(json).toEqual([ + { + datetime: + int_float_string_data[0]["datetime"].getTime(), + __INDEX__: [0], + }, + ]); + view.delete(); + table.delete(); + }); + + test("should return correct pkey for float indexed table", async function () { + let table = await perspective.table(int_float_string_data, { + index: "float", + }); + let view = await table.view({ + columns: ["float", "int"], + }); + let json = await view.to_json({ + start_row: 0, + end_row: 1, + start_col: 1, + end_col: 2, + index: true, + }); + expect(json).toEqual([{ int: 1, __INDEX__: [2.25] }]); + view.delete(); + table.delete(); + }); + + test("should return correct pkey for string indexed table", async function () { + let table = await perspective.table(int_float_string_data, { + index: "string", + }); + let view = await table.view({ + columns: ["string", "datetime"], + }); + let json = await view.to_json({ + start_row: 0, + end_row: 1, + start_col: 1, + end_col: 2, + index: true, + }); + expect(json).toEqual([ + { + datetime: + int_float_string_data[0]["datetime"].getTime(), + __INDEX__: ["a"], + }, + ]); + view.delete(); + table.delete(); + }); + + test("should return correct pkey for date indexed table", async function () { + // default data generates the same datetime for each row, thus pkeys get collapsed + const data = [ + { int: 1, datetime: new Date() }, + { int: 2, datetime: new Date() }, + ]; + data[1].datetime.setDate(data[1].datetime.getDate() + 1); + let table = await perspective.table(data, { + index: "datetime", + }); + let view = await table.view({ + columns: ["int"], + }); + let json = await view.to_json({ + start_row: 1, + end_row: 2, + index: true, + }); + expect(json).toEqual([ + { int: 2, __INDEX__: [data[1].datetime.getTime()] }, + ]); + view.delete(); + table.delete(); + }); + + test("should return correct pkey for all rows + columns on an unindexed table", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + columns: ["int"], + }); + let json = await view.to_json({ + index: true, + }); + + for (let i = 0; i < json.length; i++) { + expect(json[i].__INDEX__).toEqual([i]); + } + view.delete(); + table.delete(); + }); + + test("should return correct pkey for all rows + columns on an indexed table", async function () { + let table = await perspective.table(int_float_string_data, { + index: "string", + }); + let view = await table.view(); + let json = await view.to_json({ + index: true, + }); + + let pkeys = ["a", "b", "c", "d"]; + for (let i = 0; i < json.length; i++) { + expect(json[i].__INDEX__).toEqual([pkeys[i]]); + } + view.delete(); + table.delete(); + }); + }); + + test.describe("0-sided column subset invalid bounds", function () { + test("should return correct pkey for unindexed table, invalid column", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + columns: ["int"], + }); + let json = await view.to_json({ + start_row: 0, + end_row: 1, + start_col: 1, + end_col: 2, + index: true, + }); + expect(json).toEqual([{ __INDEX__: [0] }]); + view.delete(); + table.delete(); + }); + + test("should not return pkey for unindexed table, invalid row", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + columns: ["int"], + }); + let json = await view.to_json({ + start_row: 10, + end_row: 15, + index: true, + }); + expect(json).toEqual([]); + view.delete(); + table.delete(); + }); + + test("should return correct pkey for float indexed table, invalid column", async function () { + let table = await perspective.table(int_float_string_data, { + index: "float", + }); + let view = await table.view({ + columns: ["float"], + }); + let json = await view.to_json({ + start_row: 0, + end_row: 1, + start_col: 1, + end_col: 2, + index: true, + }); + expect(json).toEqual([{ __INDEX__: [2.25] }]); + view.delete(); + table.delete(); + }); + + test("should not return pkey for float indexed table, invalid row", async function () { + let table = await perspective.table(int_float_string_data, { + index: "float", + }); + let view = await table.view({ + columns: ["float"], + }); + let json = await view.to_json({ + start_row: 10, + end_row: 15, + start_col: 1, + end_col: 2, + index: true, + }); + expect(json).toEqual([]); + view.delete(); + table.delete(); + }); + + test("should return correct pkey for string indexed table, invalid column", async function () { + let table = await perspective.table(int_float_string_data, { + index: "string", + }); + let view = await table.view({ + columns: ["string"], + }); + let json = await view.to_json({ + start_row: 0, + end_row: 1, + start_col: 1, + end_col: 2, + index: true, + }); + expect(json).toEqual([{ __INDEX__: ["a"] }]); + view.delete(); + table.delete(); + }); + + test("should not return pkey for string indexed table, invalid row", async function () { + let table = await perspective.table(int_float_string_data, { + index: "string", + }); + let view = await table.view({ + columns: ["string"], + }); + let json = await view.to_json({ + start_row: 10, + end_row: 11, + start_col: 1, + end_col: 2, + index: true, + }); + expect(json).toEqual([]); + view.delete(); + table.delete(); + }); + + test("should return correct pkey for date indexed table, invalid column", async function () { + // default data generates the same datetime for each row, thus pkeys get collapsed + const data = [ + { int: 1, datetime: new Date() }, + { int: 2, datetime: new Date() }, + ]; + data[1].datetime.setDate(data[1].datetime.getDate() + 1); + let table = await perspective.table(data, { + index: "datetime", + }); + let view = await table.view({ + columns: ["int"], + }); + let json = await view.to_json({ + start_col: 2, + index: true, + }); + expect(json).toEqual([ + { + __INDEX__: [data[0].datetime.getTime()], + }, + { + __INDEX__: [data[1].datetime.getTime()], + }, + ]); + view.delete(); + table.delete(); + }); + + test("should not return pkey for date indexed table, invalid row", async function () { + // default data generates the same datetime for each row, thus pkeys get collapsed + const data = [ + { int: 1, datetime: new Date() }, + { int: 2, datetime: new Date() }, + ]; + data[1].datetime.setDate(data[1].datetime.getDate() + 1); + let table = await perspective.table(data, { + index: "datetime", + }); + let view = await table.view({ + columns: ["int"], + }); + let json = await view.to_json({ + start_row: 11, + start_row: 12, + index: true, + }); + expect(json).toEqual([]); + view.delete(); + table.delete(); + }); + }); + + test.describe("0-sided sorted", function () { + test("should return correct pkey for unindexed table", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + sort: [["float", "desc"]], + }); + let json = await view.to_json({ + start_row: 0, + end_row: 1, + start_col: 1, + end_col: 2, + index: true, + }); + expect(json).toEqual([{ float: 5.25, __INDEX__: [3] }]); + view.delete(); + table.delete(); + }); + + test("should return correct pkey for float indexed table", async function () { + let table = await perspective.table(int_float_string_data, { + index: "float", + }); + let view = await table.view({ + sort: [["float", "desc"]], + }); + let json = await view.to_json({ + start_row: 0, + end_row: 1, + start_col: 1, + end_col: 2, + index: true, + }); + expect(json).toEqual([{ float: 5.25, __INDEX__: [5.25] }]); + view.delete(); + table.delete(); + }); + + test("should return correct pkey for string indexed table", async function () { + let table = await perspective.table(int_float_string_data, { + index: "string", + }); + let view = await table.view({ + sort: [["float", "desc"]], + }); + let json = await view.to_json({ + start_row: 0, + end_row: 1, + start_col: 1, + end_col: 2, + index: true, + }); + expect(json).toEqual([{ float: 5.25, __INDEX__: ["d"] }]); + view.delete(); + table.delete(); + }); + + test("should return correct pkey for date indexed table", async function () { + // default data generates the same datetime for each row, + // thus pkeys get collapsed + const data = [ + { int: 200, datetime: new Date() }, + { int: 100, datetime: new Date() }, + ]; + data[1].datetime.setDate(data[1].datetime.getDate() + 1); + let table = await perspective.table(data, { + index: "datetime", + }); + let view = await table.view({ + sort: [["int", "desc"]], + }); + let json = await view.to_json({ + index: true, + }); + expect(json).toEqual([ + { + int: 200, + datetime: data[0].datetime.getTime(), + __INDEX__: [data[0].datetime.getTime()], + }, + { + int: 100, + datetime: data[1].datetime.getTime(), + __INDEX__: [data[1].datetime.getTime()], + }, + ]); + view.delete(); + table.delete(); + }); + + test("should return correct pkey for all rows + columns on an unindexed table", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + sort: [["float", "asc"]], + }); + let json = await view.to_json({ + index: true, + }); + + for (let i = 0; i < json.length; i++) { + expect(json[i].__INDEX__).toEqual([i]); + } + view.delete(); + table.delete(); + }); + + test("should return correct pkey for all rows + columns on an indexed table", async function () { + let table = await perspective.table(int_float_string_data, { + index: "string", + }); + let view = await table.view({ + sort: [["float", "desc"]], + }); + let json = await view.to_json({ + index: true, + }); + + let pkeys = ["d", "c", "b", "a"]; + for (let i = 0; i < json.length; i++) { + expect(json[i].__INDEX__).toEqual([pkeys[i]]); + } + view.delete(); + table.delete(); + }); + }); + + test.describe("1-sided", function () { + test("should generate pkeys of aggregated rows for 1-sided", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + group_by: ["int"], + }); + let json = await view.to_json({ + index: true, + }); + + // total row contains all pkeys for underlying rows; each aggregated row should have pkeys for the rows that belong to it + expect(json).toEqual(pivoted_output); + view.delete(); + table.delete(); + }); + }); + + test.describe("2-sided", function () { + test.skip("should generate pkey for 2-sided", async function () { + // 2-sided implicit pkeys do not work + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + group_by: ["int"], + split_by: ["float"], + }); + let json = await view.to_json({ + index: true, + }); + + expect(json[0]["__INDEX__"]).toEqual([0, 1]); // total row should have indices for every row inside it + + let idx = 0; + for (let item of json.slice(1)) { + expect(item["__INDEX__"]).toEqual([idx]); + idx++; + } + }); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/to_format/to_column_string.spec.js b/rust/perspective-js/test/js/to_format/to_column_string.spec.js new file mode 100644 index 0000000..9e76e8d --- /dev/null +++ b/rust/perspective-js/test/js/to_format/to_column_string.spec.js @@ -0,0 +1,25 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client"; + +test.describe("to_columns_string", () => { + test("should return a string", async () => { + const table = await perspective.table([{ x: 1 }]); + const view = await table.view(); + const result = await view.to_columns_string(); + expect(result).toEqual('{"x":[1]}'); + view.delete(); + table.delete(); + }); +}); diff --git a/rust/perspective-js/test/js/to_format/to_csv.spec.ts b/rust/perspective-js/test/js/to_format/to_csv.spec.ts new file mode 100644 index 0000000..a2bcef1 --- /dev/null +++ b/rust/perspective-js/test/js/to_format/to_csv.spec.ts @@ -0,0 +1,108 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client"; + +test.describe("to_csv", () => { + test("0-sided view returns CSV string", async () => { + const table = await perspective.table([ + { x: 1, y: "a" }, + { x: 2, y: "b" }, + ]); + const view = await table.view(); + const csv = await view.to_csv(); + expect(csv).toEqual('"x","y"\n1,"a"\n2,"b"\n'); + view.delete(); + table.delete(); + }); + + test("respects start_row/end_row viewport", async () => { + const table = await perspective.table([ + { x: 1, y: "a" }, + { x: 2, y: "b" }, + { x: 3, y: "c" }, + ]); + const view = await table.view(); + const csv = await view.to_csv({ start_row: 1, end_row: 2 }); + expect(csv).toEqual('"x","y"\n2,"b"\n'); + view.delete(); + table.delete(); + }); + + test("1-sided view emits row paths", async () => { + const table = await perspective.table([ + { x: 1, y: "a" }, + { x: 2, y: "a" }, + { x: 3, y: "b" }, + ]); + const view = await table.view({ group_by: ["y"] }); + const csv = await view.to_csv(); + expect(csv).toEqual( + '"y (Group by 1)","x","y"\n,6,3\n"a",3,2\n"b",3,1\n', + ); + view.delete(); + table.delete(); + }); + + test("escapes quotes and commas in string values", async () => { + const table = await perspective.table([ + { x: 'he said "hi"', y: "a,b" }, + ]); + const view = await table.view(); + const csv = await view.to_csv(); + expect(csv).toEqual('"x","y"\n"he said ""hi""","a,b"\n'); + view.delete(); + table.delete(); + }); + + // https://github.com/perspective-dev/perspective/issues/3152 + test("escapes utf8 in string values", async () => { + const table = await perspective.table([ + { + 年月: "2023/01/01", + 轄區分局: "第四分局", + 路口名稱: "南屯區", + 路口名稱split: "五權西路與環中路口", + A1: 0.0, + A2: 3.0, + A3: 130.0, + 總件數: 18.0, + 死亡人數: 0.0, + 受傷人數: null, + 主要肇因: "未注意車前狀態", + }, + { + 年月: "2023/01/01", + 轄區分局: "第六分局", + 路口名稱: "西屯區", + 路口名稱split: "中清聯絡道與環中路口", + A1: 0.0, + A2: 2.0, + A3: 100.0, + 總件數: 15.0, + 死亡人數: 0.0, + 受傷人數: null, + 主要肇因: "未注意車前狀態", + }, + ]); + + const view = await table.view(); + const csv = await view.to_csv(); + expect(csv).toEqual( + `"年月","轄區分局","路口名稱","路口名稱split","A1","A2","A3","總件數","死亡人數","主要肇因","受傷人數"\n2023-01-01,"第四分局","南屯區","五權西路與環中路口",0,3,130,18,0,"未注意車前狀態",\n2023-01-01,"第六分局","西屯區","中清聯絡道與環中路口",0,2,100,15,0,"未注意車前狀態",\n`, + ); + + view.delete(); + table.delete(); + }); +}); diff --git a/rust/perspective-js/test/js/to_format/to_format_regressions.spec.js b/rust/perspective-js/test/js/to_format/to_format_regressions.spec.js new file mode 100644 index 0000000..d326e8f --- /dev/null +++ b/rust/perspective-js/test/js/to_format/to_format_regressions.spec.js @@ -0,0 +1,62 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client"; + +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +import * as fs from "node:fs"; + +const superstore_uncompressed = fs.readFileSync( + require.resolve("superstore-arrow/superstore.arrow"), +).buffer; + +const superstore_lz4 = fs.readFileSync( + require.resolve("superstore-arrow/superstore.lz4.arrow"), +).buffer; + +test.describe("to_format regressions", function () { + test("start_col is respected", async () => { + let table = await perspective.table(superstore_uncompressed.slice()); + let view = await table.view({ + group_by: ["State"], + split_by: ["Sub-Category"], + // sort: [["Customer Name", "desc"]], + group_rollup_mode: "rollup", + columns: ["Sales", "Quantity", "Discount", "Profit"], + }); + + const result1 = await view.to_columns({ start_col: 4, end_row: 1 }); + const result2 = await view.to_columns({ start_col: 5, end_row: 1 }); + + expect(result1).not.toEqual(result2); + }); + + test("start_col is respected with sort", async () => { + let table = await perspective.table(superstore_uncompressed.slice()); + let view = await table.view({ + group_by: ["State"], + split_by: ["Sub-Category"], + sort: [["Customer Name", "desc"]], + group_rollup_mode: "rollup", + columns: ["Sales", "Quantity", "Discount", "Profit"], + }); + + const result1 = await view.to_columns({ start_col: 4, end_row: 1 }); + const result2 = await view.to_columns({ start_col: 5, end_row: 1 }); + + expect(result1).not.toEqual(result2); + }); +}); diff --git a/rust/perspective-js/test/js/to_format/to_format_viewport.spec.js b/rust/perspective-js/test/js/to_format/to_format_viewport.spec.js new file mode 100644 index 0000000..db9eee2 --- /dev/null +++ b/rust/perspective-js/test/js/to_format/to_format_viewport.spec.js @@ -0,0 +1,292 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client"; + +const data = { + w: [ + 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, -1.5, -3.5, -1.5, -4.5, -9.5, + -5.5, -8.5, -7.5, + ], + x: [1, 2, 3, 4, 4, 3, 2, 1, 3, 4, 2, 1, 4, 3, 1, 2], + y: [ + "a", + "b", + "c", + "d", + "a", + "b", + "c", + "d", + "a", + "b", + "c", + "d", + "a", + "b", + "c", + "d", + ], + z: [ + true, + false, + true, + false, + true, + false, + true, + false, + true, + true, + true, + true, + false, + false, + false, + false, + ], +}; + +test.describe("to_format viewport", function () { + test.describe("0 sided", function () { + test("start_col 0 is the first col", async function () { + var table = await perspective.table(data); + var view = await table.view({}); + const cols = await view.to_columns({ start_col: 0, end_col: 1 }); + expect(cols).toEqual({ w: data.w }); + view.delete(); + table.delete(); + }); + + test("start_col 2 is the second col", async function () { + var table = await perspective.table(data); + var view = await table.view({}); + const cols = await view.to_columns({ start_col: 1, end_col: 2 }); + expect(cols).toEqual({ x: data.x }); + view.delete(); + table.delete(); + }); + + test("start_col 0, end_col 2 is the first two columns", async function () { + var table = await perspective.table(data); + var view = await table.view({}); + const cols = await view.to_columns({ start_col: 0, end_col: 2 }); + expect(cols).toEqual({ w: data.w, x: data.x }); + view.delete(); + table.delete(); + }); + }); + + test.describe("1 sided", function () { + test("start_col 0 is the first col", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["y"], + }); + const cols = await view.to_columns({ start_col: 0, end_col: 1 }); + expect(cols).toEqual({ + __ROW_PATH__: [[], ["a"], ["b"], ["c"], ["d"]], + w: [-2, -4, 0, 1, 1], + }); + view.delete(); + table.delete(); + }); + + test("start_col 2 is the second col", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["y"], + }); + const cols = await view.to_columns({ start_col: 1, end_col: 2 }); + expect(cols).toEqual({ + __ROW_PATH__: [[], ["a"], ["b"], ["c"], ["d"]], + x: [40, 12, 12, 8, 8], + }); + view.delete(); + table.delete(); + }); + + test("start_col 0, end_col 2 is the first two columns", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["y"], + }); + const cols = await view.to_columns({ start_col: 0, end_col: 2 }); + expect(cols).toEqual({ + __ROW_PATH__: [[], ["a"], ["b"], ["c"], ["d"]], + w: [-2, -4, 0, 1, 1], + x: [40, 12, 12, 8, 8], + }); + view.delete(); + table.delete(); + }); + }); + + test.describe("2 sided", function () { + test("start_col 0 is the first col", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["y"], + split_by: ["z"], + }); + const cols = await view.to_columns({ start_col: 0, end_col: 1 }); + expect(cols).toEqual({ + __ROW_PATH__: [[], ["a"], ["b"], ["c"], ["d"]], + "false|w": [-9, -9.5, 3.5, -8.5, 5.5], + }); + view.delete(); + table.delete(); + }); + + test("start_col 2 is the second col", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["y"], + split_by: ["z"], + }); + const cols = await view.to_columns({ start_col: 1, end_col: 2 }); + expect(cols).toEqual({ + __ROW_PATH__: [[], ["a"], ["b"], ["c"], ["d"]], + "false|x": [20, 4, 8, 1, 7], + }); + view.delete(); + table.delete(); + }); + + test("start_col 0, end_col 2 is the first two columns", async function () { + var table = await perspective.table(data); + var view = await table.view({ + group_by: ["y"], + split_by: ["z"], + }); + const cols = await view.to_columns({ start_col: 0, end_col: 2 }); + expect(cols).toEqual({ + __ROW_PATH__: [[], ["a"], ["b"], ["c"], ["d"]], + "false|w": [-9, -9.5, 3.5, -8.5, 5.5], + "false|x": [20, 4, 8, 1, 7], + }); + view.delete(); + table.delete(); + }); + }); + + test.describe("column only", function () { + test("start_col 0 is the first col", async function () { + var table = await perspective.table(data); + var view = await table.view({ + split_by: ["z"], + }); + const cols = await view.to_columns({ start_col: 0, end_col: 1 }); + expect(cols).toEqual({ + "false|w": [ + null, + 2.5, + null, + 4.5, + null, + 6.5, + null, + 8.5, + null, + null, + null, + null, + -9.5, + -5.5, + -8.5, + -7.5, + ], + }); + view.delete(); + table.delete(); + }); + + test("start_col 2 is the second col", async function () { + var table = await perspective.table(data); + var view = await table.view({ + split_by: ["z"], + }); + const cols = await view.to_columns({ start_col: 1, end_col: 2 }); + expect(cols).toEqual({ + "false|x": [ + null, + 2, + null, + 4, + null, + 3, + null, + 1, + null, + null, + null, + null, + 4, + 3, + 1, + 2, + ], + }); + view.delete(); + table.delete(); + }); + + test("start_col 0, end_col 2 is the first two columns", async function () { + var table = await perspective.table(data); + var view = await table.view({ + split_by: ["z"], + }); + const cols = await view.to_columns({ start_col: 0, end_col: 2 }); + expect(cols).toEqual({ + "false|w": [ + null, + 2.5, + null, + 4.5, + null, + 6.5, + null, + 8.5, + null, + null, + null, + null, + -9.5, + -5.5, + -8.5, + -7.5, + ], + "false|x": [ + null, + 2, + null, + 4, + null, + 3, + null, + 1, + null, + null, + null, + null, + 4, + 3, + 1, + 2, + ], + }); + view.delete(); + table.delete(); + }); + }); +}); diff --git a/rust/perspective-js/test/js/to_format/to_json.spec.js b/rust/perspective-js/test/js/to_format/to_json.spec.js new file mode 100644 index 0000000..dd2ea4d --- /dev/null +++ b/rust/perspective-js/test/js/to_format/to_json.spec.js @@ -0,0 +1,98 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client"; +import { int_float_string_data } from "./_shared"; + +((perspective) => { + test.describe("to_json", function () { + test("should emit same number of column names as number of pivots", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + group_by: ["int"], + split_by: ["float", "string"], + sort: [["int", "asc"]], + }); + let json = await view.to_json(); + // Get the first emitted column name that is not __ROW_PATH__ + let name = Object.keys(json[0])[1]; + // make sure that number of separators = num of split by + expect((name.match(/\|/g) || []).length).toEqual(2); + view.delete(); + table.delete(); + }); + + test("should return dates in native form by default", async function () { + let table = await perspective.table([ + { datetime: new Date("2016-06-13") }, + { datetime: new Date("2016-06-14") }, + ]); + let view = await table.view(); + let json = await view.to_json(); + expect(json).toEqual([ + { datetime: 1465776000000 }, + { datetime: 1465862400000 }, + ]); + view.delete(); + table.delete(); + }); + + test.skip("OG - should return dates in readable format on passing string in options", async function () { + let table = await perspective.table([ + { datetime: new Date("2016-06-13") }, + { datetime: new Date("2016-06-14") }, + ]); + let view = await table.view(); + let json = await view.to_json({ formatted: true }); + expect(json).toEqual([ + { datetime: "2016-06-13" }, + { datetime: "2016-06-14" }, + ]); + view.delete(); + table.delete(); + }); + + test("should return dates in readable format on passing string in options", async function () { + let table = await perspective.table({ + date: "date", + }); + await table.update([ + { date: new Date("2016-06-13") }, + { date: new Date("2016-06-14") }, + ]); + let view = await table.view(); + let json = await view.to_json({ formatted: true }); + expect(json).toEqual([ + { date: "2016-06-13" }, + { date: "2016-06-14" }, + ]); + view.delete(); + table.delete(); + }); + + test("should return datetimes in readable format on passing string in options", async function () { + let table = await perspective.table([ + { datetime: new Date(2016, 0, 1, 0, 30) }, + { datetime: new Date(2016, 5, 15, 19, 20) }, + ]); + let view = await table.view(); + let json = await view.to_json({ formatted: true }); + expect(json).toEqual([ + { datetime: "2016-01-01 00:30:00.000" }, + { datetime: "2016-06-15 19:20:00.000" }, + ]); + view.delete(); + table.delete(); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/to_format/to_ndjson.spec.js b/rust/perspective-js/test/js/to_format/to_ndjson.spec.js new file mode 100644 index 0000000..1655e4a --- /dev/null +++ b/rust/perspective-js/test/js/to_format/to_ndjson.spec.js @@ -0,0 +1,63 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client"; +import { STD_DATE, int_float_string_data } from "./_shared"; + +((perspective) => { + test.describe("to_ndjson", function () { + test("should work with context 0", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({}); + let json = await view.to_ndjson(); + expect(json) + .toEqual(`{"int":1,"float":2.25,"string":"a","datetime":${+STD_DATE}} +{"int":2,"float":3.5,"string":"b","datetime":${+STD_DATE}} +{"int":3,"float":4.75,"string":"c","datetime":${+STD_DATE}} +{"int":4,"float":5.25,"string":"d","datetime":${+STD_DATE}}`); + view.delete(); + table.delete(); + }); + + test("should work with context 1", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ group_by: ["string"] }); + let json = await view.to_ndjson(); + expect(json) + .toEqual(`{"__ROW_PATH__":[],"int":10,"float":15.75,"string":4,"datetime":4} +{"__ROW_PATH__":["a"],"int":1,"float":2.25,"string":1,"datetime":1} +{"__ROW_PATH__":["b"],"int":2,"float":3.5,"string":1,"datetime":1} +{"__ROW_PATH__":["c"],"int":3,"float":4.75,"string":1,"datetime":1} +{"__ROW_PATH__":["d"],"int":4,"float":5.25,"string":1,"datetime":1}`); + view.delete(); + table.delete(); + }); + + test("should work with context 2", async function () { + let table = await perspective.table(int_float_string_data); + let view = await table.view({ + group_by: ["string"], + split_by: ["int"], + }); + let json = await view.to_ndjson(); + expect(json) + .toEqual(`{"__ROW_PATH__":[],"1|int":1,"1|float":2.25,"1|string":1,"1|datetime":1,"2|int":2,"2|float":3.5,"2|string":1,"2|datetime":1,"3|int":3,"3|float":4.75,"3|string":1,"3|datetime":1,"4|int":4,"4|float":5.25,"4|string":1,"4|datetime":1} +{"__ROW_PATH__":["a"],"1|int":1,"1|float":2.25,"1|string":1,"1|datetime":1,"2|int":null,"2|float":null,"2|string":null,"2|datetime":null,"3|int":null,"3|float":null,"3|string":null,"3|datetime":null,"4|int":null,"4|float":null,"4|string":null,"4|datetime":null} +{"__ROW_PATH__":["b"],"1|int":null,"1|float":null,"1|string":null,"1|datetime":null,"2|int":2,"2|float":3.5,"2|string":1,"2|datetime":1,"3|int":null,"3|float":null,"3|string":null,"3|datetime":null,"4|int":null,"4|float":null,"4|string":null,"4|datetime":null} +{"__ROW_PATH__":["c"],"1|int":null,"1|float":null,"1|string":null,"1|datetime":null,"2|int":null,"2|float":null,"2|string":null,"2|datetime":null,"3|int":3,"3|float":4.75,"3|string":1,"3|datetime":1,"4|int":null,"4|float":null,"4|string":null,"4|datetime":null} +{"__ROW_PATH__":["d"],"1|int":null,"1|float":null,"1|string":null,"1|datetime":null,"2|int":null,"2|float":null,"2|string":null,"2|datetime":null,"3|int":null,"3|float":null,"3|string":null,"3|datetime":null,"4|int":4,"4|float":5.25,"4|string":1,"4|datetime":1}`); + view.delete(); + table.delete(); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/to_format/with_column_typed_array.spec.ts b/rust/perspective-js/test/js/to_format/with_column_typed_array.spec.ts new file mode 100644 index 0000000..b801103 --- /dev/null +++ b/rust/perspective-js/test/js/to_format/with_column_typed_array.spec.ts @@ -0,0 +1,825 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "../perspective_client"; + +test.describe("with_typed_arrays()", () => { + test("awaits promise returned by async callback before releasing the batch", async () => { + // The callback returns a Promise that only resolves after a + // microtask tick; the zero-copy views must remain valid for the + // full awaited duration. We copy *after* the tick to prove the + // backing WASM memory is still readable. + const table = await perspective.table({ + x: ["a", "b", "a", "c"], + }); + const view = await table.view(); + let resolved: string[] | null = null; + await view.with_typed_arrays( + {}, + async ( + _n: string[], + vals: ArrayLike[], + _valids: any[], + dicts: (string[] | null)[], + ) => { + const keys = vals[0] as Int32Array; + const dict = dicts[0]!; + // Yield twice to prove the Rust side is actually awaiting + // the returned promise rather than firing a sync call and + // dropping the batch immediately. + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + resolved = Array.from(keys).map((k) => dict[k]); + }, + ); + expect(resolved).toEqual(["a", "b", "a", "c"]); + await view.delete(); + await table.delete(); + }); + + test("rejected promise from async callback surfaces as a rejection", async () => { + const table = await perspective.table({ x: [1, 2, 3] }); + const view = await table.view(); + let caught: unknown = null; + try { + await view.with_typed_arrays({}, async () => { + throw new Error("callback boom"); + }); + } catch (e) { + caught = e; + } + expect(String(caught)).toContain("callback boom"); + await view.delete(); + await table.delete(); + }); + + test("returns all columns with names, values, validities, dictionaries", async () => { + const table = await perspective.table({ + a: [1, 2, 3], + b: [10.0, 20.0, 30.0], + }); + + const view = await table.view(); + let names: string[] = []; + let valuesMap: Record> = {}; + await view.with_typed_arrays( + {}, + ( + n: string[], + vals: ArrayLike[], + _valids: (Uint8Array | null)[], + _dicts: (string[] | null)[], + ) => { + names = Array.from(n); + for (let i = 0; i < names.length; i++) { + valuesMap[names[i]] = vals[i]; + } + }, + ); + + expect(names).toContain("a"); + expect(names).toContain("b"); + expect(Array.from(valuesMap["b"])).toEqual([10.0, 20.0, 30.0]); + await view.delete(); + await table.delete(); + }); + + test("returns Float64Array for float column by default", async () => { + const table = await perspective.table({ x: [1.5, 2.5, 3.5] }); + const view = await table.view(); + let result: Float64Array | null = null; + await view.with_typed_arrays( + {}, + (_n: string[], vals: ArrayLike[]) => { + result = new Float64Array(vals[0] as Float64Array); + }, + ); + + expect(Array.from(result!)).toEqual([1.5, 2.5, 3.5]); + await view.delete(); + await table.delete(); + }); + + test("returns Float32Array when float32 option is set", async () => { + const table = await perspective.table({ x: [1.5, 2.5, 3.5] }); + const view = await table.view(); + let isFloat32 = false; + await view.with_typed_arrays( + { float32: true }, + (_n: string[], vals: any[]) => { + isFloat32 = vals[0] instanceof Float32Array; + }, + ); + + expect(isFloat32).toBe(true); + await view.delete(); + await table.delete(); + }); + + test("returns Int32Array for integer column", async () => { + const table = await perspective.table({ x: "integer" }); + table.update({ x: [10, 20, 30] }); + const view = await table.view(); + let result: Int32Array | null = null; + await view.with_typed_arrays( + {}, + (_n: string[], vals: ArrayLike[]) => { + result = new Int32Array(vals[0] as Int32Array); + }, + ); + + expect(Array.from(result!)).toEqual([10, 20, 30]); + await view.delete(); + await table.delete(); + }); + + test("passes validity bitmap for columns with nulls", async () => { + const table = await perspective.table({ x: [1.5, null, 3.5, null] }); + const view = await table.view(); + let validity: Uint8Array | null = null; + await view.with_typed_arrays( + {}, + (_n: string[], _vals: any[], valids: (Uint8Array | null)[]) => { + validity = valids[0] ? new Uint8Array(valids[0]) : null; + }, + ); + + // Bits 0 and 2 set => 0b0101 = 5 + expect(validity![0]).toEqual(0b0101); + await view.delete(); + await table.delete(); + }); + + test("validity is null when no nulls present", async () => { + const table = await perspective.table({ x: [1.0, 2.0, 3.0] }); + const view = await table.view(); + let validityWasNull = false; + await view.with_typed_arrays( + {}, + (_n: string[], _vals: any[], valids: (Uint8Array | null)[]) => { + validityWasNull = valids[0] === null; + }, + ); + + expect(validityWasNull).toBe(true); + await view.delete(); + await table.delete(); + }); + + test("returns dictionary keys and values for string column", async () => { + const table = await perspective.table({ + x: ["apple", "banana", "apple", "cherry"], + }); + const view = await table.view(); + let keys: Int32Array | null = null; + let dict: string[] | null = null; + await view.with_typed_arrays( + {}, + ( + _n: string[], + vals: ArrayLike[], + _valids: any[], + dicts: (string[] | null)[], + ) => { + keys = new Int32Array(vals[0] as Int32Array); + dict = dicts[0] ? Array.from(dicts[0]) : null; + }, + ); + + expect(keys!.length).toEqual(4); + const resolved = Array.from(keys!).map((k) => dict![k]); + expect(resolved).toEqual(["apple", "banana", "apple", "cherry"]); + await view.delete(); + await table.delete(); + }); + + test("dictionary is null for non-string columns", async () => { + const table = await perspective.table({ x: [1.0, 2.0, 3.0] }); + const view = await table.view(); + let dictWasNull = false; + await view.with_typed_arrays( + {}, + ( + _n: string[], + _vals: any[], + _valids: any[], + dicts: (string[] | null)[], + ) => { + dictWasNull = dicts[0] === null; + }, + ); + + expect(dictWasNull).toBe(true); + await view.delete(); + await table.delete(); + }); + + test("supports row windowing via start_row/end_row", async () => { + const table = await perspective.table({ x: [10, 20, 30, 40, 50] }); + const view = await table.view(); + let result: Int32Array | null = null; + await view.with_typed_arrays( + { start_row: 1, end_row: 3 }, + (_n: string[], vals: ArrayLike[]) => { + result = new Int32Array(vals[0] as Int32Array); + }, + ); + + expect(Array.from(result!)).toEqual([20, 30]); + await view.delete(); + await table.delete(); + }); + + test("returns Float64Array of millis for datetime column", async () => { + const d1 = new Date("2020-01-01T00:00:00Z"); + const d2 = new Date("2021-06-15T12:30:00Z"); + const table = await perspective.table({ + x: [d1.getTime(), d2.getTime()], + }); + const view = await table.view(); + let result: Float64Array | null = null; + await view.with_typed_arrays( + {}, + (_n: string[], vals: ArrayLike[]) => { + result = new Float64Array(vals[0] as Float64Array); + }, + ); + + expect(Array.from(result!)).toEqual([d1.getTime(), d2.getTime()]); + await view.delete(); + await table.delete(); + }); + + test("returns Float64Array of millis for date column", async () => { + const table = await perspective.table({ x: "date" }); + table.update({ x: ["2020-01-01", "2021-06-15"] }); + const view = await table.view(); + let result: Float64Array | null = null; + await view.with_typed_arrays( + {}, + (_n: string[], vals: ArrayLike[]) => { + result = new Float64Array(vals[0] as Float64Array); + }, + ); + + const expected = [ + new Date("2020-01-01").getTime(), + new Date("2021-06-15").getTime(), + ]; + expect(Array.from(result!)).toEqual(expected); + await view.delete(); + await table.delete(); + }); + + test.describe("float32 option", () => { + test("Float64 column contents narrowed to Float32Array", async () => { + const table = await perspective.table({ x: [1.5, 2.5, 3.5, 4.5] }); + const view = await table.view(); + let result: Float32Array | null = null; + await view.with_typed_arrays( + { float32: true }, + (_n: string[], vals: any[]) => { + result = new Float32Array(vals[0]); + }, + ); + + expect(result).toBeInstanceOf(Float32Array); + // Values exactly representable in float32 + expect(Array.from(result!)).toEqual([1.5, 2.5, 3.5, 4.5]); + await view.delete(); + await table.delete(); + }); + + test("Int32 column is NOT affected by float32 option", async () => { + const table = await perspective.table({ x: "integer" }); + table.update({ x: [10, 20, 30] }); + const view = await table.view(); + let isInt32 = false; + let result: Int32Array | null = null; + await view.with_typed_arrays( + { float32: true }, + (_n: string[], vals: any[]) => { + isInt32 = vals[0] instanceof Int32Array; + result = new Int32Array(vals[0]); + }, + ); + + expect(isInt32).toBe(true); + expect(Array.from(result!)).toEqual([10, 20, 30]); + await view.delete(); + await table.delete(); + }); + + test("Date column narrowed to Float32Array of millis", async () => { + const table = await perspective.table({ x: "date" }); + table.update({ x: ["2020-01-01", "2021-06-15"] }); + const view = await table.view(); + let result: Float32Array | null = null; + await view.with_typed_arrays( + { float32: true }, + (_n: string[], vals: any[]) => { + result = new Float32Array(vals[0]); + }, + ); + + expect(result).toBeInstanceOf(Float32Array); + // Float32 precision loss is expected for large millis values + const expected = [ + new Date("2020-01-01").getTime(), + new Date("2021-06-15").getTime(), + ]; + // Narrow to f32 then back to compare (lossy conversion) + const expectedF32 = Array.from(new Float32Array(expected)); + expect(Array.from(result!)).toEqual(expectedF32); + await view.delete(); + await table.delete(); + }); + + test("Datetime column narrowed to Float32Array of millis", async () => { + const d1 = new Date("2020-01-01T00:00:00Z"); + const d2 = new Date("2021-06-15T12:30:00Z"); + const table = await perspective.table({ + x: [d1.getTime(), d2.getTime()], + }); + const view = await table.view(); + let result: Float32Array | null = null; + await view.with_typed_arrays( + { float32: true }, + (_n: string[], vals: any[]) => { + result = new Float32Array(vals[0]); + }, + ); + + expect(result).toBeInstanceOf(Float32Array); + const expectedF32 = Array.from( + new Float32Array([d1.getTime(), d2.getTime()]), + ); + expect(Array.from(result!)).toEqual(expectedF32); + await view.delete(); + await table.delete(); + }); + + test("Dictionary string column returns Int32Array keys (unaffected by float32)", async () => { + const table = await perspective.table({ + x: ["a", "b", "a", "c"], + }); + const view = await table.view(); + let isInt32 = false; + let dict: string[] | null = null; + await view.with_typed_arrays( + { float32: true }, + ( + _n: string[], + vals: any[], + _valids: any[], + dicts: (string[] | null)[], + ) => { + isInt32 = vals[0] instanceof Int32Array; + dict = dicts[0] ? Array.from(dicts[0]) : null; + }, + ); + + expect(isInt32).toBe(true); + expect(dict).not.toBeNull(); + await view.delete(); + await table.delete(); + }); + + test("float32 omitted defaults to Float64Array", async () => { + const table = await perspective.table({ x: [1.5, 2.5, 3.5] }); + const view = await table.view(); + let isFloat64 = false; + await view.with_typed_arrays({}, (_n: string[], vals: any[]) => { + isFloat64 = vals[0] instanceof Float64Array; + }); + + expect(isFloat64).toBe(true); + await view.delete(); + await table.delete(); + }); + + test("float32: false is equivalent to omitting it", async () => { + const table = await perspective.table({ x: [1.5, 2.5, 3.5] }); + const view = await table.view(); + let isFloat64 = false; + await view.with_typed_arrays( + { float32: false }, + (_n: string[], vals: any[]) => { + isFloat64 = vals[0] instanceof Float64Array; + }, + ); + + expect(isFloat64).toBe(true); + await view.delete(); + await table.delete(); + }); + + test("float32 combines with row windowing", async () => { + const table = await perspective.table({ + x: [1.5, 2.5, 3.5, 4.5, 5.5], + }); + const view = await table.view(); + let result: Float32Array | null = null; + await view.with_typed_arrays( + { float32: true, start_row: 1, end_row: 4 }, + (_n: string[], vals: any[]) => { + result = new Float32Array(vals[0]); + }, + ); + + expect(result).toBeInstanceOf(Float32Array); + expect(Array.from(result!)).toEqual([2.5, 3.5, 4.5]); + await view.delete(); + await table.delete(); + }); + + test("float32 on multi-column view: floats narrowed, ints unchanged", async () => { + const table = await perspective.table({ + a: "integer", + b: "float", + }); + table.update({ a: [1, 2, 3], b: [1.5, 2.5, 3.5] }); + const view = await table.view(); + const typeMap: Record = {}; + await view.with_typed_arrays( + { float32: true }, + (n: string[], vals: any[]) => { + for (let i = 0; i < n.length; i++) { + typeMap[n[i]] = vals[i].constructor.name; + } + }, + ); + + expect(typeMap["a"]).toEqual("Int32Array"); + expect(typeMap["b"]).toEqual("Float32Array"); + await view.delete(); + await table.delete(); + }); + }); + + test.describe("group_by", () => { + test("emits __ROW_PATH_0__ column for single group_by", async () => { + const table = await perspective.table({ + category: ["a", "a", "b", "b"], + value: [1, 2, 3, 4], + }); + const view = await table.view({ + group_by: ["category"], + aggregates: { value: "sum" }, + }); + + let names: string[] = []; + await view.with_typed_arrays({}, (n: string[]) => { + names = Array.from(n); + }); + + expect(names).toContain("__ROW_PATH_0__"); + // Should NOT contain the legacy "category (Group by 1)" naming + expect(names).not.toContain("category (Group by 1)"); + await view.delete(); + await table.delete(); + }); + + test("__ROW_PATH_0__ is a Dictionary column with expected values", async () => { + const table = await perspective.table({ + category: ["a", "a", "b", "b", "c"], + value: [1, 2, 3, 4, 5], + }); + const view = await table.view({ + group_by: ["category"], + aggregates: { value: "sum" }, + }); + + let keys: Int32Array | null = null; + let dict: string[] | null = null; + await view.with_typed_arrays( + {}, + ( + n: string[], + vals: any[], + _valids: any[], + dicts: (string[] | null)[], + ) => { + const idx = n.indexOf("__ROW_PATH_0__"); + expect(idx).toBeGreaterThanOrEqual(0); + keys = new Int32Array(vals[idx]); + dict = dicts[idx] ? Array.from(dicts[idx]) : null; + }, + ); + + expect(dict).not.toBeNull(); + // Resolve keys to strings. First row is the total (empty/null), + // remaining rows are the groups. + const resolved = Array.from(keys!).map((k) => + k >= 0 ? dict![k] : null, + ); + expect(resolved.slice(1).sort()).toEqual(["a", "b", "c"]); + await view.delete(); + await table.delete(); + }); + + test("emits __ROW_PATH_0__ and __ROW_PATH_1__ for two-level group_by", async () => { + const table = await perspective.table({ + region: ["US", "US", "EU", "EU"], + country: ["x", "y", "a", "b"], + value: [1, 2, 3, 4], + }); + const view = await table.view({ + group_by: ["region", "country"], + aggregates: { value: "sum" }, + }); + + let names: string[] = []; + await view.with_typed_arrays({}, (n: string[]) => { + names = Array.from(n); + }); + + expect(names).toContain("__ROW_PATH_0__"); + expect(names).toContain("__ROW_PATH_1__"); + expect(names).not.toContain("region (Group by 1)"); + expect(names).not.toContain("country (Group by 2)"); + await view.delete(); + await table.delete(); + }); + + test("aggregate value column is present alongside row path", async () => { + const table = await perspective.table({ + category: ["a", "a", "b"], + value: [10, 20, 30], + }); + const view = await table.view({ + group_by: ["category"], + aggregates: { value: "sum" }, + }); + + const columns: Record = {}; + await view.with_typed_arrays( + {}, + ( + n: string[], + vals: any[], + _valids: any[], + dicts: (string[] | null)[], + ) => { + for (let i = 0; i < n.length; i++) { + columns[n[i]] = { vals: vals[i], dict: dicts[i] }; + } + }, + ); + + expect(columns["__ROW_PATH_0__"]).toBeDefined(); + expect(columns["value"]).toBeDefined(); + // Value column should be numeric (not Dictionary) + expect(columns["value"].dict).toBeNull(); + await view.delete(); + await table.delete(); + }); + + test("regular to_arrow still uses legacy naming by default", async () => { + // Sanity check: `to_arrow` (not with_typed_arrays) should + // still use legacy "colname (Group by N)" naming for backwards + // compatibility. `with_typed_arrays` forces the new naming. + const table = await perspective.table({ + category: ["a", "b"], + value: [1, 2], + }); + const view = await table.view({ + group_by: ["category"], + aggregates: { value: "sum" }, + }); + + const arrow = await view.to_arrow(); + // The Arrow IPC bytes should contain the legacy name. + const bytes = new Uint8Array(arrow); + const text = new TextDecoder().decode(bytes); + expect(text.includes("(Group by 1)")).toBe(true); + await view.delete(); + await table.delete(); + }); + }); + + test.describe("group_rollup_mode: flat", () => { + test("emits one row per distinct group (no total/aggregate rows)", async () => { + // In flat mode, only leaf rows are emitted — no intermediate + // rollup totals. 5 input rows with 3 distinct categories yields + // 3 output rows (one per group). + const table = await perspective.table({ + category: ["a", "a", "b", "b", "c"], + value: [1, 2, 3, 4, 5], + }); + const view = await table.view({ + group_by: ["category"], + group_rollup_mode: "flat", + aggregates: { value: "sum" }, + }); + + let rowCount = 0; + await view.with_typed_arrays({}, (_n: string[], vals: any[]) => { + rowCount = vals[0].length; + }); + + expect(rowCount).toEqual(3); + await view.delete(); + await table.delete(); + }); + + test("__ROW_PATH_0__ contains one entry per distinct group", async () => { + const table = await perspective.table({ + category: ["a", "b", "a", "c"], + value: [10, 20, 30, 40], + }); + const view = await table.view({ + group_by: ["category"], + group_rollup_mode: "flat", + aggregates: { value: "sum" }, + }); + + let paths: (string | null)[] = []; + await view.with_typed_arrays( + {}, + ( + n: string[], + vals: any[], + _valids: any[], + dicts: (string[] | null)[], + ) => { + const idx = n.indexOf("__ROW_PATH_0__"); + const keys = vals[idx] as Int32Array; + const dict = dicts[idx]!; + paths = Array.from(keys).map((k) => + k >= 0 ? dict[k] : null, + ); + }, + ); + + expect(paths.slice().sort()).toEqual(["a", "b", "c"]); + await view.delete(); + await table.delete(); + }); + + test("aggregate column contains per-group sums in flat mode", async () => { + // Sum aggregate per group: a=10+20=30, b=30. + const table = await perspective.table({ + category: ["a", "a", "b"], + value: [10, 20, 30], + }); + const view = await table.view({ + group_by: ["category"], + group_rollup_mode: "flat", + aggregates: { value: "sum" }, + }); + + const rowMap: Record = {}; + await view.with_typed_arrays( + {}, + ( + n: string[], + vals: any[], + _valids: any[], + dicts: (string[] | null)[], + ) => { + const pathIdx = n.indexOf("__ROW_PATH_0__"); + const valueIdx = n.indexOf("value"); + const keys = vals[pathIdx] as Int32Array; + const dict = dicts[pathIdx]!; + const values = vals[valueIdx] as ArrayLike; + for (let i = 0; i < keys.length; i++) { + rowMap[dict[keys[i]]] = values[i]; + } + }, + ); + + expect(rowMap).toEqual({ a: 30, b: 30 }); + await view.delete(); + await table.delete(); + }); + + test("flat mode with two-level group_by emits both __ROW_PATH_N__ columns", async () => { + const table = await perspective.table({ + region: ["US", "US", "EU"], + country: ["x", "y", "a"], + value: [1, 2, 3], + }); + const view = await table.view({ + group_by: ["region", "country"], + group_rollup_mode: "flat", + aggregates: { value: "sum" }, + }); + + let names: string[] = []; + const paths: Record = {}; + await view.with_typed_arrays( + {}, + ( + n: string[], + vals: any[], + _valids: any[], + dicts: (string[] | null)[], + ) => { + names = Array.from(n); + for (const key of ["__ROW_PATH_0__", "__ROW_PATH_1__"]) { + const idx = n.indexOf(key); + if (idx >= 0) { + const keys = vals[idx] as Int32Array; + const dict = dicts[idx]!; + paths[key] = Array.from(keys).map((k) => + k >= 0 ? dict[k] : null, + ); + } + } + }, + ); + + expect(names).toContain("__ROW_PATH_0__"); + expect(names).toContain("__ROW_PATH_1__"); + // 3 distinct (region, country) combinations — one row each. + expect(paths["__ROW_PATH_0__"].length).toEqual(3); + expect(paths["__ROW_PATH_1__"].length).toEqual(3); + // Ensure (region, country) pairs are preserved. + const pairs = paths["__ROW_PATH_0__"].map( + (r, i) => `${r}|${paths["__ROW_PATH_1__"][i]}`, + ); + expect(pairs.slice().sort()).toEqual(["EU|a", "US|x", "US|y"]); + await view.delete(); + await table.delete(); + }); + + test("flat mode: all __ROW_PATH_N__ values are non-null (no aggregate rows)", async () => { + // In rollup mode, aggregate/total rows have null path segments. + // In flat mode, every row is a leaf so all paths are fully + // populated. + const table = await perspective.table({ + region: ["US", "EU"], + country: ["x", "y"], + value: [1, 2], + }); + const view = await table.view({ + group_by: ["region", "country"], + group_rollup_mode: "flat", + aggregates: { value: "sum" }, + }); + + let hasNullPath = false; + await view.with_typed_arrays( + {}, + ( + n: string[], + vals: any[], + _valids: any[], + dicts: (string[] | null)[], + ) => { + for (let i = 0; i < n.length; i++) { + if (!n[i].startsWith("__ROW_PATH_")) continue; + const keys = vals[i] as Int32Array; + const dict = dicts[i]; + for (let j = 0; j < keys.length; j++) { + if (keys[j] < 0 || !dict || !dict[keys[j]]) { + hasNullPath = true; + return; + } + } + } + }, + ); + + expect(hasNullPath).toBe(false); + await view.delete(); + await table.delete(); + }); + + test("flat mode preserves value types (Int64 aggregate not coerced)", async () => { + // Flat-mode sum over integer input still flows through as an + // Int64 column (from the C++ engine's aggregate type), which + // with_typed_arrays converts to Float64Array. + const table = await perspective.table({ + category: ["a", "b", "c"], + value: [10, 20, 30], + }); + const view = await table.view({ + group_by: ["category"], + group_rollup_mode: "flat", + aggregates: { value: "sum" }, + }); + + let valueType = ""; + await view.with_typed_arrays({}, (n: string[], vals: any[]) => { + const idx = n.indexOf("value"); + valueType = vals[idx].constructor.name; + }); + + expect(valueType).toEqual("Float64Array"); + await view.delete(); + await table.delete(); + }); + }); +}); diff --git a/rust/perspective-js/test/js/updates.spec.js b/rust/perspective-js/test/js/updates.spec.js new file mode 100644 index 0000000..ed94b56 --- /dev/null +++ b/rust/perspective-js/test/js/updates.spec.js @@ -0,0 +1,3531 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 _ from "lodash"; +import * as arrows from "./test_arrows.js"; + +import { test, expect } from "@perspective-dev/test"; +import perspective from "./perspective_client"; + +function it_old_behavior(name, capture, opts = { skip: false }) { + let test_fn = test; + if (opts.skip) { + test_fn = test.skip; + } + test_fn(name, async function () { + let done; + let result = new Promise((x) => { + done = x; + }); + + await capture(done); + await result; + }); +} + +var data = [ + { x: 1, y: "a", z: true }, + { x: 2, y: "b", z: false }, + { x: 3, y: "c", z: true }, + { x: 4, y: "d", z: false }, +]; + +var col_data = { + x: [1, 2, 3, 4], + y: ["a", "b", "c", "d"], + z: [true, false, true, false], +}; + +var meta = { + x: "integer", + y: "string", + z: "boolean", +}; + +var data_2 = [ + { x: 3, y: "c", z: false }, + { x: 4, y: "d", z: true }, + { x: 5, y: "g", z: false }, + { x: 6, y: "h", z: true }, +]; + +const arrow_result = [ + { + f32: 1.5, + f64: 1.5, + i64: 1, + i32: 1, + i16: 1, + i8: 1, + bool: true, + char: "a", + dict: "a", + datetime: +new Date("2018-01-25"), + }, + { + f32: 2.5, + f64: 2.5, + i64: 2, + i32: 2, + i16: 2, + i8: 2, + bool: false, + char: "b", + dict: "b", + datetime: +new Date("2018-01-26"), + }, + { + f32: 3.5, + f64: 3.5, + i64: 3, + i32: 3, + i16: 3, + i8: 3, + bool: true, + char: "c", + dict: "c", + datetime: +new Date("2018-01-27"), + }, + { + f32: 4.5, + f64: 4.5, + i64: 4, + i32: 4, + i16: 4, + i8: 4, + bool: false, + char: "d", + dict: "d", + datetime: +new Date("2018-01-28"), + }, + { + f32: 5.5, + f64: 5.5, + i64: 5, + i32: 5, + i16: 5, + i8: 5, + bool: true, + char: "d", + dict: "d", + datetime: +new Date("2018-01-29"), + }, +]; + +const arrow_partial_result = [ + { + f32: 1.5, + f64: 1.5, + i64: 1, + i32: 1, + i16: 1, + i8: 1, + bool: false, + char: "a", + dict: "a", + datetime: +new Date("2018-01-25"), + }, + { + f32: 2.5, + f64: 2.5, + i64: 2, + i32: 2, + i16: 2, + i8: 2, + bool: true, + char: "b", + dict: "b", + datetime: +new Date("2018-01-26"), + }, + { + f32: 3.5, + f64: 3.5, + i64: 3, + i32: 3, + i16: 3, + i8: 3, + bool: false, + char: "c", + dict: "c", + datetime: +new Date("2018-01-27"), + }, + { + f32: 4.5, + f64: 4.5, + i64: 4, + i32: 4, + i16: 4, + i8: 4, + bool: true, + char: "d", + dict: "d", + datetime: +new Date("2018-01-28"), + }, + { + f32: 5.5, + f64: 5.5, + i64: 5, + i32: 5, + i16: 5, + i8: 5, + bool: false, + char: "d", + dict: "d", + datetime: +new Date("2018-01-29"), + }, +]; + +const arrow_partial_missing_result = [ + { + f32: 1.5, + f64: 1.5, + i64: 1, + i32: 1, + i16: 1, + i8: 1, + bool: false, + char: "a", + dict: "a", + datetime: +new Date("2018-01-25"), + }, + { + f32: 2.5, + f64: 2.5, + i64: 2, + i32: 2, + i16: 2, + i8: 2, + bool: false, + char: "b", + dict: "b", + datetime: +new Date("2018-01-26"), + }, + { + f32: 3.5, + f64: 3.5, + i64: 3, + i32: 3, + i16: 3, + i8: 3, + bool: false, + char: "c", + dict: "c", + datetime: +new Date("2018-01-27"), + }, + { + f32: 4.5, + f64: 4.5, + i64: 4, + i32: 4, + i16: 4, + i8: 4, + bool: false, + char: "d", + dict: "d", + datetime: +new Date("2018-01-28"), + }, + { + f32: 5.5, + f64: 5.5, + i64: 5, + i32: 5, + i16: 5, + i8: 5, + bool: false, + char: "d", + dict: "d", + datetime: +new Date("2018-01-29"), + }, +]; + +const arrow_indexed_result = [ + { + f32: 1.5, + f64: 1.5, + i64: 1, + i32: 1, + i16: 1, + i8: 1, + bool: true, + char: "a", + dict: "a", + datetime: +new Date("2018-01-25"), + }, + { + f32: 2.5, + f64: 2.5, + i64: 2, + i32: 2, + i16: 2, + i8: 2, + bool: false, + char: "b", + dict: "b", + datetime: +new Date("2018-01-26"), + }, + { + f32: 3.5, + f64: 3.5, + i64: 3, + i32: 3, + i16: 3, + i8: 3, + bool: true, + char: "c", + dict: "c", + datetime: +new Date("2018-01-27"), + }, + { + f32: 5.5, + f64: 5.5, + i64: 5, + i32: 5, + i16: 5, + i8: 5, + bool: true, + char: "d", + dict: "d", + datetime: +new Date("2018-01-29"), + }, +]; + +async function match_delta(perspective, delta, expected) { + let table = await perspective.table(delta); + let view = await table.view(); + let json = await view.to_json(); + expect(json).toEqual(expected); + view.delete(); + table.delete(); +} + +((perspective) => { + test.describe("Removes", function () { + test("should not remove without explicit index", async function () { + const table = await perspective.table(meta); + await table.update(data); + const view = await table.view(); + let threw = false; + try { + // In 3.0, this throws an exception + await table.remove([0, 1]); + } catch (e) { + threw = true; + } + + expect(threw).toBeTruthy(); + const result = await view.to_json(); + expect(await view.num_rows()).toEqual(4); + expect(result.length).toEqual(4); + expect(result).toEqual(data); + expect(await table.size()).toEqual(4); + view.delete(); + table.delete(); + }); + + test("after an `update()`", async function () { + const table = await perspective.table(meta, { index: "x" }); + await table.update(data); + const view = await table.view(); + await table.remove([1, 2]); + const result = await view.to_json(); + expect(await view.num_rows()).toEqual(2); + expect(result.length).toEqual(2); + expect(result).toEqual(data.slice(2, 4)); + expect(await table.size()).toEqual(2); + view.delete(); + table.delete(); + }); + + test("after a regular data load", async function () { + const table = await perspective.table(data, { index: "x" }); + const view = await table.view(); + await table.remove([1, 2]); + const result = await view.to_json(); + expect(await view.num_rows()).toEqual(2); + expect(result.length).toEqual(2); + expect(result).toEqual(data.slice(2, 4)); + expect(await table.size()).toEqual(2); + view.delete(); + table.delete(); + }); + + test("after an `update()`, string pkey", async function () { + const table = await perspective.table(meta, { index: "y" }); + await table.update(data); + const view = await table.view(); + await table.remove(["a", "b"]); + const result = await view.to_json(); + expect(await view.num_rows()).toEqual(2); + expect(result.length).toEqual(2); + expect(result).toEqual(data.slice(2, 4)); + expect(await table.size()).toEqual(2); + view.delete(); + table.delete(); + }); + + test("after a regular data load, string pkey", async function () { + const table = await perspective.table(data, { index: "y" }); + const view = await table.view(); + await table.remove(["a", "b"]); + const result = await view.to_json(); + expect(await view.num_rows()).toEqual(2); + expect(result.length).toEqual(2); + expect(result).toEqual(data.slice(2, 4)); + expect(await table.size()).toEqual(2); + view.delete(); + table.delete(); + }); + + test("after an update, date pkey", async function () { + const datetimes = [ + new Date(2020, 0, 15), + new Date(2020, 1, 15), + new Date(2020, 2, 15), + new Date(2020, 3, 15), + ]; + const table = await perspective.table( + { + x: "integer", + y: "date", + z: "float", + }, + { index: "y" }, + ); + await table.update({ + x: [1, 2, 3, 4], + y: datetimes, + z: [1.5, 2.5, 3.5, 4.5], + }); + const view = await table.view(); + await table.remove(datetimes.slice(0, 2)); + const result = await view.to_columns(); + expect(await view.num_rows()).toEqual(2); + expect(result).toEqual({ + x: [3, 4], + y: [1584230400000, 1586908800000], + z: [3.5, 4.5], + }); + // expect(await table.size()).toEqual(2); + view.delete(); + table.delete(); + }); + + test("after an update, datetime pkey", async function () { + const datetimes = [ + new Date(2020, 0, 15), + new Date(2020, 1, 15), + new Date(2020, 2, 15), + new Date(2020, 3, 15), + ]; + const table = await perspective.table( + { + x: "integer", + y: "datetime", + z: "float", + }, + { index: "y" }, + ); + await table.update({ + x: [1, 2, 3, 4], + y: datetimes, + z: [1.5, 2.5, 3.5, 4.5], + }); + const view = await table.view(); + await table.remove(datetimes.slice(0, 2)); + const result = await view.to_columns(); + expect(await view.num_rows()).toEqual(2); + expect(result).toEqual({ + x: [3, 4], + y: [1584230400000, 1586908800000], + z: [3.5, 4.5], + }); + expect(await table.size()).toEqual(2); + view.delete(); + table.delete(); + }); + + test("after a regular data load, datetime pkey", async function () { + const datetimes = [ + new Date(2020, 0, 15), + new Date(2020, 1, 15), + new Date(2020, 2, 15), + new Date(2020, 3, 15), + ]; + const table = await perspective.table( + { + x: [1, 2, 3, 4], + y: datetimes, + z: [1.5, 2.5, 3.5, 4.5], + }, + { index: "y" }, + ); + const view = await table.view(); + await table.remove(datetimes.slice(0, 2)); + const result = await view.to_columns(); + expect(await view.num_rows()).toEqual(2); + expect(result).toEqual({ + x: [3, 4], + y: [1584230400000, 1586908800000], + z: [3.5, 4.5], + }); + expect(await table.size()).toEqual(2); + view.delete(); + table.delete(); + }); + + test("multiple single element removes", async function () { + const table = await perspective.table(meta, { index: "x" }); + for (let i = 0; i < 100; i++) { + await table.update([{ x: i, y: "test", z: false }]); + } + for (let i = 1; i < 100; i++) { + await table.remove([i]); + } + const view = await table.view(); + const result = await view.to_json(); + expect(result).toEqual([{ x: 0, y: "test", z: false }]); + expect(result.length).toEqual(1); + expect(await table.size()).toEqual(1); + view.delete(); + table.delete(); + }); + + test("remove while pivoted, last and count", async function () { + const table = await perspective.table( + { + x: ["a"], + y: ["A"], + idx: [1], + }, + { index: "idx" }, + ); + + const view = await table.view({ + aggregates: { + x: "last", + y: "count", + }, + group_by: ["y"], + }); + + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[], ["A"]], + x: ["a", "a"], + y: [1, 1], + idx: [1, 1], + }); + + await table.update({ + x: ["b"], + y: ["B"], + idx: [2], + }); + + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[], ["A"], ["B"]], + x: ["b", "a", "b"], + y: [2, 1, 1], + idx: [3, 1, 2], + }); + + await table.remove([1]); + + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[], ["B"]], + x: ["b", "b"], + y: [1, 1], + idx: [2, 2], + }); + + await table.remove([2]); + + expect(await view.to_columns()).toEqual({ + __ROW_PATH__: [[]], + x: [""], + y: [0], + idx: [0], + }); + + await view.delete(); + await table.delete(); + }); + + test("Removes out of order", async function () { + const table = await perspective.table( + { + x: "string", + y: "integer", + }, + { index: "x" }, + ); + + await table.update({ + x: ["b", "1", "a", "c"], + y: [3, 1, 2, 4], + }); + + const view = await table.view(); + expect(await view.to_columns()).toEqual({ + x: ["1", "a", "b", "c"], + y: [1, 2, 3, 4], + }); + + await table.remove(["a", "c", "1", "b"]); + + // num_rows should always reflect latest - we did not call_process + // in num_rows and I think the viewer covered that up because + // it would immediately serialize and thus flush the queue. + expect(await view.num_rows()).toEqual(0); + expect(await view.to_json()).toEqual([]); + + await view.delete(); + await table.delete(); + }); + + test("No-op on pkeys not in the set", async function () { + const table = await perspective.table( + { + x: "string", + y: "integer", + }, + { index: "x" }, + ); + + await table.update({ + x: ["b", "1", "a", "c"], + y: [3, 1, 2, 4], + }); + + const view = await table.view(); + expect(await view.to_columns()).toEqual({ + x: ["1", "a", "b", "c"], + y: [1, 2, 3, 4], + }); + + await table.remove(["z", "ff", "2312", "b"]); + + expect(await view.to_columns()).toEqual({ + x: ["1", "a", "c"], + y: [1, 2, 4], + }); + + await view.delete(); + await table.delete(); + }); + + test("conflation order is consistent", async function () { + const table = await perspective.table( + { + x: "string", + y: "integer", + }, + { index: "x" }, + ); + + await table.update({ + x: ["b", "1", "a", "c"], + y: [3, 1, 2, 4], + }); + await table.remove(["1", "c"]); + + // removes applied after update + const view = await table.view(); + expect(await view.to_columns()).toEqual({ + x: ["a", "b"], + y: [2, 3], + }); + + await table.update({ + x: ["b"], + y: [103], + }); + await table.remove(["b"]); + + expect(await view.to_columns()).toEqual({ + x: ["a"], + y: [2], + }); + + await table.update({ + x: ["b"], + y: [100], + }); + + await table.remove(["a"]); + + expect(await view.to_columns()).toEqual({ + x: ["b"], + y: [100], + }); + + // remove applied after update + for (let i = 0; i < 100; i++) { + await table.update({ + x: ["c", "a"], + y: [i + 1, i + 2], + }); + await table.remove(["c", "a"]); + } + + expect(await view.to_columns()).toEqual({ + x: ["b"], + y: [100], + }); + + await view.delete(); + await table.delete(); + }); + + test("correct size", async function () { + const table = await perspective.table( + { + x: "integer", + y: "integer", + }, + { index: "x" }, + ); + + for (let i = 0; i < 100; i++) { + await table.update([{ x: i, y: i }]); + } + + expect(await table.size()).toEqual(100); + + for (let i = 0; i < 100; i++) { + await table.remove([i]); + await table.update([{ x: i, y: i }]); + } + + expect(await table.size()).toEqual(100); + + for (let i = 0; i < 100; i++) { + await table.update([{ x: i, y: i }]); + } + + expect(await table.size()).toEqual(100); + + for (let i = 0; i < 100; i++) { + await table.remove([i]); + } + + expect(await table.size()).toEqual(0); + + await table.delete(); + }); + }); + + test.describe("Schema", function () { + test("updates with columns not in schema", async function () { + var table = await perspective.table({ x: "integer", y: "string" }); + await table.update(data); + var view = await table.view(); + let result = await view.to_json(); + expect(result).toEqual([ + { x: 1, y: "a" }, + { x: 2, y: "b" }, + { x: 3, y: "c" }, + { x: 4, y: "d" }, + ]); + view.delete(); + table.delete(); + }); + + test("coerces to string", async function () { + var table = await perspective.table({ + x: "string", + y: "string", + z: "string", + }); + await table.update(data); + var view = await table.view(); + let result = await view.to_json(); + expect(result).toEqual([ + { x: "1", y: "a", z: "true" }, + { x: "2", y: "b", z: "false" }, + { x: "3", y: "c", z: "true" }, + { x: "4", y: "d", z: "false" }, + ]); + view.delete(); + table.delete(); + }); + }); + + test.describe("Updates", function () { + test("Meta constructor then `update()`", async function () { + var table = await perspective.table(meta); + await table.update(data); + var view = await table.view(); + let result = await view.to_json(); + expect(result).toEqual(data); + view.delete(); + table.delete(); + }); + + test("Meta constructor then column oriented `update()`", async function () { + var table = await perspective.table(meta); + await table.update(col_data); + var view = await table.view(); + let result = await view.to_json(); + expect(result).toEqual(data); + view.delete(); + table.delete(); + }); + + test("Column oriented `update()` with columns in different order to schema", async function () { + var table = await perspective.table(meta); + + var reordered_col_data = { + y: ["a", "b", "c", "d"], + z: [true, false, true, false], + x: [1, 2, 3, 4], + }; + + await table.update(reordered_col_data); + var view = await table.view(); + let result = await view.to_json(); + expect(result).toEqual(data); + view.delete(); + table.delete(); + }); + + test("Column data constructor then column oriented `update()`", async function () { + var colUpdate = { + x: [3, 4, 5], + y: ["h", "i", "j"], + z: [false, true, false], + }; + + var expected = [ + { x: 1, y: "a", z: true }, + { x: 2, y: "b", z: false }, + { x: 3, y: "h", z: false }, + { x: 4, y: "i", z: true }, + { x: 5, y: "j", z: false }, + ]; + + var table = await perspective.table(col_data, { index: "x" }); + await table.update(colUpdate); + var view = await table.view(); + let result = await view.to_json(); + expect(result).toEqual(expected); + view.delete(); + table.delete(); + }); + + test("`update()` should increment `table.size()` without a view created", async function () { + const table = await perspective.table(data); + expect(await table.size()).toEqual(4); + await table.update(data); + expect(await table.size()).toEqual(8); + await table.update(data); + expect(await table.size()).toEqual(12); + table.delete(); + }); + + test.skip("`update()` unbound to table", async function () { + var table = await perspective.table(meta); + var updater = await table.update; + console.log(updater); + updater(data); + let view = await table.view(); + let result = await view.to_json(); + expect(result).toEqual(data); + view.delete(); + table.delete(); + }); + + test("`update([])` does not error", async function () { + let table = await perspective.table(meta); + let view = await table.view(); + await table.update([]); + let result = await view.to_json(); + expect(result).toEqual([]); + view.delete(); + table.delete(); + }); + + test("Multiple `update()`s", async function () { + var table = await perspective.table(meta); + await table.update(data); + await table.update(data); + var view = await table.view(); + let result = await view.to_json(); + expect(result).toEqual(data.concat(data)); + view.delete(); + table.delete(); + }); + + test("`update()` called after `view()`", async function () { + var table = await perspective.table(meta); + var view = await table.view(); + await table.update(data); + let result = await view.to_json(); + expect(result).toEqual(data); + view.delete(); + table.delete(); + }); + }); + + test.describe("Arrow Updates", function () { + test("arrow contructor then arrow `update()`", async function () { + const arrow = arrows.test_arrow; + var table = await perspective.table(arrow.slice()); + await table.update(arrow.slice()); + var view = await table.view(); + let result = await view.to_json(); + expect(result).toEqual(arrow_result.concat(arrow_result)); + view.delete(); + table.delete(); + }); + + test("non-arrow constructor then arrow `update()`", async function () { + let table = await perspective.table(arrow_result); + let view = await table.view(); + let generated_arrow = await view.to_arrow(); + await table.update(generated_arrow); + let result = await view.to_json(); + expect(result).toEqual(arrow_result.concat(arrow_result)); + view.delete(); + table.delete(); + }); + + test("arrow dict contructor then arrow dict `update()`", async function () { + var table = await perspective.table(arrows.dict_arrow.slice()); + await table.update(arrows.dict_update_arrow.slice()); + var view = await table.view(); + let result = await view.to_columns(); + expect(result).toEqual({ + a: [ + "abc", + "def", + "def", + null, + "abc", + null, + "update1", + "update2", + ], + b: [ + "klm", + "hij", + null, + "hij", + "klm", + "update3", + null, + "update4", + ], + }); + view.delete(); + table.delete(); + }); + + test("non-arrow constructor then arrow dict `update()`", async function () { + let table = await perspective.table({ + a: ["a", "b", "c"], + b: ["d", "e", "f"], + }); + let view = await table.view(); + await table.update(arrows.dict_update_arrow.slice()); + let result = await view.to_columns(); + expect(result).toEqual({ + a: ["a", "b", "c", null, "update1", "update2"], + b: ["d", "e", "f", "update3", null, "update4"], + }); + view.delete(); + table.delete(); + }); + + test("arrow dict contructor then arrow dict `update()` subset of columns", async function () { + var table = await perspective.table(arrows.dict_arrow.slice()); + await table.update(arrows.dict_update_arrow.slice()); + var view = await table.view({ + columns: ["a"], + }); + let result = await view.to_columns(); + expect(result).toEqual({ + a: [ + "abc", + "def", + "def", + null, + "abc", + null, + "update1", + "update2", + ], + }); + view.delete(); + table.delete(); + }); + + test("non-arrow constructor then arrow dict `update()`, subset of columns", async function () { + let table = await perspective.table({ + a: ["a", "b", "c"], + b: ["d", "e", "f"], + }); + let view = await table.view({ + columns: ["a"], + }); + await table.update(arrows.dict_update_arrow.slice()); + let result = await view.to_columns(); + expect(result).toEqual({ + a: ["a", "b", "c", null, "update1", "update2"], + }); + view.delete(); + table.delete(); + }); + + test("arrow partial `update()` a single column", async function () { + let table = await perspective.table(arrows.test_arrow.slice(), { + index: "i64", + }); + await table.update(arrows.partial_arrow.slice()); + const view = await table.view(); + const result = await view.to_json(); + expect(result).toEqual(arrow_partial_result); + view.delete(); + table.delete(); + }); + + test("arrow partial `update()` a single column with missing rows", async function () { + let table = await perspective.table(arrows.test_arrow.slice(), { + index: "i64", + }); + await table.update(arrows.partial_missing_rows_arrow.slice()); + const view = await table.view(); + const result = await view.to_json(); + expect(result).toEqual(arrow_partial_missing_result); + view.delete(); + table.delete(); + }); + + test("schema constructor, then arrow `update()`", async function () { + const table = await perspective.table({ + a: "integer", + b: "float", + c: "string", + }); + await table.update(arrows.int_float_str_arrow.slice()); + const view = await table.view(); + const size = await table.size(); + expect(size).toEqual(4); + const result = await view.to_columns(); + expect(result).toEqual({ + a: [1, 2, 3, 4], + b: [1.5, 2.5, 3.5, 4.5], + c: ["a", "b", "c", "d"], + }); + view.delete(); + table.delete(); + }); + + test("schema constructor, then arrow dictionary `update()`", async function () { + const table = await perspective.table({ + a: "string", + b: "string", + }); + await table.update(arrows.dict_arrow.slice()); + const view = await table.view(); + const size = await table.size(); + expect(size).toEqual(5); + const result = await view.to_columns(); + expect(result).toEqual({ + a: ["abc", "def", "def", null, "abc"], + b: ["klm", "hij", null, "hij", "klm"], + }); + view.delete(); + table.delete(); + }); + + test.skip("schema constructor, then indexed arrow dictionary `update()`", async function () { + const table = await perspective.table( + { + a: "string", + b: "string", + }, + { index: "a" }, + ); + await table.update(arrows.dict_arrow.slice()); + const view = await table.view(); + const size = await table.size(); + expect(size).toEqual(3); + const result = await view.to_columns(); + expect(result).toEqual({ + a: [null, "abc", "def"], + b: ["hij", "klm", "hij"], + }); + view.delete(); + table.delete(); + }); + + test.skip("schema constructor, then indexed arrow dictionary `update()` with more columns than in schema", async function () { + const table = await perspective.table( + { + a: "string", + }, + { index: "a" }, + ); + await table.update(arrows.dict_arrow.slice()); + const view = await table.view(); + const size = await table.size(); + console.log(await view.to_columns()); + expect(size).toEqual(3); + const result = await view.to_columns(); + expect(result).toEqual({ + a: [null, "abc", "def"], + }); + view.delete(); + table.delete(); + }); + + test.skip("schema constructor, then indexed arrow dictionary `update()` with less columns than in schema", async function () { + const table = await perspective.table( + { + a: "string", + b: "string", + x: "integer", + }, + { index: "a" }, + ); + await table.update(arrows.dict_arrow.slice()); + const view = await table.view(); + const size = await table.size(); + console.log(await view.to_columns()); + expect(size).toEqual(3); + const result = await view.to_columns(); + expect(result).toEqual({ + a: [null, "abc", "def"], + b: ["hij", "klm", "hij"], + x: [null, null, null], + }); + view.delete(); + table.delete(); + }); + + test("schema constructor, then indexed arrow `update()`", async function () { + const table = await perspective.table( + { + a: "integer", + b: "float", + c: "string", + }, + { index: "a" }, + ); + + const view = await table.view(); + await table.update(arrows.int_float_str_arrow.slice()); + await table.update(arrows.int_float_str_update_arrow.slice()); + expect(await table.size()).toBe(4); + const result = await view.to_columns(); + expect(result).toEqual({ + a: [1, 2, 3, 4], + b: [100.5, 2.5, 3.5, 400.5], + c: ["x", "b", "c", "y"], + }); + view.delete(); + table.delete(); + }); + + test("schema constructor, then arrow update() with more columns than in the Table", async function () { + const table = await perspective.table({ + a: "integer", + }); + const view = await table.view(); + await table.update(arrows.int_float_str_arrow.slice()); + expect(await table.size()).toBe(4); + const result = await view.to_columns(); + expect(result).toEqual({ + a: [1, 2, 3, 4], + }); + view.delete(); + table.delete(); + }); + + test.skip("Table.get_num_views", async function () { + const table = await perspective.table({ + a: "integer", + }); + const view1 = await table.view(); + expect(await table.get_num_views()).toBe(1); + const view2 = await table.view(); + const view3 = await table.view(); + const view4 = await table.view(); + const view5 = await table.view(); + expect(await table.get_num_views()).toBe(5); + await view1.delete(); + await view2.delete(); + await view3.delete(); + await view4.delete(); + await view5.delete(); + expect(await table.get_num_views()).toBe(0); + table.delete(); + }); + + test("schema constructor indexed, then arrow update() with more columns than in the Table", async function () { + const table = await perspective.table( + { + a: "integer", + b: "float", + }, + { + index: "a", + }, + ); + const view = await table.view(); + await table.update(arrows.int_float_str_arrow.slice()); + await table.update(arrows.int_float_str_update_arrow.slice()); + expect(await table.size()).toBe(4); + const result = await view.to_columns(); + expect(result).toEqual({ + a: [1, 2, 3, 4], + b: [100.5, 2.5, 3.5, 400.5], + }); + view.delete(); + table.delete(); + }); + + test("schema constructor, then arrow update() with less columns than in the Table", async function () { + const table = await perspective.table({ + a: "integer", + x: "float", + y: "string", + }); + const view = await table.view(); + await table.update(arrows.int_float_str_arrow.slice()); + expect(await table.size()).toBe(4); + const result = await view.to_columns(); + expect(result).toEqual({ + a: [1, 2, 3, 4], + x: [null, null, null, null], + y: [null, null, null, null], + }); + view.delete(); + table.delete(); + }); + + test("schema constructor indexed, then arrow update() with less columns than in the Table", async function () { + const table = await perspective.table( + { + a: "integer", + x: "float", + y: "string", + }, + { + index: "a", + }, + ); + const view = await table.view(); + await table.update(arrows.int_float_str_arrow.slice()); + await table.update(arrows.int_float_str_update_arrow.slice()); + expect(await table.size()).toBe(4); + const result = await view.to_columns(); + expect(result).toEqual({ + a: [1, 2, 3, 4], + x: [null, null, null, null], + y: [null, null, null, null], + }); + view.delete(); + table.delete(); + }); + + test("schema constructor, then arrow stream and arrow file `update()`", async function () { + const table = await perspective.table({ + a: "integer", + b: "float", + c: "string", + }); + + await table.update(arrows.int_float_str_arrow.slice()); + await table.update(arrows.int_float_str_file_arrow.slice()); + const view = await table.view(); + const result = await view.to_columns(); + expect(result).toEqual({ + a: [1, 2, 3, 4, 1, 2, 3, 4], + b: [1.5, 2.5, 3.5, 4.5, 1.5, 2.5, 3.5, 4.5], + c: ["a", "b", "c", "d", "a", "b", "c", "d"], + }); + view.delete(); + table.delete(); + }); + }); + + test.describe("Notifications", function () { + it_old_behavior("`on_update()`", async function (done) { + var table = await perspective.table(meta); + var view = await table.view(); + view.on_update( + async function (updated) { + await match_delta(perspective, updated.delta, data); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(data); + }); + + it_old_behavior( + "`on_update` before and after `update()`", + async function (done) { + var table = await perspective.table(meta); + var view = await table.view(); + await table.update(data); + var ran = false; + view.on_update( + async function (updated) { + if (!ran) { + await match_delta(perspective, updated.delta, data); + ran = true; + view.delete(); + table.delete(); + done(); + } + }, + { mode: "row" }, + ); + await table.update(data); + }, + ); + + it_old_behavior( + "`on_update(await table.update) !`", + async function (done) { + var table1 = await perspective.table(meta); + var table2 = await perspective.table(meta); + var view1 = await table1.view(); + var view2 = await table2.view(); + view1.on_update( + async function (updated) { + await table2.update(updated.delta); + let result = await view2.to_json(); + expect(result).toEqual(data); + view1.delete(); + view2.delete(); + table1.delete(); + table2.delete(); + done(); + }, + { mode: "row" }, + ); + await table1.update(data); + }, + ); + + it_old_behavior( + "`on_update( table.update)` before and after `update()`", + async function (done) { + var table1 = await perspective.table(meta); + var table2 = await perspective.table(meta); + var view1 = await table1.view(); + var view2 = await table2.view(); + + await table1.update(data); + await table2.update(data); + view1.on_update( + async function (updated) { + await table2.update(updated.delta); + let result = await view2.to_json(); + expect(result).toEqual(data.concat(data)); + view1.delete(); + view2.delete(); + table1.delete(); + table2.delete(); + done(); + }, + { mode: "row" }, + ); + await table1.update(data); + }, + ); + + it_old_behavior( + "OG - properly removes a failed update callback on a table", + async function (done) { + const table = await perspective.table({ x: "integer" }); + const view = await table.view(); + let size = await table.size(); + let counter = 0; + + // when a callback throws, it should delete that callback + view.on_update(() => { + counter++; + throw new Error("something went wrong!"); + }); + + view.on_update(async () => { + // failed callback gets removed; non-failing callback gets called + let sz = await table.size(); + expect(counter).toEqual(1); + expect(sz).toEqual(size++); + }); + + await table.update([{ x: 1 }]); + await table.update([{ x: 2 }]); + await table.update([{ x: 3 }]); + + const view2 = await table.view(); // create a new view to make sure we process every update transacation. + const final_size = await table.size(); + expect(final_size).toEqual(3); + + view2.delete(); + view.delete(); + table.delete(); + done(); + }, + { skip: true }, + ); + + it_old_behavior( + "properly removes a failed update callback on a table", + async function (done) { + const table = await perspective.table({ x: "integer" }); + const view = await table.view(); + let size = await table.size(); + + view.on_update(async () => { + // failed callback gets removed; non-failing callback gets called + let sz = await table.size(); + expect(sz).toEqual(++size); + }); + + await table.update([{ x: 1 }]); + await table.size(); + await table.update([{ x: 2 }]); + await table.size(); + await table.update([{ x: 3 }]); + await table.size(); + + const final_size = await table.size(); + expect(final_size).toEqual(3); + + // view2.delete(); + view.delete(); + table.delete(); + done(); + }, + ); + + it_old_behavior( + "`on_update()` that calls operations on the table should not recurse", + async function (done) { + var table = await perspective.table(meta); + var view = await table.view(); + view.on_update(async function (updated) { + expect(updated.port_id).toEqual(0); + const json = await view.to_json(); + // Not checking for correctness, literally just to assert + // that the `process()` call triggered by `to_json` will not + // infinitely recurse. + expect(json).toEqual(await view.to_json()); + view.delete(); + table.delete(); + done(); + }); + await table.update(data); + }, + ); + + it_old_behavior( + "`on_update()` should be triggered in sequence", + async function (done) { + var table = await perspective.table(meta); + var view = await table.view(); + + let order = []; + + const finish = function () { + if (order.length === 3) { + expect(order).toEqual([0, 1, 2]); + view.delete(); + table.delete(); + done(); + } + }; + + for (let i = 0; i < 3; i++) { + view.on_update(() => { + order.push(i); + finish(); + }); + } + + await table.update(data); + }, + ); + + it_old_behavior( + "`on_update()` should be triggered in sequence across multiple views", + async function (done) { + var table = await perspective.table(meta); + const views = [ + await table.view(), + await table.view(), + await table.view(), + ]; + + let order = []; + + const finish = function () { + if (order.length === 3) { + expect(order).toEqual([0, 1, 2]); + for (const view of views) { + view.delete(); + } + table.delete(); + done(); + } + }; + + for (let i = 0; i < views.length; i++) { + views[i].on_update(() => { + order.push(i); + finish(); + }); + } + + await table.update(data); + }, + ); + }); + + test.describe("Limit", function () { + test("{limit: 2} with table of size 4", async function () { + var table = await perspective.table(data, { limit: 2 }); + var view = await table.view(); + let result = await view.to_json(); + expect(result).toEqual(data.slice(2)); + view.delete(); + table.delete(); + }); + + test("{limit: 5} with 2 updates of size 4", async function () { + var table = await perspective.table(data, { limit: 5 }); + await table.update(data); + var view = await table.view(); + let result = await view.to_json(); + let expected = data.concat(data).slice(-5); + expect(result).toEqual(expected); + view.delete(); + table.delete(); + }); + + test("{limit: 1} with arrow update", async function () { + const arrow = arrows.test_arrow.slice(); + var table = await perspective.table(arrow, { limit: 1 }); + await table.update(arrow.slice()); + var view = await table.view(); + let result = await view.to_json(); + expect(result).toEqual([arrow_result[arrow_result.length - 1]]); + view.delete(); + table.delete(); + }); + }); + + test.describe("Indexed", function () { + test("{index: 'x'} (int)", async function () { + var table = await perspective.table(data, { index: "x" }); + var view = await table.view(); + await table.update(data); + let result = await view.to_json(); + expect(result).toEqual(data); + view.delete(); + table.delete(); + }); + + test("{index: 'y'} (string)", async function () { + var table = await perspective.table(data, { index: "y" }); + var view = await table.view(); + await table.update(data); + let result = await view.to_json(); + expect(result).toEqual(data); + view.delete(); + table.delete(); + }); + + test("{index: 'x'} (int) with null and 0", async function () { + const data = { + x: [0, 1, null, 2, 3], + y: ["a", "b", "c", "d", "e"], + }; + const table = await perspective.table(data, { index: "x" }); + const view = await table.view(); + const result = await view.to_columns(); + expect(result).toEqual({ + x: [null, 0, 1, 2, 3], // null before 0 + y: ["c", "a", "b", "d", "e"], + }); + view.delete(); + table.delete(); + }); + + test("{index: 'y'} (str) with null and empty string", async function () { + const data = { + x: [0, 1, 2, 3, 4], + y: ["", "a", "b", "c", null], + }; + const table = await perspective.table(data, { index: "y" }); + const view = await table.view(); + const result = await view.to_columns(); + expect(result).toEqual({ + x: [4, 0, 1, 2, 3], + y: [null, "", "a", "b", "c"], // null before empties + }); + view.delete(); + table.delete(); + }); + + test("{index: 'x'} (int) with null and 0, update", async function () { + const data = { + x: [0, 1, null, 2, 3], + y: ["a", "b", "c", "d", "e"], + }; + const table = await perspective.table(data, { index: "x" }); + await table.update({ + x: [null, 0], + y: ["x", "y"], + }); + const view = await table.view(); + const result = await view.to_columns(); + expect(result).toEqual({ + x: [null, 0, 1, 2, 3], // null before 0 + y: ["x", "y", "b", "d", "e"], + }); + view.delete(); + table.delete(); + }); + + test("{index: 'y'} (str) with null and empty string, update", async function () { + const data = { + x: [0, 1, 2, 3, 4], + y: ["", "a", "b", "c", null], + }; + const table = await perspective.table(data, { index: "y" }); + await table.update({ + x: [5, 6], + y: ["", null], + }); + const view = await table.view(); + const result = await view.to_columns(); + expect(result).toEqual({ + x: [6, 5, 1, 2, 3], + y: [null, "", "a", "b", "c"], // null before empties + }); + view.delete(); + table.delete(); + }); + + test("{index: 'x'} (date) with null", async function () { + const data = { + x: ["10/30/2016", "11/1/2016", null, "1/1/2000"], + y: [1, 2, 3, 4], + }; + const table = await perspective.table( + { + x: "date", + y: "integer", + }, + { index: "x" }, + ); + await table.update(data); + const view = await table.view(); + const result = await view.to_columns(); + expect(result).toEqual({ + x: [ + null, + new Date("2000-1-1").getTime(), + new Date("2016-10-30").getTime(), + new Date("2016-11-1").getTime(), + ], + y: [3, 4, 1, 2], + }); + view.delete(); + table.delete(); + }); + + test("{index: 'y'} (datetime) with datetime and null", async function () { + const data = { + x: [ + "2016-11-01 11:00:00", + "2016-11-01 11:10:00", + null, + "2016-11-01 11:20:00", + ], + y: [1, 2, 3, 4], + }; + const table = await perspective.table( + { + x: "datetime", + y: "integer", + }, + { index: "x" }, + ); + await table.update(data); + const view = await table.view(); + const result = await view.to_columns(); + expect(result).toEqual({ + x: [ + null, + new Date("2016-11-1 11:00:00").getTime(), + new Date("2016-11-1 11:10:00").getTime(), + new Date("2016-11-1 11:20:00").getTime(), + ], + y: [3, 1, 2, 4], + }); + view.delete(); + table.delete(); + }); + + test("Arrow with {index: 'i64'} (int)", async function () { + var table = await perspective.table(arrows.test_arrow.slice(), { + index: "i64", + }); + var view = await table.view(); + let result = await view.to_json(); + expect(result).toEqual(arrow_result); + view.delete(); + table.delete(); + }); + + test("Arrow with {index: 'char'} (char)", async function () { + var table = await perspective.table(arrows.test_arrow.slice(), { + index: "char", + }); + var view = await table.view(); + let result = await view.to_json(); + expect(result).toEqual(arrow_indexed_result); + view.delete(); + table.delete(); + }); + + test("Arrow with {index: 'dict'} (dict)", async function () { + var table = await perspective.table(arrows.test_arrow.slice(), { + index: "dict", + }); + var view = await table.view(); + let result = await view.to_json(); + expect(result).toEqual(arrow_indexed_result); + view.delete(); + table.delete(); + }); + + test("multiple updates on int {index: 'x'}", async function () { + var table = await perspective.table(data, { index: "x" }); + var view = await table.view(); + await table.update(data); + await table.update(data); + await table.update(data); + let result = await view.to_json(); + expect(result).toEqual(data); + view.delete(); + table.delete(); + }); + + test("multiple updates on str {index: 'y'}", async function () { + var table = await perspective.table(data, { index: "y" }); + var view = await table.view(); + await table.update(data); + await table.update(data); + await table.update(data); + let result = await view.to_json(); + expect(result).toEqual(data); + view.delete(); + table.delete(); + }); + + test("multiple updates on str {index: 'y'} with new, old, null pkey", async function () { + var table = await perspective.table(data, { index: "y" }); + var view = await table.view(); + await table.update([{ x: 1, y: "a", z: true }]); + await table.update([{ x: 100, y: null, z: true }]); + await table.update([{ x: 123, y: "abc", z: true }]); + let result = await view.to_json(); + expect(result).toEqual([ + { x: 100, y: null, z: true }, + { x: 1, y: "a", z: true }, + { x: 123, y: "abc", z: true }, + { x: 2, y: "b", z: false }, + { x: 3, y: "c", z: true }, + { x: 4, y: "d", z: false }, + ]); + view.delete(); + table.delete(); + }); + + test.skip("multiple updates on str {index: 'y'} with new, old, null in dataset", async function () { + // FIXME: this is an engine bug + const table = await perspective.table( + { + x: [1, 2, 3], + y: ["a", null, "c"], + }, + { index: "y" }, + ); + + const view = await table.view(); + + await table.update([{ x: 12345, y: "a" }]); + await table.update([{ x: 100, y: null }]); + await table.update([{ x: 123, y: "abc" }]); + + const result = await view.to_json(); + expect(result).toEqual([ + { x: 100, y: null }, + { x: 12345, y: "a" }, + { x: 123, y: "abc" }, + { x: 3, y: "c" }, + ]); + + await view.delete(); + await table.delete(); + }); + + test("{index: 'x'} with overlap", async function () { + var table = await perspective.table(data, { index: "x" }); + var view = await table.view(); + await table.update(data); + await table.update(data_2); + let result = await view.to_json(); + expect(result).toEqual(data.slice(0, 2).concat(data_2)); + view.delete(); + table.delete(); + }); + + it_old_behavior("update and index (int)", async function (done) { + var table = await perspective.table(meta, { index: "x" }); + var view = await table.view(); + await table.update(data); + view.on_update( + async function (updated) { + await match_delta(perspective, updated.delta, data_2); + let json = await view.to_json(); + expect(json).toEqual(data.slice(0, 2).concat(data_2)); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(data_2); + }); + + it_old_behavior("update and index (string)", async function (done) { + var table = await perspective.table(meta, { index: "y" }); + var view = await table.view(); + await table.update(data); + view.on_update( + async function (updated) { + await match_delta(perspective, updated.delta, data_2); + let json = await view.to_json(); + expect(json).toEqual(data.slice(0, 2).concat(data_2)); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(data_2); + }); + + test("update with depth expansion", async function () { + var table = await perspective.table(meta, { index: "y" }); + var view = await table.view({ + group_by: ["z", "y"], + columns: [], + }); + await table.update(data); + + let result = await view.to_json(); + + let expected = [ + { __ROW_PATH__: [] }, + { __ROW_PATH__: [false] }, + { __ROW_PATH__: [false, "b"] }, + { __ROW_PATH__: [false, "d"] }, + { __ROW_PATH__: [true] }, + { __ROW_PATH__: [true, "a"] }, + { __ROW_PATH__: [true, "c"] }, + ]; + expect(result).toEqual(expected); + view.delete(); + table.delete(); + }); + + it_old_behavior("partial update", async function (done) { + var partial = [ + { x: 5, y: "a" }, + { y: "b", z: true }, + ]; + var expected = [ + { x: 5, y: "a", z: true }, + { x: 2, y: "b", z: true }, + { x: 3, y: "c", z: true }, + { x: 4, y: "d", z: false }, + ]; + var table = await perspective.table(meta, { index: "y" }); + var view = await table.view(); + await table.update(data); + view.on_update( + async function (updated) { + await match_delta( + perspective, + updated.delta, + expected.slice(0, 2), + ); + let json = await view.to_json(); + expect(json).toEqual(expected); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial); + }); + + it_old_behavior( + "OG - partial column oriented update", + async function (done) { + var partial = { + x: [5, undefined], + y: ["a", "b"], + z: [undefined, true], + }; + + var expected = [ + { x: 5, y: "a", z: true }, + { x: 2, y: "b", z: true }, + { x: 3, y: "c", z: true }, + { x: 4, y: "d", z: false }, + ]; + var table = await perspective.table(meta, { index: "y" }); + var view = await table.view(); + await table.update(col_data); + view.on_update( + async function (updated) { + await match_delta( + perspective, + updated.delta, + expected.slice(0, 2), + ); + let json = await view.to_json(); + expect(json).toEqual(expected); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial); + }, + { skip: true }, + ); + + it_old_behavior( + "partial column oriented update", + async function (done) { + var batches = [ + { + x: [5], + y: ["a"], + }, + { + y: ["b"], + z: [true], + }, + ]; + + var expected = [ + { x: 5, y: "a", z: true }, + { x: 2, y: "b", z: true }, + { x: 3, y: "c", z: true }, + { x: 4, y: "d", z: false }, + ]; + var table = await perspective.table(meta, { index: "y" }); + var view = await table.view(); + await table.update(col_data); + view.on_update( + async function (updated) { + await match_delta( + perspective, + updated.delta, + expected.slice(0, 2), + ); + let json = await view.to_json(); + expect(json).toEqual(expected); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + for (const batch of batches) { + table.update(batch); + } + }, + ); + + it_old_behavior( + "Partial column oriented update with new pkey", + async function (done) { + const data = { + x: [0, 1, null, 2, 3], + y: ["a", "b", "c", "d", "e"], + }; + const table = await perspective.table(data, { index: "x" }); + const view = await table.view(); + const result = await view.to_columns(); + + expect(result).toEqual({ + x: [null, 0, 1, 2, 3], // null before 0 + y: ["c", "a", "b", "d", "e"], + }); + + const expected = { + x: [null, 0, 1, 2, 3, 4], + y: ["c", "a", "b", "d", "e", "f"], + }; + + view.on_update( + async function (updated) { + await match_delta(perspective, updated.delta, [ + { + x: 4, + y: "f", + }, + ]); + const result2 = await view.to_columns(); + expect(result2).toEqual(expected); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + + await table.update({ + x: [4], + y: ["f"], + }); + }, + ); + + it_old_behavior( + "Partial column oriented update with new pkey, missing columns", + async function (done) { + const data = { + x: [0, 1, null, 2, 3], + y: ["a", "b", "c", "d", "e"], + z: [true, false, true, false, true], + }; + const table = await perspective.table(data, { index: "x" }); + const view = await table.view(); + const result = await view.to_columns(); + + expect(result).toEqual({ + x: [null, 0, 1, 2, 3], // null before 0 + y: ["c", "a", "b", "d", "e"], + z: [true, true, false, false, true], + }); + + const expected = { + x: [null, 0, 1, 2, 3, 4], + y: ["c", "a", "b", "d", "e", "f"], + z: [true, true, false, false, true, null], + }; + + view.on_update( + async function (updated) { + await match_delta(perspective, updated.delta, [ + { + x: 4, + y: "f", + z: null, + }, + ]); + const result2 = await view.to_columns(); + expect(result2).toEqual(expected); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + + await table.update({ + x: [4], + y: ["f"], + }); + }, + ); + + it_old_behavior( + "partial column oriented update with entire columns missing", + async function (done) { + var partial = { + y: ["a", "b"], + z: [false, true], + }; + + var expected = [ + { x: 1, y: "a", z: false }, + { x: 2, y: "b", z: true }, + { x: 3, y: "c", z: true }, + { x: 4, y: "d", z: false }, + ]; + var table = await perspective.table(meta, { index: "y" }); + var view = await table.view(); + await table.update(col_data); + view.on_update( + async function (updated) { + await match_delta( + perspective, + updated.delta, + expected.slice(0, 2), + ); + let json = await view.to_json(); + expect(json).toEqual(expected); + view.delete(); + table.delete(); + done(); + }, + { mode: "row" }, + ); + await table.update(partial); + }, + ); + }); + + test.describe("null handling", function () { + test("recalculates sum aggregates when a null unsets a value", async function () { + var table = await perspective.table( + [ + { x: 1, y: 1 }, + { x: 2, y: 1 }, + ], + { index: "x" }, + ); + await table.update([{ x: 2, y: null }]); + var view = await table.view({ + group_by: ["x"], + columns: ["y"], + }); + let json = await view.to_json(); + expect(json).toEqual([ + { __ROW_PATH__: [], y: 1 }, + { __ROW_PATH__: [1], y: 1 }, + { __ROW_PATH__: [2], y: 0 }, + ]); + view.delete(); + table.delete(); + }); + + test("can be removed entirely", async function () { + var table = await perspective.table([{ x: 1, y: 1 }], { + index: "x", + }); + await table.update([{ x: 1, y: null }]); + await table.update([{ x: 1, y: 1 }]); + var view = await table.view(); + let json = await view.to_json(); + expect(json).toEqual([{ x: 1, y: 1 }]); + view.delete(); + table.delete(); + }); + + test("partial update with null unsets value", async function () { + var partial = [{ x: null, y: "a", z: false }]; + var expected = [ + { x: null, y: "a", z: false }, + { x: 2, y: "b", z: false }, + { x: 3, y: "c", z: true }, + { x: 4, y: "d", z: false }, + ]; + var table = await perspective.table(meta, { index: "y" }); + var view = await table.view(); + await table.update(data); + await table.update(partial); + const json = await view.to_json(); + expect(json).toEqual(expected); + view.delete(); + table.delete(); + }); + + test("update by adding rows (new pkeys) with partials/nulls", async function () { + var update = [{ x: null, y: "e", z: null }]; + var expected = [ + { x: 1, y: "a", z: true }, + { x: 2, y: "b", z: false }, + { x: 3, y: "c", z: true }, + { x: 4, y: "d", z: false }, + { x: null, y: "e", z: null }, + ]; + var table = await perspective.table(meta, { index: "y" }); + var view = await table.view(); + await table.update(data); + await table.update(update); + const json = await view.to_json(); + expect(json).toEqual(expected); + view.delete(); + table.delete(); + }); + + test("partial column oriented update with null unsets value", async function () { + var partial = { + x: [null], + y: ["a"], + }; + + var expected = [ + { x: null, y: "a", z: true }, + { x: 2, y: "b", z: false }, + { x: 3, y: "c", z: true }, + { x: 4, y: "d", z: false }, + ]; + var table = await perspective.table(meta, { index: "y" }); + var view = await table.view(); + await table.update(col_data); + await table.update(partial); + const json = await view.to_json(); + expect(json).toEqual(expected); + view.delete(); + table.delete(); + }); + }); + + test.describe("Viewport", function () { + test.skip("`height`", async function () { + var table = await perspective.table(data); + var view = await table.view({ + viewport: { + height: 2, + }, + }); + let result = await view.to_json(); + expect(result).toEqual(data.slice(0, 2)); + view.delete(); + table.delete(); + }); + + test.skip("`top`", async function () { + var table = await perspective.table(data); + var view = await table.view({ + viewport: { + top: 2, + }, + }); + let result = await view.to_json(); + expect(result).toEqual(data.slice(2)); + view.delete(); + table.delete(); + }); + + test.skip("`width`", async function () { + var table = await perspective.table(data); + var view = await table.view({ + viewport: { + width: 2, + }, + }); + var result2 = _.map(data, (x) => _.pick(x, "x", "y")); + let result = await view.to_json(); + expect(result).toEqual(result2); + view.delete(); + table.delete(); + }); + + test.skip("`left`", async function () { + var table = await perspective.table(data); + var view = await table.view({ + viewport: { + left: 1, + }, + }); + var result = _.map(data, function (x) { + return _.pick(x, "y", "z"); + }); + let result2 = await view.to_json(); + expect(result).toEqual(result2); + view.delete(); + table.delete(); + }); + + test.skip("All", async function () { + var table = await perspective.table(data); + var view = await table.view({ + viewport: { + top: 1, + left: 1, + width: 1, + height: 2, + }, + }); + var result = _.map(data, function (x) { + return _.pick(x, "y"); + }); + let result2 = await view.to_json(); + expect(result2).toEqual(result.slice(1, 3)); + view.delete(); + table.delete(); + }); + }); + + test.describe("implicit index", function () { + test("should apply single partial update on unindexed table using row id from '__INDEX__'", async function () { + let table = await perspective.table(data); + await table.update([ + { + __INDEX__: 2, + y: "new_string", + }, + ]); + let view = await table.view(); + let result = await view.to_json(); + + let expected = JSON.parse(JSON.stringify(data)); + expected[2]["y"] = "new_string"; + + expect(result).toEqual(expected); + view.delete(); + table.delete(); + }); + + test("should partial update on unindexed table, column dataset", async function () { + let table = await perspective.table(data); + await table.update({ + __INDEX__: [2], + y: ["new_string"], + }); + + let view = await table.view(); + let result = await view.to_json(); + + // does not unset any values + expect(result).toEqual([ + { x: 1, y: "a", z: true }, + { x: 2, y: "b", z: false }, + { x: 3, y: "new_string", z: true }, + { x: 4, y: "d", z: false }, + ]); + view.delete(); + table.delete(); + }); + + test("should partial update and unset on unindexed table", async function () { + let table = await perspective.table(data); + await table.update([ + { + __INDEX__: 2, + y: "new_string", + z: null, + }, + ]); + + let view = await table.view(); + let result = await view.to_json(); + + // does not unset any values + expect(result).toEqual([ + { x: 1, y: "a", z: true }, + { x: 2, y: "b", z: false }, + { x: 3, y: "new_string", z: null }, + { x: 4, y: "d", z: false }, + ]); + view.delete(); + table.delete(); + }); + + // ORIGINAL VERSION OF TEST BELOW + test.skip("OG - should partial update and unset on unindexed table, column dataset", async function () { + let table = await perspective.table(data); + await table.update({ + __INDEX__: [0, 2], + y: [undefined, "new_string"], + z: [null, undefined], + }); + + let view = await table.view(); + let result = await view.to_json(); + + // does not unset any values + expect(result).toEqual([ + { x: 1, y: "a", z: null }, + { x: 2, y: "b", z: false }, + { x: 3, y: "new_string", z: true }, + { x: 4, y: "d", z: false }, + ]); + view.delete(); + table.delete(); + }); + + // Split the updates to avoid handling `undefined` values. + test("should partial update and unset on unindexed table, column dataset", async function () { + let table = await perspective.table(data); + await table.update({ + __INDEX__: [0], + z: [null], + }); + await table.update({ + __INDEX__: [2], + y: ["new_string"], + }); + + let view = await table.view(); + let result = await view.to_json(); + + // does not unset any values + expect(result).toEqual([ + { x: 1, y: "a", z: null }, + { x: 2, y: "b", z: false }, + { x: 3, y: "new_string", z: true }, + { x: 4, y: "d", z: false }, + ]); + view.delete(); + table.delete(); + }); + + test("should apply single multi-column partial update on unindexed table using row id from '__INDEX__'", async function () { + let table = await perspective.table(data); + await table.update([ + { + __INDEX__: 2, + y: "new_string", + x: 100, + }, + ]); + let view = await table.view(); + let result = await view.to_json(); + + let expected = JSON.parse(JSON.stringify(data)); + expected[2]["x"] = 100; + expected[2]["y"] = "new_string"; + + expect(result).toEqual(expected); + view.delete(); + table.delete(); + }); + + test("should apply updates using '__INDEX__' on a table with explicit index set", async function () { + let table = await perspective.table(data, { index: "x" }); + await table.update([ + { + __INDEX__: 2, + y: "new_string", + }, + ]); + let view = await table.view(); + let result = await view.to_json(); + + let expected = JSON.parse(JSON.stringify(data)); + expected[1]["y"] = "new_string"; + + expect(result).toEqual(expected); + view.delete(); + table.delete(); + }); + + test("should apply multiple sequential updates using '__INDEX__' on a table with explicit index set", async function () { + let table = await perspective.table(data, { index: "x" }); + await table.update([ + { + __INDEX__: 2, + y: "new_string", + }, + { + __INDEX__: 3, + y: "new_string", + }, + ]); + let view = await table.view(); + let result = await view.to_json(); + + let expected = JSON.parse(JSON.stringify(data)); + expected[1]["y"] = "new_string"; + expected[2]["y"] = "new_string"; + + expect(result).toEqual(expected); + view.delete(); + table.delete(); + }); + + test("should apply mulitple nonsequential updates using '__INDEX__' on a table with explicit index set", async function () { + let table = await perspective.table(data, { index: "x" }); + await table.update([ + { + __INDEX__: 2, + y: "new_string", + }, + { + __INDEX__: 4, + y: "new_string", + }, + ]); + let view = await table.view(); + let result = await view.to_json(); + + let expected = JSON.parse(JSON.stringify(data)); + expected[1]["y"] = "new_string"; + expected[3]["y"] = "new_string"; + + expect(result).toEqual(expected); + view.delete(); + table.delete(); + }); + + test("should apply multiple sequential partial updates on unindexed table using '__INDEX__'", async function () { + let table = await perspective.table(data); + await table.update([ + { + __INDEX__: 0, + y: "new_string1", + }, + { + __INDEX__: 1, + y: "new_string2", + }, + { + __INDEX__: 2, + y: "new_string3", + }, + ]); + let view = await table.view(); + let result = await view.to_json(); + + let expected = JSON.parse(JSON.stringify(data)); + expected[0]["y"] = "new_string1"; + expected[1]["y"] = "new_string2"; + expected[2]["y"] = "new_string3"; + + expect(result).toEqual(expected); + view.delete(); + table.delete(); + }); + + test("should correctly apply multiple out-of-sequence partial updates on unindexed table using '__INDEX__'", async function () { + let table = await perspective.table(data); + await table.update([ + { + __INDEX__: 0, + y: "new_string1", + }, + { + __INDEX__: 2, + y: "new_string3", + }, + { + __INDEX__: 3, + y: "new_string4", + }, + { + __INDEX__: 1, + y: "new_string2", + }, + ]); + let view = await table.view(); + let result = await view.to_json(); + + let expected = JSON.parse(JSON.stringify(data)); + expected[0]["y"] = "new_string1"; + expected[1]["y"] = "new_string2"; + expected[2]["y"] = "new_string3"; + expected[3]["y"] = "new_string4"; + + expect(result).toEqual(expected); + view.delete(); + table.delete(); + }); + + test("should stack multiple partial updates on unindexed table using the same '__INDEX__'", async function () { + let table = await perspective.table(data); + await table.update([ + { + __INDEX__: 0, + y: "new_string1", + }, + { + __INDEX__: 0, + y: "new_string2", + }, + { + __INDEX__: 0, + y: "new_string3", + }, + ]); + let view = await table.view(); + let result = await view.to_json(); + + let expected = JSON.parse(JSON.stringify(data)); + expected[0]["y"] = "new_string3"; + + expect(result).toEqual(expected); + view.delete(); + table.delete(); + }); + + test("updates without '__INDEX' should append", async function () { + let table = await perspective.table(data); + await table.update([ + { + __INDEX__: 0, + y: "new_string", + }, + ]); + await table.update([ + { + y: "new_string", + }, + ]); + let view = await table.view(); + let result = await view.to_json(); + + let expected = JSON.parse(JSON.stringify(data)); + expected[0]["y"] = "new_string"; + expected.push({ x: null, y: "new_string", z: null }); + + expect(result).toEqual(expected); + view.delete(); + table.delete(); + }); + + test("should partial update on 1-sided views using implicit '__INDEX__'", async function () { + let table = await perspective.table(data); + let view = await table.view({ + group_by: ["x"], + }); + + await table.update([ + { + __INDEX__: 0, + x: 100, + }, + ]); + + let result = await view.to_json(); + // update should be applied properly + expect(result).toEqual([ + { __ROW_PATH__: [], x: 109, y: 4, z: 4 }, + { __ROW_PATH__: [2], x: 2, y: 1, z: 1 }, + { __ROW_PATH__: [3], x: 3, y: 1, z: 1 }, + { __ROW_PATH__: [4], x: 4, y: 1, z: 1 }, + { __ROW_PATH__: [100], x: 100, y: 1, z: 1 }, + ]); + + // check that un-pivoted view reflects data correctly + let view2 = await table.view(); + let result2 = await view2.to_json(); + let expected = JSON.parse(JSON.stringify(data)); + expected[0]["x"] = 100; + + expect(result2).toEqual(expected); + + view.delete(); + view2.delete(); + table.delete(); + }); + + test("should partial update on 2-sided views using implicit '__INDEX__'", async function () { + let table = await perspective.table(data); + let view = await table.view({ + group_by: ["x"], + split_by: ["y"], + }); + + await table.update([ + { + __INDEX__: 0, + x: 100, + }, + ]); + + let result = await view.to_json(); + // update should be applied properly + expect(result).toEqual([ + { + __ROW_PATH__: [], + "a|x": 100, + "a|y": 1, + "a|z": 1, + "b|x": 2, + "b|y": 1, + "b|z": 1, + "c|x": 3, + "c|y": 1, + "c|z": 1, + "d|x": 4, + "d|y": 1, + "d|z": 1, + }, + { + __ROW_PATH__: [2], + "a|x": null, + "a|y": null, + "a|z": null, + "b|x": 2, + "b|y": 1, + "b|z": 1, + "c|x": null, + "c|y": null, + "c|z": null, + "d|x": null, + "d|y": null, + "d|z": null, + }, + { + __ROW_PATH__: [3], + "a|x": null, + "a|y": null, + "a|z": null, + "b|x": null, + "b|y": null, + "b|z": null, + "c|x": 3, + "c|y": 1, + "c|z": 1, + "d|x": null, + "d|y": null, + "d|z": null, + }, + { + __ROW_PATH__: [4], + "a|x": null, + "a|y": null, + "a|z": null, + "b|x": null, + "b|y": null, + "b|z": null, + "c|x": null, + "c|y": null, + "c|z": null, + "d|x": 4, + "d|y": 1, + "d|z": 1, + }, + { + __ROW_PATH__: [100], + "a|x": 100, + "a|y": 1, + "a|z": 1, + "b|x": null, + "b|y": null, + "b|z": null, + "c|x": null, + "c|y": null, + "c|z": null, + "d|x": null, + "d|y": null, + "d|z": null, + }, + ]); + + // check that un-pivoted view reflects data correctly + let view2 = await table.view(); + let result2 = await view2.to_json(); + let expected = JSON.parse(JSON.stringify(data)); + expected[0]["x"] = 100; + + expect(result2).toEqual(expected); + + view.delete(); + view2.delete(); + table.delete(); + }); + }); + + test.describe("Remove update", function () { + it_old_behavior("Should remove a single update", async function (done) { + let count = 0; + const cb1 = () => { + count += 1; + }; + + const cb2 = () => { + expect(count).toEqual(0); + setTimeout(() => { + view.delete(); + table.delete(); + done(); + }, 0); + }; + + const table = await perspective.table(meta); + const view = await table.view(); + const sub1 = view.on_update(cb1); + view.on_update(cb2); + view.remove_update(await sub1); + await table.update(data); + }); + + it_old_behavior( + "Should remove multiple updates", + async function (done) { + let count1 = 0; + const cb1 = () => { + count1 += 1; + }; + + let count2 = 0; + const cb2 = () => { + count2 += 1; + }; + + const cb3 = function () { + // cb2 should have been called + expect(count1).toEqual(0); + expect(count2).toEqual(0); + setTimeout(() => { + view.delete(); + table.delete(); + done(); + }, 0); + }; + + const table = await perspective.table(meta); + const view = await table.view(); + const sub1 = view.on_update(cb1); + const sub2 = view.on_update(cb2); + view.on_update(cb3); + view.remove_update(await sub1); + view.remove_update(await sub2); + await table.update(data); + }, + ); + }); + + test.describe("Filtered update and remove", () => { + let table, flat_view, filtered_view; + + test.beforeEach(async () => { + table = await perspective.table( + { + x: "integer", + y: "string", + }, + { index: "x" }, + ); + + flat_view = await table.view(); + filtered_view = await table.view({ filter: [["y", "==", "a"]] }); + }); + + test.afterEach(async () => { + await filtered_view.delete(); + await flat_view.delete(); + + filtered_view = null; + flat_view = null; + }); + + test.afterAll(async () => { + if (filtered_view) await filtered_view.delete(); + if (flat_view) await flat_view.delete(); + await table.delete(); + }); + + test("View output should always be consistent with filter", async () => { + let i = 0, + op = 0, + update_idx = 1, + remove_idx = 0; + + for (i; i < 90; i++) { + if (op > 2) op = 0; + + switch (op) { + case 0: + { + // insert + await table.update({ + x: [i], + y: [i % 2 ? "a" : "b"], + }); + } + break; + case 1: + { + // partial update + await table.update({ + x: [update_idx], + y: [update_idx % 2 ? "b" : "a"], + }); + + update_idx++; + } + break; + case 2: + { + // remove + await table.remove([remove_idx]); + remove_idx++; + } + break; + default: + break; + } + + const flat = await flat_view.to_json(); + expect(await filtered_view.to_json()).toEqual( + flat.filter((row) => row.y === "a"), + ); + op++; + } + }); + + test("View output should always be consistent with filter, recreating views", async () => { + let i = 0, + op = 0, + update_idx = 1, + remove_idx = 0; + + for (i; i < 90; i++) { + if (op > 3) op = 0; + + switch (op) { + case 0: + { + // insert + await table.update({ + x: [i], + y: [i % 2 ? "a" : "b"], + }); + } + break; + case 1: + { + // partial update + await table.update({ + x: [update_idx], + y: [update_idx % 2 ? "b" : "a"], + }); + + update_idx++; + } + break; + case 2: + { + // remove + await table.remove([remove_idx]); + remove_idx++; + } + break; + case 3: { + // recreate views + await flat_view.delete(); + flat_view = await table.view(); + + await filtered_view.delete(); + filtered_view = await table.view({ + filter: [["y", "==", "a"]], + }); + } + default: + break; + } + + const flat = await flat_view.to_json(); + expect(await filtered_view.to_json()).toEqual( + flat.filter((row) => row.y === "a"), + ); + op++; + } + }); + + test("View output should always be consistent with filter, deterministic", async () => { + filtered_view = await table.view({ filter: [["y", "==", "b"]] }); + + for (let i = 0; i < 10; i++) { + // append new rows + await table.update([{ x: i, y: "a" }]); + expect(await table.size()).toEqual(i + 1); + + // make sure appended + const result = await flat_view.to_json(); + expect(result[result.length - 1]).toEqual({ x: i, y: "a" }); + + // filtering + expect(await filtered_view.to_json()).toEqual( + result.filter((row) => row.y === "b"), + ); + } + + await filtered_view.delete(); + + // new view with filter + filtered_view = await table.view({ filter: [["y", "==", "a"]] }); + let flat = await flat_view.to_json(); + let filtered = await filtered_view.to_json(); + + expect(flat.length).toEqual(filtered.length); + expect(filtered).toEqual(flat); + + for (let i = 0; i < 10; i++) { + // partial update rows + if (i % 2 == 0) { + await table.update([{ x: i, y: "b" }]); + + flat = await flat_view.to_json(); + filtered = await filtered_view.to_json(); + expect(flat.length).toBeGreaterThan(filtered.length); + expect(flat.filter((row) => row.x === i)[0]).toEqual({ + x: i, + y: "b", + }); + expect(filtered).toEqual( + flat.filter((row) => row.y === "a"), + ); + + // partial updates not appends + expect(await table.size()).toEqual(10); + } + } + + // Remove "b" rows + flat = await flat_view.to_json(); + await table.remove( + flat.filter((row) => row.y === "b").map((row) => row.x), + ); + expect(await flat_view.to_json()).toEqual([ + { x: 1, y: "a" }, + { x: 3, y: "a" }, + { x: 5, y: "a" }, + { x: 7, y: "a" }, + { x: 9, y: "a" }, + ]); + }); + + test("Output consistent with filter, out of order", async () => { + const tbl = await perspective.table( + { + index: "string", + x: "string", + }, + { index: "index" }, + ); + const view = await tbl.view(); + + await tbl.update({ + index: ["a", "d", "b"], + x: ["abc", "def", "acc"], + }); + await tbl.remove(["b"]); + + const view2 = await tbl.view({ filter: [["x", "==", "abc"]] }); + await view.delete(); + + expect(await view2.to_json()).toEqual([{ index: "a", x: "abc" }]); + console.log(await view2.to_json()); + + await view2.delete(); + await tbl.delete(); + }); + + test("Output consistent with filter, string small", async () => { + const tbl = await perspective.table( + { + index: "string", + x: "string", + }, + { index: "index" }, + ); + const view = await tbl.view(); + + await tbl.update({ index: ["a", "c"], x: ["abc", "def"] }); + await tbl.remove(["c"]); + + const view2 = await tbl.view({ filter: [["x", "==", "abc"]] }); + await view.delete(); + + expect(await view2.to_json()).toEqual([{ index: "a", x: "abc" }]); + console.log(await view2.to_json()); + + await view2.delete(); + await tbl.delete(); + }); + + test("Output consistent with filter, ordered", async () => { + const tbl = await perspective.table( + { + index: "string", + x: "string", + }, + { index: "index" }, + ); + const view = await tbl.view(); + + await tbl.update({ + index: ["a", "b", "c"], + x: ["abc", "def", "acc"], + }); + await tbl.remove(["b"]); + + const view2 = await tbl.view({ filter: [["x", "==", "abc"]] }); + await view.delete(); + + expect(await view2.to_json()).toEqual([{ index: "a", x: "abc" }]); + console.log(await view2.to_json()); + + await view2.delete(); + await tbl.delete(); + }); + + test("Output consistent with filter, numeric index out of order", async () => { + const tbl = await perspective.table( + { + index: "float", + x: "string", + }, + { index: "index" }, + ); + const view = await tbl.view(); + + await tbl.update({ index: [50, 1, 2], x: ["abc", "def", "acc"] }); + await tbl.remove([1]); + + const view2 = await tbl.view({ filter: [["x", "==", "acc"]] }); + await view.delete(); + + expect(await view2.to_json()).toEqual([{ index: 2, x: "acc" }]); + console.log(await view2.to_json()); + + await view2.delete(); + await tbl.delete(); + }); + + test("Output consistent with filter, numeric index ordered", async () => { + const tbl = await perspective.table( + { + index: "float", + x: "string", + }, + { index: "index" }, + ); + const view = await tbl.view(); + + await tbl.update({ index: [1, 2, 3], x: ["abc", "def", "acc"] }); + await tbl.remove([3]); + + const view2 = await tbl.view({ filter: [["x", "==", "def"]] }); + await view.delete(); + + expect(await view2.to_json()).toEqual([{ index: 2, x: "def" }]); + console.log(await view2.to_json()); + + await view2.delete(); + await tbl.delete(); + }); + + test("Output consistent with filter, date index out of order", async () => { + const tbl = await perspective.table( + { + index: "date", + x: "string", + }, + { index: "index" }, + ); + const view = await tbl.view(); + + await tbl.update({ + index: [ + new Date(2021, 3, 15), + new Date(2009, 1, 13), + new Date(2015, 5, 10), + ], + x: ["abc", "def", "acc"], + }); + await tbl.remove([1]); + + const view2 = await tbl.view({ filter: [["x", "==", "acc"]] }); + await view.delete(); + + expect(await view2.to_json()).toEqual([ + { index: new Date(2015, 5, 10).getTime(), x: "acc" }, + ]); + console.log(await view2.to_json()); + + await view2.delete(); + await tbl.delete(); + }); + + test("Output consistent with filter, date index ordered", async () => { + const tbl = await perspective.table( + { + index: "date", + x: "string", + }, + { index: "index" }, + ); + const view = await tbl.view(); + + await tbl.update({ + index: [ + new Date(2009, 1, 13), + new Date(2015, 5, 10), + new Date(2021, 3, 15), + ], + x: ["abc", "def", "acc"], + }); + await tbl.remove([3]); + + const view2 = await tbl.view({ filter: [["x", "==", "def"]] }); + await view.delete(); + + expect(await view2.to_json()).toEqual([ + { index: new Date(2015, 5, 10).getTime(), x: "def" }, + ]); + console.log(await view2.to_json()); + + await view2.delete(); + await tbl.delete(); + }); + + test("Output consistent with filter, datetime index out of order", async () => { + const tbl = await perspective.table( + { + index: "datetime", + x: "string", + }, + { index: "index" }, + ); + const view = await tbl.view(); + + await tbl.update({ + index: [ + new Date(2021, 3, 15, 5, 10, 3), + new Date(2009, 1, 13, 0, 0, 1), + new Date(2015, 5, 10, 15, 10, 2), + ], + x: ["abc", "def", "acc"], + }); + await tbl.remove([1]); + + const view2 = await tbl.view({ filter: [["x", "==", "acc"]] }); + await view.delete(); + + expect(await view2.to_json()).toEqual([ + { index: new Date(2015, 5, 10, 15, 10, 2).getTime(), x: "acc" }, + ]); + console.log(await view2.to_json()); + + await view2.delete(); + await tbl.delete(); + }); + + test("Output consistent with filter, datetime index ordered", async () => { + const tbl = await perspective.table( + { + index: "datetime", + x: "string", + }, + { index: "index" }, + ); + const view = await tbl.view(); + + await tbl.update({ + index: [ + new Date(2009, 1, 13, 0, 0, 1), + new Date(2015, 5, 10, 15, 10, 2), + new Date(2021, 3, 15, 5, 10, 3), + ], + x: ["abc", "def", "acc"], + }); + await tbl.remove([3]); + + const view2 = await tbl.view({ filter: [["x", "==", "def"]] }); + await view.delete(); + + expect(await view2.to_json()).toEqual([ + { index: new Date(2015, 5, 10, 15, 10, 2).getTime(), x: "def" }, + ]); + console.log(await view2.to_json()); + + await view2.delete(); + await tbl.delete(); + }); + + test("Output consistent with filter, out of order updates", async () => { + const tbl = await perspective.table( + { + index: "string", + x: "string", + }, + { index: "index" }, + ); + const view = await tbl.view(); + + await tbl.update({ + index: ["a", "d", "b"], + x: ["abc", "def", "acc"], + }); + await tbl.remove(["b"]); + + const view2 = await tbl.view({ filter: [["x", "==", "abc"]] }); + await view.delete(); + + expect(await view2.to_json()).toEqual([{ index: "a", x: "abc" }]); + + await tbl.update([{ index: "b", x: "abc" }]); + + expect(await view2.to_json()).toEqual([ + { index: "a", x: "abc" }, + { index: "b", x: "abc" }, + ]); + + await tbl.remove(["a"]); + + expect(await view2.to_json()).toEqual([{ index: "b", x: "abc" }]); + + await tbl.update([ + { index: "a", x: "abc" }, + { index: null, x: "abc" }, + ]); + + expect(await view2.to_json()).toEqual([ + { index: null, x: "abc" }, + { index: "a", x: "abc" }, + { index: "b", x: "abc" }, + ]); + + const indices = "abcdefghijklmnopqrstuvwxyz".split(""); + + for (const idx of indices) { + await tbl.update([{ index: idx, x: "abc" }]); + } + + let result = await view2.to_json(); + expect(result[0]).toEqual({ index: null, x: "abc" }); + + result = result.slice(1); + + for (let i = 0; i < result.length; i++) { + expect(result[i]).toEqual({ index: indices[i], x: "abc" }); + } + + await view2.delete(); + await tbl.delete(); + }); + + test("Output consistent with filter, out of order updates random", async () => { + const tbl = await perspective.table( + { + index: "string", + x: "string", + }, + { index: "index" }, + ); + const view = await tbl.view(); + + await tbl.update({ + index: ["a", "d", "b"], + x: ["abc", "def", "acc"], + }); + await tbl.remove(["b"]); + + const view2 = await tbl.view({ filter: [["x", "==", "abc"]] }); + await view.delete(); + + expect(await view2.to_json()).toEqual([{ index: "a", x: "abc" }]); + + await tbl.update([{ index: "b", x: "abc" }]); + + expect(await view2.to_json()).toEqual([ + { index: "a", x: "abc" }, + { index: "b", x: "abc" }, + ]); + + await tbl.remove(["a"]); + + expect(await view2.to_json()).toEqual([{ index: "b", x: "abc" }]); + + await tbl.update([ + { index: "a", x: "abc" }, + { index: null, x: "abc" }, + ]); + + expect(await view2.to_json()).toEqual([ + { index: null, x: "abc" }, + { index: "a", x: "abc" }, + { index: "b", x: "abc" }, + ]); + + // randomize + let indices = "abcdefghijklmnopqrstuvwxyz" + .split("") + .map((v) => { + return { value: v, key: Math.random() }; + }) + .sort((a, b) => a.key - b.key) + .map((v) => v.value); + + // console.log(indices); + + for (const idx of indices) { + await tbl.update([{ index: idx, x: "abc" }]); + } + + let result = await view2.to_json(); + expect(result[0]).toEqual({ index: null, x: "abc" }); + + result = result.slice(1); + + indices = indices.sort(); + + for (let i = 0; i < result.length; i++) { + expect(result[i]).toEqual({ index: indices[i], x: "abc" }); + } + + await view2.delete(); + await tbl.delete(); + }); + + test("Delete a table with pending updates", async () => { + const tbl = await perspective.table( + { + index: "string", + x: "string", + }, + { index: "index" }, + ); + + for (let i = 0; i < 3; i++) { + tbl.update({ + index: ["a", "d", "b"], + x: ["abc", "def", "acc"], + }); + } + await tbl.delete(); + }); + }); + + // Regression coverage for the `t_ftrav::step_end` fast-path leak: a + // pass containing only `update_row` calls (every pkey already exists) + // used to take the fast path because `m_step_inserts` and + // `m_step_deletes` are both zero — leaving `m_updated` flags stamped + // on `m_index` and dropping the staged replacements from + // `m_new_elems`. The next pass that *did* trigger a rebuild (any + // `add_row` or `delete_row`) skipped the flagged entries with no + // counterpart to reinstate them, and the rows silently disappeared + // from the sorted view. + test.describe("Update under sorted view", function () { + const d1 = +new Date("2024-01-01T00:00:00Z"); + const d2 = +new Date("2024-01-02T00:00:00Z"); + const d3 = +new Date("2024-01-03T00:00:00Z"); + const d4 = +new Date("2024-01-04T00:00:00Z"); + const d5 = +new Date("2024-01-05T00:00:00Z"); + + async function seed(perspective) { + const table = await perspective.table( + { id: "integer", ts: "datetime", payload: "string" }, + { index: "id" }, + ); + await table.update({ + id: [1, 2, 3, 4], + ts: [d1, d2, d3, d4], + payload: ["a", "b", "c", "d"], + }); + return table; + } + + test("baseline: update existing rows then append a new row", async function () { + const table = await seed(perspective); + const view = await table.view({ sort: [["ts", "asc"]] }); + await table.update({ id: [2, 3], payload: ["bb", "cc"] }); + await table.update({ id: [5], ts: [d5], payload: ["e"] }); + + const cols = await view.to_columns(); + expect(await view.num_rows()).toEqual(5); + expect(cols.id).toEqual([1, 2, 3, 4, 5]); + expect(cols.payload).toEqual(["a", "bb", "cc", "d", "e"]); + expect(cols.ts).toEqual([d1, d2, d3, d4, d5]); + + await view.delete(); + await table.delete(); + }); + + test("update-only pass is correct on its own", async function () { + const table = await seed(perspective); + const view = await table.view({ sort: [["ts", "asc"]] }); + await table.update({ id: [2, 3], payload: ["bb", "cc"] }); + + const cols = await view.to_columns(); + expect(await view.num_rows()).toEqual(4); + expect(cols.id).toEqual([1, 2, 3, 4]); + expect(cols.payload).toEqual(["a", "bb", "cc", "d"]); + + await view.delete(); + await table.delete(); + }); + + test("multiple update-only passes before an insert", async function () { + const table = await seed(perspective); + const view = await table.view({ sort: [["ts", "asc"]] }); + await table.update({ id: [2], payload: ["b1"] }); + await table.update({ id: [3], payload: ["c1"] }); + await table.update({ id: [2], payload: ["b2"] }); + + await table.update({ id: [5], ts: [d5], payload: ["e"] }); + + const cols = await view.to_columns(); + expect(await view.num_rows()).toEqual(5); + expect(cols.id).toEqual([1, 2, 3, 4, 5]); + expect(cols.payload).toEqual(["a", "b2", "c1", "d", "e"]); + + await view.delete(); + await table.delete(); + }); + + test("update that changes the sort key, then append", async function () { + const table = await seed(perspective); + const view = await table.view({ sort: [["ts", "asc"]] }); + const d_late = +new Date("2024-01-10T00:00:00Z"); + await table.update({ id: [2], ts: [d_late] }); + + await table.update({ id: [5], ts: [d5], payload: ["e"] }); + + const cols = await view.to_columns(); + expect(await view.num_rows()).toEqual(5); + expect(cols.id).toEqual([1, 3, 4, 5, 2]); + expect(cols.ts).toEqual([d1, d3, d4, d5, d_late]); + + await view.delete(); + await table.delete(); + }); + + test("update-only pass followed by a delete", async function () { + const table = await seed(perspective); + const view = await table.view({ sort: [["ts", "asc"]] }); + + await table.update({ id: [2, 3], payload: ["bb", "cc"] }); + await table.remove([4]); + + const cols = await view.to_columns(); + expect(await view.num_rows()).toEqual(3); + expect(cols.id).toEqual([1, 2, 3]); + expect(cols.payload).toEqual(["a", "bb", "cc"]); + + await view.delete(); + await table.delete(); + }); + + test("no sort: update-only pass + append is unaffected", async function () { + const table = await seed(perspective); + const view = await table.view(); + + await table.update({ id: [2, 3], payload: ["bb", "cc"] }); + await table.update({ id: [5], ts: [d5], payload: ["e"] }); + + expect(await view.num_rows()).toEqual(5); + + await view.delete(); + await table.delete(); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/view_config.spec.js b/rust/perspective-js/test/js/view_config.spec.js new file mode 100644 index 0000000..56caa8a --- /dev/null +++ b/rust/perspective-js/test/js/view_config.spec.js @@ -0,0 +1,179 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test, expect } from "@perspective-dev/test"; +import perspective from "./perspective_client"; + +const data = [ + { x: 1, y: "a", z: true }, + { x: 2, y: "b", z: false }, + { x: 3, y: "c", z: true }, + { + x: 4, + y: "abcdefghijklmnopqrstuvwxyz", + z: false, + }, +]; + +((perspective) => { + test.describe("View config", function () { + test("Non-interned filter strings do not create corrupted view configs", async function () { + const table = await perspective.table(data); + const view = await table.view({ + filter: [["y", "==", "abcdefghijklmnopqrstuvwxyz"]], + }); + + const config = await view.get_config(); + expect(config.filter).toEqual([ + ["y", "==", "abcdefghijklmnopqrstuvwxyz"], + ]); + + view.delete(); + table.delete(); + }); + + test("Non-default aggregates are provided", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_by: ["y"], + columns: ["x", "z"], + aggregates: { x: "count", z: "sum" }, + }); + + const config = await view.get_config(); + expect(config).toEqual({ + aggregates: { x: "count", z: "sum" }, + columns: ["x", "z"], + expressions: {}, + filter: [], + group_by: ["y"], + sort: [], + split_by: [], + group_rollup_mode: "rollup", + }); + + view.delete(); + table.delete(); + }); + + test("Compound aggregates are provided", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_by: ["y"], + columns: ["x"], + aggregates: { x: ["weighted mean", ["y"]] }, + }); + + const config = await view.get_config(); + expect(config).toEqual({ + aggregates: { x: ["weighted mean", ["y"]] }, + columns: ["x"], + expressions: {}, + filter: [], + group_by: ["y"], + sort: [], + split_by: [], + group_rollup_mode: "rollup", + }); + + view.delete(); + table.delete(); + }); + + test("Expression aggregates are provided", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_by: ["y"], + columns: ["x", "z", "new"], + expressions: { new: `"x" + 1` }, + aggregates: { x: ["weighted mean", ["y"]] }, + }); + + const config = await view.get_config(); + expect(config).toEqual({ + aggregates: { + new: "sum", + z: "count", + x: ["weighted mean", ["y"]], + }, + columns: ["x", "z", "new"], + expressions: { new: `"x" + 1` }, + filter: [], + group_by: ["y"], + sort: [], + split_by: [], + group_rollup_mode: "rollup", + }); + + view.delete(); + table.delete(); + }); + + // The `aggregates` field was omitted entirely in `3.0.0` until `3.9.0` + // (due to a bug introduced by an inebriated office drone). Prior to + // this, columns were sleectively omitted from the result if they were + // _non-default_ (to shorten JSON encoding). For example if a `"flaot"` + // column was anything other than `"sum"`. + // + // In revisiting this broken feature, I've updated the behavior to + // reflect a less certain view of what is "default", and output all + // columns which need aggregates. These tests preserve the `2.x` + // behavior. + test.skip("Default aggregates are omitted", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_by: ["y"], + columns: ["x", "z"], + aggregates: { x: "sum", z: "count" }, + }); + + const config = await view.get_config(); + expect(config).toEqual({ + aggregates: {}, + columns: ["x", "z"], + expressions: {}, + filter: [], + group_by: ["y"], + sort: [], + split_by: [], + group_rollup_mode: "rollup", + }); + + view.delete(); + table.delete(); + }); + + test.skip("Mixed aggregates are provided and omitted", async function () { + const table = await perspective.table(data); + const view = await table.view({ + group_by: ["y"], + columns: ["x", "z"], + aggregates: { x: "sum", z: "mean" }, + }); + + const config = await view.get_config(); + expect(config).toEqual({ + aggregates: { z: "mean" }, + columns: ["x", "z"], + expressions: {}, + filter: [], + group_by: ["y"], + sort: [], + split_by: [], + group_rollup_mode: "rollup", + }); + + view.delete(); + table.delete(); + }); + }); +})(perspective); diff --git a/rust/perspective-js/test/js/workers.spec.js b/rust/perspective-js/test/js/workers.spec.js new file mode 100644 index 0000000..5526740 --- /dev/null +++ b/rust/perspective-js/test/js/workers.spec.js @@ -0,0 +1,114 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { test } from "@perspective-dev/test"; + +test.describe("worker types", () => { + test("Worker", async ({ page }) => { + await page.goto( + "http://localhost:6598/node_modules/@perspective-dev/client/test/html/worker.html", + ); + + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + const s = await page.evaluate(async (x) => { + const t = await window.table; + return await t.size(); + }); + + test.expect(s).toEqual(99); + }); + + test("SharedWorker", async ({ page }) => { + await page.goto( + "http://localhost:6598/node_modules/@perspective-dev/client/test/html/shared_worker.html", + ); + + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + const s = await page.evaluate(async (x) => { + const t = await window.table; + return await t.size(); + }); + + test.expect(s).toEqual(99); + }); + + // Not supported https://github.com/microsoft/playwright/issues/30981 + test.skip("ServiceWorker", async ({ page }) => { + await page.goto( + "/node_modules/@perspective-dev/client/test/html/service_worker.html", + ); + + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); + + const s = await page.evaluate(async (x) => { + const t = await window.table; + return await t.size(); + }); + + test.expect(s).toEqual(99); + }); + + test("No SharedWorker or ServiceWorker (embedded)", async ({ page }) => { + await page.goto( + "http://localhost:6598/node_modules/@perspective-dev/client/test/html/test.html", + ); + + const s = await page.evaluate(async () => { + window.SharedWorker = undefined; + window.ServiceWorker = undefined; + const perspective = await import( + "http://localhost:6598/node_modules/@perspective-dev/client/dist/esm/perspective.js" + ); + + const wasm = fetch( + "http://localhost:6598/node_modules/@perspective-dev/client/dist/wasm/perspective-js.wasm", + ); + + const wasm2 = fetch( + "http://localhost:6598/node_modules/@perspective-dev/server/dist/wasm/perspective-server.wasm", + ); + + perspective.init_client(wasm); + perspective.init_server(wasm2); + + const worker = await perspective.worker( + new Worker( + "http://localhost:6598/node_modules/@perspective-dev/client/dist/cdn/perspective-server.worker.js", + ), + ); + + let resp = await fetch( + "http://localhost:6598/node_modules/@perspective-dev/test/assets/superstore.csv", + ); + + let csv = await resp.text(); + const t = await worker.table(csv); + return await t.size(); + }); + + test.expect(s).toEqual(99); + }); +}); diff --git a/rust/perspective-js/tsconfig.browser.json b/rust/perspective-js/tsconfig.browser.json new file mode 100644 index 0000000..cd49f75 --- /dev/null +++ b/rust/perspective-js/tsconfig.browser.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "strict": true, + "module": "ESNext", + "target": "ESNext", + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./dist/esm", + "rootDir": "./src/ts", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "types": [] + }, + "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 new file mode 100644 index 0000000..fb19376 --- /dev/null +++ b/rust/perspective-js/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "strict": true, + "module": "NodeNext", + "target": "ESNext", + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./dist/esm", + "rootDir": ".", + "moduleResolution": "NodeNext", + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": [ + "./src/ts/perspective.node.ts", + "./src/ts/perspective.cdn.ts", + "./src/ts/virtual_servers/duckdb.ts", + "./src/ts/virtual_servers/clickhouse.ts", + "./test/js/*.ts" + ] +} diff --git a/rust/perspective-js/tsconfig.node.json b/rust/perspective-js/tsconfig.node.json new file mode 100644 index 0000000..d841d9a --- /dev/null +++ b/rust/perspective-js/tsconfig.node.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "strict": true, + "module": "NodeNext", + "target": "ESNext", + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./dist/esm", + "rootDir": "./src/ts", + "moduleResolution": "NodeNext", + "skipLibCheck": true, + "resolveJsonModule": true + }, + "files": ["./src/ts/perspective.node.ts"] +} diff --git a/rust/perspective-js/tsconfig.virtual_servers.json b/rust/perspective-js/tsconfig.virtual_servers.json new file mode 100644 index 0000000..ecec138 --- /dev/null +++ b/rust/perspective-js/tsconfig.virtual_servers.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "strict": true, + "module": "NodeNext", + "target": "ESNext", + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./dist/esm", + "rootDir": ".", + "moduleResolution": "NodeNext", + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": [ + "./src/ts/virtual_servers/duckdb.ts", + "./src/ts/virtual_servers/clickhouse.ts", + ] +} diff --git a/rust/perspective-python/Cargo.toml b/rust/perspective-python/Cargo.toml new file mode 100644 index 0000000..2f596ff --- /dev/null +++ b/rust/perspective-python/Cargo.toml @@ -0,0 +1,88 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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-python" +version = "4.5.2" +edition = "2024" +description = "A data visualization and analytics component, especially well-suited for large and/or streaming datasets." +repository = "https://github.com/perspective-dev/perspective" +license = "Apache-2.0" +homepage = "https://perspective-dev.github.io" +authors = ["Andrew Stein "] +keywords = [] +build = "build.rs" +include = [ + "build.rs", + "./Cargo.toml", + "LICENSE.md", + "LICENSE_*", + "README.md", + "./package.json", + "perspective/**/*.py", + "perspective/extension", + "perspective/templates", + "perspective/tests/**/*.arrow", + "./pyproject.toml", + "src/**/*.rs", + "docs/**/*", +] + +[package.metadata.docs.rs] +rustdoc-args = ["--html-in-header", "docs/index.html"] + +[features] +default = [] +abi3 = ["pyo3/abi3-py311"] +external-cpp = ["perspective-server/external-cpp"] +generate-proto = ["perspective-client/generate-proto"] +protobuf-src = ["perspective-client/protobuf-src"] + +[lib] +crate-type = ["cdylib"] + +[build-dependencies] +cmake = "0.1.58" +num_cpus = "^1.16.0" +pyo3-build-config = "0.25.1" +python-config-rs = "0.1.2" + +[dependencies] +perspective-client = { version = "4.5.2" } +perspective-server = { version = "4.5.2" } +bytes = "1.10.1" +chrono = "0.4" +macro_rules_attribute = "0.2.2" +async-lock = "2.5.0" +pollster = "0.3.0" +extend = "1.1.2" +indexmap = "2.12.1" +futures = "0.3.28" +serde = { version = "1.0" } +pyo3 = { version = "0.25.1", features = [ + "experimental-async", + "extension-module", + "serde", +] } +pythonize = "0.25.0" +tracing = { version = ">=0.1.36" } +tracing-subscriber = { version = "0.3.15", features = ["env-filter"] } + +[target.'cfg(target_os="emscripten")'.dependencies] +pyo3-async-runtimes = { version = "0.25.0", features = ["attributes"] } + +[target.'cfg(not(target_os="emscripten"))'.dependencies] +pyo3-async-runtimes = { version = "0.25.0", features = [ + "attributes", + "tokio-runtime", +] } +tokio = { version = "1.45.1", features = ["full"] } diff --git a/rust/perspective-python/README.md b/rust/perspective-python/README.md new file mode 100644 index 0000000..9673a78 --- /dev/null +++ b/rust/perspective-python/README.md @@ -0,0 +1,3 @@ +# `perspective-python` + +This crate provides the Python bindings for Perspective. diff --git a/rust/perspective-python/bench/__init__.py b/rust/perspective-python/bench/__init__.py new file mode 100644 index 0000000..284e708 --- /dev/null +++ b/rust/perspective-python/bench/__init__.py @@ -0,0 +1,11 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ diff --git a/rust/perspective-python/bench/runtime/__init__.py b/rust/perspective-python/bench/runtime/__init__.py new file mode 100644 index 0000000..eb1eaf2 --- /dev/null +++ b/rust/perspective-python/bench/runtime/__init__.py @@ -0,0 +1,15 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 bench import Benchmark, Suite, Runner + +__all__ = ["Benchmark", "Suite", "Runner"] diff --git a/rust/perspective-python/bench/runtime/bench.py b/rust/perspective-python/bench/runtime/bench.py new file mode 100644 index 0000000..e83a80d --- /dev/null +++ b/rust/perspective-python/bench/runtime/bench.py @@ -0,0 +1,349 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 logging +import os +import sys +import signal +import subprocess +import venv +import tornado +from timeit import timeit +from perspective import ( + Table, + PerspectiveManager, + PerspectiveTornadoHandler, +) + +logging.basicConfig(level=logging.INFO) + +ARROW_PATH = os.path.join( + os.path.dirname(__file__), + "..", + "..", + "..", + "..", + "tools", + "perspective-bench", + "dist", + "benchmark-python.arrow", +) + +BASELINE = { + "name": [ + "arrow", + "csv", + "one", + "one_1_pivot", + "one_2_pivot", + "to_arrow", + "to_arrow_r1", + "to_arrow_r1_c1", + "to_arrow_r2", + "to_arrow_r2_c2", + "to_columns", + "to_columns_r1", + "to_columns_r1_c1", + "to_columns_r2", + "to_columns_r2_c2", + "to_csv", + "to_csv_r1", + "to_csv_r1_c1", + "to_csv_r2", + "to_csv_r2_c2", + "to_records", + "to_records_r1", + "to_records_r1_c1", + "to_records_r2", + "to_records_r2_c2", + "two", + "two_1x1_pivot", + "two_2x2_pivot", + "zero", + ], + "time": [ + 0.007100801400002865, + 0.2905632236999963, + 0.31363245369999504, + 0.5827624402000027, + 0.5723526209999988, + 0.3929776023999921, + 0.00021701669998037688, + 0.005837792799991348, + 0.0012358780999875308, + 0.06712399980000328, + 1.5685727534000022, + 0.00040022939999744266, + 0.007451989500009404, + 0.0027319223000063174, + 0.08117095989999826, + 0.5763108057000068, + 0.0003611342000112927, + 0.008143171199992593, + 0.0019054690000075425, + 0.07623720379999668, + 2.040859387900002, + 0.00047493509999867455, + 0.008475803500004986, + 0.0036788466000075458, + 0.09640174390000311, + 1.1455542396000056, + 1.9211396786000023, + 1.9244194315999947, + 0.10238117819999956, + ], +} + + +class VirtualEnvHandler(object): + """Creates and manages a virtualenv for benchmarking, which allows for + clean dependency management and benchmarking of multiple versions without + contaminating the system's `site-packages` folder.""" + + def __init__(self, virtualenv_path): + self._virtualenv_path = virtualenv_path + self._is_activated = False + + def virtualenv_exists(self): + """Returns whether the directory specified by `VIRTUALENV_PATH` + exists.""" + return os.path.exists(self._virtualenv_path) + + def activate_virtualenv(self): + """Activates the virtualenv at `VIRTUALENV_PATH`.""" + logging.info("Activating virtualenv at: `{}`".format(self._virtualenv_path)) + self._is_activated = True + return ". {}/bin/activate".format(self._virtualenv_path) + + def create_virtualenv(self): + """Clears the folder and creates a new virtualenv at + `self._virtualenv_path`.""" + if self.virtualenv_exists(): + logging.ERROR("Virtualenv already exists at: `{0}`".format(self._virtualenv_path)) + return + logging.info("Creating virtualenv at: `{}`".format(self._virtualenv_path)) + venv.create(self._virtualenv_path, clear=True, with_pip=True) + + def deactivate_virtualenv(self): + if self.virtualenv_exists() and self._is_activated: + subprocess.check_output("deactivate", shell=True) + logging.info("Virtualenv deactivated!") + self._is_activated = False + + +class BenchmarkTornadoHandler(tornado.web.RequestHandler): + """Host the results of the benchmark suite over a websocket.""" + + def set_default_headers(self): + self.set_header("Access-Control-Allow-Origin", "*") + self.set_header("Access-Control-Allow-Headers", "x-requested-with") + self.set_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS") + + def get(self): + self.render("benchmark_hosted.html") + + +class Benchmark(object): + """A single Benchmark function. Use as a wrapper for stateless lambdas + with no parameters. + + Example: + >>> func = Benchmark(lambda: self._view.to_records(), meta={ + "name": "to_records", + "group": "view" + }) + """ + + def __init__(self, func, meta={}): + """A decorator for a benchmark function, which will attach each attribute + of the `meta` dictionary to the decorated function as well as provide + it with the `benchmark` attribute. + + Args: + meta (dict) : a metadata dictionary, whose keys and values will + become attributes on the benchmark function. The metadata dictionary + should be consistent within each suite, i.e. there should be no + additional values in between different methods decorated with + `@benchmark`. + """ + self._func = func + self._meta = meta + self.benchmark = True + + for k, v in self._meta.items(): + marked_key = "__BENCH__{0}".format(k) + setattr(self, marked_key, v) + + def __call__(self): + """Call the lambda bound to the decorator. This call asserts that the + lambda has no parameters and no reference to a `self` object. + """ + self._func() + + +class Suite(object): + """A benchmark suite stub that contains `register_benchmarks` and generic + before/after methods. + + Inherit from this class and implement `register_benchmarks`, which should + set all benchmark methods as attributes on the class. + """ + + def register_benchmarks(self): + """Registers all callbacks with `Runner`. + + This function must be implemented in all child classes of `Suite.` + """ + raise NotImplementedError("Must implement `register_benchmarks` to run benchmark suite.") + + def before_all(self): + pass + + def after_all(self): + pass + + def before_each(self): + pass + + def after_each(self): + pass + + +class Runner(object): + ITERATIONS = 10 + + def __init__(self, suite): + """Initializes a benchmark runner for the `Suite`. + + Args: + suite (Suite) : A class that inherits from `Suite`, with any number + of instance methods decorated with `@benchmark`. + """ + self._suite = suite + self._benchmarks = [] + self._table = None + self._WROTE_RESULTS = False + self._HOSTING = False + + self._suite.register_benchmarks() + + class_attrs = self._suite.__class__.__dict__.items() + instance_attrs = self._suite.__dict__.items() + + for k, v in class_attrs: + if hasattr(v, "benchmark") and getattr(v, "benchmark") is True: + logging.debug("Registering {0}".format(k)) + self._benchmarks.append(v) + + for k, v in instance_attrs: + if hasattr(v, "benchmark") and getattr(v, "benchmark") is True: + logging.debug("Registering {0}".format(k)) + self._benchmarks.append(v) + + # Write results on SIGINT + signal.signal(signal.SIGINT, self.sigint_handler) + + def sigint_handler(self, signum, frame): + """On SIGINT, host the results over a websocket.""" + if not self._WROTE_RESULTS: + self.write_results() + if not self._HOSTING: + self.host_results() + else: + sys.exit(0) + + def host_results(self): + """Create a tornado application that hosts the results table over a + websocket.""" + if self._table is None: + return + MANAGER = PerspectiveManager() + MANAGER.host_table("benchmark_results", self._table) + application = tornado.web.Application( + [ + (r"/", BenchmarkTornadoHandler), + # create a websocket endpoint that the client Javascript can access + ( + r"/websocket", + PerspectiveTornadoHandler, + {"manager": MANAGER, "check_origin": True}, + ), + ] + ) + self._HOSTING = True + application.listen(8888) + logging.warn("Displaying results at http://localhost:8888") + loop = tornado.ioloop.IOLoop.current() + loop.start() + + def write_results(self): + if self._table is None: + return + + logging.debug("Writing results to `benchmark-python.arrow`") + if not os.path.exists(os.path.dirname(ARROW_PATH)): + os.makedirs(os.path.dirname(ARROW_PATH)) + + with open(ARROW_PATH, "wb") as file: + arrow = self._table.view().to_arrow(compression=None) + file.write(arrow) + + html_path = os.path.join(os.path.dirname(__file__), "benchmark-python.html") + out_path = os.path.join(os.path.dirname(ARROW_PATH), "benchmark-python.html") + with open(html_path, "r") as input: + with open(out_path, "w") as output: + output.write(input.read()) + + self._WROTE_RESULTS = True + + def run_method(self, func, *args, **kwargs): + """Wrap the benchmark `func` with timing code and run for n + `ITERATIONS`, returning a result row that can be fed into Perspective. + """ + overall_result = {k.replace("__BENCH__", ""): v for (k, v) in func.__dict__.items() if "__BENCH__" in k} + + result = timeit(func, number=Runner.ITERATIONS) / Runner.ITERATIONS + overall_result["__TIME__"] = result + overall_result["baseline"] = BASELINE["time"][BASELINE["name"].index(overall_result["name"])] + return overall_result + + def print_result(self, result): + logging.info( + "{:<9} {:<14} {:<7} {:>2.4f}s".format( + result["group"], + result["name"], + result["version"], + result["__TIME__"], + ) + ) + + def run(self, version): + """Runs each benchmark function from the suite for n `ITERATIONS`, + timing each function and writing the results to a `perspective.Table`. + """ + logging.info("Running benchmark suite...") + for benchmark in self._benchmarks: + result = self.run_method(benchmark) + result["version"] = version + self.print_result(result) + if self._table is None: + if os.path.exists(ARROW_PATH): + # if arrow exists, append to it + with open(ARROW_PATH, "rb") as arr: + print("Reading table from pre-existing benchmark-python.arrow") + self._table = Table(arr.read()) + self._table.update([result]) + else: + print("Creating new table") + self._table = Table([result]) + else: + self._table.update([result]) diff --git a/rust/perspective-python/bench/runtime/benchmark-python.html b/rust/perspective-python/bench/runtime/benchmark-python.html new file mode 100644 index 0000000..7dcd8c2 --- /dev/null +++ b/rust/perspective-python/bench/runtime/benchmark-python.html @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/rust/perspective-python/bench/runtime/benchmark_hosted.html b/rust/perspective-python/bench/runtime/benchmark_hosted.html new file mode 100644 index 0000000..66dfff1 --- /dev/null +++ b/rust/perspective-python/bench/runtime/benchmark_hosted.html @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/rust/perspective-python/bench/runtime/perspective_benchmark.py b/rust/perspective-python/bench/runtime/perspective_benchmark.py new file mode 100644 index 0000000..994c1d5 --- /dev/null +++ b/rust/perspective-python/bench/runtime/perspective_benchmark.py @@ -0,0 +1,242 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 os +from functools import partial +from bench import Benchmark, Suite, Runner +from perspective import Table +import perspective +import logging + +SUPERSTORE_ARROW = os.path.join( + os.path.dirname(__file__), + "..", + "..", + "..", + "..", + "node_modules", + "superstore-arrow", + "superstore.arrow", +) + +with open(SUPERSTORE_ARROW, "rb") as f: + SUPERSTORE_ARROW_DATA = f.read() + +VERSIONS = ["master", "0.4.1", "0.4.0rc6"] + + +def make_meta(group, name): + return {"group": group, "name": name} + + +def empty_callback(port_id): + pass + + +class PerspectiveBenchmark(Suite): + AGG_OPTIONS = [ + [{"column": "Sales", "op": "sum"}], + [{"column": "State", "op": "dominant"}], + [{"column": "Order Date", "op": "dominant"}], + ] + split_by_OPTIONS = [[], ["Sub-Category"], ["Category", "Sub-Category"]] + group_by_OPTIONS = [[], ["State"], ["State", "City"]] + + def __init__(self): + """Create a benchmark suite for the `perspective-python` runtime.""" + tbl = Table(SUPERSTORE_ARROW_DATA) + for x in range(19): + tbl.update(SUPERSTORE_ARROW_DATA) + self._schema = tbl.schema() + self._df_schema = tbl.schema() + # mutate schema to have some integer columns, so as to force numpy + # float-to-int demotion + self._df_schema["Sales"] = int + self._df_schema["Profit"] = int + self._df_schema["Quantity"] = int + self._view = tbl.view() + self._table = tbl + + def _get_update_data(self, n=30): + """Retrieve n rows from self.records to be used as update data.""" + return SUPERSTORE_ARROW_DATA + + def register_benchmarks(self): + """Register all the benchmark methods - each method creates a number of + lambdas, and then calls `setattr` on the Suite itself so that the + `Runner` can find the tests at runtime.""" + self.benchmark_table_arrow() + self.benchmark_table_csv() + self.benchmark_view_zero() + self.benchmark_view_one() + self.benchmark_view_two() + self.benchmark_view_zero_updates() + self.benchmark_view_one_updates() + self.benchmark_view_two_updates() + self.benchmark_to_format_zero() + self.benchmark_to_format_one() + self.benchmark_to_format_two() + + def benchmark_table_arrow(self): + """Benchmark table from arrow separately as it requires opening the + Arrow file from the filesystem.""" + test_meta = make_meta("table", "arrow") + func = Benchmark(lambda: Table(SUPERSTORE_ARROW_DATA), meta=test_meta) + setattr(self, "table_arrow", func) + + def benchmark_table_csv(self): + """Benchmark table from csv separately as it requires opening the + Arrow file from the filesystem.""" + csv = self._view.to_csv() + test_meta = make_meta("table", "csv") + func = Benchmark(lambda: Table(csv), meta=test_meta) + setattr(self, "table_csv", func) + + def benchmark_view_zero(self): + """Benchmark view creation with zero pivots.""" + func = Benchmark(lambda: self._table.view(), meta=make_meta("view", "zero")) + setattr(self, "view_zero", func) + + def benchmark_view_zero_updates(self): + """Benchmark how long it takes for each update to resolve fully, using + the on update callback that forces resolution of updates across + 10 views.""" + table = Table(self._schema) + views = [table.view() for i in range(25)] + for v in views: + v.on_update(empty_callback) + update_data = self._get_update_data(1000) + + def resolve_update(): + table.update(update_data) + table.size() + + func = Benchmark(resolve_update, meta=make_meta("update", "zero")) + setattr(self, "update_zero", func) + + def benchmark_view_one(self): + """Benchmark view creation with different pivots.""" + for pivot in PerspectiveBenchmark.group_by_OPTIONS: + if len(pivot) == 0: + continue + test_meta = make_meta("view", "one_{0}_pivot".format(len(pivot))) + view_constructor = partial(self._table.view, group_by=pivot) + func = Benchmark(lambda: view_constructor(), meta=test_meta) + setattr(self, "view_{0}".format(test_meta["name"]), func) + + def benchmark_view_one_updates(self): + """Benchmark how long it takes for each update to resolve fully, using + the on update callback that forces resolution of updates across + 25 views.""" + table = Table(self._schema) + views = [table.view(group_by=["State", "City"]) for i in range(25)] + for v in views: + v.on_update(empty_callback) + update_data = self._get_update_data(1000) + + def resolve_update(): + table.update(update_data) + table.size() + + func = Benchmark(resolve_update, meta=make_meta("update", "one")) + setattr(self, "update_one", func) + + def benchmark_view_two(self): + """Benchmark view creation with row and Split By.""" + for i in range(len(PerspectiveBenchmark.group_by_OPTIONS)): + RP = PerspectiveBenchmark.group_by_OPTIONS[i] + CP = PerspectiveBenchmark.split_by_OPTIONS[i] + if len(RP) == 0 and len(CP) == 0: + continue + test_meta = make_meta("view", "two_{0}x{1}_pivot".format(len(RP), len(CP))) + view_constructor = partial(self._table.view, group_by=RP, split_by=CP) + func = Benchmark(lambda: view_constructor(), meta=test_meta) + setattr(self, "view_{0}".format(test_meta["name"]), func) + + def benchmark_view_two_updates(self): + """Benchmark how long it takes for each update to resolve fully, using + the on update callback that forces resolution of updates across + 25 views.""" + table = Table(self._schema) + views = [table.view(group_by=["State", "City"], split_by=["Category", "Sub-Category"]) for i in range(25)] + for v in views: + v.on_update(empty_callback) + update_data = self._get_update_data(1000) + + def resolve_update(): + table.update(update_data) + table.size() + + func = Benchmark(resolve_update, meta=make_meta("update", "two")) + setattr(self, "update_two", func) + + def benchmark_to_format_zero(self): + """Benchmark each `to_format` method.""" + for name in ( + "arrow", + "csv", + "columns", + "records", + ): + method = "to_{0}".format(name) + test_meta = make_meta("to_format", method) + func = Benchmark(getattr(self._view, method), meta=test_meta) + setattr(self, "to_format_{0}".format(name), func) + + def benchmark_to_format_one(self): + """Benchmark each `to_format` method for one-sided contexts.""" + for name in ( + "arrow", + "csv", + "columns", + "records", + ): + for pivot in PerspectiveBenchmark.group_by_OPTIONS: + if len(pivot) == 0: + continue + test_meta = make_meta("to_format", "to_{0}_r{1}".format(name, len(pivot))) + view = self._table.view(group_by=pivot) + method = "to_{0}".format(name) + func = Benchmark(getattr(view, method), meta=test_meta) + setattr(self, "to_format_{0}".format(test_meta["name"]), func) + + def benchmark_to_format_two(self): + """Benchmark each `to_format` method for two-sided contexts.""" + for name in ( + "arrow", + "csv", + "columns", + "records", + ): + for i in range(len(PerspectiveBenchmark.group_by_OPTIONS)): + RP = PerspectiveBenchmark.group_by_OPTIONS[i] + CP = PerspectiveBenchmark.split_by_OPTIONS[i] + if len(RP) == 0 and len(CP) == 0: + continue + test_meta = make_meta("to_format", "to_{0}_r{1}_c{2}".format(name, len(RP), len(CP))) + view = self._table.view(group_by=RP, split_by=CP) + method = "to_{0}".format(name) + func = Benchmark(getattr(view, method), meta=test_meta) + setattr(self, "to_format_{0}".format(test_meta["name"]), func) + + +if __name__ == "__main__": + VERSION = os.environ.get("PSP_VERSION", "Unknown Version") + + # Initialize a suite and runner, then call `.run()` + suite = PerspectiveBenchmark() + runner = Runner(suite) + + logging.info("Benchmarking perspective-python=={}".format(VERSION)) + logging.info("Detected {} {}".format(perspective.__version__, perspective.__file__)) + runner.run(VERSION) + runner.write_results() diff --git a/rust/perspective-python/bench/runtime/run_perspective_benchmark.py b/rust/perspective-python/bench/runtime/run_perspective_benchmark.py new file mode 100644 index 0000000..ca573c4 --- /dev/null +++ b/rust/perspective-python/bench/runtime/run_perspective_benchmark.py @@ -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). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import os +import subprocess +from bench import VirtualEnvHandler +import logging + +if __name__ == "__main__": + """Benchmark the `perspective-python` runtime locally.""" + VERSIONS = [ + "master", + "2.7.0", + "2.6.0", + "2.5.0", + "2.4.0", + "2.3.2", + "2.3.1", + # "2.3.0", + "2.2.1", + "2.2.0", + ] + + # Access the benchmark virtualenv + HERE = os.path.abspath(os.path.dirname(__file__)) + VIRTUALENV_NAME = "benchmark_venv" + VIRTUALENV_PATH = os.path.join(HERE, "..", "..", "..", "..", VIRTUALENV_NAME) + venv_handler = VirtualEnvHandler(VIRTUALENV_PATH) + + print("Benchmarking perspective-python==master") + logging.info(venv_handler.activate_virtualenv()) + env = {k: v for (k, v) in os.environ.items()} + env["PSP_VERSION"] = "master" + + subprocess.check_output( + "python3 {}/perspective_benchmark.py master".format(HERE), + shell=True, + env=env, + ) + + # Run previous versions in virtualenv + if not venv_handler.virtualenv_exists(): + venv_handler.create_virtualenv() + + last_version = "master" + for version in VERSIONS[1:]: + env["PSP_VERSION"] = version + env["PYTHONPATH"] = "" + logging.info("Uninstalling perspective-python=={}".format(last_version)) + last_version = version + logging.debug( + subprocess.check_output( + "{} && yes | python3 -m pip uninstall perspective-python".format(venv_handler.activate_virtualenv()), + shell=True, + env=env, + ) + ) + logging.info("Installing perspective-python=={}".format(version)) + logging.debug( + subprocess.check_output( + "{} && yes | python3 -m pip install perspective-python[dev]=={}".format(venv_handler.activate_virtualenv(), version), + shell=True, + env=env, + ) + ) + logging.info("Benchmarking perspective-python=={}".format(version)) + subprocess.check_output( + "{} && python3 {}/perspective_benchmark.py {}".format(venv_handler.activate_virtualenv(), HERE, version), + shell=True, + env=env, + ) diff --git a/rust/perspective-python/build.mjs b/rust/perspective-python/build.mjs new file mode 100644 index 0000000..08af760 --- /dev/null +++ b/rust/perspective-python/build.mjs @@ -0,0 +1,210 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 "node:fs"; +import { execSync } from "child_process"; +import * as url from "url"; +import * as toml from "@iarna/toml"; +import * as tar from "tar"; +import * as path from "path"; +import "zx/globals"; + +const __dirname = url.fileURLToPath(new URL(".", import.meta.url)).slice(0, -1); +const pkg = JSON.parse( + fs.readFileSync(__dirname + "/package.json", { encoding: "utf-8" }), +); + +let flags = "--release"; +let features = []; +if (!!process.env.PSP_DEBUG) { + flags = ""; +} + +const is_pyodide = !!process.env.PSP_PYODIDE; + +const python_version = + process.env.PSP_PYTHON_VERSION || (is_pyodide ? "3.13" : "3.12"); + +const version = pkg.version; + +fs.mkdirSync(`./perspective_python-${version}.data`, { recursive: true }); +fs.copyFileSync("../../LICENSE.md", "./LICENSE.md"); + +const cwd = process.cwd(); +const env = { ...process.env }; + +let emsdk_prefix = ""; +if (is_pyodide) { + const emsdkdir = path.resolve(__dirname, "../../.emsdk"); + const { emscripten } = JSON.parse( + fs.readFileSync(path.resolve(__dirname, "../../package.json")), + ); + emsdk_prefix = `cd ${emsdkdir} && . ./emsdk_env.sh && ./emsdk activate ${emscripten} && cd ${cwd} && `; + env.MATURIN_PYEMSCRIPTEN_PLATFORM_VERSION = + process.env.MATURIN_PYEMSCRIPTEN_PLATFORM_VERSION || "2025_0"; +} + +// if not windows +if (process.platform !== "win32") { + env.PSP_ROOT_DIR = "../.."; +} + +const build_wheel = !!process.env.PSP_BUILD_WHEEL || is_pyodide; +const build_sdist = !!process.env.PSP_BUILD_SDIST; + +let target = ""; +if (is_pyodide) { + target = `--target=wasm32-unknown-emscripten -i${python_version}`; +} else if (process.env.PSP_ARCH === "x86_64" && process.platform === "darwin") { + target = "--target=x86_64-apple-darwin"; +} else if ( + process.env.PSP_ARCH === "aarch64" && + process.platform === "darwin" +) { + target = "--target=aarch64-apple-darwin"; +} else if (process.env.PSP_ARCH === "x86_64" && process.platform === "linux") { + target = "--target=x86_64-unknown-linux-gnu --compatibility manylinux_2_28"; +} else if (process.env.PSP_ARCH === "aarch64" && process.platform === "linux") { + target = "--target=aarch64-unknown-linux-gnu"; +} + +if (build_wheel) { + if (!!process.env.PSP_BUILD_VERBOSE) { + flags += " -vv"; + } + + if (process.env.CONDA_BUILD === "1") { + console.log("Building with Conda flags and features"); + if (process.env.PYTHON) { + console.log(`interpreter: ${process.env.PYTHON}`); + flags += ` --interpreter=${process.env.PYTHON}`; + } else { + console.warn( + "Expected PYTHON to be set in CONDA_BUILD environment, but it isn't. maturin will likely detect the wrong Python.", + ); + } + // we need to generate proto.rs using conda's protoc, which is set in + // the environment. we use the unstable "versioned" python abi + features.push(["generate-proto"]); + } else { + // standard for in-repo builds. a different set will be standard in the sdist + const standard_features = ["abi3", "generate-proto", "protobuf-src"]; + + console.log("Building with standard flags and features"); + features.push(...standard_features); + } + + execSync(`${emsdk_prefix}maturin build ${flags} --features=${features.join(",")} ${target}`, { stdio: "inherit", env }); +} + +if (build_sdist) { + // `maturin sdist` has some issues with Cargo workspaces, so we assemble the sdist by hand here + // Note that the resulting sdist is _not_ a Cargo workspace, it is rooted in this package. + const cargo_toml = fs.readFileSync("./Cargo.toml").toString("utf-8"); + const pyproject_toml = fs + .readFileSync("./pyproject.toml") + .toString("utf-8"); + const cargo = toml.parse(cargo_toml); + const pyproject = toml.parse(pyproject_toml); + + const version = cargo["package"]["version"]; + const data_dir = `perspective_python-${version}.data`; + const testfile = path.join( + data_dir, + "data/share/jupyter/labextensions/@perspective-dev/jupyterlab/package.json", + ); + if (!fs.existsSync(testfile)) { + throw new Error( + "labextension is not present in data directory, please build `perspective-jupyterlab`", + ); + } + const readme_md = fs.readFileSync("./README.md"); + const pkg_info = generatePkgInfo(pyproject, cargo, readme_md); + fs.writeFileSync("./PKG-INFO", pkg_info); + + // Maturin finds extra license files in the root of the source directory, + // then packages them into .dist-info in the wheel. As of Nov 2024, + // Maturin does not yet support explicitly declaring `license-files` in + // pyproject.toml. See https://github.com/PyO3/maturin/pull/862 + // https://github.com/PyO3/maturin/issues/861 + const crate_files = glob.sync(Array.from(cargo["package"]["include"])); + const wheel_dir = `../target/wheels`; + fs.mkdirSync(wheel_dir, { recursive: true }); + await tar.create( + { + gzip: true, + file: path.join(wheel_dir, `perspective_python-${version}.tar.gz`), + prefix: `perspective_python-${version}`, + strict: true, + }, + crate_files.concat(["PKG-INFO", data_dir]), + ); +} + +if (process.env["PSP_UV"] === "1") { + flags += " --uv"; +} + +if (!build_wheel && !build_sdist) { + const dev_features = ["abi3"]; + execSync( + `${emsdk_prefix}maturin develop --features=${dev_features.join(",")} ${flags} ${target}`, + { stdio: "inherit", env }, + ); +} + +// Generates version 2.3 according to https://packaging.python.org/en/latest/specifications/core-metadata/ +// Takes parsed pyproject.toml, Cargo.toml, and contents of README.md. +function generatePkgInfo(pyproject, cargo, readme_md) { + const project = pyproject["project"]; + const field = (name, value) => { + if (typeof value !== "string") { + throw new Error( + `PKG-INFO value for field ${name} was not a string:\n${value}`, + ); + } + return `${name}: ${value}`; + }; + const lines = []; + const addField = (key, value) => lines.push(field(key, value)); + addField("Metadata-Version", "2.3"); + addField("Name", project.name); + addField("Version", cargo.package.version); + for (const c of project["classifiers"]) { + addField("Classifier", c); + } + for (const [extra, deps] of Object.entries( + project["optional-dependencies"], + )) { + for (const dep of deps) { + addField("Requires-Dist", `${dep} ; extra == '${extra}'`); + } + } + for (const extra of Object.keys(project["optional-dependencies"])) { + addField("Provides-Extra", extra); + } + addField("Summary", cargo.package.description); + addField("Home-page", cargo.package.homepage); + addField("Author", cargo.package.authors[0]); + addField("Author-email", cargo.package.authors[0]); + addField("License", cargo.package.license); + addField("Requires-Python", project["requires-python"]); + addField( + "Description-Content-Type", + "text/markdown; charset=UTF-8; variant=GFM", + ); + addField("Project-URL", `Source Code, ${cargo.package.repository}`); + lines.push(""); + lines.push(readme_md); + + return lines.join("\n"); +} diff --git a/rust/perspective-python/build.rs b/rust/perspective-python/build.rs new file mode 100644 index 0000000..364f9a4 --- /dev/null +++ b/rust/perspective-python/build.rs @@ -0,0 +1,24 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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; + +fn main() -> Result<(), Box> { + pyo3_build_config::add_extension_module_link_args(); + + if std::option_env!("PSP_DISABLE_CPP").is_some() { + return Ok(()); + } + + unsafe { std::env::set_var("CARGO_FEATURE_PYTHON", "1") }; + Ok(()) +} diff --git a/rust/perspective-python/clean.mjs b/rust/perspective-python/clean.mjs new file mode 100644 index 0000000..d971b54 --- /dev/null +++ b/rust/perspective-python/clean.mjs @@ -0,0 +1,31 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 "node:fs"; +import "zx/globals"; + +const version = JSON.parse(fs.readFileSync("./package.json")).version; + +fs.rmSync("dist", { recursive: true, force: true }); +fs.rmSync("build", { recursive: true, force: true }); +fs.rmSync(`perspective_python-${version}.data`, { + recursive: true, + force: true, +}); + +fs.rmSync("LICENSE.md", { recursive: true, force: true }); +fs.rmSync("LICENSE_THIRDPARTY_cargo.yml", { recursive: true, force: true }); +fs.rmSync("perspective/*.so", { recursive: true, force: true }); + +// for (const path in glob("*.data")) { +// fs.rmSync(path, { recursive: true, force: true }); +// } diff --git a/rust/perspective-python/docs.mjs b/rust/perspective-python/docs.mjs new file mode 100644 index 0000000..ec25a5d --- /dev/null +++ b/rust/perspective-python/docs.mjs @@ -0,0 +1,52 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 "zx/globals"; + +const FILES = [ + "perspective/__init__.py", + "perspective/handlers/aiohttp.py", + "perspective/handlers/starlette.py", + "perspective/handlers/tornado.py", + "perspective/widget/__init__.py", + "perspective/virtual_servers/clickhouse.py", + "perspective/virtual_servers/duckdb.py", +]; + +await $`pdoc ${FILES.join(" ")} -o ../../docs/static/python`; + +let content = fs.readFileSync("../../docs/static/python/perspective.html"); +const reg = /\[([a-zA-Z0-9:_]+)<\/code>\]/g; + +content = content.toString().replaceAll(reg, function (_, arg) { + if (arg.includes("::")) { + const r = arg.replace("::", "."); + return `${r}`; + } else { + return ( + `` + + arg + + "" + ); + } +}); + +const reg2 = /\[([<>\/="# a-zA-Z0-9:_]+)<\/code>\]/g; + +content = content.toString().replaceAll(reg2, function (_, arg) { + return arg; +}); + +fs.writeFileSync("../../docs/static/python/index.html", content); diff --git a/rust/perspective-python/docs/index.html b/rust/perspective-python/docs/index.html new file mode 100644 index 0000000..4c3093e --- /dev/null +++ b/rust/perspective-python/docs/index.html @@ -0,0 +1,25 @@ + + + + + diff --git a/rust/perspective-python/docs/lib.md b/rust/perspective-python/docs/lib.md new file mode 100644 index 0000000..10919b9 --- /dev/null +++ b/rust/perspective-python/docs/lib.md @@ -0,0 +1,327 @@ +The Python language bindings for +[Perspective](https://perspective-dev.github.io), a high performance +data-visualization and analytics component for the web browser. + +
    +The examples in this module are in Python. See perspective docs for the Rust API. +
    + +A simple example which loads an [Apache Arrow](https://arrow.apache.org/) and +computes a "Group By" operation, returning a new Arrow. + +```python +from perspective import Server + +client = Server().new_local_client() +table = client.table(arrow_bytes_data) +view = table.view(group_by = ["CounterParty", "Security"]) +arrow = view.to_arrow() +``` + +Perspective for Python uses the exact same C++ data engine used by the +[WebAssembly version](https://docs.rs/perspective-js/latest/perspective_js/) and +[Rust version](https://docs.rs/crate/perspective/latest). The library consists +of many of the same abstractions and API as in JavaScript, as well as +Python-specific data loading support for [NumPy](https://numpy.org/), +[Pandas](https://pandas.pydata.org/) (and +[Apache Arrow](https://arrow.apache.org/), as in JavaScript). + +Additionally, `perspective-python` provides a session manager suitable for +integration into server systems such as +[Tornado websockets](https://www.tornadoweb.org/en/stable/websocket.html), +[AIOHTTP](https://docs.aiohttp.org/en/stable/web_quickstart.html#websockets), or +[Starlette](https://www.starlette.io/websockets/)/[FastAPI](https://fastapi.tiangolo.com/advanced/websockets/), +which allows fully _virtual_ Perspective tables to be interacted with by +multiple `` in a web browser. You can also interact with a +Perspective table from python clients, and to that end client libraries are +implemented for both Tornado and AIOHTTP. + +As `` will only consume the data necessary to render the +current screen, this runtime mode allows _ludicrously-sized_ datasets with +instant-load after they've been manifest on the server (at the expense of +network latency on UI interaction). + +The included `PerspectiveWidget` allows running such a viewer in +[JupyterLab](https://jupyterlab.readthedocs.io/en/stable/) in either server or +client (via WebAssembly) mode, and the included `PerspectiveTornadoHandler` +makes it simple to extend a Tornado server with virtual Perspective support. + +The `perspective` module exports several tools: + +- `Server` the constructor for a new isntance of the Perspective data engine. +- The `perspective.widget` module exports `PerspectiveWidget`, the JupyterLab + widget for interactive visualization in a notebook cell. +- The `perspective.handlers` modules exports web frameworks handlers that + interface with a `perspective-client` in JavaScript. + - `perspective.handlers.tornado.PerspectiveTornadoHandler` for + [Tornado](https://www.tornadoweb.org/) + - `perspective.handlers.starlette.PerspectiveStarletteHandler` for + [Starlette](https://www.starlette.io/) and + [FastAPI](https://fastapi.tiangolo.com) + - `perspective.handlers.aiohttp.PerspectiveAIOHTTPHandler` for + [AIOHTTP](https://docs.aiohttp.org), + +This user's guide provides an overview of the most common ways to use +Perspective in Python: the `Table` API, the JupyterLab widget, and the Tornado +handler. + +[More Examples](https://github.com/perspective-dev/perspective/tree/master/examples) +are available on GitHub. + +## Installation + +`perspective-python` contains full bindings to the Perspective API, a JupyterLab +widget, and a WebSocket handlers for several webserver libraries that allow you +to host Perspective using server-side Python. + +### PyPI + +`perspective-python` can be installed from [PyPI](https://pypi.org) via `pip`: + +```bash +pip install perspective-python +``` + + + +## `Table` + +A `Table` can be created from a dataset or a schema, the specifics of which are +[discussed](#loading-data-with-table) in the JavaScript section of the user's +guide. In Python, however, Perspective supports additional data types that are +commonly used when processing data: + +- `pandas.DataFrame` +- `polars.DataFrame` +- `bytes` (encoding an Apache Arrow) +- `objects` (either extracting a repr or via reference) +- `str` (encoding as a CSV) + +A `Table` is created in a similar fashion to its JavaScript equivalent: + +```python +from datetime import date, datetime +import numpy as np +import pandas as pd +import perspective + +data = pd.DataFrame({ + "int": np.arange(100), + "float": [i * 1.5 for i in range(100)], + "bool": [True for i in range(100)], + "date": [date.today() for i in range(100)], + "datetime": [datetime.now() for i in range(100)], + "string": [str(i) for i in range(100)] +}) + +table = perspective.table(data, index="float") +``` + +Likewise, a `View` can be created via the `view()` method: + +```python +view = table.view(group_by=["float"], filter=[["bool", "==", True]]) +column_data = view.to_columns() +row_data = view.to_json() +``` + +### Pandas and Polars Support + +Perspective's `Table` can be constructed from `pandas.DataFrame` and +`polars.DataFrame` objects. Internally, this just uses +[`pyarrow::from_pandas`](https://arrow.apache.org/docs/python/pandas.html), +which dictates behavior of this feature including type support. + +If the dataframe does not have an index set, an integer-typed column named +`"index"` is created. If you want to preserve the indexing behavior of the +dataframe passed into Perspective, simply create the `Table` with +`index="index"` as a keyword argument. This tells Perspective to once again +treat the index as a primary key: + +```python +data.set_index("datetime") +table = perspective.table(data, index="index") +``` + +#### Time Zone Handling + +When parsing `"datetime"` strings, times without an explicit timezone offset are +interpreted as _UTC_; strings with an offset are converted to UTC. All +`"datetime"` values are stored internally as milliseconds since the Unix epoch +and are _output_ as integer timestamps from methods like `to_columns()` and +`to_json()`. `"date"` values are timezone-agnostic calendar days, output as +integer timestamps at _UTC midnight_ of the calendar day. The host process +timezone never affects engine results; localization is the display layer's +responsibility. + +### Callbacks and Events + +`perspective.Table` allows for `on_update` and `on_delete` callbacks to be +set—simply call `on_update` or `on_delete` with a reference to a function or a +lambda without any parameters: + +```python +def update_callback(): + print("Updated!") + +# set the update callback +on_update_id = view.on_update(update_callback) + + +def delete_callback(): + print("Deleted!") + +# set the delete callback +on_delete_id = view.on_delete(delete_callback) + +# set a lambda as a callback +view.on_delete(lambda: print("Deleted x2!")) +``` + +If the callback is a named reference to a function, it can be removed with +`remove_update` or `remove_delete`: + +```python +view.remove_update(on_update_id) +view.remove_delete(on_delete_id) +``` + +Callbacks defined with a lambda function cannot be removed, as lambda functions +have no identifier. + +### Hosting `Table` and `View` instances + +`Server` "hosts" all `perspective.Table` and `perspective.View` instances +created by its connected `Client`s. Hosted tables/views can have their methods +called from other sources than the Python server, i.e. by a `perspective-viewer` +running in a JavaScript client over the network, interfacing with +`perspective-python` through the websocket API. + +The server has full control of all hosted `Table` and `View` instances, and can +call any public API method on hosted instances. This makes it extremely easy to +stream data to a hosted `Table` using `.update()`: + +```python +server = perspective.Server() +client = server.new_local_client() +table = client.table(data, name="data_source") + +for i in range(10): + # updates continue to propagate automatically + table.update(new_data) +``` + +The `name` provided is important, as it enables Perspective in JavaScript to +look up a `Table` and get a handle to it over the network. Otherwise, `name` +will be assigned randomlu and the `Client` must look this up with +`CLient.get_hosted_table_names()` + +### Client/Server Replicated Mode + +Using Tornado and +[`PerspectiveTornadoHandler`](python.md#perspectivetornadohandler), as well as +`Perspective`'s JavaScript library, we can set up "distributed" Perspective +instances that allows multiple browser `perspective-viewer` clients to read from +a common `perspective-python` server, as in the +[Tornado Example Project](https://github.com/perspective-dev/perspective/tree/master/examples/python-tornado). + +This architecture works by maintaining two `Tables`—one on the server, and one +on the client that mirrors the server's `Table` automatically using `on_update`. +All updates to the table on the server are automatically applied to each client, +which makes this architecture a natural fit for streaming dashboards and other +distributed use-cases. In conjunction with [multithreading](#multi-threading), +distributed Perspective offers consistently high performance over large numbers +of clients and large datasets. + +_*server.py*_ + +```python +from perspective import Server +from perspective.hadnlers.tornado import PerspectiveTornadoHandler + +# Create an instance of Server, and host a Table +SERVER = Server() +CLIENT = SERVER.new_local_client() + +# The Table is exposed at `localhost:8888/websocket` with the name `data_source` +client.table(data, name = "data_source") + +app = tornado.web.Application([ + # create a websocket endpoint that the client JavaScript can access + (r"/websocket", PerspectiveTornadoHandler, {"perspective_server": SERVER}) +]) + +# Start the Tornado server +app.listen(8888) +loop = tornado.ioloop.IOLoop.current() +loop.start() +``` + +Instead of calling `load(server_table)`, create a `View` using `server_table` +and pass that into `viewer.load()`. This will automatically register an +`on_update` callback that synchronizes state between the server and the client. + +_*index.html*_ + +```html + + + +``` + +For a more complex example that offers distributed editing of the server +dataset, see +[client_server_editing.html](https://github.com/perspective-dev/perspective/blob/master/examples/python-tornado/client_server_editing.html). + +We also provide examples for Starlette/FastAPI and AIOHTTP: + +- [Starlette Example Project](https://github.com/perspective-dev/perspective/tree/master/examples/python-starlette). +- [AIOHTTP Example Project](https://github.com/perspective-dev/perspective/tree/master/examples/python-aiohttp). + +### Server-only Mode + +The server setup is identical to [Distributed Mode](#distributed-mode) above, +but instead of creating a view, the client calls `load(server_table)`: In +Python, use `Server` and `PerspectiveTornadoHandler` to create a websocket +server that exposes a `Table`. In this example, `table` is a proxy for the +`Table` we created on the server. All API methods are available on _proxies_, +the.g.us calling `view()`, `schema()`, `update()` on `table` will pass those +operations to the Python `Table`, execute the commands, and return the result +back to Javascript. + +```html + +``` + +```javascript +const websocket = perspective.websocket("ws://localhost:8888/websocket"); +const table = websocket.open_table("data_source"); +document.getElementById("viewer").load(table); +``` diff --git a/rust/perspective-python/package.json b/rust/perspective-python/package.json new file mode 100644 index 0000000..f9dd3d4 --- /dev/null +++ b/rust/perspective-python/package.json @@ -0,0 +1,28 @@ +{ + "name": "@perspective-dev/python", + "version": "4.5.2", + "description": "", + "private": true, + "repository": { + "type": "git", + "url": "https://github.com/perspective-dev/perspective" + }, + "type": "module", + "license": "Apache-2.0", + "scripts": { + "build": "node ./build.mjs", + "clean": "node ./clean.mjs", + "test": "node test.mjs", + "docs": "node docs.mjs" + }, + "devDependencies": { + "@perspective-dev/scripts": "workspace:*", + "@iarna/toml": "catalog:", + "superstore-arrow": "catalog:", + "tar": "catalog:", + "zx": "catalog:" + }, + "dependencies": { + "@perspective-dev/jupyterlab": "workspace:*" + } +} diff --git a/rust/perspective-python/perspective/__init__.py b/rust/perspective-python/perspective/__init__.py new file mode 100644 index 0000000..dad0803 --- /dev/null +++ b/rust/perspective-python/perspective/__init__.py @@ -0,0 +1,403 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +__version__ = "4.5.2" +__all__ = [ + "_jupyter_labextension_paths", + "Server", + "Client", + "Table", + "View", + "PerspectiveError", + "ProxySession", + "AsyncClient", + "AsyncServer", + "GenericSQLVirtualServerModel", + "VirtualDataSlice", + "VirtualServer", + "num_cpus", + "set_num_cpus", + "system_info", +] + +__doc__ = """ +The Python language bindings for [Perspective](https://perspective-dev.github.io), a +high performance data-visualization and analytics component for the web browser. + +A simple example which loads an [Apache Arrow](https://arrow.apache.org/) and +computes a "Group By" operation, returning a new Arrow. + +```python +from perspective import Server + +client = Server().new_local_client() +table = client.table(arrow_bytes_data) +view = table.view(group_by = ["CounterParty", "Security"]) +arrow = view.to_arrow() +``` + +Perspective for Python uses the exact same C++ data engine used by the +[WebAssembly version](https://docs.rs/perspective-js/latest/perspective_js/) and +[Rust version](https://docs.rs/crate/perspective/latest). The library consists +of many of the same abstractions and API as in JavaScript, as well as +Python-specific data loading support for [NumPy](https://numpy.org/), +[Pandas](https://pandas.pydata.org/) (and +[Apache Arrow](https://arrow.apache.org/), as in JavaScript). + +Additionally, `perspective-python` provides a session manager suitable for +integration into server systems such as +[Tornado websockets](https://www.tornadoweb.org/en/stable/websocket.html), +[AIOHTTP](https://docs.aiohttp.org/en/stable/web_quickstart.html#websockets), or +[Starlette](https://www.starlette.io/websockets/)/[FastAPI](https://fastapi.tiangolo.com/advanced/websockets/), +which allows fully _virtual_ Perspective tables to be interacted with by +multiple `` in a web browser. You can also interact with a +Perspective table from python clients, and to that end client libraries are +implemented for both Tornado and AIOHTTP. + +As `` will only consume the data necessary to render the +current screen, this runtime mode allows _ludicrously-sized_ datasets with +instant-load after they've been manifest on the server (at the expense of +network latency on UI interaction). + +The included `PerspectiveWidget` allows running such a viewer in +[JupyterLab](https://jupyterlab.readthedocs.io/en/stable/) in either server or +client (via WebAssembly) mode, and the included `PerspectiveTornadoHandler` +makes it simple to extend a Tornado server with virtual Perspective support. + +The `perspective` module exports several tools: + +- `Server` the constructor for a new isntance of the Perspective data engine. +- The `perspective.widget` module exports `PerspectiveWidget`, the JupyterLab + widget for interactive visualization in a notebook cell. +- The `perspective.handlers` modules exports web frameworks handlers that + interface with a `perspective-client` in JavaScript. + - `perspective.handlers.tornado.PerspectiveTornadoHandler` for + [Tornado](https://www.tornadoweb.org/) + - `perspective.handlers.starlette.PerspectiveStarletteHandler` for + [Starlette](https://www.starlette.io/) and + [FastAPI](https://fastapi.tiangolo.com) + - `perspective.handlers.aiohttp.PerspectiveAIOHTTPHandler` for + [AIOHTTP](https://docs.aiohttp.org), + +This user's guide provides an overview of the most common ways to use +Perspective in Python: the `Table` API, the JupyterLab widget, and the Tornado +handler. + +[More Examples](https://github.com/perspective-dev/perspective/tree/master/examples) are +available on GitHub. + +## Installation + +`perspective-python` contains full bindings to the Perspective API, a JupyterLab +widget, and a WebSocket handlers for several webserver libraries that allow you +to host Perspective using server-side Python. + +`perspective-python` can be installed from [PyPI](https://pypi.org) via `pip`: + +```bash +pip install perspective-python +``` + +## Quick Start + +A `Table` can be created from a dataset or a schema, the specifics of which are +[discussed](#loading-data-with-table) in the JavaScript section of the user's +guide. In Python, however, Perspective supports additional data types that are +commonly used when processing data: + +- `pandas.DataFrame` +- `polars.DataFrame` +- `bytes` (encoding an Apache Arrow) +- `objects` (either extracting a repr or via reference) +- `str` (encoding as a CSV) + +A `Table` is created in a similar fashion to its JavaScript equivalent: + +```python +from datetime import date, datetime +import numpy as np +import pandas as pd +import perspective + +data = pd.DataFrame({ + "int": np.arange(100), + "float": [i * 1.5 for i in range(100)], + "bool": [True for i in range(100)], + "date": [date.today() for i in range(100)], + "datetime": [datetime.now() for i in range(100)], + "string": [str(i) for i in range(100)] +}) + +table = perspective.table(data, index="float") +``` + +Likewise, a `View` can be created via the `view()` method: + +```python +view = table.view(group_by=["float"], filter=[["bool", "==", True]]) +column_data = view.to_columns() +row_data = view.to_json() +``` + +#### Pandas and Polars Support + +Perspective's `Table` can be constructed from `pandas.DataFrame` and +`polars.DataFrame` objects. Internally, this just uses +[`pyarrow::from_pandas`](https://arrow.apache.org/docs/python/pandas.html), +which dictates behavior of this feature including type support. + +If the dataframe does not have an index set, an integer-typed column named +`"index"` is created. If you want to preserve the indexing behavior of the +dataframe passed into Perspective, simply create the `Table` with +`index="index"` as a keyword argument. This tells Perspective to once again +treat the index as a primary key: + +```python +data.set_index("datetime") +table = perspective.table(data, index="index") +``` + +#### Time Zone Handling + +When parsing `"datetime"` strings, times are assumed _local time_ unless an +explicit timezone offset is parsed. All `"datetime"` columns (regardless of +input time zone) are _output_ to the user as `datetime.datetime` objects in +_local time_ according to the Python runtime. + +This behavior is consistent with Perspective's behavior in JavaScript. For more +details, see this in-depth +[explanation](https://github.com/perspective-dev/perspective/pull/867) of +`perspective-python` semantics around time zone handling. + +#### Callbacks and Events + +`perspective.Table` allows for `on_update` and `on_delete` callbacks to be +set—simply call `on_update` or `on_delete` with a reference to a function or a +lambda without any parameters: + +```python +def update_callback(): + print("Updated!") + +# set the update callback +on_update_id = view.on_update(update_callback) + + +def delete_callback(): + print("Deleted!") + +# set the delete callback +on_delete_id = view.on_delete(delete_callback) + +# set a lambda as a callback +view.on_delete(lambda: print("Deleted x2!")) +``` + +If the callback is a named reference to a function, it can be removed with +`remove_update` or `remove_delete`: + +```python +view.remove_update(on_update_id) +view.remove_delete(on_delete_id) +``` + +Callbacks defined with a lambda function cannot be removed, as lambda functions +have no identifier. + +### Hosting `Table` and `View` instances + +`Server` "hosts" all `perspective.Table` and `perspective.View` instances +created by its connected `Client`s. Hosted tables/views can have their methods +called from other sources than the Python server, i.e. by a `perspective-viewer` +running in a JavaScript client over the network, interfacing with +`perspective-python` through the websocket API. + +The server has full control of all hosted `Table` and `View` instances, and can +call any public API method on hosted instances. This makes it extremely easy to +stream data to a hosted `Table` using `.update()`: + +```python +server = perspective.Server() +client = server.new_local_client() +table = client.table(data, name="data_source") + +for i in range(10): + # updates continue to propagate automatically + table.update(new_data) +``` + +The `name` provided is important, as it enables Perspective in JavaScript to +look up a `Table` and get a handle to it over the network. Otherwise, `name` +will be assigned randomlu and the `Client` must look this up with +`CLient.get_hosted_table_names()` + +### Client/Server Replicated Mode + +Using Tornado and +[`PerspectiveTornadoHandler`](python.md#perspectivetornadohandler), as well as +`Perspective`'s JavaScript library, we can set up "distributed" Perspective +instances that allows multiple browser `perspective-viewer` clients to read from +a common `perspective-python` server, as in the +[Tornado Example Project](https://github.com/perspective-dev/perspective/tree/master/examples/python-tornado). + +This architecture works by maintaining two `Tables`—one on the server, and one +on the client that mirrors the server's `Table` automatically using `on_update`. +All updates to the table on the server are automatically applied to each client, +which makes this architecture a natural fit for streaming dashboards and other +distributed use-cases. In conjunction with [multithreading](#multi-threading), +distributed Perspective offers consistently high performance over large numbers +of clients and large datasets. + +_*server.py*_ + +```python +from perspective import Server +from perspective.hadnlers.tornado import PerspectiveTornadoHandler + +# Create an instance of Server, and host a Table +SERVER = Server() +CLIENT = SERVER.new_local_client() + +# The Table is exposed at `localhost:8888/websocket` with the name `data_source` +client.table(data, name = "data_source") + +app = tornado.web.Application([ + # create a websocket endpoint that the client JavaScript can access + (r"/websocket", PerspectiveTornadoHandler, {"perspective_server": SERVER}) +]) + +# Start the Tornado server +app.listen(8888) +loop = tornado.ioloop.IOLoop.current() +loop.start() +``` + +Instead of calling `load(server_table)`, create a `View` using `server_table` +and pass that into `viewer.load()`. This will automatically register an +`on_update` callback that synchronizes state between the server and the client. + +_*index.html*_ + +```html + + + +``` + +For a more complex example that offers distributed editing of the server +dataset, see +[client_server_editing.html](https://github.com/perspective-dev/perspective/blob/master/examples/python-tornado/client_server_editing.html). + +We also provide examples for Starlette/FastAPI and AIOHTTP: + +- [Starlette Example Project](https://github.com/perspective-dev/perspective/tree/master/examples/python-starlette). +- [AIOHTTP Example Project](https://github.com/perspective-dev/perspective/tree/master/examples/python-aiohttp). + +### Server-only Mode + +The server setup is identical to [Distributed Mode](#distributed-mode) above, +but instead of creating a view, the client calls `load(server_table)`: In +Python, use `Server` and `PerspectiveTornadoHandler` to create a websocket +server that exposes a `Table`. In this example, `table` is a proxy for the +`Table` we created on the server. All API methods are available on _proxies_, +the.g.us calling `view()`, `schema()`, `update()` on `table` will pass those +operations to the Python `Table`, execute the commands, and return the result +back to Javascript. + +```html + +``` + +```javascript +const websocket = perspective.websocket("ws://localhost:8888/websocket"); +const table = websocket.open_table("data_source"); +document.getElementById("viewer").load(table); +``` + +""" + + +import functools + +from .perspective import ( + Client, + PerspectiveError, + ProxySession, + Server, + AsyncServer, + AsyncClient, + VirtualServer, + VirtualDataSlice, + GenericSQLVirtualServerModel, + # NOTE: these are classes without constructors, + # so we import them just for type hinting + Table, # noqa: F401 + View, # noqa: F401 + num_cpus, + set_num_cpus, +) + + +GLOBAL_SERVER = Server() +GLOBAL_CLIENT = GLOBAL_SERVER.new_local_client() + + +@functools.wraps(Client.table) +def table(*args, **kwargs): + return GLOBAL_CLIENT.table(*args, **kwargs) + + +@functools.wraps(Client.open_table) +def open_table(*args, **kwargs): + return GLOBAL_CLIENT.table(*args, **kwargs) + + +@functools.wraps(Client.get_hosted_table_names) +def get_hosted_table_names(*args, **kwargs): + return GLOBAL_CLIENT.get_hosted_table_names(*args, **kwargs) + + +@functools.wraps(Client.join) +def join(*args, **kwargs): + return GLOBAL_CLIENT.join(*args, **kwargs) + + +@functools.wraps(Client.system_info) +def system_info(*args, **kwargs): + return GLOBAL_CLIENT.system_info(*args, **kwargs) + + +def _jupyter_labextension_paths(): + """ + Read by `jupyter labextension develop` + @private + """ + return [{"src": "labextension", "dest": "@perspective-dev/jupyterlab"}] diff --git a/rust/perspective-python/perspective/extension/finos-perspective-nbextension.json b/rust/perspective-python/perspective/extension/finos-perspective-nbextension.json new file mode 100644 index 0000000..e47ddb2 --- /dev/null +++ b/rust/perspective-python/perspective/extension/finos-perspective-nbextension.json @@ -0,0 +1,5 @@ +{ + "load_extensions": { + "@perspective-dev/jupyterlab/extension": true + } +} diff --git a/rust/perspective-python/perspective/handlers/__init__.py b/rust/perspective-python/perspective/handlers/__init__.py new file mode 100644 index 0000000..284e708 --- /dev/null +++ b/rust/perspective-python/perspective/handlers/__init__.py @@ -0,0 +1,11 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ diff --git a/rust/perspective-python/perspective/handlers/aiohttp.py b/rust/perspective-python/perspective/handlers/aiohttp.py new file mode 100644 index 0000000..3822bfc --- /dev/null +++ b/rust/perspective-python/perspective/handlers/aiohttp.py @@ -0,0 +1,69 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 aiohttp import web, WSMsgType +import perspective +import asyncio + + +class PerspectiveAIOHTTPHandler(object): + """`PerspectiveAIOHTTPHandler` is a drop-in implementation of Perspective. + + Use it inside AIOHTTP routing to create a server-side Perspective that is + ready to receive websocket messages from the front-end `perspective-viewer`. + + The Perspective client and server will automatically keep the Websocket + alive without timing out. + + # Security + + `PerspectiveAIOHTTPHandler` is a reference integration with no + authentication, authorization, origin enforcement, or rate limiting, + and is not safe to expose to untrusted networks — see + [`SECURITY.md`](https://github.com/perspective-dev/perspective/blob/master/SECURITY.md) + for the full threat model. + + # Examples + + >>> server = Server() + >>> async def websocket_handler(request): + ... handler = PerspectiveAIOHTTPHandler(perspective_server=server, request=request) + ... await handler.run() + + >>> app = web.Application() + >>> app.router.add_get("/websocket", websocket_handler) + """ + + def __init__(self, **kwargs): + self.server = kwargs.pop("perspective_server", perspective.GLOBAL_SERVER) + self._request = kwargs.pop("request") + self._executor = kwargs.pop("executor", None) + self._loop = kwargs.pop("loop", asyncio.get_event_loop()) + super().__init__(**kwargs) + + async def run(self) -> web.WebSocketResponse: + def inner(msg): + self._loop.create_task(self._ws.send_bytes(msg)) + + self.session = self.server.new_session(inner) + try: + self._ws = web.WebSocketResponse() + await self._ws.prepare(self._request) + async for msg in self._ws: + if msg.type == WSMsgType.BINARY: + if self._executor is not None: + self._executor.submit(self.session.handle_request, msg.data) + else: + self.session.handle_request(msg.data) + finally: + self.session.close() + return self._ws diff --git a/rust/perspective-python/perspective/handlers/starlette.py b/rust/perspective-python/perspective/handlers/starlette.py new file mode 100644 index 0000000..792eb33 --- /dev/null +++ b/rust/perspective-python/perspective/handlers/starlette.py @@ -0,0 +1,63 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 asyncio +import perspective + + +class PerspectiveStarletteHandler(object): + """`PerspectiveStarletteHandler` is a drop-in implementation of Perspective. + + # Security + + `PerspectiveStarletteHandler` is a reference integration with no + authentication, authorization, origin enforcement, or rate limiting, + and is not safe to expose to untrusted networks — see + [`SECURITY.md`](https://github.com/perspective-dev/perspective/blob/master/SECURITY.md) + for the full threat model. + + # Examples + + >>> server = Server() + >>> client = server.client() + >>> client.table(pd.read_csv("superstore.csv"), name="data_source_one") + >>> app = FastAPI() + >>> async def endpoint(websocket: Websocket): + ... handler = PerspectiveStarletteHandler(server, websocket) + ... await handler.run() + ... app.add_api_websocket_route('/websocket', endpoint) + """ + + def __init__(self, **kwargs): + self._server = kwargs.pop("perspective_server", perspective.GLOBAL_SERVER) + self._websocket = kwargs.pop("websocket") + self._executor = kwargs.pop("executor", None) + self._loop = kwargs.pop("loop", asyncio.get_event_loop()) + super().__init__(**kwargs) + + async def run(self) -> None: + def inner(msg): + self._loop.create_task(self._websocket.send_bytes(msg)) + + self.session = self._server.new_session(inner) + + try: + await self._websocket.accept() + while True: + message = await self._websocket.receive() + self._websocket._raise_on_disconnect(message) + if self._executor is not None: + self._executor.submit(self.session.handle_request, message["bytes"]) + else: + self.session.handle_request(message["bytes"]) + finally: + self.session.close() diff --git a/rust/perspective-python/perspective/handlers/tornado.py b/rust/perspective-python/perspective/handlers/tornado.py new file mode 100644 index 0000000..7132ab7 --- /dev/null +++ b/rust/perspective-python/perspective/handlers/tornado.py @@ -0,0 +1,192 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 tornado.websocket import WebSocketHandler, WebSocketClosedError +from tornado.ioloop import IOLoop +import perspective + +__doc__ = """ +Perspective ships with a pre-built Tornado handler that makes integration with +`tornado.websockets` extremely easy. This allows you to run an instance of +`Perspective` on a server using Python, open a websocket to a `Table`, and +access the `Table` in JavaScript and through ``. All +instructions sent to the `Table` are processed in Python, which executes the +commands, and returns its output through the websocket back to Javascript. + +### Python setup + +To use the handler, we need to first have a `Server`, a `Client` and an instance +of a `Table`: + +```python +SERVER = Server() +CLIENT = SERVER.new_local_client() +``` + +Once the server has been created, create a `Table` instance with a name. The +name that you host the table under is important — it acts as a unique accessor +on the JavaScript side, which will look for a Table hosted at the websocket with +the name you specify. + +```python +TABLE = client.table(data, name="data_source_one") +``` + +After the server and table setup is complete, create a websocket endpoint and +provide it a reference to `PerspectiveTornadoHandler`. You must provide the +configuration object in the route tuple, and it must contain +`"perspective_server"`, which is a reference to the `Server` you just created. + +```python +from perspective.handlers.tornado import PerspectiveTornadoHandler + +app = tornado.web.Application([ + + # ... other handlers ... + + # Create a websocket endpoint that the client JavaScript can access + (r"/websocket", PerspectiveTornadoHandler, {"perspective_server": SERVER, "check_origin": True}) +]) +``` + +Optionally, the configuration object can also include `check_origin`, a boolean +that determines whether the websocket accepts requests from origins other than +where the server is hosted. See +[Tornado docs](https://www.tornadoweb.org/en/stable/websocket.html#tornado.websocket.WebSocketHandler.check_origin) +for more details. + +### JavaScript setup + +Once the server is up and running, you can access the Table you just hosted +using `perspective.websocket` and `open_table()`. First, create a client that +expects a Perspective server to accept connections at the specified URL: + +```javascript +const websocket = await perspective.websocket("ws://localhost:8888/websocket"); +``` + +Next open the `Table` we created on the server by name: + +```javascript +const table = await websocket.open_table("data_source_one"); +``` + +`table` is a proxy for the `Table` we created on the server. All operations that +are possible through the JavaScript API are possible on the Python API as well, +thus calling `view()`, `schema()`, `update()` etc. on `const table` will pass +those operations to the Python `Table`, execute the commands, and return the +result back to JavaScript. Similarly, providing this `table` to a +`` instance will allow virtual rendering: + +```javascript +await viewer.load(table); +``` + +`perspective.websocket` expects a Websocket URL where it will send instructions. +When `open_table` is called, the name to a hosted Table is passed through, and a +request is sent through the socket to fetch the Table. No actual `Table` +instance is passed inbetween the runtimes; all instructions are proxied through +websockets. + +This provides for great flexibility — while `Perspective.js` is full of +features, browser WebAssembly runtimes currently have some performance +restrictions on memory and CPU feature utilization, and the architecture in +general suffers when the dataset itself is too large to download to the client +in full. + +The Python runtime does not suffer from memory limitations, utilizes Apache +Arrow internal threadpools for threading and parallel processing, and generates +architecture optimized code, which currently makes it more suitable as a +server-side runtime than `node.js`. +""" + + +class PerspectiveTornadoHandler(WebSocketHandler): + """`PerspectiveTornadoHandler` is a `perspective.Server` API as a `tornado` + websocket handler. + + Use it inside `tornado` routing to create a `perspective.Server` that can + connect to a JavaScript (Wasm) `Client`, providing a virtual interface to + the `Server`'s resources for e.g. ``. + + You may need to increase the `websocket_max_message_size` kwarg + to the `tornado.web.Application` constructor, as well as provide the + `max_buffer_size` optional arg, for large datasets. + + # Security + + `PerspectiveTornadoHandler` is a reference integration with no + authentication, authorization, origin enforcement, or rate limiting, + and is not safe to expose to untrusted networks — see + [`SECURITY.md`](https://github.com/perspective-dev/perspective/blob/master/SECURITY.md) + for the full threat model. + + # Arguments + + - `loop`: An optional `IOLoop` instance to use for scheduling IO calls, + defaults to `IOLoop.current()`. + - `executor`: An optional executor for scheduling `perspective.Server` + message processing calls from websocket `Client`s. + + # Examples + + >>> server = psp.Server() + >>> client = server.new_local_client() + >>> client.table(pd.read_csv("superstore.csv"), name="data_source_one") + >>> app = tornado.web.Application([ + ... (r"/", MainHandler), + ... (r"/websocket", PerspectiveTornadoHandler, { + ... "perspective_server": server, + ... }) + ... ]) + """ + + def check_origin(self, origin): + return True + + def initialize( + self, + perspective_server=perspective.GLOBAL_SERVER, + loop=None, + executor=None, + max_buffer_size=None, + ): + self.server = perspective_server + self.loop = loop or IOLoop.current() + self.executor = executor + if max_buffer_size is not None: + self.request.connection.stream.max_buffer_size = max_buffer_size + + def open(self): + def write(msg): + try: + self.write_message(msg, binary=True) + except WebSocketClosedError: + self.close() + + def send_response(msg): + self.loop.add_callback(write, msg) + + self.session = self.server.new_session(send_response) + + def on_close(self) -> None: + self.session.close() + del self.session + + def on_message(self, msg: bytes): + if not isinstance(msg, bytes): + return + + if self.executor is None: + self.session.handle_request(msg) + else: + self.executor.submit(self.session.handle_request, msg) diff --git a/rust/perspective-python/perspective/templates/exported_widget.html.template b/rust/perspective-python/perspective/templates/exported_widget.html.template new file mode 100644 index 0000000..080f46d --- /dev/null +++ b/rust/perspective-python/perspective/templates/exported_widget.html.template @@ -0,0 +1,35 @@ + + + + + + +
    + + + +
    diff --git a/rust/perspective-python/perspective/tests/__init__.py b/rust/perspective-python/perspective/tests/__init__.py new file mode 100644 index 0000000..284e708 --- /dev/null +++ b/rust/perspective-python/perspective/tests/__init__.py @@ -0,0 +1,11 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ diff --git a/rust/perspective-python/perspective/tests/async/test_async_client.py b/rust/perspective-python/perspective/tests/async/test_async_client.py new file mode 100644 index 0000000..4c5128c --- /dev/null +++ b/rust/perspective-python/perspective/tests/async/test_async_client.py @@ -0,0 +1,83 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 perspective import ( + AsyncClient, + AsyncServer, + PerspectiveError, +) + + +import pytest +from unittest.mock import Mock + + +@pytest.fixture +def server(): + return AsyncServer() + + +@pytest.fixture +def client(server): + async def send_request(msg): + await sess.handle_request(msg) + + async def send_response(msg): + await client.handle_response(msg) + + sess = server.new_session(send_response) + client = AsyncClient(send_request) + return client + + +@pytest.mark.asyncio +async def test_send_request_callback_awaited(client): + table_names = await client.get_hosted_table_names() + assert table_names == [] + + +# This test also exists in the pyodide-tests/ pytest suite, with the same name. +# Unfortunately code sharing between the two test suites is currently not easy. +@pytest.mark.asyncio +async def test_async_client_kitchen_sink(client): + """run various things on the async client""" + table = await client.table({"a": [0]}, name="my-cool-data", limit=100) + view = await table.view() + # synchronous methods + assert table.get_index() is None + assert table.get_name() == "my-cool-data" + limit = table.get_limit() + assert limit == 100 + + # view update callbacks + view_updated = Mock() + await view.on_update(view_updated) + for i in range(1, limit): + await table.update([{"a": i}]) + await table.size() # force update to process + assert (await table.size()) == limit + assert view_updated.call_count == limit - 1 + rex = await view.to_records() + assert rex == [{"a": i} for i in range(limit)] + + # view/table delete callbacks + view_deleted = Mock() + table_deleted = Mock() + await table.on_delete(table_deleted) + await view.on_delete(view_deleted) + with pytest.raises(PerspectiveError) as excinfo: + await table.delete() + assert excinfo.match(r"Cannot delete table with views") + await view.delete() + view_deleted.assert_called_once() + await table.delete() + table_deleted.assert_called_once() diff --git a/rust/perspective-python/perspective/tests/async/test_websocket_client.py b/rust/perspective-python/perspective/tests/async/test_websocket_client.py new file mode 100644 index 0000000..3e32021 --- /dev/null +++ b/rust/perspective-python/perspective/tests/async/test_websocket_client.py @@ -0,0 +1,124 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 asyncio +import threading +import websocket + +import tornado.websocket +import tornado.web +import tornado.ioloop + +import perspective +import perspective.handlers.tornado + +PORT = 8082 + + +def test_big_multi_thing(superstore): + async def init_table(client): + global SERVER_DATA + global SERVER_TABLE + + SERVER_DATA = "x,y\n1,2\n3,4" + # with open(file_path, mode="rb") as file: + SERVER_TABLE = client.table(SERVER_DATA, name="superstore") + + global ws + ws = websocket.WebSocketApp( + "ws://localhost:{}/websocket".format(PORT), + on_open=on_open, + on_message=on_message, + # on_error=on_error, + # on_close=on_close, + ) + + global ws_thread + ws_thread = threading.Thread(target=ws.run_forever) + ws_thread.start() + + def server_thread(): + def make_app(perspective_server): + return tornado.web.Application( + [ + ( + r"/websocket", + perspective.handlers.tornado.PerspectiveTornadoHandler, + {"perspective_server": perspective_server}, + ), + ] + ) + + perspective_server = perspective.Server() + app = make_app(perspective_server) + global server + server = app.listen(PORT, "0.0.0.0") + + global server_loop + server_loop = tornado.ioloop.IOLoop.current() + client = perspective_server.new_local_client() + server_loop.call_later(0, init_table, client) + server_loop.start() + + server_thread = threading.Thread(target=server_thread) + server_thread.start() + + client_loop = asyncio.new_event_loop() + client_loop.set_debug(True) + client_thread = threading.Thread(target=client_loop.run_forever) + client_thread.start() + + async def send_request(msg): + global ws + ws.send(msg, websocket.ABNF.OPCODE_BINARY) + + def on_message(ws, message): + async def poke_client(): + await client.handle_response(message) + + asyncio.run_coroutine_threadsafe(poke_client(), client_loop) + + # def on_error(ws, error): + # print(f"Error!: {error}") + + # def on_close(ws, close_status_code, close_msg): + # print("Connection closed") + + def on_open(ws): + global client + client = perspective.AsyncClient(send_request) + asyncio.run_coroutine_threadsafe(test(client), client_loop) + + global count + count = 0 + + def update(x): + global count + count += 1 + + async def test(client): + table = await client.open_table("superstore") + view = await table.view() + await view.on_update(update) + SERVER_TABLE.update(SERVER_DATA) + assert await table.size() == 4 + assert count == 1 + await server.close_all_connections() + client_loop.stop() + + client_thread.join() + client_loop.close() + ws.close() + ws_thread.join() + server_loop.add_callback(server_loop.stop) + server_thread.join() + server_loop.close() diff --git a/rust/perspective-python/perspective/tests/conftest.py b/rust/perspective-python/perspective/tests/conftest.py new file mode 100644 index 0000000..69cd247 --- /dev/null +++ b/rust/perspective-python/perspective/tests/conftest.py @@ -0,0 +1,276 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 datetime import datetime, date, timezone + +import numpy as np +import pandas as pd +import pyarrow as pa +from pytest import fixture +from random import random, randint, choice +from faker import Faker + + +# Our tests construct naive datetimes everywhere +# so setting it here is an easy way to fix it globally. +import os + +# Perspective used to support datetime.date and datetime.datetime +# as Table() constructor arguments, but now we forward the parameters +# directly to JSON.loads. So to make sure the tests dont need to be +# so utterly transmogrified, we have this little hack :) +import json + + +os.environ["TZ"] = "UTC" + + +def new_encoder(self, obj): + if isinstance(obj, datetime): + return str(obj) + elif isinstance(obj, date): + return str(obj) + else: + return old(self, obj) + + +old = json.JSONEncoder.default +json.JSONEncoder.default = new_encoder + +fake = Faker() + + +def _make_date_time_index(size, time_unit): + return pd.date_range("2000-01-01", periods=size, freq=time_unit) + + +def _make_period_index(size, time_unit): + return pd.period_range(start="2000", periods=size, freq=time_unit) + + +def _make_dataframe(index, size=10): + """Create a new random dataframe of `size` and with a DateTimeIndex of + frequency `time_unit`. + """ + return pd.DataFrame( + index=index, + data={ + "a": np.random.rand(size), + "b": np.random.rand(size), + "c": np.random.rand(size), + "d": np.random.rand(size), + }, + ) + + +class Util: + @staticmethod + def make_arrow(names, data, types=None, legacy=False): + """Create an arrow binary that can be loaded and manipulated from memory. + + Args: + names (list): a list of str column names + data (list): a list of lists containing data for each column + types (list): an optional list of `pyarrow.type` function references. + Types will be inferred if not provided. + legacy (bool): if True, use legacy IPC format (pre-pyarrow 0.15). Defaults to False. + + Returns: + bytes : a bytes object containing the arrow-serialized output. + """ + stream = pa.BufferOutputStream() + arrays = [] + + for idx, column in enumerate(data): + # only apply types if array is present + kwargs = {} + if types: + kwargs["type"] = types[idx] + arrays.append(pa.array(column, **kwargs)) + + batch = pa.RecordBatch.from_arrays(arrays, names) + table = pa.Table.from_batches([batch]) + writer = pa.RecordBatchStreamWriter( + stream, table.schema + ) + + writer.write_table(table) + writer.close() + return stream.getvalue().to_pybytes() + + @staticmethod + def make_arrow_from_pandas(df, schema=None, legacy=False): + """Create a pyarrow Table from a Pandas dataframe. + + Args: + df (:obj:`pandas.DataFrame`) + schema (:obj:`pyarrow.Schema`) + legacy (bool): unused; retained for backwards compatibility. + + Returns: + pyarrow.Table + """ + return pa.Table.from_pandas(df, schema=schema) + + @staticmethod + def make_dictionary_arrow(names, data, types=None, legacy=False): + """Create an arrow binary that can be loaded and manipulated from memory, with + each column being a dictionary array of `str` values and `int` indices. + + Args: + names (list): a list of str column names + data (list:tuple): a list of tuples, the first value being a list of indices, + and the second value being a list of values. + types (list:list:pyarrow.func): a list of lists, containing the indices type and + dictionary value type for each array. + legacy (bool): if True, use legacy IPC format (pre-pyarrow 0.15). Defaults to False. + + Returns: + bytes : a bytes object containing the arrow-serialized output. + """ + stream = pa.BufferOutputStream() + + arrays = [] + for idx, column in enumerate(data): + indice_type = pa.int64() + value_type = pa.string() + + if types is not None: + indice_type = types[idx][0] + value_type = types[idx][1] + + indices = pa.array(column[0], type=indice_type) + values = pa.array(column[1], type=value_type) + parray = pa.DictionaryArray.from_arrays(indices, values) + arrays.append(parray) + + batch = pa.RecordBatch.from_arrays(arrays, names) + table = pa.Table.from_batches([batch]) + writer = pa.RecordBatchStreamWriter( + stream, table.schema + ) + + writer.write_table(table) + writer.close() + return stream.getvalue().to_pybytes() + + @staticmethod + def to_timestamp(obj): + """Return an integer timestamp based on a date/datetime object. + + A `date` is a timezone-agnostic calendar day and serializes to epoch + ms at *UTC* midnight regardless of the host process timezone, so it + is converted here in UTC. A naive `datetime` is a wall-clock reading + in the process-local timezone (callers express expected instants + this way, e.g. `aware.astimezone(TZ).replace(tzinfo=None)`), so it is + converted via local `timestamp()`. + """ + classname = obj.__class__.__name__ + if classname == "date": + return int( + datetime( + obj.year, obj.month, obj.day, tzinfo=timezone.utc + ).timestamp() + * 1000 + ) + elif classname == "datetime": + return int(obj.timestamp() * 1000) + else: + return -1 + + @staticmethod + def make_dataframe(size=10, freq="D"): + index = _make_date_time_index(size, freq) + return _make_dataframe(index, size) + + @staticmethod + def make_period_dataframe(size=10): + index = _make_period_index(size, "M") + return _make_dataframe(index, size) + + @staticmethod + def make_series(size=10, freq="D"): + index = _make_date_time_index(size, freq) + return pd.Series(data=np.random.rand(size), index=index) + + +class Sentinel(object): + """Generic sentinel class for testing side-effectful code in Python 2 and + 3. + """ + + def __init__(self, value): + self.value = value + + def get(self): + return self.value + + def set(self, new_value): + self.value = new_value + + +@fixture() +def sentinel(): + """Pass `sentinel` into a test and call it with `value` to create a new + instance of the Sentinel class. + + Example: + >>> def test_with_sentinel(self, sentinel): + >>> s = sentinel(True) + >>> s.set(False) + >>> s.get() # returns False + """ + + def _sentinel(value): + return Sentinel(value) + + return _sentinel + + +@fixture +def util(): + """Pass the `Util` class in to a test.""" + return Util + + +@fixture +def superstore(count=100): + data = [] + for id in range(count): + dat = {} + dat["Row ID"] = id + dat["Order ID"] = "{}-{}".format(fake.ein(), fake.zipcode()) + dat["Order Date"] = fake.date_this_year() + dat["Ship Date"] = fake.date_between_dates(dat["Order Date"]).strftime( + "%Y-%m-%d" + ) + dat["Order Date"] = dat["Order Date"].strftime("%Y-%m-%d") + dat["Ship Mode"] = choice(["First Class", "Standard Class", "Second Class"]) + dat["Ship Mode"] = choice(["First Class", "Standard Class", "Second Class"]) + dat["Customer ID"] = fake.zipcode() + dat["Segment"] = choice(["A", "B", "C", "D"]) + dat["Country"] = "US" + dat["City"] = fake.city() + dat["State"] = fake.state() + dat["Postal Code"] = fake.zipcode() + dat["Region"] = choice(["Region %d" % i for i in range(5)]) + dat["Product ID"] = fake.bban() + sector = choice(["Industrials", "Technology", "Financials"]) + industry = choice(["A", "B", "C"]) + dat["Category"] = sector + dat["Sub-Category"] = industry + dat["Sales"] = randint(1, 100) * 100 + dat["Quantity"] = randint(1, 100) * 10 + dat["Discount"] = round(random() * 100, 2) + dat["Profit"] = round(random() * 1000, 2) + data.append(dat) + return pd.DataFrame(data) diff --git a/rust/perspective-python/perspective/tests/core/__init__.py b/rust/perspective-python/perspective/tests/core/__init__.py new file mode 100644 index 0000000..284e708 --- /dev/null +++ b/rust/perspective-python/perspective/tests/core/__init__.py @@ -0,0 +1,11 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ diff --git a/rust/perspective-python/perspective/tests/core/test_async.py b/rust/perspective-python/perspective/tests/core/test_async.py new file mode 100644 index 0000000..4c4f3b2 --- /dev/null +++ b/rust/perspective-python/perspective/tests/core/test_async.py @@ -0,0 +1,351 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 queue +import random +import threading +from functools import partial +import tornado + +from perspective import ( + Server, + Client, +) +from pytest import mark + + +def syncify(f): + """Given a function `f` that must be run on `TestAsync.loop`, queue `f` on + the loop, block until it is evaluated, and return the result. + """ + sem = queue.Queue() + + def _syncify_task(): + assert threading.current_thread().ident == TestAsync.thread.ident + result = f() + TestAsync.loop.add_callback(lambda: sem.put(result)) + + def _syncify(): + TestAsync.loop.add_callback(_syncify_task) + return sem.get() + + return _syncify + + +data = [{"a": i, "b": i * 0.5, "c": str(i)} for i in range(10)] + + +class TestAsync(object): + @classmethod + def setup_class(cls): + import asyncio + + # Storing the current loop, which was set up by the pytest-asyncio + # tests running in tests/async, delays its cleanup, which foregoes a + # pytest exception about an unclosed socket. A cleaner way to fix this + # probably exists (use an event loop fixture? convert these tests to + # pytest-asyncio?) + cls.save_old_loop_to_prevent_unclosed_socket_exception = ( + asyncio.get_event_loop() + ) + asyncio.set_event_loop(asyncio.new_event_loop()) + cls.loop = tornado.ioloop.IOLoop.current() + cls.thread = threading.Thread(target=cls.loop.start) + cls.thread.daemon = True + cls.thread.start() + + @classmethod + def teardown_class(cls): + cls.loop.add_callback(lambda: tornado.ioloop.IOLoop.current().stop()) + cls.thread.join() + cls.loop.close(all_fds=True) + + @classmethod + def loop_is_running(cls): + return cls.loop.asyncio_loop.is_running() + + def test_async_queue_process(self): + server = Server() + client = Client.from_server( + server, + ) + + tbl = client.table({"a": "integer", "b": "float", "c": "string"}) + + @syncify + def _task(): + assert tbl.size() == 0 + for i in range(5): + tbl.update([data[i]]) + return tbl.size() + + assert _task() == 5 + tbl.delete() + + def test_async_queue_process_csv(self): + """Make sure GIL release during CSV loading works""" + server = Server() + client = Client.from_server( + server, + ) + tbl = client.table("x,y,z\n1,a,true\n2,b,false\n3,c,true\n4,d,false") + + @syncify + def _task(): + assert tbl.size() == 4 + for i in range(5): + tbl.update("x,y,z\n1,a,true\n2,b,false\n3,c,true\n4,d,false") + return tbl.size() + + assert _task() == 24 + + tbl.delete() + + def test_async_call_loop(self): + server = Server() + client = Client.from_server( + server, + ) + tbl = client.table({"a": "integer", "b": "float", "c": "string"}) + tbl.update(data) + + @syncify + def _task(): + return tbl.size() + + assert _task() == 10 + tbl.delete() + + def test_async_multiple_managers_queue_process(self): + server = Server() + client = Client.from_server(server) + server2 = Server() + client2 = Client.from_server(server2) + tbl = client.table({"a": "integer", "b": "float", "c": "string"}) + tbl2 = client2.table({"a": "integer", "b": "float", "c": "string"}) + + @syncify + def _update_task(): + for i in range(5): + tbl.update([data[i]]) + tbl2.update([data[i]]) + return tbl.size() + + assert _update_task() == 5 + + @syncify + def _flush_to_process(): + view = tbl2.view() + records = view.to_records() + for i in range(5): + tbl2.update([data[i]]) + + view.delete() + return records + + assert _flush_to_process() == data[:5] + + @syncify + def _delete_task(): + tbl2.delete() + tbl.delete() + + _delete_task() + + @mark.skip( + reason="This test is failing because we're not calling process after each update like before" + ) + def test_async_multiple_managers_mixed_queue_process(self): + sentinel = {"called": 0} + + def sync_queue_process(f, *args, **kwargs): + sentinel["called"] += 1 + f(*args, **kwargs) + + server = Server() + client = Client.from_server(server) + + server2 = Server() + client2 = Client.from_server(server2) + tbl = client.table({"a": "integer", "b": "float", "c": "string"}) + tbl2 = client2.table({"a": "integer", "b": "float", "c": "string"}) + + @syncify + def _tbl_task(): + for i in range(5): + tbl.update([data[i]]) + return tbl.size() + + assert _tbl_task() == 5 + + for i in range(5): + tbl2.update([data[i]]) + + assert sentinel["called"] == 5 + + @syncify + def _tbl_task2(): + view = tbl.view() + records = view.to_records() + view.delete() + tbl.delete() + return records + + assert _tbl_task2() == data[:5] + + view = tbl2.view() + assert view.to_records() == data[:5] + + view.delete() + tbl2.delete() + + @mark.skip( + reason="This test is failing because we're not calling process after each update like before" + ) + def test_async_multiple_managers_delayed_process(self): + sentinel = {"async": 0, "sync": 0} + + def _counter(key, f, *args, **kwargs): + sentinel[key] += 1 + return f(*args, **kwargs) + + short_delay_queue_process = partial(_counter, "sync") + long_delay_queue_process = partial( + TestAsync.loop.add_timeout, 1, _counter, "async" + ) + + server = Server(on_poll_request=short_delay_queue_process) + client = Client.from_server(server) + + server2 = Server(on_poll_request=long_delay_queue_process) + client2 = Client.from_server(server2) + tbl = client.table({"a": "integer", "b": "float", "c": "string"}) + tbl2 = client2.table({"a": "integer", "b": "float", "c": "string"}) + + @syncify + def _tbl_task(): + for i in range(10): + tbl2.update([data[i]]) + + _tbl_task() + for i in range(10): + tbl.update([data[i]]) + + @syncify + def _tbl_task2(): + size = tbl2.size() + tbl2.delete() + return size + + assert _tbl_task2() == 10 + assert tbl.size() == 10 + assert sentinel["async"] == 1 + assert sentinel["sync"] == 10 + + tbl.delete() + + def test_async_single_manager_tables_chained(self): + def call_loop(fn, *args): + TestAsync.loop.add_callback(fn, *args) + + server = Server() + client = Client.from_server(server) + columns = {"index": "integer", "num1": "integer", "num2": "integer"} + # tbl = client.table(columns, index="index") + tbl = client.table(columns) + view = tbl.view() + tbl2 = client.table(view.to_arrow()) + + def _update(port_id, delta): + print("Updating tbl2", delta) + tbl2.update(delta) + + view.on_update(_update, mode="row") + for i in range(1000): + call_loop(tbl.update, [{"index": i, "num1": i, "num2": 2 * i}]) + i += 1 + + call_loop(tbl.size) + + q = queue.Queue() + call_loop(q.put, True) + q.get() + + @syncify + def _tbl_task2(): + size = tbl2.size() + return size + + assert _tbl_task2() == 1000 + # assert tbl2.size() == 1000 + view.delete() + tbl.delete() + tbl2.delete() + + def test_async_queue_process_multiple_ports(self): + server = Server() + client = Client.from_server(server) + tbl = client.table({"a": "integer", "b": "float", "c": "string"}) + port_ids = [0] + port_data = [{"a": 0, "b": 0, "c": "0"}] + + for i in range(10): + port_id = tbl.make_port() + port_ids.append(port_id) + port_data.append({"a": port_id, "b": port_id * 1.5, "c": str(port_id)}) + + assert port_ids == list(range(0, 11)) + + assert syncify(lambda: tbl.size())() == 0 + + random.shuffle(port_ids) + + @syncify + def _tbl_task(): + for port_id in port_ids: + idx = port_id if port_id < len(port_ids) else len(port_ids) - 1 + tbl.update([port_data[idx]], port_id=port_id) + size = tbl.size() + tbl.delete() + return size + + assert len(port_ids) == 11 + assert _tbl_task() == 11 + + def test_async_multiple_managers_queue_process_multiple_ports(self): + server = Server() + client = Client.from_server(server) + + server2 = Server() + client2 = Client.from_server(server2) + tbl = client.table({"a": "integer", "b": "float", "c": "string"}) + tbl2 = client2.table({"a": "integer", "b": "float", "c": "string"}) + port_ids = [0] + port_data = [{"a": 0, "b": 0, "c": "0"}] + + for i in range(10): + port_id = tbl.make_port() + port_id2 = tbl2.make_port() + assert port_id == port_id2 + port_ids.append(port_id) + port_data.append({"a": port_id, "b": port_id * 1.5, "c": str(port_id)}) + + @syncify + def _task(): + random.shuffle(port_ids) + for port_id in port_ids: + idx = port_id if port_id < len(port_ids) else len(port_ids) - 1 + tbl.update([port_data[idx]], port_id=port_id) + tbl2.update([port_data[idx]], port_id=port_id) + return (tbl.size(), tbl2.size()) + + assert _task() == (11, 11) diff --git a/rust/perspective-python/perspective/tests/multi_threaded/__init__.py b/rust/perspective-python/perspective/tests/multi_threaded/__init__.py new file mode 100644 index 0000000..284e708 --- /dev/null +++ b/rust/perspective-python/perspective/tests/multi_threaded/__init__.py @@ -0,0 +1,11 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ diff --git a/rust/perspective-python/perspective/tests/multi_threaded/test_multi_threaded.py b/rust/perspective-python/perspective/tests/multi_threaded/test_multi_threaded.py new file mode 100644 index 0000000..dac0db0 --- /dev/null +++ b/rust/perspective-python/perspective/tests/multi_threaded/test_multi_threaded.py @@ -0,0 +1,201 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 perspective import Server + +import asyncio +import random +import threading + +from random import sample +from string import ascii_letters +from threading import Thread +from time import sleep + +class TestServer(object): + def test_sync_updates_with_loop_callback_are_sync(self): + def feed(table): + y = 1000 + while y > 0: + y -= 1 + table.update([{"a": random.randint(0, 10), "index": y}]) + + perspective_server = Server() + loop = asyncio.new_event_loop() + thread = threading.Thread(target=loop.run_forever) + thread.start() + + client = perspective_server.new_local_client() + table = client.table( + {"a": "integer", "index": "integer"}, index="index", name="test" + ) + + view = table.view() + global count + count = 0 + + def update(x): + global count + count += 1 + + view.on_update(update) + feed(table) + assert table.size() == 1000 + assert count == 1000 + loop.call_soon_threadsafe(loop.stop) + thread.join() + loop.close() + + def test_concurrent_updates(self): + async def feed(table, loop): + y = 1000 + while y > 0: + y -= 1 + table.update([{"a": random.randint(0, 10), "index": y}]) + await asyncio.sleep(0.001) + + perspective_server = Server() + loop = asyncio.new_event_loop() + thread = threading.Thread(target=loop.run_forever) + thread.start() + + client = perspective_server.new_local_client() + table = client.table( + {"a": "integer", "index": "integer"}, index="index", name="test" + ) + + view = table.view() + global count + count = 0 + + def update(x): + global count + count += 1 + + view.on_update(update) + asyncio.run_coroutine_threadsafe(feed(table, loop), loop).result() + assert table.size() == 1000 + assert count == 1000 + loop.call_soon_threadsafe(loop.stop) + thread.join() + loop.close() + + def test_concurrent_updates_with_limit_tables_are_threadsafe(self): + TEST_ITERATIONS = 100 + global running + perspective_server = Server() + client = perspective_server.new_local_client() + table = client.table( + {"col{}".format(i): "integer" for i in range(100)}, limit=100 + ) + + running = True + + # Create an updating thread that overlaps the index alot + def feed(table): + row = {"col{}".format(i): random.randint(0, 100) for i in range(100)} + while running: + table.update([row for _ in range(100)]) + + thread = threading.Thread(target=feed, args=(table,)) + thread.start() + + results = [] + + # Create a thread that serialized the table alot, checking for nulls + def feed2(table): + global running + view = table.view() + while len(results) < TEST_ITERATIONS: + arr = view.to_arrow() + table2 = client.table(arr) + view2 = table2.view() + json = view2.to_json(end_row=1) + view2.delete() + table2.delete() + results.append(json) + + view.delete() + running = False + + thread2 = threading.Thread(target=feed2, args=(table,)) + thread2.start() + + thread.join() + thread2.join() + + assert table.size() == 100 + for result in results: + for row in result: + for col, val in row.items(): + assert val is not None + + table.delete() + + def test_concurrent_view_creation_on_separate_servers_are_threadsafe(self): + def run_perspective(): + x = 1000 + s = Server() + c = s.new_local_client() + t = c.table({"a": "string", "b": int}) + t.view(expressions=["//tmp\nfalse"]) + while x > 0: + t.update([{"a": "foo", "b": 42}]) + x -= 1 + + thread1 = threading.Thread(target=run_perspective, daemon=True) + thread2 = threading.Thread(target=run_perspective, daemon=True) + thread1.start() + thread2.start() + thread1.join() + thread2.join() + + def test_concurrent_view_creation_with_updates_are_threadsafe(self): + global running + s = Server() + schema = { + "a": "string", + "b": "string", + "c": "string", + } + + group_bys = ["a", "b", "c"] + c = s.new_local_client() + t = c.table(schema, limit=10000) + running = True + + def gen_views(): + global running + for _ in range(100): + t.view(columns=list(schema.keys()), group_by=group_bys) + sleep(0.01) + running = False + + def run_psp(): + global running + while running: + t.update( + [ + { + "a": "".join(sample(ascii_letters, 4)), + "b": "".join(sample(ascii_letters, 4)), + "c": "".join(sample(ascii_letters, 4)), + } + ] + ) + + thread1 = Thread(target=run_psp, daemon=True) + thread2 = Thread(target=gen_views, daemon=True) + thread1.start() + thread2.start() + thread1.join() + thread2.join() diff --git a/rust/perspective-python/perspective/tests/server/__init__.py b/rust/perspective-python/perspective/tests/server/__init__.py new file mode 100644 index 0000000..284e708 --- /dev/null +++ b/rust/perspective-python/perspective/tests/server/__init__.py @@ -0,0 +1,11 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ diff --git a/rust/perspective-python/perspective/tests/server/test_server.py b/rust/perspective-python/perspective/tests/server/test_server.py new file mode 100644 index 0000000..d238ef7 --- /dev/null +++ b/rust/perspective-python/perspective/tests/server/test_server.py @@ -0,0 +1,1016 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 json +import numpy as np +import pyarrow as pa +from functools import partial +from pytest import raises, mark +from perspective import Server, Client, PerspectiveError + + +client = Server().new_local_client() +Table = client.table + + +data = {"a": [1, 2, 3], "b": ["a", "b", "c"]} + + +class TestServer(object): + def post(self, msg): + """boilerplate callback to simulate a client's `post()` method.""" + msg = json.loads(msg) + assert msg["id"] is not None + + def validate_post(self, msg, expected=None): + msg = json.loads(msg) + if expected: + assert msg == expected + + def test_server_host_table(self): + server = Server() + client = Client.from_server(server) + client.table(data, name="table1") + table2 = client.open_table("table1") + assert table2.schema() == {"a": "integer", "b": "string"} + + def test_session_close(self): + server = Server() + client = Client.from_server(server) + client.table(data) + client.terminate() + + def test_server_host(self): + server = Server() + client = Client.from_server(server) + table = client.table(data) + table.update({"a": [4, 5, 6], "b": ["d", "e", "f"]}) + names = client.get_hosted_table_names() + assert client.open_table(names[0]).size() == 6 + + def test_server_host_table_transitive(self): + server = Server() + client = Client.from_server(server) + table = client.table(data, name="table1") + table.update({"a": [4, 5, 6], "b": ["d", "e", "f"]}) + assert client.open_table("table1").size() == 6 + + def test_server_get_hosted_table_names(self): + server = Server() + client = Client.from_server(server) + client.table(data, name="table1") + assert client.get_hosted_table_names() == ["table1"] + + client.table(data, name="table2") + assert client.get_hosted_table_names() == ["table1", "table2"] + + def test_server_get_hosted_table_names_with_cmd(self): + server = Server() + client = Client.from_server(server) + client.table(data, name="table1") + assert client.get_hosted_table_names() == ["table1"] + + @mark.skip(reason="Have not implemented locking yet") + def test_locked_client_create_table(self): + post_callback = partial( + self.validate_post, + expected={"id": 1, "error": "`table` failed - access denied"}, + ) + message = {"id": 1, "name": "table1", "cmd": "table", "args": [data]} + client = Server(lock=True) + client._process(message, post_callback) + + def test_server_create_indexed_table(self): + server = Server() + client = Client.from_server(server) + client.table(data, name="table1", index="a") + table = client.open_table("table1") + assert table.schema() == {"a": "integer", "b": "string"} + assert table.get_index() == "a" + + def test_server_create_indexed_table_and_update(self): + server = Server() + client = Client.from_server(server) + client.table(data, name="table1", index="a") + table = client.open_table("table1") + + assert table.schema() == {"a": "integer", "b": "string"} + assert table.get_index() == "a" + + table.update({"a": [1, 2, 3], "b": ["str1", "str2", "str3"]}) + assert table.view().to_columns() == { + "a": [1, 2, 3], + "b": ["str1", "str2", "str3"], + } + + def test_server_create_indexed_table_and_remove(self): + server = Server() + client = Client.from_server(server) + client.table(data, name="table1", index="a") + table = client.open_table("table1") + assert table.schema() == {"a": "integer", "b": "string"} + assert table.get_index() == "a" + table.remove([1, 2]) + assert table.view().to_columns() == {"a": [3], "b": ["c"]} + + @mark.skip(reason="Have not implemented locking yet") + def test_locked_client_create_view(self): + message = {"id": 1, "table_name": "table1", "view_name": "view1", "cmd": "view"} + client = Server(lock=True) + table = Table(data) + client.host_table("table1", table) + client._process(message, self.post) + assert client._get_view("view1").schema() == {"a": "integer", "b": "string"} + + def test_server_create_view_zero(self): + server = Server() + client = Client.from_server(server) + client.table(data, name="table1") + table = client.open_table("table1") + assert table.view().dimensions()["num_view_rows"] == 3 + + def test_server_create_view_one(self): + server = Server() + client = Client.from_server(server) + client.table(data, name="table1") + table = client.open_table("table1") + view = table.view(group_by=["a"]) + assert view.to_columns() == { + "__ROW_PATH__": [[], [1], [2], [3]], + "a": [6, 1, 2, 3], + "b": [3, 1, 1, 1], + } + + def test_server_create_view_two(self): + server = Server() + client = Client.from_server(server) + client.table(data, name="table1") + table = client.open_table("table1") + view = table.view(group_by=["a"], split_by=["b"]) + assert view.to_columns() == { + "__ROW_PATH__": [[], [1], [2], [3]], + "a|a": [1, 1, None, None], + "a|b": [1, 1, None, None], + "b|a": [2, None, 2, None], + "b|b": [1, None, 1, None], + "c|a": [3, None, None, 3], + "c|b": [1, None, None, 1], + } + + # clear views + + @mark.skip("Need to implement clear_views") + def test_server_clear_view(self): + messages = [ + {"id": 1, "table_name": "table1", "view_name": "view1", "cmd": "view"}, + {"id": 2, "table_name": "table1", "view_name": "view2", "cmd": "view"}, + {"id": 3, "table_name": "table1", "view_name": "view3", "cmd": "view"}, + ] + server = Server() + client = Client.from_server(server) + table = Table(data) + client.host_table("table1", table) + for message in messages: + client._process(message, self.post, client_id=1) + client.clear_views(1) + assert client._views == {} + + @mark.skip("Need to implement clear_views") + def test_server_clear_view_nonseq(self): + messages = [ + {"id": 1, "table_name": "table1", "view_name": "view1", "cmd": "view"}, + {"id": 2, "table_name": "table1", "view_name": "view2", "cmd": "view"}, + {"id": 3, "table_name": "table1", "view_name": "view3", "cmd": "view"}, + ] + server = Server() + client = Client.from_server(server) + table = Table(data) + client.host_table("table1", table) + for i, message in enumerate(messages, 1): + client._process(message, self.post, client_id=i) + client.clear_views(1) + client.clear_views(3) + assert "view1" not in client._views + assert "view3" not in client._views + assert "view2" in client._views + + @mark.skip("Need to implement clear_views") + def test_server_clear_view_no_client_id(self): + messages = [ + {"id": 1, "table_name": "table1", "view_name": "view1", "cmd": "view"}, + {"id": 2, "table_name": "table1", "view_name": "view2", "cmd": "view"}, + {"id": 3, "table_name": "table1", "view_name": "view3", "cmd": "view"}, + ] + server = Server() + client = Client.from_server(server) + table = Table(data) + client.host_table("table1", table) + for message in messages: + client._process(message, self.post) + with raises(PerspectiveError): + client.clear_views(None) + + # locked client + @mark.skip(reason="Have not implemented locking yet") + def test_locked_client_update_table(self): + post_callback = partial( + self.validate_post, + expected={"id": 1, "error": "`table_method.update` failed - access denied"}, + ) + message = { + "id": 1, + "name": "table1", + "cmd": "table_method", + "method": "update", + "args": [data], + } + client = Server(lock=True) + client._process(message, post_callback) + + @mark.skip(reason="Have not implemented locking yet") + def test_locked_client_clear_table(self): + post_callback = partial( + self.validate_post, + expected={"id": 1, "error": "`table_method.clear` failed - access denied"}, + ) + message = { + "id": 1, + "name": "table1", + "cmd": "table_method", + "method": "clear", + "args": [], + } + client = Server(lock=True) + client._process(message, post_callback) + + @mark.skip(reason="Have not implemented locking yet") + def test_locked_client_replace_table(self): + post_callback = partial( + self.validate_post, + expected={ + "id": 1, + "error": "`table_method.replace` failed - access denied", + }, + ) + message = { + "id": 1, + "name": "table1", + "cmd": "table_method", + "method": "replace", + "args": [data], + } + client = Server(lock=True) + client._process(message, post_callback) + + @mark.skip(reason="Have not implemented locking yet") + def test_locked_client_remove_table(self): + post_callback = partial( + self.validate_post, + expected={"id": 1, "error": "`table_method.remove` failed - access denied"}, + ) + message = { + "id": 1, + "name": "table1", + "cmd": "table_method", + "method": "remove", + "args": [], + } + client = Server(lock=True) + client._process(message, post_callback) + + @mark.skip(reason="Have not implemented locking yet") + def test_locked_client_delete_table(self): + post_callback = partial( + self.validate_post, + expected={"id": 1, "error": "`table_method.delete` failed - access denied"}, + ) + message = { + "id": 1, + "name": "table1", + "cmd": "table_method", + "method": "delete", + "args": [], + } + client = Server(lock=True) + client._process(message, post_callback) + + @mark.skip(reason="Have not implemented locking yet") + def test_arbitary_lock_unlock_client(self): + client = Server(lock=True) + make_table_message = {"id": 1, "name": "table1", "cmd": "table", "args": [data]} + client._process( + make_table_message, + partial( + self.validate_post, + expected={"id": 1, "error": "`table` failed - access denied"}, + ), + ) + client.unlock() + client._process(make_table_message, self.post) + assert client._tables["table1"].schema() == {"a": "integer", "b": "string"} + update_message = { + "id": 2, + "name": "table1", + "cmd": "table_method", + "method": "update", + "args": [data], + } + client._process(update_message, self.post) + assert client._tables["table1"].size() == 6 + client.lock() + update_message_new = { + "id": 3, + "name": "table1", + "cmd": "table_method", + "method": "update", + "args": [data], + } + client._process( + update_message_new, + partial( + self.validate_post, + expected={ + "id": 3, + "error": "`table_method.update` failed - access denied", + }, + ), + ) + + # serialization + + def test_server_to_dict(self): + server = Server() + client = Client.from_server(server) + client.table(data, name="table1") + table = client.open_table("table1") + view = table.view() + assert view.to_columns() == data + + @mark.skip(reason="Have not implemented locking yet") + def test_locked_client_to_dict(self, sentinel): + s = sentinel(False) + + def handle_to_dict(msg): + s.set(True) + message = json.loads(msg) + assert message["data"] == data + + message = {"id": 1, "table_name": "table1", "view_name": "view1", "cmd": "view"} + client = Server(lock=True) + table = Table(data) + client.host_table("table1", table) + client._process(message, self.post) + to_dict_message = { + "id": 2, + "name": "view1", + "cmd": "view_method", + "method": "to_dict", + } + client._process(to_dict_message, handle_to_dict) + assert s.get() is True + + def test_server_to_dict_with_options(self, sentinel): + server = Server() + client = Client.from_server(server) + client.table(data, name="table1") + table = client.open_table("table1") + view = table.view() + d = view.to_columns(start_row=0, end_row=1) + assert d == {"a": [1], "b": ["a"]} + + def test_server_to_dict_with_nan(self, util, sentinel): + data = util.make_arrow( + ["a"], [[1.5, np.nan, 2.5, np.nan]], types=[pa.float64()] + ) + server = Server() + client = Client.from_server(server) + client.table(data, name="table1") + table = client.open_table("table1") + view = table.view() + assert view.to_columns() == {"a": [1.5, None, 2.5, None]} + + def test_server_to_dict_unix_timestamps(self, sentinel): + """The conversion from `datetime` to a Unix timestamp should not + alter the timestamp in any way if both are in local time.""" + timestamp_data = {"a": [1580515140000]} + + server = Server() + client = Client.from_server(server) + schema = {"a": "datetime"} + table = client.table(schema, name="table1") + table.update(timestamp_data) + + table = client.open_table("table1") + view = table.view() + assert view.to_columns() == timestamp_data + + def test_server_create_view_and_update_table(self): + server = Server() + client = Client.from_server(server) + client.table(data, name="table1") + table = client.open_table("table1") + view = table.view() + table.update([{"a": 4, "b": "d"}]) + assert view.dimensions()["num_view_rows"] == 4 + + def test_server_on_update_rows(self, sentinel): + s = sentinel(0) + + def update_callback(port_id, delta): + table = Table(delta) + assert table.size() == 1 + assert table.schema() == {"a": "integer", "b": "string"} + table.delete() + s.set(s.get() + 1) + + # create a table and view using client + server = Server() + client = Client.from_server(server) + client.table(data, name="table1") + table = client.open_table("table1") + view = table.view() + view.on_update(update_callback, mode="row") + table.update({"a": [4], "b": ["d"]}) + table.update({"a": [5], "b": ["e"]}) + assert s.get() == 2 + + def test_repro(self, sentinel): + s = sentinel(0) + + def update_callback(*args, **kwargs): + s.set(s.get() + 1) + + table = Table(data) + view = table.view() + view.on_update(update_callback) + table.update({"a": [5], "b": ["e"]}) + assert s.get() == 1 + + @mark.skip + def test_server_on_update_rows_with_port_id(self, sentinel): + s = sentinel(0) + + def update_callback(port_id, delta): + table = Table(delta) + assert table.size() == 1 + assert table.schema() == {"a": "integer", "b": "string"} + table.delete() + s.set(s.get() + 1) + + # create a table and view using client + make_table = {"id": 1, "name": "table1", "cmd": "table", "args": [data]} + server = Server() + client = Client.from_server(server) + client._process(make_table, self.post) + make_view = { + "id": 2, + "table_name": "table1", + "view_name": "view1", + "cmd": "view", + } + client._process(make_view, self.post) + + # Get two ports on the table + make_port = { + "id": 3, + "name": "table1", + "cmd": "table_method", + "method": "make_port", + } + make_port2 = { + "id": 4, + "name": "table1", + "cmd": "table_method", + "method": "make_port", + } + + client._process(make_port, self.post) + client._process(make_port2, self.post) + + # hook into the created view and pass it the callback + view = client._views["view1"] + view.on_update(update_callback, mode="row") + + # call updates + update1 = { + "id": 5, + "name": "table1", + "cmd": "table_method", + "method": "update", + "args": [{"a": [4], "b": ["d"]}, {"port_id": 1}], + } + update2 = { + "id": 6, + "name": "table1", + "cmd": "table_method", + "method": "update", + "args": [{"a": [5], "b": ["e"]}, {"port_id": 2}], + } + + client._process(update1, self.post) + client._process(update2, self.post) + + assert s.get() == 2 + + @mark.skip + def test_server_remove_update(self, sentinel): + s = sentinel(0) + + def update_callback(port_id, delta): + s.set(s.get() + 1) + + # create a table and view using client + make_table = {"id": 1, "name": "table1", "cmd": "table", "args": [data]} + server = Server() + client = Client.from_server(server) + client._process(make_table, self.post) + make_view = { + "id": 2, + "table_name": "table1", + "view_name": "view1", + "cmd": "view", + } + client._process(make_view, self.post) + + # hook into the created view and pass it the callback + view = client._views["view1"] + view.on_update(update_callback) + view.remove_update(update_callback) + + # call updates + update1 = { + "id": 4, + "name": "table1", + "cmd": "table_method", + "method": "update", + "args": [{"a": [4], "b": ["d"]}], + } + update2 = { + "id": 5, + "name": "table1", + "cmd": "table_method", + "method": "update", + "args": [{"a": [5], "b": ["e"]}], + } + client._process(update1, self.post) + client._process(update2, self.post) + assert s.get() == 0 + + @mark.skip + def test_server_on_update_through_wire_API(self, sentinel): + s = sentinel(0) + + # create a table and view using client + make_table = {"id": 1, "name": "table1", "cmd": "table", "args": [data]} + server = Server() + client = Client.from_server(server) + client._process(make_table, self.post) + make_view = { + "id": 2, + "table_name": "table1", + "view_name": "view1", + "cmd": "view", + } + client._process(make_view, self.post) + + def callback(updated): + assert updated["port_id"] == 0 + s.set(s.get() + 100) + + # simulate a client that holds callbacks by id + callbacks = {3: callback} + + def post_update(msg): + # when `on_update` is triggered, this callback gets the message + # and has to decide which callback to trigger. + message = json.loads(msg) + assert message["id"] is not None + if message["id"] == 3: + # trigger callback + assert message["data"] == {"port_id": 0} + callbacks[message["id"]](message["data"]) + + # hook into the created view and pass it the callback + make_on_update = { + "id": 3, + "name": "view1", + "cmd": "view_method", + "subscribe": True, + "method": "on_update", + "callback_id": "callback_1", + } + client._process(make_on_update, post_update) + + # call updates + update1 = { + "id": 4, + "name": "table1", + "cmd": "table_method", + "method": "update", + "args": [{"a": [4], "b": ["d"]}], + } + update2 = { + "id": 5, + "name": "table1", + "cmd": "table_method", + "method": "update", + "args": [{"a": [5], "b": ["e"]}], + } + client._process(update1, self.post) + client._process(update2, self.post) + assert s.get() == 200 + + @mark.skip + def test_server_on_update_rows_through_wire_API(self, sentinel): + s = sentinel(0) + + # create a table and view using client + make_table = {"id": 1, "name": "table1", "cmd": "table", "args": [data]} + server = Server() + client = Client.from_server(server) + client._process(make_table, self.post) + make_view = { + "id": 2, + "table_name": "table1", + "view_name": "view1", + "cmd": "view", + } + client._process(make_view, self.post) + + def callback(delta): + table = Table(delta) + assert table.size() == 1 + assert table.schema() == {"a": "integer", "b": "string"} + table.delete() + s.set(s.get() + 100) + + # simulate a client that holds callbacks by id + callbacks = {3: callback} + + def post_update(msg, binary=False): + # when `on_update` is triggered, this callback gets the message + # and has to decide which callback to trigger. + if binary: + # trigger callback - "msg" here is binary + callbacks[3](msg) + return + + message = json.loads(msg) + assert message["id"] is not None + if message["id"] == 3: + # wait for transferable + assert message["data"]["port_id"] == 0 + return + + # hook into the created view and pass it the callback + make_on_update = { + "id": 3, + "name": "view1", + "cmd": "view_method", + "subscribe": True, + "method": "on_update", + "callback_id": "callback_1", + "args": [{"mode": "row"}], + } + client._process(make_on_update, post_update) + + # call updates + update1 = { + "id": 4, + "name": "table1", + "cmd": "table_method", + "method": "update", + "args": [{"a": [4], "b": ["d"]}], + } + update2 = { + "id": 5, + "name": "table1", + "cmd": "table_method", + "method": "update", + "args": [{"a": [5], "b": ["e"]}], + } + client._process(update1, self.post) + client._process(update2, self.post) + assert s.get() == 200 + + @mark.skip + def test_server_on_update_rows_with_port_id_through_wire_API(self, sentinel): + s = sentinel(0) + + def update_callback(port_id, delta): + table = Table(delta) + assert table.size() == 1 + assert table.schema() == {"a": "integer", "b": "string"} + table.delete() + s.set(s.get() + 1) + + # create a table and view using client + make_table = {"id": 1, "name": "table1", "cmd": "table", "args": [data]} + server = Server() + client = Client.from_server(server) + client._process(make_table, self.post) + make_view = { + "id": 2, + "table_name": "table1", + "view_name": "view1", + "cmd": "view", + } + client._process(make_view, self.post) + + # Get two ports on the table + make_port = { + "id": 3, + "name": "table1", + "cmd": "table_method", + "method": "make_port", + } + make_port2 = { + "id": 4, + "name": "table1", + "cmd": "table_method", + "method": "make_port", + } + + client._process(make_port, self.post) + client._process(make_port2, self.post) + + # define an update callback + def callback(delta): + table = Table(delta) + assert table.size() == 1 + assert table.schema() == {"a": "integer", "b": "string"} + table.delete() + s.set(s.get() + 100) + + # simulate a client that holds callbacks by id + callbacks = {3: callback} + + def post_update(msg, binary=False): + # when `on_update` is triggered, this callback gets the message + # and has to decide which callback to trigger. + if binary: + # trigger callback - "msg" here is binary + callbacks[3](msg) + return + + message = json.loads(msg) + + assert message["id"] is not None + + if message["id"] == 3: + # wait for transferable + assert message["data"]["port_id"] in (1, 2) + return + + # hook into the created view and pass it the callback + make_on_update = { + "id": 5, + "name": "view1", + "cmd": "view_method", + "subscribe": True, + "method": "on_update", + "callback_id": "callback_1", + "args": [{"mode": "row"}], + } + client._process(make_on_update, post_update) + + # call updates + update1 = { + "id": 6, + "name": "table1", + "cmd": "table_method", + "method": "update", + "args": [{"a": [4], "b": ["d"]}, {"port_id": 1}], + } + update2 = { + "id": 7, + "name": "table1", + "cmd": "table_method", + "method": "update", + "args": [{"a": [5], "b": ["e"]}, {"port_id": 2}], + } + + client._process(update1, self.post) + client._process(update2, self.post) + + assert s.get() == 200 + + @mark.skip + def test_server_remove_update_through_wire_API(self, sentinel): + s = sentinel(0) + + def update_callback(port_id, delta): + s.set(s.get() + 1) + + # create a table and view using client + make_table = {"id": 1, "name": "table1", "cmd": "table", "args": [data]} + server = Server() + client = Client.from_server(server) + client._process(make_table, self.post) + make_view = { + "id": 2, + "table_name": "table1", + "view_name": "view1", + "cmd": "view", + } + client._process(make_view, self.post) + + def callback(updated): + assert updated["port_id"] == 0 + s.set(s.get() + 100) + + # simulate a client that holds callbacks by id + callbacks = {3: callback} + + def post_update(msg): + # when `on_update` is triggered, this callback gets the message + # and has to decide which callback to trigger. + message = json.loads(msg) + assert message["id"] is not None + if message["id"] == 3: + # trigger callback + assert message["data"] == {"port_id": 0} + callbacks[message["id"]](message["data"]) + + # create an on_update callback + make_on_update = { + "id": 3, + "name": "view1", + "cmd": "view_method", + "subscribe": True, + "method": "on_update", + "callback_id": "callback_1", + } + client._process(make_on_update, post_update) + + # call updates + update1 = { + "id": 4, + "name": "table1", + "cmd": "table_method", + "method": "update", + "args": [{"a": [4], "b": ["d"]}], + } + client._process(update1, self.post) + + # remove update callback + remove_on_update = { + "id": 5, + "name": "view1", + "cmd": "view_method", + "subscribe": True, + "method": "remove_update", + "callback_id": "callback_1", + } + client._process(remove_on_update, self.post) + + update2 = { + "id": 6, + "name": "table1", + "cmd": "table_method", + "method": "update", + "args": [{"a": [5], "b": ["e"]}], + } + client._process(update2, self.post) + assert s.get() == 100 + + @mark.skip + def test_server_delete_table_should_fail(self): + post_callback = partial( + self.validate_post, + expected={ + "id": 2, + "error": "table.delete() cannot be called on a remote table, as the remote has full ownership.", + }, + ) + make_table = {"id": 1, "name": "table1", "cmd": "table", "args": [data]} + server = Server() + client = Client.from_server(server) + client._process(make_table, self.post) + delete_table = { + "id": 2, + "name": "table1", + "cmd": "table_method", + "method": "delete", + "args": [], + } + client._process(delete_table, post_callback) + assert len(client._tables) == 1 + + @mark.skip + def test_server_delete_view(self): + make_table = {"id": 1, "name": "table1", "cmd": "table", "args": [data]} + server = Server() + client = Client.from_server(server) + client._process(make_table, self.post) + make_view = { + "id": 2, + "table_name": "table1", + "view_name": "view1", + "cmd": "view", + } + client._process(make_view, self.post) + delete_view = { + "id": 3, + "name": "view1", + "cmd": "view_method", + "method": "delete", + } + client._process(delete_view, self.post) + assert len(client._views) == 0 + + @mark.skip + def test_server_on_delete_view(self, sentinel): + s = sentinel(False) + + def delete_callback(): + s.set(True) + + make_table = {"id": 1, "name": "table1", "cmd": "table", "args": [data]} + server = Server() + client = Client.from_server(server) + client._process(make_table, self.post) + make_view = { + "id": 2, + "table_name": "table1", + "view_name": "view1", + "cmd": "view", + } + client._process(make_view, self.post) + + view = client._get_view("view1") + view.on_delete(delete_callback) + + delete_view = { + "id": 3, + "name": "view1", + "cmd": "view_method", + "method": "delete", + } + + client._process(delete_view, self.post) + + assert len(client._views) == 0 + assert s.get() is True + + @mark.skip + def test_server_remove_delete_view(self, sentinel): + s = sentinel(False) + + def delete_callback(): + s.set(True) + + make_table = {"id": 1, "name": "table1", "cmd": "table", "args": [data]} + server = Server() + client = Client.from_server(server) + client._process(make_table, self.post) + make_view = { + "id": 2, + "table_name": "table1", + "view_name": "view1", + "cmd": "view", + } + client._process(make_view, self.post) + + view = client._get_view("view1") + view.on_delete(delete_callback) + view.remove_delete(delete_callback) + + delete_view = { + "id": 3, + "name": "view1", + "cmd": "view_method", + "method": "delete", + } + + client._process(delete_view, self.post) + + assert len(client._views) == 0 + assert s.get() is False + + def test_server_set_queue_process(self, sentinel): + s = sentinel(0) + + def fake_poll_process(server): + s.set(s.get() + 1) + server.poll() + + server = Server(on_poll_request=fake_poll_process) + client = Client.from_server(server) + table = client.table({"a": [1, 2, 3]}, name="tbl") + view = table.view() + view.on_update(lambda *args: print(args)) + table.update({"a": [4, 5, 6]}) + assert table.view().to_columns() == {"a": [1, 2, 3, 4, 5, 6]} + + table.update({"a": [7, 8, 9]}) + assert s.get() == 7 diff --git a/rust/perspective-python/perspective/tests/server/test_session.py b/rust/perspective-python/perspective/tests/server/test_session.py new file mode 100644 index 0000000..c74b8b9 --- /dev/null +++ b/rust/perspective-python/perspective/tests/server/test_session.py @@ -0,0 +1,110 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 perspective import Server, Client, ProxySession, AsyncClient +import pytest + + +client = Server().new_local_client() +Table = client.table + + +data = {"a": [1, 2, 3], "b": ["a", "b", "c"]} + + +class TestProxySession(object): + def test_proxy_client(self): + server = Server() + client = server.new_local_client() + + def handle_request(bytes): + sub_session.handle_request(bytes) + + def handle_response(bytes): + sub_client.handle_response(bytes) + + sub_session = ProxySession(client, handle_response) + sub_client = Client(handle_request, sub_session.close) + table = sub_client.table(data, name="table1") + assert table.schema() == {"a": "integer", "b": "string"} + + def test_proxy_and_local_client_can_share_handles(self): + server = Server() + client = server.new_local_client() + + def handle_request(bytes): + sub_session.handle_request(bytes) + + def handle_response(bytes): + sub_client.handle_response(bytes) + + sub_session = ProxySession(client, handle_response) + sub_client = Client(handle_request, sub_session.close) + table = sub_client.table(data, name="table1") + table2 = client.open_table("table1") + assert table.size() == 3 + table2.update([{"a": 4, "d": 5}]) + assert table.size() == 4 + + @pytest.mark.asyncio + async def test_handle_request_async(self): + """tests use of ProxySession.handle_request_async() in an AsyncClient callback""" + server = Server() + client = server.new_local_client() + + def handle_response(bytes): + import asyncio + + asyncio.create_task(sub_client.handle_response(bytes)) + + sub_session = ProxySession(client, handle_response) + sub_client = AsyncClient(sub_session.handle_request_async, sub_session.close) + table = await sub_client.table(data, name="table1") + table2 = client.open_table("table1") + assert (await table.size()) == 3 + table2.update([{"a": 4, "d": 5}]) + assert (await table.size()) == 4 + + @pytest.mark.asyncio + async def test_dismabiguate_message_ids_in_proxy_session(self): + """tests concurrent calls from clients on Session + ProxySession""" + server = Server() + client = server.new_local_client() + + def send_response(bytes): + import asyncio + + asyncio.create_task(sub_client.handle_response(bytes)) + + async def send_request(bytes): + await sub_session.handle_request_async(bytes) + + sub_session = ProxySession(client, send_response) + sub_client = AsyncClient(send_request, sub_session.close) + + table = client.table("x\n1", name="test_table") + table2 = await sub_client.open_table("test_table") + view = table.view() + view2 = await table2.view() + + sentinel = [] + + def callback1(*args): + sentinel.append("Callback 1") + + def callback2(*args): + sentinel.append("Callback 2") + + view.on_update(callback1) + await view2.on_update(callback2) + await table2.update("x\n2") + assert sentinel == ["Callback 1", "Callback 2"] diff --git a/rust/perspective-python/perspective/tests/table/__init__.py b/rust/perspective-python/perspective/tests/table/__init__.py new file mode 100644 index 0000000..284e708 --- /dev/null +++ b/rust/perspective-python/perspective/tests/table/__init__.py @@ -0,0 +1,11 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ diff --git a/rust/perspective-python/perspective/tests/table/arrow/date32.arrow b/rust/perspective-python/perspective/tests/table/arrow/date32.arrow new file mode 100644 index 0000000000000000000000000000000000000000..9487d4c90770d149894c1f979d2b8d976a99ebc8 GIT binary patch literal 1112 zcmZ|OSx*yD6u|LQ3S~n2&<8?z(+5g%FfUsSI3 zYYFNnJ@q$hZye1Zl62M#NUF1&ko$f<>m+lf!|YF}(YgR_8oJM0(fXrxncg`c9itJH z3~Kd+g3iq1_lv%BD9P#Ol13hMPD{_QU;A(ZCUlKN5pz4t-PFd9xAV=sVYzwCZH&9Q z8Q*)XU(56iy!+BNlbhEux%O$8+$_I-MPvh;*uocV<12RX4d3wtKe3BF?Bf>>@Ed<{ zh`;y;8*-Ij6q4XaGExvgDm3^!&X$_4i8SL5?xF?va32rQiidcF7#^by?dU)!p5Q6s z=)yB}<2hcS2QSf!K6oRG{hY#SoIy5na2C19Lp};nh%kx}K{3vu1m{tTGL+*2Do}}w zxP&TH<1%WX!8ugv{T2Hf!Z2Rr4Ms4EF^uCa-r+qaFo`KlV+J2Ei;wt(Ief-E7O;pV OEMo<$Froh!ef|%x6P+9Y literal 0 HcmV?d00001 diff --git a/rust/perspective-python/perspective/tests/table/arrow/date64.arrow b/rust/perspective-python/perspective/tests/table/arrow/date64.arrow new file mode 100644 index 0000000000000000000000000000000000000000..18d10a210ed1f12a24daa046de26470dd6eb97fa GIT binary patch literal 1576 zcmZ|PZAep57zgk(XLi$@SS(#AGz4pep{^;FrYpiG6I-O2HnN$MW!a`pNL(6fq$Otb z!pcQtlMJLpi}qn?)(FE91_g`yAYm^bMwuAwL%7cQ@7_^?2k!ly=RD6n_ndRbLZMKI zlMs?XSc@e*i6JWpM|-qMq%o0lR@Z?QDy>3rccbJ7v^ zt_@raE4=bNGPTLuiqXT#F$9dP7u0p#2F zK=V)$^u8{EieCqyJ-Gt3ha}o!a)8e zs5PZS*QG2-MsuLSp9|ef8YoLEgjSsn`Wp05=_rGp!JK~8MNK3 zgfkOWF#Ssn?BeU-oXP?<6*lN>YlgwWR#@umfFlcCkYC>m%>`GX*W`eTp4-qqIspCt z`%oPkhJ9(HFra%1wT&L=a!f)p@e&$7PeXUy8z%kk1vm%is;v!d|G|uHu=LQDc9yx|Y|8_fH}DWTrpDmncQ@3p8;8TWFCbSv1%;2#{tsFA5B4S* AsQ>@~ literal 0 HcmV?d00001 diff --git a/rust/perspective-python/perspective/tests/table/arrow/dict.arrow b/rust/perspective-python/perspective/tests/table/arrow/dict.arrow new file mode 100644 index 0000000000000000000000000000000000000000..62d20b50b0681fa3f899add35efd62cffa3ae148 GIT binary patch literal 936 zcmc&yyG{c^4D@C1E~jXV6N(@bKahfgGG#u1PoN;-p+G{%Cs9yPq~vq>lVxVTLpTHy z9hOF8k9U3c`cg{i-OPHxY+}(`YpjC@tAf>_C!8UVfDJ%@4-E&JCHw`z`)5FBQ=kT1 ztH83?e8;L~5uOn^}ZCD`0QY3o x+LSDbdza~b#q*LlFD!jgV&xff7pklMe~(YfJ(13drHfCC^IKl$l;?j-?E@}NG@Sqd literal 0 HcmV?d00001 diff --git a/rust/perspective-python/perspective/tests/table/arrow/dict_update.arrow b/rust/perspective-python/perspective/tests/table/arrow/dict_update.arrow new file mode 100644 index 0000000000000000000000000000000000000000..e57fa59030dc0d078bfe9e037a5ff6d2629bd94d GIT binary patch literal 920 zcmc(dJ5Iwu5QfL`Q_%`pC_*VyQqb@yD188qKodkHN{VnJ3JQvp9EY1M-@n7+7`dS1 zmwwN4XJ@<)d;r&eOpOe?6%3ePtJK$-P7xSlfMGxvz^|z3#;sLpW zLO}CL13FlgDYlUgIy;4a&l-E2V@anWMf8vEpnBE6$tbfjBYiGEqj0vR>yG0(@(C?G zNt)Tq^W%2EyLNHobG;G!`&{aJd>_5}|Ic&j^IZ8nACq~`rtegi^lLUi$F%=@ocgE7 tZ=gNM&|V5zs+O=x-_fsK6LMU--X_=0`|#UtlsJQ&U(dwk%5m+f>@PrlI!FKj literal 0 HcmV?d00001 diff --git a/rust/perspective-python/perspective/tests/table/arrow/int_float_str.arrow b/rust/perspective-python/perspective/tests/table/arrow/int_float_str.arrow new file mode 100644 index 0000000000000000000000000000000000000000..17f8a6614c2a70567b3d791683312dbbd6a95f9e GIT binary patch literal 584 zcmaix%}oPA429qRBw0msSRsTs=0E}IAX^4Ul28I=PzdF4q?Fu-?|Ej#A|c_G{rvOB z}pEP1vzoX&`z-kCBx==mB~2NbEi%Y8m6Bf`3In3vW2! z-=mEu?wrsqIjIl+1#gDs{4S=L>x^ChL~VV3qCUa$lt{POpl6gT^9?lLlJr1Fzc58t z4TC)Qi;E$9;>2ua2k+$8Z!%>UyuZz%rW-a^iyJPjP_f0H#qwOBy(uE^{by%|@L%jb jSo#9-@9h3QcRQ8xxD-qk?*iS!=hye2FV|O6v0vjiW?vvI literal 0 HcmV?d00001 diff --git a/rust/perspective-python/perspective/tests/table/arrow/int_float_str_file.arrow b/rust/perspective-python/perspective/tests/table/arrow/int_float_str_file.arrow new file mode 100644 index 0000000000000000000000000000000000000000..fbb0836f1cd976185517a926efc1ee2bf529a05e GIT binary patch literal 866 zcmdUuJx;?w5QSfCC$XX}EQC2O;YKQio(_*>SsEB5Dj)rn{J_>7p;g#N&8K;`&6x)@52ljf4#_kN98WD$=VCP z$9=yOXuU1zf{MOjimGZ`x$m8mVGDSow>*OvV&gkmG7sJF%c_JcR#oK{*ELbFa`$3; z%uw$X%ya*qSr+`oJqKHpA>N(0KhK@+a=Ke*Oa;#ZUBlb>!3`M^!ClLY<9*Ux)haMnf$6TXh(kpam?$CquI30VGZtDBA1P4Wc&>+Rn z|4mUWrIa5@6E5vFYO9Sh8P+ARK$$qyC?(K4eu%2{MGwTAC$RerxNV3JhWrixUUX?D zf54kg+?nw$HK`B%nQTJk`YxuJ>Wp3g40rv1hR>)xCDt7s@Dt9J<(JcX+o(r6`h{b3 zRZ8;QFTpzO!6UORJNTsHH}$5f?OJl4t_kKNxi}$ei ZU|V+$m|u^sDPQ8WruF%_m{|7a^ADqJ9tZ#c literal 0 HcmV?d00001 diff --git a/rust/perspective-python/perspective/tests/table/object_sequence.py b/rust/perspective-python/perspective/tests/table/object_sequence.py new file mode 100644 index 0000000..3ff8b5c --- /dev/null +++ b/rust/perspective-python/perspective/tests/table/object_sequence.py @@ -0,0 +1,402 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 sys +from random import randint, choice +import perspective as psp + +client = psp.Server().new_local_client() +Table = client.table + + +class CustomObjectStore(object): + def __init__(self, value): + self._value = value + + def _psp_dtype_(self): + return "object" + + def __int__(self): + return int(self._value) + + def __repr__(self): + return "test" if self._value == 1 else "test{}".format(self._value) + + +def run(): + t = CustomObjectStore(1) + t2 = CustomObjectStore(2) + + assert sys.getrefcount(t) == 2 + assert sys.getrefcount(t2) == 2 + + data = {"a": [0], "b": [t]} + assert sys.getrefcount(t) == 3 + + tbl = Table(data, index="a") + assert sys.getrefcount(t) == 4 + + assert tbl.schema() == {"a": int, "b": object} + assert tbl.size() == 1 + assert tbl.view().to_columns() == {"a": [0], "b": [t]} + + # Count references + # 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 1 for the table + print(sys.getrefcount(t), "should be", 4) + print("t:", id(t)) + assert sys.getrefcount(t) == 4 + + print(sys.getrefcount(t2), "should be", 2) + print("t2:", id(t2)) + assert sys.getrefcount(t2) == 2 + + tbl.update([{"a": i, "b": None} for i in range(1, 6)]) + + assert tbl.size() == 6 + # 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 1 for the table + print(sys.getrefcount(t), "should be", 4) + assert sys.getrefcount(t) == 4 + + print() + tbl.update([data]) + # 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 1 for the table + print(sys.getrefcount(t), "should be", 4) + assert sys.getrefcount(t) == 4 + + print() + tbl.update([data]) + # 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 1 for the table + print(sys.getrefcount(t), "should be", 4) + assert sys.getrefcount(t) == 4 + + print() + tbl.update([data]) + # 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 1 for the table + print(sys.getrefcount(t), "should be", 4) + assert sys.getrefcount(t) == 4 + + print() + tbl.update([{"a": 1, "b": t}]) + # 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 2 for the table + print(sys.getrefcount(t), "should be", 5) + assert sys.getrefcount(t) == 5 + + print() + tbl.update([{"a": 1, "b": t}]) + # 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 2 for the table + print(sys.getrefcount(t), "should be", 5) + assert sys.getrefcount(t) == 5 + + print() + tbl.update([{"a": 3, "b": t}]) + # 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 3 for the table + print(sys.getrefcount(t), "should be", 6) + assert sys.getrefcount(t) == 6 + + print() + tbl.update([{"a": 3, "b": t}]) + # 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 3 for the table + print(sys.getrefcount(t), "should be", 6) + assert sys.getrefcount(t) == 6 + + print() + tbl.update([{"a": 5, "b": t}]) + # 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 4for the table + print(sys.getrefcount(t), "should be", 7) + # assert sys.getrefcount(t) == 7 + print(tbl.view().to_columns()["b"]) + assert list(_ is not None for _ in tbl.view().to_columns()["b"]) == [ + True, + True, + False, + True, + False, + True, + ] + + print() + # clear some, overwrite some with same + tbl.update([{"a": 0, "b": t}]) + # 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 4 for the table + print(sys.getrefcount(t), "should be", 7) + assert list(_ is not None for _ in tbl.view().to_columns()["b"]) == [ + True, + True, + False, + True, + False, + True, + ] + + print() + tbl.update([{"a": 1, "b": t2}]) + # 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 3 for the table + print(sys.getrefcount(t), "should be", 6) + # 1 for `t2`, 1 for argument to sys.getrefcount, and 1 for the table + print(sys.getrefcount(t2), "should be", 3) + print(tbl.view().to_columns()["b"]) + assert list(_ is not None for _ in tbl.view().to_columns()["b"]) == [ + True, + True, + False, + True, + False, + True, + ] + + print() + tbl.update([{"a": 1, "b": t2}]) + # 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 3 for the table + print(sys.getrefcount(t), "should be", 6) + # 1 for `t2`, 1 for argument to sys.getrefcount, and 1 for the table + print(sys.getrefcount(t2), "should be", 3) + print(tbl.view().to_columns()["b"]) + assert list(_ is not None for _ in tbl.view().to_columns()["b"]) == [ + True, + True, + False, + True, + False, + True, + ] + + print() + tbl.update([{"a": 1, "b": t2}]) + # 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 3 for the table + print(sys.getrefcount(t), "should be", 6) + # 1 for `t2`, 1 for argument to sys.getrefcount, and 1 for the table + print(sys.getrefcount(t2), "should be", 3) + print(tbl.view().to_columns()["b"]) + assert list(_ is not None for _ in tbl.view().to_columns()["b"]) == [ + True, + True, + False, + True, + False, + True, + ] + + print() + tbl.update([{"a": 1, "b": t2}]) + # 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 3 for the table + print(sys.getrefcount(t), "should be", 6) + # 1 for `t2`, 1 for argument to sys.getrefcount, and 1 for the table + print(sys.getrefcount(t2), "should be", 3) + print(tbl.view().to_columns()["b"]) + assert list(_ is not None for _ in tbl.view().to_columns()["b"]) == [ + True, + True, + False, + True, + False, + True, + ] + + print() + tbl.update([{"a": 2, "b": t2}]) + # 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 3 for the table + print(sys.getrefcount(t), "should be", 6) + # 1 for `t2`, 1 for argument to sys.getrefcount, and 1 for the table + print(sys.getrefcount(t2), "should be", 4) + print(tbl.view().to_columns()["b"]) + assert list(_ is not None for _ in tbl.view().to_columns()["b"]) == [ + True, + True, + True, + True, + False, + True, + ] + + print() + tbl.update([{"a": 2, "b": None}]) + # 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 3 for the table + print(sys.getrefcount(t), "should be", 6) + # 1 for `t2`, 1 for argument to sys.getrefcount, and 1 for the table + print(sys.getrefcount(t2), "should be", 3) + print(tbl.view().to_columns()["b"]) + assert list(_ is not None for _ in tbl.view().to_columns()["b"]) == [ + True, + True, + False, + True, + False, + True, + ] + + print() + tbl.update([{"a": 2, "b": t2}]) + # 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 3 for the table + print(sys.getrefcount(t), "should be", 6) + # 1 for `t2`, 1 for argument to sys.getrefcount, and 1 for the table + print(sys.getrefcount(t2), "should be", 4) + print(tbl.view().to_columns()["b"]) + assert list(_ is not None for _ in tbl.view().to_columns()["b"]) == [ + True, + True, + True, + True, + False, + True, + ] + + print() + tbl.update([{"a": 2, "b": None}]) + # 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 3 for the table + print(sys.getrefcount(t), "should be", 6) + # 1 for `t2`, 1 for argument to sys.getrefcount, and 1 for the table + print(sys.getrefcount(t2), "should be", 3) + print(tbl.view().to_columns()["b"]) + assert list(_ is not None for _ in tbl.view().to_columns()["b"]) == [ + True, + True, + False, + True, + False, + True, + ] + + print() + tbl.update([{"a": 3, "b": None}]) + # 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 2 for the table + print(sys.getrefcount(t), "should be", 5) + # 1 for `t2`, 1 for argument to sys.getrefcount, and 1 for the table + print(sys.getrefcount(t2), "should be", 3) + print(tbl.view().to_columns()["b"]) + assert list(_ is not None for _ in tbl.view().to_columns()["b"]) == [ + True, + True, + False, + False, + False, + True, + ] + + print() + tbl.update([{"a": 3, "b": None}]) + # 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 2 for the table + print(sys.getrefcount(t), "should be", 5) + # 1 for `t2`, 1 for argument to sys.getrefcount, and 1 for the table + print(sys.getrefcount(t2), "should be", 3) + print(tbl.view().to_columns()["b"]) + assert list(_ is not None for _ in tbl.view().to_columns()["b"]) == [ + True, + True, + False, + False, + False, + True, + ] + + print() + tbl.update([{"a": 5, "b": None}]) + # 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 1 for the table + print(sys.getrefcount(t), "should be", 4) + # 1 for `t2`, 1 for argument to sys.getrefcount, and 1 for the table + print(sys.getrefcount(t2), "should be", 3) + print(tbl.view().to_columns()["b"]) + assert list(_ is not None for _ in tbl.view().to_columns()["b"]) == [ + True, + True, + False, + False, + False, + False, + ] + + print() + tbl.update([{"a": 5, "b": None}]) + # 1 for `t`, 1 for `data`, 1 for argument to sys.getrefcount, and 1 for the table + print(sys.getrefcount(t), "should be", 4) + # 1 for `t2`, 1 for argument to sys.getrefcount, and 1 for the table + print(sys.getrefcount(t2), "should be", 3) + print(tbl.view().to_columns()["b"]) + assert list(_ is not None for _ in tbl.view().to_columns()["b"]) == [ + True, + True, + False, + False, + False, + False, + ] + + print() + tbl.clear() + assert tbl.size() == 0 + assert tbl.view().to_columns() == {} + # 1 for `t`, one for `data`, one for argument to sys.getrefcount + print(sys.getrefcount(t), "should be", 3) + assert sys.getrefcount(t) == 3 + + +def run2(): + t = CustomObjectStore(1) + t_ref_count = 2 + assert sys.getrefcount(t) == t_ref_count + + indexes = set([0]) + + tbl = Table({"a": [0], "b": [t]}, index="a") + assert sys.getrefcount(t) == 3 + t_ref_count += 1 + + assert tbl.schema() == {"a": int, "b": object} + assert tbl.size() == 1 + assert tbl.view().to_columns() == {"a": [0], "b": [t]} + + # seed a few to check + tbl.remove([1]) + tbl.remove([1]) + tbl.remove([1]) + + for _ in range(10): + pick = randint(1, 2) if indexes else 1 + if pick == 1: + ind = randint(1, 10) + while ind in indexes: + ind = randint(1, 100) + + print( + "adding", ind, "refcount", t_ref_count, "should be", sys.getrefcount(t) + ) + tbl.update({"a": [ind], "b": [t]}) + t_ref_count += 1 + indexes.add(ind) + assert sys.getrefcount(t) == t_ref_count + + else: + ind = choice(list(indexes)) + indexes.remove(ind) + tbl.remove([ind]) + t_ref_count -= 1 + print( + "removing", + ind, + "refcount", + t_ref_count, + "should be", + sys.getrefcount(t), + ) + assert sys.getrefcount(t) == t_ref_count + + print(t_ref_count) + print(tbl.view().to_columns()) + + assert sys.getrefcount(t) == t_ref_count + + print() + tbl.clear() + assert tbl.size() == 0 + assert tbl.view().to_columns() == {} + # 1 for `t`, one for `data`, one for argument to sys.getrefcount + print(sys.getrefcount(t), "should be", 2) + assert sys.getrefcount(t) == 2 diff --git a/rust/perspective-python/perspective/tests/table/test_column_paths.py b/rust/perspective-python/perspective/tests/table/test_column_paths.py new file mode 100644 index 0000000..66e2134 --- /dev/null +++ b/rust/perspective-python/perspective/tests/table/test_column_paths.py @@ -0,0 +1,89 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 as psp + +client = psp.Server().new_local_client() + + +class TestViewColumnPaths(object): + def test_column_paths(self, superstore): + tbl = client.table(superstore) + view = tbl.view() + paths = view.column_paths() + assert paths == [ + "index", + "Row ID", + "Order ID", + "Order Date", + "Ship Date", + "Ship Mode", + "Customer ID", + "Segment", + "Country", + "City", + "State", + "Postal Code", + "Region", + "Product ID", + "Category", + "Sub-Category", + "Sales", + "Quantity", + "Discount", + "Profit", + ] + + view.delete() + tbl.delete() + + def test_column_paths_split_by(self, superstore): + tbl = client.table(superstore) + view = tbl.view(group_by=["State"], columns=["Sales"], split_by=["Ship Mode"]) + paths = view.column_paths() + assert paths == [ + "First Class|Sales", + "Second Class|Sales", + "Standard Class|Sales", + ] + + view.delete() + tbl.delete() + + def test_column_paths_split_by_range(self, superstore): + tbl = client.table(superstore) + view = tbl.view(group_by=["State"], columns=["Sales"], split_by=["Ship Mode"]) + paths = view.column_paths(start_col=1) + assert paths == [ + "Second Class|Sales", + "Standard Class|Sales", + ] + + view.delete() + view = tbl.view(group_by=["State"], columns=["Sales"], split_by=["Ship Mode"]) + paths = view.column_paths(start_col=1, end_col=2) + assert paths == [ + "Second Class|Sales", + "Standard Class|Sales", + ] + + view.delete() + tbl = client.table(superstore) + view = tbl.view(group_by=["State"], columns=["Sales"], split_by=["Ship Mode"]) + paths = view.column_paths(end_col=1) + assert paths == [ + "First Class|Sales", + "Second Class|Sales", + ] + + view.delete() + tbl.delete() diff --git a/rust/perspective-python/perspective/tests/table/test_delete.py b/rust/perspective-python/perspective/tests/table/test_delete.py new file mode 100644 index 0000000..5781cd0 --- /dev/null +++ b/rust/perspective-python/perspective/tests/table/test_delete.py @@ -0,0 +1,124 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 as psp + +client = psp.Server().new_local_client() +Table = client.table + + +class TestDelete(object): + # delete + + def test_table_delete(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + tbl.delete() + # don't segfault + + def test_table_delete_callback(self, sentinel): + s = sentinel(False) + + def callback(): + s.set(True) + + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + tbl.on_delete(callback) + tbl.delete() + assert s.get() is True + + def test_table_delete_with_view(self, sentinel): + s = sentinel(False) + + def callback(): + s.set(True) + + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + tbl.on_delete(callback) + view = tbl.view() + view.delete() + tbl.delete() + assert s.get() is True + + def test_table_delete_multiple_callback(self, sentinel): + s1 = sentinel(False) + s2 = sentinel(False) + + def callback1(): + s1.set(True) + + def callback2(): + s2.set(True) + + tbl = Table([{"a": 1}]) + tbl.on_delete(callback1) + tbl.on_delete(callback2) + + tbl.delete() + + assert s1.get() is True + assert s2.get() is True + + def test_table_remove_delete_callback(self, sentinel): + s = sentinel(False) + + def callback(): + s.set(True) + + tbl = Table([{"a": 1}]) + callback_id = tbl.on_delete(callback) + tbl.remove_delete(callback_id) + + tbl.delete() + + assert s.get() is False + + def test_view_delete_multiple_callback(self, sentinel): + s1 = sentinel(False) + s2 = sentinel(False) + + def callback1(): + s1.set(True) + + def callback2(): + s2.set(True) + + tbl = Table([{"a": 1}]) + view = tbl.view() + + view.on_delete(callback1) + view.on_delete(callback2) + + view.delete() + tbl.delete() + + assert s1.get() is True + assert s2.get() is True + + def test_view_remove_delete_callback(self, sentinel): + s = sentinel(False) + + def callback(): + s.set(True) + + tbl = Table([{"a": 1}]) + view = tbl.view() + + callback_id = view.on_delete(callback) + view.remove_delete(callback_id) + + view.delete() + tbl.delete() + + assert s.get() is False diff --git a/rust/perspective-python/perspective/tests/table/test_exception.py b/rust/perspective-python/perspective/tests/table/test_exception.py new file mode 100644 index 0000000..9613bb9 --- /dev/null +++ b/rust/perspective-python/perspective/tests/table/test_exception.py @@ -0,0 +1,65 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 pytest import raises +from perspective import PerspectiveError + +import perspective as psp + +client = psp.Server().new_local_client() +Table = client.table + + +class TestException(object): + def test_instantiation_exception_table(self): + from perspective import Table + with raises(TypeError) as ex: + Table() + assert "Do not call Table's constructor directly" in str(ex.value) + + def test_instantiation_exception_view(self): + from perspective import View + with raises(TypeError) as ex: + View() + assert "Do not call View's constructor directly" in str(ex.value) + + def test_exception_from_core(self): + tbl = Table({"a": [1, 2, 3]}) + + with raises(PerspectiveError) as ex: + # creating view with unknown column should throw + tbl.view(group_by=["b"]) + + assert str(ex.value) == "Abort(): Invalid column 'b' found in View group_by.\n" + + def test_exception_from_core_catch_generic(self): + tbl = Table({"a": [1, 2, 3]}) + # `PerspectiveCppError` should inherit from `Exception` + with raises(Exception) as ex: + tbl.view(group_by=["b"]) + + assert str(ex.value) == "Abort(): Invalid column 'b' found in View group_by.\n" + + def test_exception_from_core_correct_types(self): + tbl = Table({"a": [1, 2, 3]}) + + # `PerspectiveError` should be raised from the Python layer + with raises(PerspectiveError) as ex: + tbl.view() + tbl.delete() + + assert str(ex.value) == "Abort(): Cannot delete table with views" + + with raises(PerspectiveError) as ex: + tbl.view(group_by=["b"]) + + assert str(ex.value) == "Abort(): Invalid column 'b' found in View group_by.\n" diff --git a/rust/perspective-python/perspective/tests/table/test_join.py b/rust/perspective-python/perspective/tests/table/test_join.py new file mode 100644 index 0000000..d29ef4b --- /dev/null +++ b/rust/perspective-python/perspective/tests/table/test_join.py @@ -0,0 +1,115 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 as psp + +client = psp.Server().new_local_client() + + +class TestJoin: + def test_inner_join_two_tables(self): + left = client.table( + [{"id": 1, "x": 10}, {"id": 2, "x": 20}, {"id": 3, "x": 30}] + ) + right = client.table( + [{"id": 1, "y": "a"}, {"id": 2, "y": "b"}, {"id": 4, "y": "d"}] + ) + joined = client.join(left, right, "id") + view = joined.view() + json = view.to_json() + assert len(json) == 2 + view.delete() + joined.delete() + right.delete() + left.delete() + + def test_join_has_correct_schema(self): + left = client.table({"id": "integer", "x": "float"}) + right = client.table({"id": "integer", "y": "string"}) + joined = client.join(left, right, "id") + schema = joined.schema() + assert schema == {"id": "integer", "x": "float", "y": "string"} + joined.delete() + right.delete() + left.delete() + + def test_join_reacts_to_left_updates(self): + left = client.table( + [{"id": 1, "x": 10}, {"id": 2, "x": 20}] + ) + right = client.table( + [{"id": 1, "y": "a"}, {"id": 2, "y": "b"}] + ) + joined = client.join(left, right, "id") + view = joined.view() + left.update([{"id": 1, "x": 99}]) + json = view.to_json() + assert json == [ + {"id": 1, "x": 10, "y": "a"}, + {"id": 2, "x": 20, "y": "b"}, + {"id": 1, "x": 99, "y": "a"}, + ] + view.delete() + joined.delete() + right.delete() + left.delete() + + def test_left_join(self): + left = client.table( + [{"id": 1, "x": 10}, {"id": 2, "x": 20}, {"id": 3, "x": 30}] + ) + right = client.table( + [{"id": 1, "y": "a"}, {"id": 2, "y": "b"}] + ) + joined = client.join(left, right, "id", "left") + view = joined.view() + json = view.to_json() + assert len(json) == 3 + view.delete() + joined.delete() + right.delete() + left.delete() + + def test_join_by_table_names(self): + left = client.table( + [{"id": 1, "x": 10}, {"id": 2, "x": 20}], + name="left_py", + ) + right = client.table( + [{"id": 1, "y": "a"}, {"id": 2, "y": "b"}], + name="right_py", + ) + joined = client.join("left_py", "right_py", "id") + view = joined.view() + json = view.to_json() + assert len(json) == 2 + view.delete() + joined.delete() + right.delete() + left.delete() + + def test_join_mixed_table_and_string(self): + left = client.table( + [{"id": 1, "x": 10}, {"id": 2, "x": 20}] + ) + right = client.table( + [{"id": 1, "y": "a"}, {"id": 2, "y": "b"}], + name="right_py_mixed", + ) + joined = client.join(left, "right_py_mixed", "id") + view = joined.view() + json = view.to_json() + assert len(json) == 2 + view.delete() + joined.delete() + right.delete() + left.delete() diff --git a/rust/perspective-python/perspective/tests/table/test_leaks.py b/rust/perspective-python/perspective/tests/table/test_leaks.py new file mode 100644 index 0000000..b9cfcdd --- /dev/null +++ b/rust/perspective-python/perspective/tests/table/test_leaks.py @@ -0,0 +1,105 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 psutil +import os +import perspective as psp + +client = psp.Server().new_local_client() +Table = client.table + + +class TestDelete(object): + # delete + + def test_table_delete(self): + process = psutil.Process(os.getpid()) + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + tbl.delete() + mem = process.memory_info().rss + + for x in range(10000): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + tbl.delete() + + mem2 = process.memory_info().rss + + # assert 1 < (max2 / max) < 1.01 + assert (mem2 - mem) < 2000000 + + def test_table_delete_with_view(self, sentinel): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + + process = psutil.Process(os.getpid()) + mem = process.memory_info().rss + for x in range(10000): + view = tbl.view() + view.delete() + + tbl.delete() + mem2 = process.memory_info().rss + assert (mem2 - mem) < 2000000 + + +class TestExpressionVocab(object): + # Regression tests for the `t_expression_vocab` leak fixed in PR #3175. + # Pre-fix, updates against a view with a string-producing expression + # interned each result into a new 4KB vocab page once the prior page + # filled, and never deduplicated against older pages. RSS grew without + # bound. The fix adds cross-page deduplication; these tests assert the + # steady state is flat. + + def test_string_expression_update_no_leak(self): + long_literal = "X" * 256 + tbl = Table({"x": "integer", "c": "string"}, index="x") + view = tbl.view(expressions={"e": 'concat("c", \'' + long_literal + "')"}) + + for _ in range(100): + tbl.update([{"x": 1, "c": "value"}]) + + process = psutil.Process(os.getpid()) + mem = process.memory_info().rss + for _ in range(5000): + tbl.update([{"x": 1, "c": "value"}]) + mem2 = process.memory_info().rss + + view.delete() + tbl.delete() + + assert (mem2 - mem) < 2000000 + + def test_string_expression_update_bounded_vocab_no_leak(self): + # Cycles through a small fixed set of distinct values. Exercises the + # cross-page `string_exists` lookup the fix relies on: once the + # vocabulary is fully populated, subsequent interns must resolve to + # existing pointers rather than allocating new ones. + values = ["alpha", "bravo", "charlie", "delta", "echo"] + tbl = Table({"x": "integer", "c": "string"}, index="x") + view = tbl.view(expressions={"e": 'upper("c")'}) + + for i in range(100): + tbl.update([{"x": 1, "c": values[i % len(values)]}]) + + process = psutil.Process(os.getpid()) + mem = process.memory_info().rss + for i in range(5000): + tbl.update([{"x": 1, "c": values[i % len(values)]}]) + mem2 = process.memory_info().rss + + view.delete() + tbl.delete() + + assert (mem2 - mem) < 2000000 diff --git a/rust/perspective-python/perspective/tests/table/test_ports.py b/rust/perspective-python/perspective/tests/table/test_ports.py new file mode 100644 index 0000000..1e907d0 --- /dev/null +++ b/rust/perspective-python/perspective/tests/table/test_ports.py @@ -0,0 +1,178 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 random +import perspective as psp + +client = psp.Server().new_local_client() +Table = client.table + +data = {"a": [1, 2, 3, 4], "b": ["a", "b", "c", "d"], "c": [True, False, True, False]} + + +class TestPorts(object): + def test_make_port_sequential(self): + table = Table(data) + port_ids = [] + + for i in range(10): + port_ids.append(table.make_port()) + + assert port_ids == list(range(1, 11)) + + def test_make_port_sequential_and_update(self): + table = Table(data) + port_ids = [] + + for i in range(10): + port_ids.append(table.make_port()) + + assert port_ids == list(range(1, 11)) + + for i in range(1, 11): + table.update({"a": [i], "b": ["a"], "c": [True]}, port_id=i) + + view = table.view() + result = view.to_columns() + + assert result == { + "a": [1, 2, 3, 4] + [i for i in range(1, 11)], + "b": ["a", "b", "c", "d"] + ["a" for i in range(10)], + "c": [True, False, True, False] + [True for i in range(10)], + } + + def test_arbitary_port_updates(self): + table = Table(data) + port_ids = [] + + for i in range(10): + port_ids.append(table.make_port()) + + assert port_ids == list(range(1, 11)) + + port = random.randint(0, 10) + + table.update(data, port_id=port) + + assert table.size() == 8 + + assert table.view().to_columns() == { + "a": [1, 2, 3, 4] * 2, + "b": ["a", "b", "c", "d"] * 2, + "c": [True, False, True, False] * 2, + } + + def test_ports_should_only_notify_if_they_have_a_queued_update(self): + table = Table(data) + port_ids = [] + + for i in range(10): + port_ids.append(table.make_port()) + + assert port_ids == list(range(1, 11)) + + view = table.view() + ports_to_update = [random.randint(0, 10) for i in range(5)] + + def callback(port_id): + assert port_id in ports_to_update + + view.on_update(callback) + + for port in ports_to_update: + table.update(data, port_id=port) + + def test_ports_should_have_unique_deltas(self): + table = Table(data) + port_ids = [] + + for i in range(10): + port_ids.append(table.make_port()) + + assert port_ids == list(range(1, 11)) + + view = table.view() + ports_to_update = [random.randint(0, 10) for i in range(5)] + unique_data = { + port: [{"a": port, "b": str(port), "c": True}] for port in ports_to_update + } + + def callback(port_id, delta): + assert port_id in ports_to_update + _t = Table(delta) + _v = _t.view() + assert _v.to_records() == unique_data[port] + _v.delete() + _t.delete() + + view.on_update(callback, mode="row") + + for port in ports_to_update: + table.update(unique_data[port], port_id=port) + + def test_ports_should_queue_updates_properly(self): + table = Table(data) + port_ids = [] + + for i in range(10): + port_ids.append(table.make_port()) + + assert port_ids == list(range(1, 11)) + + view = table.view() + ports_to_update = [random.randint(0, 10) for i in range(5)] + + def callback(port_id): + assert port_id in ports_to_update + + view.on_update(callback) + + for port in ports_to_update: + table.update(data, port_id=port) + + def test_ports_multiple_tables_with_different_ports(self): + server = Table(data) + client = Table(data) + + for i in range(random.randint(5, 15)): + # reserve an arbitary number of ports + server.make_port() + + # port for client is now far above the ports "ON" the client, as the + # client ports will begin creation at 1. + server_port_for_client = server.make_port() + client_port = client.make_port() + + server_view = server.view() + client_view = client.view() + + # when the client updates, check whether the port id matches that + # of the server, and complete the test. + def client_callback(port_id, delta): + if port_id == client_port: + print("UPDATING SERVER") + server.update(delta, port_id=server_port_for_client) + + # when the server updates, pass the update back to the client + def server_callback(port_id, delta): + print("UPDATING CLIENT") + assert port_id == server_port_for_client + assert server.size() == 8 + server_view.delete() + server.delete() + client_view.delete() + client.delete() + + client_view.on_update(client_callback, mode="row") + server_view.on_update(server_callback, mode="row") + + client.update(data, port_id=client_port) diff --git a/rust/perspective-python/perspective/tests/table/test_remove.py b/rust/perspective-python/perspective/tests/table/test_remove.py new file mode 100644 index 0000000..c630aba --- /dev/null +++ b/rust/perspective-python/perspective/tests/table/test_remove.py @@ -0,0 +1,102 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 as psp + +client = psp.Server().new_local_client() +Table = client.table + + +class TestRemove(object): + def test_remove_all(self): + tbl = Table([{"a": "abc", "b": 123}], index="a") + tbl.remove(["abc"]) + assert tbl.view().to_records() == [] + # assert tbl.size() == 0 + + def test_remove_nonsequential(self): + tbl = Table( + [{"a": "abc", "b": 123}, {"a": "def", "b": 456}, {"a": "efg", "b": 789}], + index="a", + ) + tbl.remove(["abc", "efg"]) + assert tbl.view().to_records() == [{"a": "def", "b": 456}] + # assert tbl.size() == 1 + + def test_remove_multiple_single(self): + tbl = Table({"a": "integer", "b": "string"}, index="a") + for i in range(0, 10): + tbl.update([{"a": i, "b": str(i)}]) + for i in range(1, 10): + tbl.remove([i]) + assert tbl.view().to_records() == [{"a": 0, "b": "0"}] + # assert tbl.size() == 0 + + def test_remove_expressions(self): + schema = {"key": "string", "delta$": "float", "business_line": "string"} + data = [ + { + "key": "A", + "delta$": 46412.3804275, + }, + { + "key": "B", + "delta$": 2317615.875, + }, + ] + + table = Table(schema, index="key") + table.update(data) + table.remove(["A"]) + view = table.view( + group_by=["business_line"], + columns=["delta$", "alias"], + expressions={ + "alias": '"delta$"', + }, + ) + + records = view.to_records() + assert records == [ + {"__ROW_PATH__": [], "delta$": 2317615.875, "alias": 2317615.875}, + {"__ROW_PATH__": [None], "delta$": 2317615.875, "alias": 2317615.875}, + ] + + def test_remove_expressions_after_view(self): + schema = {"key": "string", "delta$": "float", "business_line": "string"} + data = [ + { + "key": "A", + "delta$": 46412.3804275, + }, + { + "key": "B", + "delta$": 2317615.875, + }, + ] + + table = Table(schema, index="key") + table.update(data) + view = table.view( + group_by=["business_line"], + columns=["delta$", "alias"], + expressions={ + "alias": '"delta$"', + }, + ) + + table.remove(["A"]) + records = view.to_records() + assert records == [ + {"__ROW_PATH__": [], "delta$": 2317615.875, "alias": 2317615.875}, + {"__ROW_PATH__": [None], "delta$": 2317615.875, "alias": 2317615.875}, + ] diff --git a/rust/perspective-python/perspective/tests/table/test_table.py b/rust/perspective-python/perspective/tests/table/test_table.py new file mode 100644 index 0000000..51e86dd --- /dev/null +++ b/rust/perspective-python/perspective/tests/table/test_table.py @@ -0,0 +1,641 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 datetime import date, datetime +import perspective +from pytest import mark, raises +import pytest +import perspective as psp + +client = psp.Server().new_local_client() +Table = client.table + + +class TestTable: + # table constructors + + @mark.skip + def test_empty_table(self): + tbl = Table([]) + assert tbl.size() == 0 + + def test_table_not_iterable(self): + data = {"a": 1} + with raises(TypeError): + Table(data) + + # def test_table_synchronous_process(self): + # tbl = Table({"a": [1, 2, 3]}) + # assert _PerspectiveStateManager.TO_PROCESS == {} + # tbl.update({"a": [4, 5, 6]}) + # assert _PerspectiveStateManager.TO_PROCESS == {} + + def test_table_csv(self): + data = "x,y,z\n1,a,true\n2,b,false\n3,c,true\n4,d,false" + tbl = Table(data) + assert tbl.schema() == {"x": "integer", "y": "string", "z": "boolean"} + view = tbl.view() + assert view.to_columns() == { + "x": [1, 2, 3, 4], + "y": ["a", "b", "c", "d"], + "z": [True, False, True, False], + } + + def test_table_csv_with_nulls(self): + tbl = Table("x,y\n1,") + assert tbl.schema() == {"x": "integer", "y": "string"} + view = tbl.view() + assert view.to_columns() == {"x": [1], "y": [None]} + + def test_table_csv_with_nulls_updated(self): + tbl = Table("x,y\n1,", index="x") + assert tbl.schema() == {"x": "integer", "y": "string"} + view = tbl.view() + assert view.to_columns() == {"x": [1], "y": [None]} + tbl.update("x,y\n1,abc\n2,123") + assert view.to_columns() == {"x": [1, 2], "y": ["abc", "123"]} + + def test_table_correct_csv_nan_end(self): + tbl = Table("string,integer\n,1\n,2\nabc,3") + assert tbl.schema() == {"string": "string", "integer": "integer"} + assert tbl.size() == 3 + assert tbl.view().to_columns() == { + "string": ["", "", "abc"], + "integer": [1, 2, 3], + } + + def test_table_correct_csv_nan_intermittent(self): + tbl = Table("string,float\nabc,\n,2.5\nghi,") + assert tbl.schema() == {"string": "string", "float": "float"} + assert tbl.size() == 3 + assert tbl.view().to_columns() == { + "string": ["abc", "", "ghi"], + "float": [None, 2.5, None], + } + + def test_table_records_from_string_with_format_override(self): + data = '{"x": [1,2,3], "y": [4,5,6]}' + tbl = Table(data, format="columns") + view = tbl.view() + assert view.to_columns() == { + "x": [1, 2, 3], + "y": [4, 5, 6], + } + + def test_table_string_column_with_nulls_update_and_filter(self): + tbl = Table( + [ + {"a": "1", "b": 2, "c": "3"}, + {"a": "2", "b": 3, "c": "4"}, + {"a": "3", "b": 3, "c": None}, + ], + index="a", + ) + view = tbl.view(filter=[["c", "==", "4"]]) + records = view.to_records() + tbl.update([{"a": "4", "b": 10}]) + assert records == view.to_records() + + def test_table_int(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + assert tbl.size() == 2 + assert tbl.schema() == {"a": "integer", "b": "integer"} + + def test_table_int_column_names(self): + data = {"a": [1, 2, 3], 0: [4, 5, 6]} + Table(data) + + def test_table_nones(self): + none_data = [{"a": 1, "b": None}, {"a": None, "b": 2}] + tbl = Table(none_data) + assert tbl.size() == 2 + assert tbl.schema() == {"a": "integer", "b": "integer"} + + def test_table_bool(self): + bool_data = [{"a": True, "b": False}, {"a": True, "b": True}] + tbl = Table(bool_data) + assert tbl.size() == 2 + assert tbl.schema() == {"a": "boolean", "b": "boolean"} + + def test_table_bool_str(self): + bool_data = [{"a": "True", "b": "False"}, {"a": "True", "b": "True"}] + tbl = Table(bool_data) + assert tbl.size() == 2 + assert tbl.schema() == {"a": "boolean", "b": "boolean"} + assert tbl.view().to_columns() == {"a": [True, True], "b": [False, True]} + + def test_table_float(self): + float_data = [{"a": 1.5, "b": 2.5}, {"a": 3.2, "b": 3.1}] + tbl = Table(float_data) + assert tbl.size() == 2 + assert tbl.schema() == {"a": "float", "b": "float"} + + def test_table_str(self): + str_data = [{"a": "b", "b": "b"}, {"a": "3", "b": "3"}] + tbl = Table(str_data) + assert tbl.size() == 2 + assert tbl.schema() == {"a": "string", "b": "string"} + + def test_table_str_with_escape(self): + str_data = [ + {"a": 'abc"def"', "b": 'abc"def"'}, + {"a": "abc'def'", "b": "abc'def'"}, + ] + tbl = Table(str_data) + assert tbl.size() == 2 + assert tbl.schema() == {"a": "string", "b": "string"} + assert tbl.view().to_records() == str_data + + def test_table_str_unicode(self): + str_data = [{"a": "ȀȁȀȃȀȁȀȃȀȁȀȃȀȁȀȃ", "b": "ЖДфйЖДфйЖДфйЖДфй"}] + tbl = Table(str_data) + assert tbl.size() == 1 + assert tbl.schema() == {"a": "string", "b": "string"} + assert tbl.view().to_records() == str_data + + def test_table_date(self): + str_data = [{"a": date.today(), "b": date.today()}] + tbl = Table(str_data) + assert tbl.size() == 1 + assert tbl.schema() == {"a": "date", "b": "date"} + + def test_table_datetime(self): + str_data = [{"a": datetime.now(), "b": datetime.now()}] + tbl = Table(str_data) + assert tbl.size() == 1 + assert tbl.schema() == {"a": "datetime", "b": "datetime"} + + def test_table_columnar(self): + data = {"a": [1, 2, 3], "b": [4, 5, 6]} + tbl = Table(data) + assert tbl.columns() == ["a", "b"] + assert tbl.size() == 3 + assert tbl.schema() == {"a": "integer", "b": "integer"} + + def test_table_columnar_mixed_length(self): + data = [{"a": 1.5, "b": 2.5}, {"a": 3.2}] + tbl = Table(data) + assert tbl.size() == 2 + assert tbl.schema() == {"a": "float", "b": "float"} + assert tbl.view().to_records() == [{"a": 1.5, "b": 2.5}, {"a": 3.2, "b": None}] + + # schema + + def test_table_schema(self): + data = { + "a": "integer", + "b": "float", + "c": "string", + "d": "boolean", + "e": "date", + "f": "datetime", + } + + tbl = Table(data) + + assert tbl.schema() == { + "a": "integer", + "b": "float", + "c": "string", + "d": "boolean", + "e": "date", + "f": "datetime", + } + + def test_table_readable_string_schema(self): + data = { + "a": "integer", + "b": "float", + "c": "string", + "d": "boolean", + "e": "date", + "f": "datetime", + } + + tbl = Table(data) + + assert tbl.schema() == { + "a": "integer", + "b": "float", + "c": "string", + "d": "boolean", + "e": "date", + "f": "datetime", + } + + def test_table_output_readable_schema(self): + data = { + "a": "integer", + "b": "float", + "c": "string", + "d": "boolean", + "e": "date", + "f": "datetime", + } + + tbl = Table(data) + + assert tbl.schema() == { + "a": "integer", + "b": "float", + "c": "string", + "d": "boolean", + "e": "date", + "f": "datetime", + } + + def test_table_mixed_schema(self): + data = { + "a": "integer", + "b": "float", + "c": "string", + "d": "boolean", + "e": "date", + "f": "datetime", + } + + tbl = Table(data) + + assert tbl.schema() == { + "a": "integer", + "b": "float", + "c": "string", + "d": "boolean", + "e": "date", + "f": "datetime", + } + + def test_table_output_string_schema(self): + data = { + "a": "integer", + "b": "float", + "c": "string", + "d": "boolean", + "e": "date", + "f": "datetime", + } + + tbl = Table(data) + + assert tbl.schema() == { + "a": "integer", + "b": "float", + "c": "string", + "d": "boolean", + "e": "date", + "f": "datetime", + } + + def test_table_symmetric_schema(self): + data = { + "a": [1, 2, 3], + "b": [1.5, 2.5, 3.5], + "c": ["a", "b", "c"], + "d": [True, False, True], + "e": [date.today(), date.today(), date.today()], + "f": [datetime.now(), datetime.now(), datetime.now()], + } + + tbl = Table(data) + schema = tbl.schema() + + assert schema == { + "a": "integer", + "b": "float", + "c": "string", + "d": "boolean", + "e": "date", + "f": "datetime", + } + + tbl2 = Table(schema) + + assert tbl2.schema() == schema + + def test_table_symmetric_string_schema(self): + data = { + "a": [1, 2, 3], + "b": [1.5, 2.5, 3.5], + "c": ["a", "b", "c"], + "d": [True, False, True], + "e": [date.today(), date.today(), date.today()], + "f": [datetime.now(), datetime.now(), datetime.now()], + } + + tbl = Table(data) + schema = tbl.schema() + + assert schema == { + "a": "integer", + "b": "float", + "c": "string", + "d": "boolean", + "e": "date", + "f": "datetime", + } + + tbl2 = Table(schema) + + assert tbl2.schema() == schema + + def test_table_python_schema(self): + data = { + "a": int, + "b": float, + "c": str, + "d": bool, + "e": date, + "f": datetime, + } + + tbl = Table(data) + assert tbl.schema() == { + "a": "integer", + "b": "float", + "c": "string", + "d": "boolean", + "e": "date", + "f": "datetime", + } + + # is_valid_filter + + # def test_table_is_valid_filter_str(self): + # filter = ["a", "<", 1] + # data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + # tbl = Table(data) + # assert tbl.is_valid_filter(filter) is True + + # def test_table_not_is_valid_filter_str(self): + # filter = ["a", "<", None] + # data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + # tbl = Table(data) + # assert tbl.is_valid_filter(filter) is False + + # def test_table_is_valid_filter_filter_op(self): + # filter = ["a", t_filter_op.FILTER_OP_IS_NULL] + # data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + # tbl = Table(data) + # assert tbl.is_valid_filter(filter) is True + + # def test_table_not_is_valid_filter_filter_op(self): + # filter = ["a", t_filter_op.FILTER_OP_GT, None] + # data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + # tbl = Table(data) + # assert tbl.is_valid_filter(filter) is False + + # def test_table_is_valid_filter_date(self): + # filter = ["a", t_filter_op.FILTER_OP_GT, date.today()] + # tbl = Table({"a": "date"}) + # assert tbl.is_valid_filter(filter) is True + + # def test_table_not_is_valid_filter_date(self): + # filter = ["a", t_filter_op.FILTER_OP_GT, None] + # tbl = Table({"a": "date"}) + # assert tbl.is_valid_filter(filter) is False + + # def test_table_is_valid_filter_datetime(self): + # filter = ["a", t_filter_op.FILTER_OP_GT, datetime.now()] + # tbl = Table({"a": "datetime"}) + # assert tbl.is_valid_filter(filter) is True + + # def test_table_not_is_valid_filter_datetime(self): + # filter = ["a", t_filter_op.FILTER_OP_GT, None] + # tbl = Table({"a": "datetime"}) + # assert tbl.is_valid_filter(filter) is False + + # def test_table_is_valid_filter_datetime_str(self): + # filter = ["a", t_filter_op.FILTER_OP_GT, "7/11/2019 5:30PM"] + # tbl = Table({"a": "datetime"}) + # assert tbl.is_valid_filter(filter) is True + + # def test_table_not_is_valid_filter_datetime_str(self): + # filter = ["a", t_filter_op.FILTER_OP_GT, None] + # tbl = Table({"a": "datetime"}) + # assert tbl.is_valid_filter(filter) is False + + # def test_table_is_valid_filter_ignores_not_in_schema(self): + # filter = ["not in schema", "<", 1] + # data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + # tbl = Table(data) + # assert tbl.is_valid_filter(filter) is True + + # index + + def test_table_index(self): + data = [{"a": 1, "b": 2}, {"a": 1, "b": 4}] + tbl = Table(data, index="a") + assert tbl.size() == 1 + assert tbl.view().to_records() == [{"a": 1, "b": 4}] + + def test_table_index_from_schema(self): + data = [{"a": 1, "b": 2}, {"a": 1, "b": 4}] + tbl = Table({"a": "integer", "b": "integer"}, index="a") + assert tbl.size() == 0 + tbl.update(data) + assert tbl.view().to_records() == [{"a": 1, "b": 4}] + + # index with None in column + + def test_table_index_int_with_none(self): + tbl = Table({"a": [0, 1, 2, None, None], "b": [4, 3, 2, 1, 0]}, index="a") + assert tbl.view().to_columns() == { + "a": [None, 0, 1, 2], + "b": [0, 4, 3, 2], + } # second `None` replaces first + + def test_table_index_float_with_none(self): + tbl = Table({"a": [0.0, 1.5, 2.5, None, None], "b": [4, 3, 2, 1, 0]}, index="a") + assert tbl.view().to_columns() == { + "a": [None, 0, 1.5, 2.5], + "b": [0, 4, 3, 2], + } # second `None` replaces first + + def test_table_index_bool_with_none(self): + # bools cannot be used as primary key columns + with raises(perspective.PerspectiveError): + Table({"a": [True, False, None, True], "b": [4, 3, 2, 1]}, index="a") + + def test_table_index_date_with_none(self): + tbl = Table( + { + "a": [date(2019, 7, 11), None, date(2019, 3, 12), date(2011, 3, 10)], + "b": [4, 3, 2, 1], + }, + index="a", + ) + + def ts(x): + return int(datetime.timestamp(x) * 1000) + + assert tbl.view().to_columns() == { + "a": [ + None, + ts(datetime(2011, 3, 10)), + ts(datetime(2019, 3, 12)), + ts(datetime(2019, 7, 11)), + ], + "b": [3, 1, 2, 4], + } + + def test_table_index_datetime_with_none(self, util): + tbl = Table( + { + "a": [ + datetime(2019, 7, 11, 15, 30), + None, + datetime(2019, 7, 11, 12, 10), + datetime(2019, 7, 11, 5, 0), + ], + "b": [4, 3, 2, 1], + }, + index="a", + ) + assert tbl.view().to_columns() == { + "a": [ + None, + util.to_timestamp(datetime(2019, 7, 11, 5, 0)), + util.to_timestamp(datetime(2019, 7, 11, 12, 10)), + util.to_timestamp(datetime(2019, 7, 11, 15, 30)), + ], + "b": [3, 1, 2, 4], + } + + def test_table_index_str_with_none(self): + tbl = Table({"a": ["", "a", None, "b"], "b": [4, 3, 2, 1]}, index="a") + assert tbl.view().to_columns() == {"a": [None, "", "a", "b"], "b": [2, 4, 3, 1]} + + def test_table_get_index(self): + tbl = Table({"a": ["", "a", None, "b"], "b": [4, 3, 2, 1]}, index="a") + assert tbl.get_index() == "a" + + def test_table_get_index_none(self): + tbl = Table({"a": ["", "a", None, "b"], "b": [4, 3, 2, 1]}) + assert tbl.get_index() is None + + # limit + + def test_table_limit(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data, limit=1) + assert tbl.size() == 1 + assert tbl.view().to_records() == [{"a": 3, "b": 4}] + + def test_table_get_limit(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data, limit=1) + assert tbl.get_limit() == 1 + + def test_table_get_limit_none(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + assert tbl.get_limit() is None + + # num_views + + @pytest.mark.skip + def test_table_get_num_views(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + assert tbl.get_num_views() == 0 + v1 = tbl.view() + v2 = tbl.view() + v3 = tbl.view() + assert tbl.get_num_views() == 3 + + v1.delete() + v2.delete() + assert tbl.get_num_views() == 1 + failed = False + try: + tbl.delete() + except perspective.PerspectiveError: + failed = True + assert failed + v3.delete() + assert tbl.get_num_views() == 0 + tbl.delete() + + # clear + + def test_table_clear(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + tbl.clear() + view = tbl.view() + assert view.to_records() == [] + + # replace + + def test_table_replace(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + data2 = [{"a": 3, "b": 4}, {"a": 1, "b": 2}] + tbl = Table(data) + tbl.replace(data2) + assert tbl.view().to_records() == data2 + + def test_table_replace_views_should_preserve(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + data2 = [{"a": 3, "b": 4}, {"a": 1, "b": 2}] + tbl = Table(data) + view = tbl.view(group_by=["a"], split_by=["b"]) + first = view.to_records() + tbl.replace(data2) + assert view.to_records() == first + + def test_table_replace_should_fire_on_update(self, sentinel): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + data2 = [{"a": 3, "b": 4}, {"a": 1, "b": 2}] + tbl = Table(data) + view = tbl.view() + + s = sentinel(False) + + def updater(port_id): + s.set(True) + + view.on_update(updater) + tbl.replace(data2) + tbl.size() + assert s.get() + + def test_table_replace_should_fire_on_update_with_delta(self, sentinel): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + data2 = [{"a": 3, "b": 4}, {"a": 1, "b": 2}] + tbl = Table(data) + view = tbl.view() + + s = sentinel(False) + + def updater(port_id, delta): + # assert port_id == 0 + table2 = Table(delta) + assert table2.view().to_records() == data2 + s.set(True) + + view.on_update(updater, mode="row") + tbl.replace(data2) + tbl.size() + assert s.get() is True + + def test_float32_table_construction(self): + float_data = [{"a": 1.5, "b": 2.5}, {"a": 3.2, "b": 3.1}] + tbl = Table(float_data, index="a") + assert tbl.size() == 2 + assert tbl.schema() == {"a": "float", "b": "float"} + + +if __name__ == "__main__": + import pytest + + pytest.main(["-vv", "-s", __file__]) diff --git a/rust/perspective-python/perspective/tests/table/test_table_arrow.py b/rust/perspective-python/perspective/tests/table/test_table_arrow.py new file mode 100644 index 0000000..27f641c --- /dev/null +++ b/rust/perspective-python/perspective/tests/table/test_table_arrow.py @@ -0,0 +1,500 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 os.path +import numpy as np +import pandas as pd +from perspective.tests.conftest import Util +import pyarrow as pa +from datetime import date, datetime +import perspective as psp + + +client = psp.Server().new_local_client() +Table = client.table + +DATE32_ARROW = os.path.join(os.path.dirname(__file__), "arrow", "date32.arrow") +DATE64_ARROW = os.path.join(os.path.dirname(__file__), "arrow", "date64.arrow") +DICT_ARROW = os.path.join(os.path.dirname(__file__), "arrow", "dict.arrow") + + +names = ["a", "b", "c", "d"] + +# Create sample data for every integer type +ALL_INTEGERS_DATA = { + "int8": pa.array([1, 2, 3], type=pa.int8()), + "int16": pa.array([1000, 2000, 3000], type=pa.int16()), + "int32": pa.array([100000, 200000, 300000], type=pa.int32()), + "int64": pa.array([10000000000, 20000000000, 30000000000], type=pa.int64()), + "uint8": pa.array([1, 2, 3], type=pa.uint8()), + "uint16": pa.array([1000, 2000, 3000], type=pa.uint16()), + "uint32": pa.array([100000, 200000, 300000], type=pa.uint32()), + "uint64": pa.array([10000000000, 20000000000, 30000000000], type=pa.uint64()), + "float32": pa.array([100000.0, 200000.0, 300000.0], type=pa.float32()), + "float64": pa.array( + [10000000000.0, 20000000000.0, 30000000000.0], type=pa.float64() + ), +} + +ALL_INTEGERS_TABLE = pa.Table.from_pydict(ALL_INTEGERS_DATA) + + +class TestTableArrow(object): + def test_table_with_integer_types(self): + tbl = Table(ALL_INTEGERS_TABLE) + assert tbl.size() == 3 + assert tbl.schema() == { + "int8": "integer", + "int16": "integer", + "int32": "integer", + "int64": "integer", + "uint8": "integer", + "uint16": "integer", + "uint32": "integer", + "uint64": "integer", + "float32": "float", + "float64": "float", + } + for k, values in ALL_INTEGERS_DATA.items(): + v = tbl.view(filter=[[k, "==", values[0].as_py()]]) + assert len(v.to_json()) == 1 + + def test_table_arrow_loads_date32_file(self, util: Util): + with open(DATE32_ARROW, mode="rb") as file: # b is important -> binary + tbl = Table(file.read()) + assert tbl.schema() == { + "jan-2019": "date", + "feb-2020": "date", + "mar-2019": "date", + "apr-2020": "date", + } + assert tbl.size() == 31 + view = tbl.view() + assert view.to_columns() == { + "jan-2019": [ + util.to_timestamp(datetime(2019, 1, i)) for i in range(1, 32) + ], + "feb-2020": [ + util.to_timestamp(datetime(2020, 2, i)) for i in range(1, 30) + ] + + [None, None], + "mar-2019": [ + util.to_timestamp(datetime(2019, 3, i)) for i in range(1, 32) + ], + "apr-2020": [ + util.to_timestamp(datetime(2020, 4, i)) for i in range(1, 31) + ] + + [None], + } + + def test_table_arrow_loads_date64_file(self, util: Util): + with open(DATE64_ARROW, mode="rb") as file: # b is important -> binary + tbl = Table(file.read()) + assert tbl.schema() == { + "jan-2019": "date", + "feb-2020": "date", + "mar-2019": "date", + "apr-2020": "date", + } + assert tbl.size() == 31 + view = tbl.view() + assert view.to_columns() == { + "jan-2019": [ + util.to_timestamp(datetime(2019, 1, i)) for i in range(1, 32) + ], + "feb-2020": [ + util.to_timestamp(datetime(2020, 2, i)) for i in range(1, 30) + ] + + [None, None], + "mar-2019": [ + util.to_timestamp(datetime(2019, 3, i)) for i in range(1, 32) + ], + "apr-2020": [ + util.to_timestamp(datetime(2020, 4, i)) for i in range(1, 31) + ] + + [None], + } + + def test_table_arrow_loads_dict_file(self): + with open(DICT_ARROW, mode="rb") as file: # b is important -> binary + tbl = Table(file.read()) + assert tbl.schema() == {"a": "string", "b": "string"} + assert tbl.size() == 5 + assert tbl.view().to_columns() == { + "a": ["abc", "def", "def", None, "abc"], + "b": ["klm", "hij", None, "hij", "klm"], + } + + # streams + + def test_table_arrow_loads_int_stream(self, util): + data = [list(range(10)) for i in range(4)] + arrow_data = util.make_arrow(names, data) + tbl = Table(arrow_data) + assert tbl.size() == 10 + assert tbl.schema() == { + "a": "integer", + "b": "integer", + "c": "integer", + "d": "integer", + } + assert tbl.view().to_columns() == { + "a": data[0], + "b": data[1], + "c": data[2], + "d": data[3], + } + + def test_empty_arrow(self, util): + table = pa.table( + { + "col1": [1, 2, 3], + "col2": ["abc", "foo", "bar"], + } + ) + + empty_table = table.schema.empty_table() + assert client.table(table, name="table2").size() == 3 + assert client.table(empty_table, name="table_empty_bad").size() == 0 + assert client.table(table, name="table3").schema() == { + "col1": "integer", + "col2": "string", + } + + assert client.table(empty_table, name="table4").schema() == { + "col1": "integer", + "col2": "string", + } + + def test_table_arrow_loads_float_stream(self, util): + data = [[i for i in range(10)], [i * 1.5 for i in range(10)]] + arrow_data = util.make_arrow(["a", "b"], data) + tbl = Table(arrow_data) + assert tbl.size() == 10 + assert tbl.schema() == { + "a": "integer", + "b": "float", + } + assert tbl.view().to_columns() == {"a": data[0], "b": data[1]} + + def test_table_arrow_loads_decimal_stream(self, util): + data = [[i * 1000 for i in range(10)]] + arrow_data = util.make_arrow(["a"], data, types=[pa.decimal128(4)]) + tbl = Table(arrow_data) + assert tbl.size() == 10 + assert tbl.schema() == { + "a": "float", + } + assert tbl.view().to_columns() == {"a": data[0]} + + def test_table_arrow_loads_bool_stream(self, util): + data = [[True if i % 2 == 0 else False for i in range(10)]] + arrow_data = util.make_arrow(["a"], data) + tbl = Table(arrow_data) + assert tbl.size() == 10 + assert tbl.schema() == {"a": "boolean"} + assert tbl.view().to_columns() == {"a": data[0]} + + def test_table_arrow_loads_date32_stream(self, util): + data = [[date(2019, 2, i) for i in range(1, 11)]] + arrow_data = util.make_arrow(["a"], data, types=[pa.date32()]) + tbl = Table(arrow_data) + assert tbl.size() == 10 + assert tbl.schema() == {"a": "date"} + assert tbl.view().to_columns() == { + "a": [util.to_timestamp(datetime(2019, 2, i)) for i in range(1, 11)] + } + + def test_table_arrow_loads_date64_stream(self, util): + data = [[date(2019, 2, i) for i in range(1, 11)]] + arrow_data = util.make_arrow(["a"], data, types=[pa.date64()]) + tbl = Table(arrow_data) + assert tbl.size() == 10 + assert tbl.schema() == {"a": "date"} + assert tbl.view().to_columns() == { + "a": [util.to_timestamp(datetime(2019, 2, i)) for i in range(1, 11)] + } + + def test_table_arrow_loads_timestamp_all_formats_stream(self, util): + data = [ + [datetime(2019, 2, i, 9) for i in range(1, 11)], + [datetime(2019, 2, i, 10) for i in range(1, 11)], + [datetime(2019, 2, i, 11) for i in range(1, 11)], + [datetime(2019, 2, i, 12) for i in range(1, 11)], + ] + arrow_data = util.make_arrow( + names, + data, + types=[ + pa.timestamp("s"), + pa.timestamp("ms"), + pa.timestamp("us"), + pa.timestamp("ns"), + ], + ) + tbl = Table(arrow_data) + assert tbl.size() == 10 + assert tbl.schema() == { + "a": "datetime", + "b": "datetime", + "c": "datetime", + "d": "datetime", + } + assert tbl.view().to_columns() == { + "a": [util.to_timestamp(i) for i in data[0]], + "b": [util.to_timestamp(i) for i in data[1]], + "c": [util.to_timestamp(i) for i in data[2]], + "d": [util.to_timestamp(i) for i in data[3]], + } + + def test_table_arrow_loads_string_stream(self, util): + data = [[str(i) for i in range(10)]] + arrow_data = util.make_arrow(["a"], data, types=[pa.string()]) + tbl = Table(arrow_data) + assert tbl.size() == 10 + assert tbl.schema() == {"a": "string"} + assert tbl.view().to_columns() == {"a": data[0]} + + def test_table_arrow_loads_dictionary_stream_int8(self, util): + data = [ + ([0, 1, 1, None], ["abc", "def"]), + ([0, 1, None, 2], ["xx", "yy", "zz"]), + ] + types = [[pa.int8(), pa.string()]] * 2 + arrow_data = util.make_dictionary_arrow(["a", "b"], data, types=types) + tbl = Table(arrow_data) + + assert tbl.size() == 4 + assert tbl.schema() == {"a": "string", "b": "string"} + assert tbl.view().to_columns() == { + "a": ["abc", "def", "def", None], + "b": ["xx", "yy", None, "zz"], + } + + def test_table_arrow_loads_dictionary_stream_int16(self, util): + data = [ + ([0, 1, 1, None], ["abc", "def"]), + ([0, 1, None, 2], ["xx", "yy", "zz"]), + ] + types = [[pa.int16(), pa.string()]] * 2 + arrow_data = util.make_dictionary_arrow(["a", "b"], data, types=types) + tbl = Table(arrow_data) + + assert tbl.size() == 4 + assert tbl.schema() == {"a": "string", "b": "string"} + assert tbl.view().to_columns() == { + "a": ["abc", "def", "def", None], + "b": ["xx", "yy", None, "zz"], + } + + def test_table_arrow_loads_dictionary_stream_int32(self, util): + data = [ + ([0, 1, 1, None], ["abc", "def"]), + ([0, 1, None, 2], ["xx", "yy", "zz"]), + ] + types = [[pa.int32(), pa.string()]] * 2 + arrow_data = util.make_dictionary_arrow(["a", "b"], data, types=types) + tbl = Table(arrow_data) + + assert tbl.size() == 4 + assert tbl.schema() == {"a": "string", "b": "string"} + assert tbl.view().to_columns() == { + "a": ["abc", "def", "def", None], + "b": ["xx", "yy", None, "zz"], + } + + def test_table_arrow_loads_dictionary_stream_int64(self, util): + data = [ + ([0, 1, 1, None], ["abc", "def"]), + ([0, 1, None, 2], ["xx", "yy", "zz"]), + ] + arrow_data = util.make_dictionary_arrow(["a", "b"], data) + tbl = Table(arrow_data) + + assert tbl.size() == 4 + assert tbl.schema() == {"a": "string", "b": "string"} + assert tbl.view().to_columns() == { + "a": ["abc", "def", "def", None], + "b": ["xx", "yy", None, "zz"], + } + + def test_table_arrow_loads_dictionary_stream_nones(self, util): + data = [([None, 0, 1, 2], ["", "abc", "def"])] + arrow_data = util.make_dictionary_arrow(["a"], data) + tbl = Table(arrow_data) + + assert tbl.size() == 4 + assert tbl.schema() == {"a": "string"} + assert tbl.view().to_columns() == {"a": [None, "", "abc", "def"]} + + def test_table_arrow_loads_dictionary_stream_nones_indexed(self, util): + data = [ + ([1, None, 0, 2], ["", "abc", "def"]), + ([2, 1, 0, None], ["", "hij", "klm"]), + ] # ["abc", None, "", "def"] # ["klm", "hij", "", None] + arrow_data = util.make_dictionary_arrow(["a", "b"], data) + tbl = Table(arrow_data, index="a") # column "a" is sorted + + assert tbl.schema() == {"a": "string", "b": "string"} + assert tbl.view().to_columns() == { + "a": [None, "", "abc", "def"], + "b": ["hij", "", "klm", None], + } + + def test_table_arrow_loads_dictionary_stream_nones_indexed_2(self, util): + """Test the other column, just in case.""" + data = [ + ([1, None, 0, 2], ["", "abc", "def"]), + ([2, 1, 0, None], ["", "hij", "klm"]), + ] # ["abc", None, "", "def"] # ["klm", "hij", "", None] + arrow_data = util.make_dictionary_arrow(["a", "b"], data) + tbl = Table(arrow_data, index="b") # column "b" is sorted + + assert tbl.schema() == {"a": "string", "b": "string"} + assert tbl.view().to_columns() == { + "a": ["def", "", None, "abc"], + "b": [None, "", "hij", "klm"], + } + + # legacy + + def test_table_arrow_loads_int_legacy(self, util): + data = [list(range(10)) for i in range(4)] + arrow_data = util.make_arrow(names, data, legacy=True) + tbl = Table(arrow_data) + assert tbl.size() == 10 + assert tbl.schema() == { + "a": "integer", + "b": "integer", + "c": "integer", + "d": "integer", + } + + def test_table_arrow_loads_float_legacy(self, util): + data = [[i for i in range(10)], [i * 1.5 for i in range(10)]] + arrow_data = util.make_arrow(["a", "b"], data, legacy=True) + tbl = Table(arrow_data) + assert tbl.size() == 10 + assert tbl.schema() == { + "a": "integer", + "b": "float", + } + assert tbl.view().to_columns() == {"a": data[0], "b": data[1]} + + def test_table_arrow_loads_decimal128_legacy(self, util): + data = [[i * 1000 for i in range(10)]] + arrow_data = util.make_arrow(["a"], data, types=[pa.decimal128(4)], legacy=True) + tbl = Table(arrow_data) + assert tbl.size() == 10 + assert tbl.schema() == { + "a": "float", + } + assert tbl.view().to_columns() == {"a": data[0]} + + def test_table_arrow_loads_bool_legacy(self, util): + data = [[True if i % 2 == 0 else False for i in range(10)]] + arrow_data = util.make_arrow(["a"], data, legacy=True) + tbl = Table(arrow_data) + assert tbl.size() == 10 + assert tbl.schema() == {"a": "boolean"} + assert tbl.view().to_columns() == {"a": data[0]} + + def test_table_arrow_loads_date32_legacy(self, util): + data = [[date(2019, 2, i) for i in range(1, 11)]] + arrow_data = util.make_arrow(["a"], data, types=[pa.date32()], legacy=True) + tbl = Table(arrow_data) + assert tbl.size() == 10 + assert tbl.schema() == {"a": "date"} + assert tbl.view().to_columns() == { + "a": [util.to_timestamp(datetime(2019, 2, i)) for i in range(1, 11)] + } + + def test_table_arrow_loads_date64_legacy(self, util): + data = [[date(2019, 2, i) for i in range(1, 11)]] + arrow_data = util.make_arrow(["a"], data, types=[pa.date64()], legacy=True) + tbl = Table(arrow_data) + assert tbl.size() == 10 + assert tbl.schema() == {"a": "date"} + assert tbl.view().to_columns() == { + "a": [util.to_timestamp(datetime(2019, 2, i)) for i in range(1, 11)] + } + + def test_table_arrow_loads_timestamp_all_formats_legacy(self, util): + data = [ + [datetime(2019, 2, i, 9) for i in range(1, 11)], + [datetime(2019, 2, i, 10) for i in range(1, 11)], + [datetime(2019, 2, i, 11) for i in range(1, 11)], + [datetime(2019, 2, i, 12) for i in range(1, 11)], + ] + arrow_data = util.make_arrow( + names, + data, + types=[ + pa.timestamp("s"), + pa.timestamp("ms"), + pa.timestamp("us"), + pa.timestamp("ns"), + ], + legacy=True, + ) + tbl = Table(arrow_data) + assert tbl.size() == 10 + assert tbl.schema() == { + "a": "datetime", + "b": "datetime", + "c": "datetime", + "d": "datetime", + } + assert tbl.view().to_columns() == { + "a": [util.to_timestamp(i) for i in data[0]], + "b": [util.to_timestamp(i) for i in data[1]], + "c": [util.to_timestamp(i) for i in data[2]], + "d": [util.to_timestamp(i) for i in data[3]], + } + + def test_table_arrow_loads_string_legacy(self, util): + data = [[str(i) for i in range(10)]] + arrow_data = util.make_arrow(["a"], data, types=[pa.string()], legacy=True) + tbl = Table(arrow_data) + assert tbl.size() == 10 + assert tbl.schema() == {"a": "string"} + assert tbl.view().to_columns() == {"a": data[0]} + + def test_table_arrow_loads_dictionary_legacy(self, util): + data = [([0, 1, 1, None], ["a", "b"]), ([0, 1, None, 2], ["x", "y", "z"])] + arrow_data = util.make_dictionary_arrow(["a", "b"], data, legacy=True) + tbl = Table(arrow_data) + + assert tbl.size() == 4 + assert tbl.schema() == {"a": "string", "b": "string"} + assert tbl.view().to_columns() == { + "a": ["a", "b", "b", None], + "b": ["x", "y", None, "z"], + } + + def test_table_arrow_loads_arrow_from_df_with_nan(self): + data = pd.DataFrame({"a": [1.5, 2.5, np.nan, 3.5, 4.5, np.nan, np.nan, np.nan]}) + + arrow_table = pa.Table.from_pandas(data, preserve_index=False) + + assert arrow_table["a"].null_count == 4 + + tbl = Table(arrow_table) + assert tbl.size() == 8 + + # check types + assert tbl.schema() == {"a": "float"} + + # check nans + json = tbl.view().to_columns() + + assert json["a"] == [1.5, 2.5, None, 3.5, 4.5, None, None, None] diff --git a/rust/perspective-python/perspective/tests/table/test_table_datetime.py b/rust/perspective-python/perspective/tests/table/test_table_datetime.py new file mode 100644 index 0000000..c9d432d --- /dev/null +++ b/rust/perspective-python/perspective/tests/table/test_table_datetime.py @@ -0,0 +1,2544 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 os +import time +from perspective.tests.conftest import Util +import pytz +import numpy as np +import pandas as pd +from datetime import date, datetime, timedelta +from dateutil import tz +from pytest import mark, raises +from perspective import PerspectiveError +import perspective as psp + +client = psp.Server().new_local_client() +Table = client.table + +LOCAL_DATETIMES = [ + datetime(2019, 1, 11, 0, 10, 20), + datetime(2019, 1, 11, 11, 10, 20), + datetime(2019, 1, 11, 19, 10, 20), +] + +LOCAL_DATETIMES_TS = [int(d.timestamp() * 1000) for d in LOCAL_DATETIMES] + +# Test the DST transition for Continental US +LOCAL_DATETIMES_DST = [ + datetime(2019, 3, 9, 12, 10, 20), + datetime(2019, 3, 19, 12, 10, 20), + datetime(2019, 11, 2, 12, 10, 20), + datetime(2019, 11, 3, 12, 10, 20), +] + +LOCAL_DATETIMES_DST_TS = [int(d.timestamp() * 1000) for d in LOCAL_DATETIMES_DST] + +LOCAL_TIMESTAMPS = [pd.Timestamp(d) for d in LOCAL_DATETIMES] +LOCAL_TIMESTAMPS_DST = [pd.Timestamp(d) for d in LOCAL_DATETIMES_DST] + +# Set up testing data +UTC = pytz.UTC + +UTC_DATETIMES = [UTC.localize(d) for d in LOCAL_DATETIMES] +UTC_TIMESTAMPS = [UTC.localize(d) for d in LOCAL_TIMESTAMPS] + +UTC_DATETIMES_DST = [UTC.localize(d, is_dst=True) for d in LOCAL_DATETIMES_DST] +UTC_TIMESTAMPS_DST = [UTC.localize(d, is_dst=True) for d in LOCAL_TIMESTAMPS_DST] + +PST = pytz.timezone("US/Pacific") +CST = pytz.timezone("US/Central") +EST = pytz.timezone("US/Eastern") +GMT = pytz.timezone("GMT") +HKT = pytz.timezone("Asia/Hong_Kong") +JPT = pytz.timezone("Asia/Tokyo") +ACT = pytz.timezone("Australia/ACT") + +TIMEZONES = [PST, CST, EST, GMT, HKT, JPT, ACT] + +TZ_DATETIMES = {} +TZ_TIMESTAMPS = {} + +TZ_DATETIMES_DST = {} +TZ_TIMESTAMPS_DST = {} + +for TZ in TIMEZONES: + TZ_DATETIMES[TZ.zone] = [TZ.localize(d) for d in LOCAL_DATETIMES] + TZ_TIMESTAMPS[TZ.zone] = [d.tz_localize(TZ) for d in LOCAL_TIMESTAMPS] + TZ_DATETIMES_DST[TZ.zone] = [d.astimezone(TZ) for d in UTC_DATETIMES_DST] + TZ_TIMESTAMPS_DST[TZ.zone] = [d.tz_convert(TZ) for d in UTC_TIMESTAMPS_DST] + +if os.name != "nt": + # no tzset on windows, run these tests on linux/mac only + + class TestTableLocalDateTime(object): + """Test datetimes across configurations such as local time, timezone-aware, + timezone-naive, and UTC implementations. + """ + + def setup_method(self): + # To make sure that local times are not changed, set timezone to EST + os.environ["TZ"] = "US/Eastern" + time.tzset() + + def teardown_method(self): + # go back to UTC at end of each test method, for consistency + os.environ["TZ"] = "UTC" + time.tzset() + + def test_table_should_assume_local_time(self): + """If a datetime object has no `tzinfo`, it should be assumed to be in + local time and not be converted at all. + """ + data = {"a": LOCAL_DATETIMES} + table = Table(data) + assert table.view().to_columns()["a"] == LOCAL_DATETIMES_TS + + @mark.skip(reason="no numpy support in JSON Table constructor") + def test_table_should_assume_local_time_numpy_datetime64(self): + data = {"a": [np.datetime64(d) for d in LOCAL_DATETIMES]} + table = Table(data) + assert table.view().to_columns()["a"] == LOCAL_DATETIMES_TS + + def test_table_should_assume_local_time_pandas_timestamp(self): + data = {"a": LOCAL_TIMESTAMPS} + + # Timestamps are assumed to be in UTC by pandas + table = Table(data) + + # Timestamps are read out in local time + assert table.view().to_columns()["a"] == LOCAL_DATETIMES_TS + + @mark.skip(reason="no numpy support in JSON Table constructor") + def test_table_should_assume_local_time_pandas_timestamp_df(self, util: Util): + data = pd.DataFrame({"a": LOCAL_TIMESTAMPS}) + + # Timestamps are assumed to be in UTC by pandas + table = Table(data) + + # Timestamps are read out in local time + assert table.view().to_columns()["a"] == [ + util.to_timestamp(datetime(2019, 1, 10, 19, 10, 20)), + util.to_timestamp(datetime(2019, 1, 11, 6, 10, 20)), + util.to_timestamp(datetime(2019, 1, 11, 14, 10, 20)), + ] + + def test_table_should_assume_local_time_dst(self): + """If a datetime object has no `tzinfo`, it should be assumed to be in + local time and not be converted at all. + """ + data = {"a": LOCAL_DATETIMES_DST} + table = Table(data) + assert table.view().to_columns()["a"] == LOCAL_DATETIMES_DST_TS + + @mark.skip(reason="Do not support numpy in JSON constructor") + def test_table_should_assume_local_time_numpy_datetime64_dst(self): + data = {"a": [np.datetime64(d) for d in LOCAL_DATETIMES_DST]} + table = Table(data) + assert table.view().to_columns()["a"] == LOCAL_DATETIMES_DST_TS + + def test_table_should_assume_local_time_pandas_timestamp_dst(self): + data = {"a": LOCAL_TIMESTAMPS_DST} + table = Table(data) + assert table.view().to_columns()["a"] == LOCAL_DATETIMES_DST_TS + + def test_table_should_assume_local_time_pandas_timestamp_dst_df(self, util): + data = pd.DataFrame({"a": LOCAL_TIMESTAMPS_DST}) + table = Table(data) + assert table.view().to_columns()["a"] == [ + util.to_timestamp(datetime(2019, 3, 9, 7, 10, 20)), + util.to_timestamp(datetime(2019, 3, 19, 8, 10, 20)), + util.to_timestamp(datetime(2019, 11, 2, 8, 10, 20)), + util.to_timestamp(datetime(2019, 11, 3, 7, 10, 20)), + ] + + @mark.skip(reason="Python specific behavior we don't need") + def test_table_datetime_min(self, util): + data = {"a": [datetime.min]} + table = Table(data) + assert table.view().to_columns()["a"] == [ + util.to_timestamp(datetime(1969, 12, 31, 19, 0)) + ] + + @mark.skip(reason="Python specific behavior we don't need") + def test_table_datetime_min_df(self, util): + data = pd.DataFrame({"a": [datetime.min]}) + table = Table(data) + assert table.view().to_columns()["a"] == [ + util.to_timestamp(datetime(1969, 12, 31, 19, 0)) + ] + + def test_table_datetime_1900(self, util): + table = Table({"a": "datetime"}) + table.update({"a": [util.to_timestamp(datetime(1900, 1, 1))]}) + assert table.view().to_columns()["a"] == [ + util.to_timestamp(datetime(1900, 1, 1)) + ] + + def test_table_datetime_1900_df(self, util): + data = pd.DataFrame({"a": [datetime(1900, 1, 1)]}) + table = Table(data) + assert table.view().to_columns()["a"] == [ + util.to_timestamp(datetime(1899, 12, 31, 19)) + ] + + def test_table_datetime_1899(self, util: Util): + table = Table({"a": "datetime"}) + table.update({"a": [datetime(1899, 1, 1)]}) + assert table.view().to_columns()["a"] == [ + util.to_timestamp(datetime(1898, 12, 31, 19)) + ] + + def test_table_datetime_1899_df(self, util): + data = pd.DataFrame({"a": [datetime(1899, 1, 1)]}) + table = Table(data) + assert table.view().to_columns()["a"] == [ + util.to_timestamp(datetime(1898, 12, 31, 19)) + ] + + def test_table_datetime_min_epoch(self, util): + data = {"a": [0]} + table = Table({"a": "datetime"}) + table.update(data) + assert table.view().to_columns()["a"] == [ + util.to_timestamp(datetime(1969, 12, 31, 19, 0)) + ] + + def test_table_datetime_cant_convert_from_int(self): + data = pd.DataFrame({"a": [0]}) + table = Table({"a": "datetime"}) + with raises(PerspectiveError): + table.update(data) + # assert str(ex.value) == "..." + + @mark.skip( + reason="we do not support this conversion, wrote a test above to ensure it fails" + ) + def test_table_datetime_min_epoch_df(self, util): + data = pd.DataFrame({"a": [0]}) + table = Table({"a": "datetime"}) + table.update(data) + assert table.view().to_columns()["a"] == [ + util.to_timestamp(datetime(1969, 12, 31, 19, 0)) + ] + + @mark.skip + def test_table_datetime_max(self, util): + data = {"a": [datetime.max]} + table = Table(data) + + # lol - result is converted from UTC to EST (local time) + assert table.view().to_columns()["a"] == [ + util.to_timestamp(datetime(9999, 12, 31, 18, 59, 59)) + ] + + @mark.skip + def test_table_datetime_max_df(self, util): + data = pd.DataFrame({"a": [datetime.max]}) + table = Table(data) + assert table.view().to_columns()["a"] == [ + util.to_timestamp(datetime(9999, 12, 31, 18, 59, 59)) + ] + + class TestTableDateTimeUTCToLocal(object): + def teardown_method(self): + # Set timezone to UTC, always + os.environ["TZ"] = "UTC" + time.tzset() + + @mark.skip(reason="Unsupported non-UTC timezone") + def test_table_should_convert_UTC_to_local_time_pytz_pacific(self): + """If the datetime has `tzinfo` set, use it to convert the datetime to + UTC. Make sure this works with both `pytz` and `dateutil` for + `datetime` and `pandas.Timestamp`. + """ + data = {"a": UTC_DATETIMES} + table = Table(data) + + os.environ["TZ"] = "US/Pacific" + time.tzset() + + # Should be in PST now + assert table.view().to_columns() == { + "a": [d.astimezone(PST).replace(tzinfo=None) for d in data["a"]] + } + + def test_table_should_convert_UTC_to_local_time_pytz_central(self, util: Util): + data = {"a": UTC_DATETIMES} + table = Table(data) + + os.environ["TZ"] = "US/Central" + time.tzset() + + # Should be in CST now + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.astimezone(CST).replace(tzinfo=None)) + for d in data["a"] + ] + } + + def test_table_should_convert_UTC_to_local_time_pytz_eastern(self, util: Util): + data = {"a": UTC_DATETIMES} + table = Table(data) + + os.environ["TZ"] = "US/Eastern" + time.tzset() + + # Should be in EST now + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.astimezone(EST).replace(tzinfo=None)) + for d in data["a"] + ] + } + + def test_table_should_convert_UTC_to_local_time_pytz_GMT(self, util: Util): + data = {"a": UTC_DATETIMES} + table = Table(data) + + os.environ["TZ"] = "GMT" + time.tzset() + + # Should be in GMT now + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.astimezone(GMT).replace(tzinfo=None)) + for d in data["a"] + ] + } + + def test_table_should_convert_UTC_to_local_time_pytz_HKT(self, util: Util): + data = {"a": UTC_DATETIMES} + table = Table(data) + + os.environ["TZ"] = "Asia/Hong_Kong" + time.tzset() + + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.astimezone(HKT).replace(tzinfo=None)) + for d in data["a"] + ] + } + + def test_table_should_convert_UTC_to_local_time_pytz_JPT(self, util: Util): + data = {"a": UTC_DATETIMES} + table = Table(data) + + os.environ["TZ"] = "Asia/Tokyo" + time.tzset() + + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.astimezone(JPT).replace(tzinfo=None)) + for d in data["a"] + ] + } + + def test_table_should_convert_UTC_to_local_time_pytz_ACT(self, util: Util): + data = {"a": UTC_DATETIMES} + table = Table(data) + + os.environ["TZ"] = "Australia/Sydney" + time.tzset() + + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.astimezone(ACT).replace(tzinfo=None)) + for d in data["a"] + ] + } + + def test_table_should_convert_UTC_to_local_time_dateutil_pacific( + self, util: Util + ): + data = {"a": UTC_DATETIMES} + table = Table(data) + + os.environ["TZ"] = "US/Pacific" + time.tzset() + + # Should be in PST now + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.astimezone(PST).replace(tzinfo=None)) + for d in data["a"] + ] + } + + def test_table_should_convert_UTC_to_local_time_dateutil_central( + self, util: Util + ): + data = {"a": UTC_DATETIMES} + table = Table(data) + + os.environ["TZ"] = "US/Central" + time.tzset() + + # Should be in CST now + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.astimezone(CST).replace(tzinfo=None)) + for d in data["a"] + ] + } + + def test_table_should_convert_UTC_to_local_time_dateutil_eastern( + self, util: Util + ): + data = {"a": UTC_DATETIMES} + table = Table(data) + + os.environ["TZ"] = "US/Eastern" + time.tzset() + + # Should be in EST now + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.astimezone(EST).replace(tzinfo=None)) + for d in data["a"] + ] + } + + def test_table_should_convert_UTC_to_local_time_dateutil_GMT(self, util: Util): + data = {"a": UTC_DATETIMES} + table = Table(data) + + os.environ["TZ"] = "GMT" + time.tzset() + + # Should be in GMT now + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.astimezone(GMT).replace(tzinfo=None)) + for d in data["a"] + ] + } + + def test_table_should_convert_UTC_to_local_time_dateutil_pacific_DST( + self, util: Util + ): + data = {"a": UTC_DATETIMES_DST} + table = Table(data) + + os.environ["TZ"] = "US/Pacific" + time.tzset() + + # Should be in PST now + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.replace(tzinfo=None)) + for d in TZ_DATETIMES_DST["US/Pacific"] + ] + } + + def test_table_should_convert_UTC_to_local_time_dateutil_central_DST( + self, util: Util + ): + data = {"a": UTC_DATETIMES_DST} + table = Table(data) + + os.environ["TZ"] = "US/Central" + time.tzset() + + # Should be in CST now + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.replace(tzinfo=None)) + for d in TZ_DATETIMES_DST["US/Central"] + ] + } + + def test_table_should_convert_UTC_to_local_time_dateutil_eastern_DST( + self, util: Util + ): + data = {"a": UTC_DATETIMES_DST} + table = Table(data) + + os.environ["TZ"] = "US/Eastern" + time.tzset() + + # Should be in EST now + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.replace(tzinfo=None)) + for d in TZ_DATETIMES_DST["US/Eastern"] + ] + } + + def test_table_should_convert_UTC_to_local_time_dateutil_GMT_DST( + self, util: Util + ): + data = {"a": UTC_DATETIMES_DST} + table = Table(data) + + os.environ["TZ"] = "GMT" + time.tzset() + + # Should be in GMT now + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.replace(tzinfo=None)) + for d in TZ_DATETIMES_DST["GMT"] + ] + } + + def test_table_should_convert_UTC_to_local_time_dateutil_pacific_DST_timestamp( + self, util + ): + data = pd.DataFrame({"a": UTC_TIMESTAMPS_DST}) + table = Table(data) + + os.environ["TZ"] = "US/Pacific" + time.tzset() + + # Should be in PST now + assert table.view().to_columns()["a"] == [ + util.to_timestamp(d.replace(tzinfo=None)) + for d in TZ_DATETIMES_DST["US/Pacific"] + ] + + def test_table_should_convert_UTC_to_local_time_dateutil_central_DST_timestamp( + self, util + ): + data = pd.DataFrame({"a": UTC_TIMESTAMPS_DST}) + table = Table(data) + + os.environ["TZ"] = "US/Central" + time.tzset() + + # Should be in CST now + assert table.view().to_columns()["a"] == [ + util.to_timestamp(d.replace(tzinfo=None)) + for d in TZ_DATETIMES_DST["US/Central"] + ] + + def test_table_should_convert_UTC_to_local_time_dateutil_eastern_DST_timestamp( + self, util + ): + data = pd.DataFrame({"a": UTC_TIMESTAMPS_DST}) + table = Table(data) + + os.environ["TZ"] = "US/Eastern" + time.tzset() + + # Should be in EST now + assert table.view().to_columns()["a"] == [ + util.to_timestamp(d.replace(tzinfo=None)) + for d in TZ_DATETIMES_DST["US/Eastern"] + ] + + def test_table_should_convert_UTC_to_local_time_dateutil_GMT_DST_timestamp( + self, util + ): + data = pd.DataFrame({"a": UTC_TIMESTAMPS_DST}) + table = Table(data) + + os.environ["TZ"] = "GMT" + time.tzset() + + # Should be in GMT now + assert table.view().to_columns()["a"] == [ + util.to_timestamp(d.replace(tzinfo=None)) + for d in TZ_DATETIMES_DST["GMT"] + ] + + def test_table_should_convert_UTC_to_local_time_dateutil_HKT(self, util: Util): + data = {"a": UTC_DATETIMES} + table = Table(data) + + os.environ["TZ"] = "Asia/Hong_Kong" + time.tzset() + + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.astimezone(HKT).replace(tzinfo=None)) + for d in data["a"] + ] + } + + def test_table_should_convert_UTC_to_local_time_dateutil_JPT(self, util: Util): + data = {"a": UTC_DATETIMES} + table = Table(data) + + os.environ["TZ"] = "Asia/Tokyo" + time.tzset() + + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.astimezone(JPT).replace(tzinfo=None)) + for d in data["a"] + ] + } + + def test_table_should_convert_UTC_to_local_time_dateutil_ACT(self, util: Util): + data = {"a": UTC_DATETIMES} + table = Table(data) + + os.environ["TZ"] = "Australia/Sydney" + time.tzset() + + ACT = tz.gettz("Australia/Sydney") + + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.astimezone(ACT).replace(tzinfo=None)) + for d in data["a"] + ] + } + + def test_table_should_convert_UTC_to_local_time_pytz_pacific_timestamp( + self, util + ): + data = pd.DataFrame({"a": UTC_TIMESTAMPS}) + table = Table(data) + + os.environ["TZ"] = "US/Pacific" + time.tzset() + + # Should be in PST now + assert table.view().to_columns()["a"] == [ + util.to_timestamp( + d.astimezone(PST).replace(tzinfo=None).to_pydatetime() + ) + for d in data["a"] + ] + + def test_table_should_convert_UTC_to_local_time_pytz_central_timestamp( + self, util + ): + data = pd.DataFrame({"a": UTC_TIMESTAMPS}) + table = Table(data) + + os.environ["TZ"] = "US/Central" + time.tzset() + + # Should be in CST now + assert table.view().to_columns()["a"] == [ + util.to_timestamp( + d.astimezone(CST).replace(tzinfo=None).to_pydatetime() + ) + for d in data["a"] + ] + + def test_table_should_convert_UTC_to_local_time_pytz_eastern_timestamp( + self, util + ): + data = pd.DataFrame({"a": UTC_TIMESTAMPS}) + table = Table(data) + + os.environ["TZ"] = "US/Eastern" + time.tzset() + + # Should be in EST now + assert table.view().to_columns()["a"] == [ + util.to_timestamp( + d.astimezone(EST).replace(tzinfo=None).to_pydatetime() + ) + for d in data["a"] + ] + + def test_table_should_convert_UTC_to_local_time_pytz_GMT_timestamp(self, util): + data = pd.DataFrame({"a": UTC_TIMESTAMPS}) + table = Table(data) + + os.environ["TZ"] = "GMT" + time.tzset() + + # Should be in GMT now + assert table.view().to_columns()["a"] == [ + util.to_timestamp( + d.astimezone(GMT).replace(tzinfo=None).to_pydatetime() + ) + for d in data["a"] + ] + + def test_table_should_convert_UTC_to_local_time_pytz_HKT_timestamp(self, util): + data = pd.DataFrame({"a": UTC_TIMESTAMPS}) + table = Table(data) + + os.environ["TZ"] = "Asia/Hong_Kong" + time.tzset() + + assert table.view().to_columns()["a"] == [ + util.to_timestamp( + d.astimezone(HKT).replace(tzinfo=None).to_pydatetime() + ) + for d in data["a"] + ] + + def test_table_should_convert_UTC_to_local_time_pytz_JPT_timestamp(self, util): + data = pd.DataFrame({"a": UTC_TIMESTAMPS}) + table = Table(data) + + os.environ["TZ"] = "Asia/Tokyo" + time.tzset() + + assert table.view().to_columns()["a"] == [ + util.to_timestamp( + d.astimezone(JPT).replace(tzinfo=None).to_pydatetime() + ) + for d in data["a"] + ] + + def test_table_should_convert_UTC_to_local_time_pytz_ACT_timestamp(self, util): + data = pd.DataFrame({"a": UTC_TIMESTAMPS}) + table = Table(data) + + os.environ["TZ"] = "Australia/Sydney" + time.tzset() + + assert table.view().to_columns()["a"] == [ + util.to_timestamp( + d.astimezone(ACT).replace(tzinfo=None).to_pydatetime() + ) + for d in data["a"] + ] + + def test_table_should_convert_UTC_to_local_time_dateutil_pacific_timestamp( + self, util + ): + data = pd.DataFrame({"a": UTC_TIMESTAMPS}) + table = Table(data) + + os.environ["TZ"] = "US/Pacific" + time.tzset() + + # Should be in PST now + assert table.view().to_columns()["a"] == [ + util.to_timestamp( + d.astimezone(PST).replace(tzinfo=None).to_pydatetime() + ) + for d in data["a"] + ] + + def test_table_should_convert_UTC_to_local_time_dateutil_central_timestamp( + self, + util, + ): + data = pd.DataFrame({"a": UTC_TIMESTAMPS}) + table = Table(data) + + os.environ["TZ"] = "US/Central" + time.tzset() + + CST = tz.gettz("US/Central") + + # Should be in CST now + assert table.view().to_columns()["a"] == [ + util.to_timestamp( + d.astimezone(CST).replace(tzinfo=None).to_pydatetime() + ) + for d in data["a"] + ] + + def test_table_should_convert_UTC_to_local_time_dateutil_eastern_timestamp( + self, util + ): + data = pd.DataFrame({"a": UTC_TIMESTAMPS}) + table = Table(data) + + os.environ["TZ"] = "US/Eastern" + time.tzset() + + # Should be in EST now + assert table.view().to_columns()["a"] == [ + util.to_timestamp( + d.astimezone(EST).replace(tzinfo=None).to_pydatetime() + ) + for d in data["a"] + ] + + def test_table_should_convert_UTC_to_local_time_dateutil_GMT_timestamp( + self, util + ): + data = pd.DataFrame({"a": UTC_TIMESTAMPS}) + table = Table(data) + + os.environ["TZ"] = "GMT" + time.tzset() + + GMT = tz.gettz("GMT") + + # Should be in GMT now + assert table.view().to_columns()["a"] == [ + util.to_timestamp( + d.astimezone(GMT).replace(tzinfo=None).to_pydatetime() + ) + for d in data["a"] + ] + + def test_table_should_convert_UTC_to_local_time_dateutil_HKT_timestamp( + self, util + ): + data = pd.DataFrame({"a": UTC_TIMESTAMPS}) + table = Table(data) + + os.environ["TZ"] = "Asia/Hong_Kong" + time.tzset() + + assert table.view().to_columns()["a"] == [ + util.to_timestamp( + d.astimezone(HKT).replace(tzinfo=None).to_pydatetime() + ) + for d in data["a"] + ] + + def test_table_should_convert_UTC_to_local_time_dateutil_JPT_timestamp( + self, util + ): + data = pd.DataFrame({"a": UTC_TIMESTAMPS}) + table = Table(data) + + os.environ["TZ"] = "Asia/Tokyo" + time.tzset() + + assert table.view().to_columns()["a"] == [ + util.to_timestamp( + d.astimezone(JPT).replace(tzinfo=None).to_pydatetime() + ) + for d in data["a"] + ] + + def test_table_should_convert_UTC_to_local_time_dateutil_ACT_timestamp( + self, util + ): + data = pd.DataFrame({"a": UTC_TIMESTAMPS}) + table = Table(data) + + os.environ["TZ"] = "Australia/Sydney" + time.tzset() + + assert table.view().to_columns()["a"] == [ + util.to_timestamp( + d.astimezone(ACT).replace(tzinfo=None).to_pydatetime() + ) + for d in data["a"] + ] + + class TestTableDateTimeArbitaryToLocal(object): + def teardown_method(self): + # Set timezone to UTC, always + os.environ["TZ"] = "UTC" + time.tzset() + + def test_table_should_convert_PST_to_local_time_pytz_central(self, util: Util): + data = {"a": TZ_DATETIMES["US/Pacific"]} + table = Table(data) + + os.environ["TZ"] = "US/Central" + time.tzset() + + # Should be in CST now + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.astimezone(CST).replace(tzinfo=None)) + for d in data["a"] + ] + } + + def test_table_should_convert_CST_to_local_time_pytz_eastern(self, util: Util): + data = {"a": TZ_DATETIMES["US/Central"]} + table = Table(data) + + os.environ["TZ"] = "US/Eastern" + time.tzset() + + # Should be in EST now + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.astimezone(EST).replace(tzinfo=None)) + for d in data["a"] + ] + } + + def test_table_should_convert_EST_to_local_time_pytz_GMT(self, util: Util): + data = {"a": TZ_DATETIMES["US/Eastern"]} + table = Table(data) + + os.environ["TZ"] = "GMT" + time.tzset() + + # Should be in GMT now + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.astimezone(GMT).replace(tzinfo=None)) + for d in data["a"] + ] + } + + def test_table_should_convert_GMT_to_local_time_pytz_HKT(self, util: Util): + data = {"a": TZ_DATETIMES["GMT"]} + table = Table(data) + + os.environ["TZ"] = "Asia/Hong_Kong" + time.tzset() + + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.astimezone(HKT).replace(tzinfo=None)) + for d in data["a"] + ] + } + + def test_table_should_convert_HKT_to_local_time_pytz_JPT(self, util: Util): + data = {"a": TZ_DATETIMES["Asia/Hong_Kong"]} + table = Table(data) + + os.environ["TZ"] = "Asia/Tokyo" + time.tzset() + + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.astimezone(JPT).replace(tzinfo=None)) + for d in data["a"] + ] + } + + def test_table_should_convert_JPT_to_local_time_pytz_ACT(self, util: Util): + data = {"a": TZ_DATETIMES["Asia/Tokyo"]} + table = Table(data) + + os.environ["TZ"] = "Australia/Sydney" + time.tzset() + + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.astimezone(ACT).replace(tzinfo=None)) + for d in data["a"] + ] + } + + def test_table_should_convert_PST_to_local_time_dateutil_central( + self, util: Util + ): + data = {"a": TZ_DATETIMES["US/Pacific"]} + table = Table(data) + + os.environ["TZ"] = "US/Central" + time.tzset() + + # Should be in CST now + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.astimezone(CST).replace(tzinfo=None)) + for d in data["a"] + ] + } + + def test_table_should_convert_CST_to_local_time_dateutil_eastern( + self, util: Util + ): + data = {"a": TZ_DATETIMES["US/Central"]} + table = Table(data) + + os.environ["TZ"] = "US/Eastern" + time.tzset() + + # Should be in EST now + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.astimezone(EST).replace(tzinfo=None)) + for d in data["a"] + ] + } + + def test_table_should_convert_EST_to_local_time_dateutil_GMT(self, util: Util): + data = {"a": TZ_DATETIMES["US/Eastern"]} + table = Table(data) + + os.environ["TZ"] = "GMT" + time.tzset() + + # Should be in GMT now + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.astimezone(GMT).replace(tzinfo=None)) + for d in data["a"] + ] + } + + def test_table_should_convert_GMT_to_local_time_dateutil_HKT(self, util: Util): + data = {"a": TZ_DATETIMES["GMT"]} + table = Table(data) + + os.environ["TZ"] = "Asia/Hong_Kong" + time.tzset() + + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.astimezone(HKT).replace(tzinfo=None)) + for d in data["a"] + ] + } + + def test_table_should_convert_HKT_to_local_time_dateutil_JPT(self, util: Util): + data = {"a": TZ_DATETIMES["Asia/Hong_Kong"]} + table = Table(data) + + os.environ["TZ"] = "Asia/Tokyo" + time.tzset() + + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.astimezone(JPT).replace(tzinfo=None)) + for d in data["a"] + ] + } + + def test_table_should_convert_JPT_to_local_time_dateutil_ACT(self, util: Util): + data = {"a": TZ_DATETIMES["Asia/Tokyo"]} + table = Table(data) + + os.environ["TZ"] = "Australia/Sydney" + time.tzset() + + assert table.view().to_columns() == { + "a": [ + util.to_timestamp(d.astimezone(ACT).replace(tzinfo=None)) + for d in data["a"] + ] + } + + def test_table_should_convert_PST_to_local_time_pytz_central_timestamp( + self, util: Util + ): + data = {"a": TZ_TIMESTAMPS["US/Pacific"]} + table = Table(pd.DataFrame(data)) + + os.environ["TZ"] = "US/Central" + time.tzset() + + # Should be in CST now + assert table.view().to_columns()["a"] == [ + util.to_timestamp( + d.astimezone(CST).replace(tzinfo=None).to_pydatetime() + ) + for d in data["a"] + ] + + def test_table_should_convert_CST_to_local_time_pytz_eastern_timestamp( + self, util + ): + data = {"a": TZ_TIMESTAMPS["US/Central"]} + table = Table(pd.DataFrame(data)) + + os.environ["TZ"] = "US/Eastern" + time.tzset() + + # Should be in EST now + assert table.view().to_columns()["a"] == [ + util.to_timestamp( + d.astimezone(EST).replace(tzinfo=None).to_pydatetime() + ) + for d in data["a"] + ] + + def test_table_should_convert_EST_to_local_time_pytz_GMT_timestamp(self, util): + data = {"a": TZ_TIMESTAMPS["US/Eastern"]} + table = Table(pd.DataFrame(data)) + + os.environ["TZ"] = "GMT" + time.tzset() + + # Should be in GMT now + assert table.view().to_columns()["a"] == [ + util.to_timestamp( + d.astimezone(GMT).replace(tzinfo=None).to_pydatetime() + ) + for d in data["a"] + ] + + def test_table_should_convert_GMT_to_local_time_pytz_HKT_timestamp(self, util): + data = {"a": TZ_TIMESTAMPS["GMT"]} + table = Table(pd.DataFrame(data)) + + os.environ["TZ"] = "Asia/Hong_Kong" + time.tzset() + + assert table.view().to_columns()["a"] == [ + util.to_timestamp( + d.astimezone(HKT).replace(tzinfo=None).to_pydatetime() + ) + for d in data["a"] + ] + + def test_table_should_convert_HKT_to_local_time_pytz_JPT_timestamp(self, util): + data = {"a": TZ_TIMESTAMPS["Asia/Hong_Kong"]} + table = Table(pd.DataFrame(data)) + + os.environ["TZ"] = "Asia/Tokyo" + time.tzset() + + assert table.view().to_columns()["a"] == [ + util.to_timestamp( + d.astimezone(JPT).replace(tzinfo=None).to_pydatetime() + ) + for d in data["a"] + ] + + def test_table_should_convert_JPT_to_local_time_pytz_ACT_timestamp(self, util): + data = {"a": TZ_TIMESTAMPS["Asia/Tokyo"]} + table = Table(pd.DataFrame(data)) + + os.environ["TZ"] = "Australia/Sydney" + time.tzset() + + assert table.view().to_columns()["a"] == [ + util.to_timestamp( + d.astimezone(ACT).replace(tzinfo=None).to_pydatetime() + ) + for d in data["a"] + ] + + def test_table_should_convert_PST_to_local_time_dateutil_central_timestamp( + self, util + ): + data = {"a": TZ_TIMESTAMPS["US/Pacific"]} + table = Table(pd.DataFrame(data)) + + os.environ["TZ"] = "US/Central" + time.tzset() + + # Should be in CST now + assert table.view().to_columns()["a"] == [ + util.to_timestamp( + d.astimezone(CST).replace(tzinfo=None).to_pydatetime() + ) + for d in data["a"] + ] + + def test_table_should_convert_CST_to_local_time_dateutil_eastern_timestamp( + self, util + ): + data = {"a": TZ_TIMESTAMPS["US/Central"]} + table = Table(pd.DataFrame(data)) + + os.environ["TZ"] = "US/Eastern" + time.tzset() + + # Should be in EST now + assert table.view().to_columns()["a"] == [ + util.to_timestamp( + d.astimezone(EST).replace(tzinfo=None).to_pydatetime() + ) + for d in data["a"] + ] + + def test_table_should_convert_EST_to_local_time_dateutil_GMT_timestamp( + self, util + ): + data = {"a": TZ_TIMESTAMPS["US/Eastern"]} + table = Table(pd.DataFrame(data)) + + os.environ["TZ"] = "GMT" + time.tzset() + + # Should be in GMT now + assert table.view().to_columns()["a"] == [ + util.to_timestamp( + d.astimezone(GMT).replace(tzinfo=None).to_pydatetime() + ) + for d in data["a"] + ] + + def test_table_should_convert_GMT_to_local_time_dateutil_HKT_timestamp( + self, util + ): + data = {"a": TZ_TIMESTAMPS["GMT"]} + table = Table(pd.DataFrame(data)) + + os.environ["TZ"] = "Asia/Hong_Kong" + time.tzset() + + assert table.view().to_columns()["a"] == [ + util.to_timestamp( + d.astimezone(HKT).replace(tzinfo=None).to_pydatetime() + ) + for d in data["a"] + ] + + def test_table_should_convert_HKT_to_local_time_dateutil_JPT_timestamp( + self, util + ): + data = {"a": TZ_TIMESTAMPS["Asia/Hong_Kong"]} + table = Table(pd.DataFrame(data)) + + os.environ["TZ"] = "Asia/Tokyo" + time.tzset() + + assert table.view().to_columns()["a"] == [ + util.to_timestamp( + d.astimezone(JPT).replace(tzinfo=None).to_pydatetime() + ) + for d in data["a"] + ] + + def test_table_should_convert_JPT_to_local_time_dateutil_ACT_timestamp( + self, util + ): + data = {"a": TZ_TIMESTAMPS["Asia/Tokyo"]} + table = Table(pd.DataFrame(data)) + + os.environ["TZ"] = "Australia/Sydney" + time.tzset() + + assert table.view().to_columns()["a"] == [ + util.to_timestamp( + d.astimezone(ACT).replace(tzinfo=None).to_pydatetime() + ) + for d in data["a"] + ] + + class TestTableDateTimeRowColumnPaths(object): + """Assert correctness of row and column paths in different timezones.""" + + def setup_method(self): + # To make sure that local times are not changed, set timezone to EST + os.environ["TZ"] = "US/Eastern" + time.tzset() + + def teardown_method(self): + # Set timezone to UTC, always + os.environ["TZ"] = "UTC" + time.tzset() + + @mark.skip(reason="Unsupported non-UTC timezone") + def test_table_group_by_datetime_row_path_local_time_EST(self, util: Util): + """Make sure that string datetimes generated in Python are in + local time and not UTC.""" + data = {"a": LOCAL_DATETIMES, "b": [i for i in range(len(LOCAL_DATETIMES))]} + + table = Table(data) + + view = table.view(group_by=["a"]) + assert view.to_columns() == { + "__ROW_PATH__": [ + [], + [util.to_timestamp(datetime(2019, 1, 11, 0, 10, 20))], + [util.to_timestamp(datetime(2019, 1, 11, 11, 10, 20))], + [util.to_timestamp(datetime(2019, 1, 11, 19, 10, 20))], + ], + "a": [3, 1, 1, 1], + "b": [3, 0, 1, 2], + } + + def test_table_group_by_datetime_row_path_UTC(self, util: Util): + """Make sure that string datetimes generated in Python are in + UTC if the timezone is UTC. + + Set the timezone before creating the table so that the local + datetime is in the intended timezone, as this test asserts that + paths in the same timezone are not edited to UTC.""" + os.environ["TZ"] = "UTC" + time.tzset() + + data = {"a": LOCAL_DATETIMES, "b": [i for i in range(len(LOCAL_DATETIMES))]} + + table = Table(data) + + view = table.view(group_by=["a"]) + assert view.to_columns() == { + "__ROW_PATH__": [ + [], + [util.to_timestamp(datetime(2019, 1, 11, 0, 10, 20))], + [util.to_timestamp(datetime(2019, 1, 11, 11, 10, 20))], + [util.to_timestamp(datetime(2019, 1, 11, 19, 10, 20))], + ], + "a": [3, 1, 1, 1], + "b": [3, 0, 1, 2], + } + + @mark.skip(reason="Unsupported non-UTC timezone") + def test_table_group_by_datetime_row_path_CST(self): + """Make sure that string datetimes generated in Python are in + CST if the timezone is CST.""" + os.environ["TZ"] = "US/Central" + time.tzset() + + data = {"a": LOCAL_DATETIMES, "b": [i for i in range(len(LOCAL_DATETIMES))]} + + table = Table(data) + + view = table.view(group_by=["a"]) + assert view.to_columns() == { + "__ROW_PATH__": [ + [], + [datetime(2019, 1, 11, 0, 10, 20)], + [datetime(2019, 1, 11, 11, 10, 20)], + [datetime(2019, 1, 11, 19, 10, 20)], + ], + "a": [3, 1, 1, 1], + "b": [3, 0, 1, 2], + } + + @mark.skip(reason="Unsupported non-UTC timezone") + def test_table_group_by_datetime_row_path_PST(self): + """Make sure that string datetimes generated in Python are in + CST if the timezone is CST.""" + os.environ["TZ"] = "US/Pacific" + time.tzset() + + data = {"a": LOCAL_DATETIMES, "b": [i for i in range(len(LOCAL_DATETIMES))]} + + table = Table(data) + + view = table.view(group_by=["a"]) + assert view.to_columns() == { + "__ROW_PATH__": [ + [], + [datetime(2019, 1, 11, 0, 10, 20)], + [datetime(2019, 1, 11, 11, 10, 20)], + [datetime(2019, 1, 11, 19, 10, 20)], + ], + "a": [3, 1, 1, 1], + "b": [3, 0, 1, 2], + } + + class TestTableDateTimeExpressions(object): + """Assert correctness of datetime-related expressions in + different timezones.""" + + def setup_method(self): + # To make sure that local times are not changed, set timezone to EST + os.environ["TZ"] = "US/Eastern" + time.tzset() + + def teardown_method(self): + # Set timezone to UTC, always + os.environ["TZ"] = "UTC" + time.tzset() + + @mark.skip(reason="Unsupported non-UTC timezone") + def test_table_now_in_EST(self, util): + data = {"a": LOCAL_DATETIMES} + + table = Table(data) + now = datetime.now() + view = table.view(expressions=["now()"]) + result = view.to_columns() + + for item in result["now()"]: + in_range = ( + now - timedelta(seconds=2) < item < now + timedelta(seconds=2) + ) + assert in_range is True + + @mark.skip(reason="Unsupported non-UTC timezone") + def test_table_now_in_CST(self, util): + os.environ["TZ"] = "US/Central" + time.tzset() + + data = {"a": LOCAL_DATETIMES} + + table = Table(data) + now = datetime.now() + view = table.view(expressions=["now()"]) + result = view.to_columns() + + for item in result["now()"]: + in_range = ( + now - timedelta(seconds=2) < item < now + timedelta(seconds=2) + ) + assert in_range is True + + @mark.skip(reason="Unsupported non-UTC timezone") + def test_table_now_in_PST(self, util): + os.environ["TZ"] = "US/Pacific" + time.tzset() + + data = {"a": LOCAL_DATETIMES} + + table = Table(data) + now = datetime.now() + view = table.view(expressions=["now()"]) + result = view.to_columns() + + for item in result["now()"]: + in_range = ( + now - timedelta(seconds=2) < item < now + timedelta(seconds=2) + ) + assert in_range is True + + @mark.skip(reason="Unsupported non-UTC timezone") + def test_table_hour_of_day_in_EST(self): + data = {"a": LOCAL_DATETIMES} + + table = Table(data) + view = table.view(expressions=['hour_of_day("a")']) + result = view.to_columns() + assert result['hour_of_day("a")'] == [0, 11, 19] + + @mark.skip(reason="Unsupported non-UTC timezone") + def test_table_hour_of_day_in_CST(self): + os.environ["TZ"] = "US/Central" + time.tzset() + + data = {"a": LOCAL_DATETIMES} + + table = Table(data) + view = table.view(expressions=['hour_of_day("a")']) + result = view.to_columns() + assert result['hour_of_day("a")'] == [0, 11, 19] + + @mark.skip(reason="Unsupported non-UTC timezone") + def test_table_hour_of_day_in_PST(self): + os.environ["TZ"] = "US/Pacific" + time.tzset() + + data = {"a": LOCAL_DATETIMES} + + table = Table(data) + view = table.view(expressions=['hour_of_day("a")']) + result = view.to_columns() + assert result['hour_of_day("a")'] == [0, 11, 19] + + def test_table_day_of_week_edge_in_EST(self): + """Naive datetimes parse as UTC and `day_of_week` is computed in + UTC, so the host timezone must not shift the result.""" + data = {"a": [datetime(2020, 1, 31, 23, 59)]} + + table = Table(data) + view = table.view(expressions=['day_of_week("a")']) + result = view.to_columns() + assert result['day_of_week("a")'] == ["6 Friday"] + + def test_table_day_of_week_edge_in_CST(self): + os.environ["TZ"] = "US/Central" + time.tzset() + + data = {"a": [datetime(2020, 1, 31, 23, 59)]} + + table = Table(data) + view = table.view(expressions=['day_of_week("a")']) + result = view.to_columns() + assert result['day_of_week("a")'] == ["6 Friday"] + + def test_table_day_of_week_edge_in_PST(self): + os.environ["TZ"] = "US/Pacific" + time.tzset() + + data = {"a": [datetime(2020, 1, 31, 23, 59)]} + + table = Table(data) + view = table.view(expressions=['day_of_week("a")']) + result = view.to_columns() + assert result['day_of_week("a")'] == ["6 Friday"] + + def test_table_month_of_year_edge_in_EST(self): + """Naive datetimes parse as UTC and `month_of_year` is computed in + UTC, so the host timezone must not shift the result.""" + data = {"a": [datetime(2020, 1, 31, 23, 59)]} + + table = Table(data) + view = table.view(expressions=['month_of_year("a")']) + result = view.to_columns() + assert result['month_of_year("a")'] == ["01 January"] + + def test_table_month_of_year_edge_in_CST(self): + os.environ["TZ"] = "US/Central" + time.tzset() + + data = {"a": [datetime(2020, 1, 31, 23, 59)]} + + table = Table(data) + view = table.view(expressions=['month_of_year("a")']) + result = view.to_columns() + assert result['month_of_year("a")'] == ["01 January"] + + def test_table_month_of_year_edge_in_PST(self): + os.environ["TZ"] = "US/Pacific" + time.tzset() + + data = {"a": [datetime(2020, 1, 31, 23, 59)]} + + table = Table(data) + view = table.view(expressions=['month_of_year("a")']) + result = view.to_columns() + assert result['month_of_year("a")'] == ["01 January"] + + def test_table_day_bucket_edge_in_EST(self, util): + """`day_bucket` is computed in UTC and the resulting `date` + serializes to UTC midnight, so the host timezone must not shift + the bucket or its serialized value.""" + data = {"a": [datetime(2020, 1, 31, 23, 59)]} + + table = Table(data) + view = table.view(expressions=["bucket(\"a\", 'D')"]) + result = view.to_columns() + assert result["bucket(\"a\", 'D')"] == [ + util.to_timestamp(date(2020, 1, 31)) + ] + + def test_table_day_bucket_edge_in_CST(self, util): + os.environ["TZ"] = "US/Central" + time.tzset() + + data = {"a": [datetime(2020, 1, 31, 23, 59)]} + + table = Table(data) + view = table.view(expressions=["bucket(\"a\", 'D')"]) + result = view.to_columns() + assert result["bucket(\"a\", 'D')"] == [ + util.to_timestamp(date(2020, 1, 31)) + ] + + def test_table_day_bucket_edge_in_PST(self, util): + os.environ["TZ"] = "US/Pacific" + time.tzset() + + data = {"a": [datetime(2020, 1, 31, 23, 59)]} + + table = Table(data) + view = table.view(expressions=["bucket(\"a\", 'D')"]) + result = view.to_columns() + assert result["bucket(\"a\", 'D')"] == [ + util.to_timestamp(date(2020, 1, 31)) + ] + + def test_table_week_bucket_edge_in_EST(self, util): + """`week_bucket` is computed in UTC and the resulting `date` + serializes to UTC midnight, so the host timezone must not shift + the bucket or its serialized value.""" + data = {"a": [datetime(2020, 2, 2, 23, 59)]} + + table = Table(data) + view = table.view(expressions=["bucket(\"a\", 'W')"]) + result = view.to_columns() + assert result["bucket(\"a\", 'W')"] == [ + util.to_timestamp(date(2020, 1, 27)) + ] + + def test_table_week_bucket_edge_in_CST(self, util): + os.environ["TZ"] = "US/Central" + time.tzset() + + data = {"a": [datetime(2020, 2, 2, 23, 59)]} + + table = Table(data) + view = table.view(expressions=["bucket(\"a\", 'W')"]) + result = view.to_columns() + assert result["bucket(\"a\", 'W')"] == [ + util.to_timestamp(date(2020, 1, 27)) + ] + + def test_table_week_bucket_edge_in_PST(self, util): + os.environ["TZ"] = "US/Pacific" + time.tzset() + + data = {"a": [datetime(2020, 2, 2, 23, 59)]} + + table = Table(data) + view = table.view(expressions=["bucket(\"a\", 'W')"]) + result = view.to_columns() + assert result["bucket(\"a\", 'W')"] == [ + util.to_timestamp(date(2020, 1, 27)) + ] + + def test_table_week_bucket_edge_flip_in_EST(self, util): + """Week bucket should flip backwards to last month.""" + data = {"a": [datetime(2020, 3, 1, 12, 59)]} + + table = Table(data) + view = table.view(expressions=["bucket(\"a\", 'W')"]) + result = view.to_columns() + assert result["bucket(\"a\", 'W')"] == [ + util.to_timestamp(date(2020, 2, 24)) + ] + + def test_table_week_bucket_edge_flip_in_CST(self, util): + os.environ["TZ"] = "US/Central" + time.tzset() + data = {"a": [datetime(2020, 3, 1, 12, 59)]} + + table = Table(data) + view = table.view(expressions=["bucket(\"a\", 'W')"]) + result = view.to_columns() + assert result["bucket(\"a\", 'W')"] == [ + util.to_timestamp(date(2020, 2, 24)) + ] + + def test_table_week_bucket_edge_flip_in_PST(self, util): + os.environ["TZ"] = "US/Pacific" + time.tzset() + data = {"a": [datetime(2020, 3, 1, 12, 59)]} + + table = Table(data) + view = table.view(expressions=["bucket(\"a\", 'W')"]) + result = view.to_columns() + assert result["bucket(\"a\", 'W')"] == [ + util.to_timestamp(date(2020, 2, 24)) + ] + + def test_table_month_bucket_edge_in_EST(self, util): + """`month_bucket` is computed in UTC and the resulting `date` + serializes to UTC midnight, so the host timezone must not shift + the bucket or its serialized value.""" + data = {"a": [datetime(2020, 6, 30, 23, 59)]} + + table = Table(data) + view = table.view(expressions=["bucket(\"a\", 'M')"]) + result = view.to_columns() + assert result["bucket(\"a\", 'M')"] == [ + util.to_timestamp(date(2020, 6, 1)) + ] + + def test_table_month_bucket_edge_in_CST(self, util): + os.environ["TZ"] = "US/Central" + time.tzset() + + data = {"a": [datetime(2020, 6, 30, 23, 59)]} + + table = Table(data) + view = table.view(expressions=["bucket(\"a\", 'M')"]) + result = view.to_columns() + assert result["bucket(\"a\", 'M')"] == [ + util.to_timestamp(date(2020, 6, 1)) + ] + + def test_table_month_bucket_edge_in_PST(self, util): + os.environ["TZ"] = "US/Pacific" + time.tzset() + + data = {"a": [datetime(2020, 6, 30, 23, 59)]} + + table = Table(data) + view = table.view(expressions=["bucket(\"a\", 'M')"]) + result = view.to_columns() + assert result["bucket(\"a\", 'M')"] == [ + util.to_timestamp(date(2020, 6, 1)) + ] + + def test_table_year_bucket_edge_in_EST(self, util): + """`year_bucket` is computed in UTC and the resulting `date` + serializes to UTC midnight, so the host timezone must not shift + the bucket or its serialized value.""" + data = {"a": [datetime(2019, 12, 31, 23, 59)]} + + table = Table(data) + view = table.view(expressions=["bucket(\"a\", 'Y')"]) + result = view.to_columns() + assert result["bucket(\"a\", 'Y')"] == [ + util.to_timestamp(date(2019, 1, 1)) + ] + + def test_table_year_bucket_edge_in_CST(self, util): + os.environ["TZ"] = "US/Central" + time.tzset() + data = {"a": [datetime(2019, 12, 31, 23, 59)]} + + table = Table(data) + view = table.view(expressions=["bucket(\"a\", 'Y')"]) + result = view.to_columns() + assert result["bucket(\"a\", 'Y')"] == [ + util.to_timestamp(date(2019, 1, 1)) + ] + + def test_table_year_bucket_edge_in_PST(self, util): + os.environ["TZ"] = "US/Pacific" + time.tzset() + data = {"a": [datetime(2019, 12, 31, 23, 59)]} + + table = Table(data) + view = table.view(expressions=["bucket(\"a\", 'Y')"]) + result = view.to_columns() + assert result["bucket(\"a\", 'Y')"] == [ + util.to_timestamp(date(2019, 1, 1)) + ] + + +class TestTableDateTimePivots(object): + def test_table_group_by_date_correct(self, util: Util): + data = { + "a": [date(2020, i, 15) for i in range(1, 13)], + "b": [i for i in range(1, 13)], + } + table = Table(data) + view = table.view(group_by=["a"]) + assert view.to_columns() == { + "__ROW_PATH__": [ + [], + [util.to_timestamp(date(2020, 1, 15))], + [util.to_timestamp(date(2020, 2, 15))], + [util.to_timestamp(date(2020, 3, 15))], + [util.to_timestamp(date(2020, 4, 15))], + [util.to_timestamp(date(2020, 5, 15))], + [util.to_timestamp(date(2020, 6, 15))], + [util.to_timestamp(date(2020, 7, 15))], + [util.to_timestamp(date(2020, 8, 15))], + [util.to_timestamp(date(2020, 9, 15))], + [util.to_timestamp(date(2020, 10, 15))], + [util.to_timestamp(date(2020, 11, 15))], + [util.to_timestamp(date(2020, 12, 15))], + ], + "a": [12, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + "b": [78, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + } + + def test_table_group_by_pandas_date_correct(self, util: Util): + data = { + "a": [date(2020, i, 15) for i in range(1, 13)], + "b": [i for i in range(1, 13)], + } + table = Table(pd.DataFrame(data)) + view = table.view(group_by=["a"]) + assert view.to_columns() == { + "__ROW_PATH__": [ + [], + [util.to_timestamp(date(2020, 1, 15))], + [util.to_timestamp(date(2020, 2, 15))], + [util.to_timestamp(date(2020, 3, 15))], + [util.to_timestamp(date(2020, 4, 15))], + [util.to_timestamp(date(2020, 5, 15))], + [util.to_timestamp(date(2020, 6, 15))], + [util.to_timestamp(date(2020, 7, 15))], + [util.to_timestamp(date(2020, 8, 15))], + [util.to_timestamp(date(2020, 9, 15))], + [util.to_timestamp(date(2020, 10, 15))], + [util.to_timestamp(date(2020, 11, 15))], + [util.to_timestamp(date(2020, 12, 15))], + ], + "index": [66, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + "a": [12, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + "b": [78, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + } + + def test_table_split_by_date_correct(self, util: Util): + data = { + "a": [date(2020, i, 15) for i in range(1, 13)], + "b": [i for i in range(1, 13)], + } + table = Table(data) + view = table.view(split_by=["a"]) + assert view.to_columns() == { + "2020-01-15|a": [ + util.to_timestamp(date(2020, 1, 15)), + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + ], + "2020-01-15|b": [ + 1, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + ], + "2020-02-15|a": [ + None, + util.to_timestamp(date(2020, 2, 15)), + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + ], + "2020-02-15|b": [ + None, + 2, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + ], + "2020-03-15|a": [ + None, + None, + util.to_timestamp(date(2020, 3, 15)), + None, + None, + None, + None, + None, + None, + None, + None, + None, + ], + "2020-03-15|b": [ + None, + None, + 3, + None, + None, + None, + None, + None, + None, + None, + None, + None, + ], + "2020-04-15|a": [ + None, + None, + None, + util.to_timestamp(date(2020, 4, 15)), + None, + None, + None, + None, + None, + None, + None, + None, + ], + "2020-04-15|b": [ + None, + None, + None, + 4, + None, + None, + None, + None, + None, + None, + None, + None, + ], + "2020-05-15|a": [ + None, + None, + None, + None, + util.to_timestamp(date(2020, 5, 15)), + None, + None, + None, + None, + None, + None, + None, + ], + "2020-05-15|b": [ + None, + None, + None, + None, + 5, + None, + None, + None, + None, + None, + None, + None, + ], + "2020-06-15|a": [ + None, + None, + None, + None, + None, + util.to_timestamp(date(2020, 6, 15)), + None, + None, + None, + None, + None, + None, + ], + "2020-06-15|b": [ + None, + None, + None, + None, + None, + 6, + None, + None, + None, + None, + None, + None, + ], + "2020-07-15|a": [ + None, + None, + None, + None, + None, + None, + util.to_timestamp(date(2020, 7, 15)), + None, + None, + None, + None, + None, + ], + "2020-07-15|b": [ + None, + None, + None, + None, + None, + None, + 7, + None, + None, + None, + None, + None, + ], + "2020-08-15|a": [ + None, + None, + None, + None, + None, + None, + None, + util.to_timestamp(date(2020, 8, 15)), + None, + None, + None, + None, + ], + "2020-08-15|b": [ + None, + None, + None, + None, + None, + None, + None, + 8, + None, + None, + None, + None, + ], + "2020-09-15|a": [ + None, + None, + None, + None, + None, + None, + None, + None, + util.to_timestamp(date(2020, 9, 15)), + None, + None, + None, + ], + "2020-09-15|b": [ + None, + None, + None, + None, + None, + None, + None, + None, + 9, + None, + None, + None, + ], + "2020-10-15|a": [ + None, + None, + None, + None, + None, + None, + None, + None, + None, + util.to_timestamp(date(2020, 10, 15)), + None, + None, + ], + "2020-10-15|b": [ + None, + None, + None, + None, + None, + None, + None, + None, + None, + 10, + None, + None, + ], + "2020-11-15|a": [ + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + util.to_timestamp(date(2020, 11, 15)), + None, + ], + "2020-11-15|b": [ + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + 11, + None, + ], + "2020-12-15|a": [ + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + util.to_timestamp(date(2020, 12, 15)), + ], + "2020-12-15|b": [ + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + 12, + ], + } + + def test_table_split_by_pandas_date_correct(self, util: Util): + data = { + "a": [date(2020, i, 15) for i in range(1, 13)], + "b": [i for i in range(1, 13)], + } + table = Table(pd.DataFrame(data)) + view = table.view(columns=["a", "b"], split_by=["a"]) + print(f"XXXX: {view.to_columns()}") + assert view.to_columns() == { + "2020-01-15|a": [ + util.to_timestamp(date(2020, 1, 15)), + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + ], + "2020-01-15|b": [ + 1, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + ], + "2020-02-15|a": [ + None, + util.to_timestamp(date(2020, 2, 15)), + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + ], + "2020-02-15|b": [ + None, + 2, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + ], + "2020-03-15|a": [ + None, + None, + util.to_timestamp(date(2020, 3, 15)), + None, + None, + None, + None, + None, + None, + None, + None, + None, + ], + "2020-03-15|b": [ + None, + None, + 3, + None, + None, + None, + None, + None, + None, + None, + None, + None, + ], + "2020-04-15|a": [ + None, + None, + None, + util.to_timestamp(date(2020, 4, 15)), + None, + None, + None, + None, + None, + None, + None, + None, + ], + "2020-04-15|b": [ + None, + None, + None, + 4, + None, + None, + None, + None, + None, + None, + None, + None, + ], + "2020-05-15|a": [ + None, + None, + None, + None, + util.to_timestamp(date(2020, 5, 15)), + None, + None, + None, + None, + None, + None, + None, + ], + "2020-05-15|b": [ + None, + None, + None, + None, + 5, + None, + None, + None, + None, + None, + None, + None, + ], + "2020-06-15|a": [ + None, + None, + None, + None, + None, + util.to_timestamp(date(2020, 6, 15)), + None, + None, + None, + None, + None, + None, + ], + "2020-06-15|b": [ + None, + None, + None, + None, + None, + 6, + None, + None, + None, + None, + None, + None, + ], + "2020-07-15|a": [ + None, + None, + None, + None, + None, + None, + util.to_timestamp(date(2020, 7, 15)), + None, + None, + None, + None, + None, + ], + "2020-07-15|b": [ + None, + None, + None, + None, + None, + None, + 7, + None, + None, + None, + None, + None, + ], + "2020-08-15|a": [ + None, + None, + None, + None, + None, + None, + None, + util.to_timestamp(date(2020, 8, 15)), + None, + None, + None, + None, + ], + "2020-08-15|b": [ + None, + None, + None, + None, + None, + None, + None, + 8, + None, + None, + None, + None, + ], + "2020-09-15|a": [ + None, + None, + None, + None, + None, + None, + None, + None, + util.to_timestamp(date(2020, 9, 15)), + None, + None, + None, + ], + "2020-09-15|b": [ + None, + None, + None, + None, + None, + None, + None, + None, + 9, + None, + None, + None, + ], + "2020-10-15|a": [ + None, + None, + None, + None, + None, + None, + None, + None, + None, + util.to_timestamp(date(2020, 10, 15)), + None, + None, + ], + "2020-10-15|b": [ + None, + None, + None, + None, + None, + None, + None, + None, + None, + 10, + None, + None, + ], + "2020-11-15|a": [ + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + util.to_timestamp(date(2020, 11, 15)), + None, + ], + "2020-11-15|b": [ + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + 11, + None, + ], + "2020-12-15|a": [ + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + util.to_timestamp(date(2020, 12, 15)), + ], + "2020-12-15|b": [ + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + 12, + ], + } + + +if hasattr(time, "tzset"): + + class TestTableDateTimeUTC(object): + """The engine is timezone-agnostic: `date` values are calendar days + that serialize to epoch ms at *UTC* midnight (matching Arrow `date32` + day arithmetic), naive datetime strings parse as UTC, and datetime + expression functions compute in UTC. None of these results may vary + with the host process timezone — the same table must serialize + identically on a server in Amsterdam, Tokyo, or New York, and a + UTC-pinned client renderer must recover the stored calendar day. + + https://github.com/perspective-dev/perspective/issues/3106 + """ + + def teardown_method(self): + os.environ["TZ"] = "UTC" + time.tzset() + + @mark.parametrize( + "timezone_name", + ["UTC", "Europe/Amsterdam", "Asia/Tokyo", "US/Pacific"], + ) + def test_table_date_serializes_to_utc_midnight(self, timezone_name): + """Issue #3106: under `mktime`-based serialization, an Amsterdam + host serialized 2024-01-01 as 2023-12-31T23:00:00Z, which a UTC + renderer displays as the previous day.""" + os.environ["TZ"] = timezone_name + time.tzset() + + table = Table({"a": "date"}) + table.update({"a": ["2024-01-01"]}) + assert table.view().to_columns()["a"] == [1704067200000] + + @mark.parametrize( + "timezone_name", + ["UTC", "Europe/Amsterdam", "Asia/Tokyo", "US/Pacific"], + ) + def test_table_date_formatted_string_is_calendar_day( + self, timezone_name + ): + """The formatted (stringified) output path must print the stored + calendar day, not the UTC rendering of a local-midnight + instant.""" + os.environ["TZ"] = timezone_name + time.tzset() + + table = Table({"a": "date"}) + table.update({"a": ["2024-01-01"]}) + assert ( + table.view().to_columns_string(formatted=True) + == '{"a":["2024-01-01"]}' + ) + + @mark.parametrize( + "timezone_name", + ["UTC", "Europe/Amsterdam", "Asia/Tokyo", "US/Pacific"], + ) + def test_table_date_epoch_ms_round_trips(self, timezone_name): + """Epoch-ms `date` input is UTC midnight, the same convention as + serialization; `localtime`-based parsing read a UTC date as the + previous day on hosts west of UTC.""" + os.environ["TZ"] = timezone_name + time.tzset() + + table = Table({"a": "date"}) + table.update({"a": [1704067200000]}) + assert table.view().to_columns()["a"] == [1704067200000] + + def test_table_date_group_by_row_path_is_utc_midnight(self, util): + os.environ["TZ"] = "Europe/Amsterdam" + time.tzset() + + table = Table({"a": [date(2024, 1, 1)], "b": [1]}) + view = table.view(group_by=["a"]) + assert view.to_columns()["__ROW_PATH__"] == [ + [], + [util.to_timestamp(date(2024, 1, 1))], + ] + + def test_table_day_bucket_edge_in_JST(self, util): + """23:59 UTC is already the next day in Asia/Tokyo — the bucket + must stay on the UTC calendar day.""" + os.environ["TZ"] = "Asia/Tokyo" + time.tzset() + + data = {"a": [datetime(2020, 1, 31, 23, 59)]} + table = Table(data) + view = table.view(expressions=["bucket(\"a\", 'D')"]) + result = view.to_columns() + assert result["bucket(\"a\", 'D')"] == [ + util.to_timestamp(date(2020, 1, 31)) + ] + + def test_table_day_of_week_edge_in_JST(self): + os.environ["TZ"] = "Asia/Tokyo" + time.tzset() + + data = {"a": [datetime(2020, 1, 31, 23, 59)]} + table = Table(data) + view = table.view(expressions=['day_of_week("a")']) + result = view.to_columns() + assert result['day_of_week("a")'] == ["6 Friday"] + + def test_table_month_of_year_edge_in_JST(self): + os.environ["TZ"] = "Asia/Tokyo" + time.tzset() + + data = {"a": [datetime(2020, 1, 31, 23, 59)]} + table = Table(data) + view = table.view(expressions=['month_of_year("a")']) + result = view.to_columns() + assert result['month_of_year("a")'] == ["01 January"] + + def test_table_hour_of_day_edge_in_JST(self): + os.environ["TZ"] = "Asia/Tokyo" + time.tzset() + + data = {"a": [datetime(2020, 1, 31, 23, 59)]} + table = Table(data) + view = table.view(expressions=['hour_of_day("a")']) + result = view.to_columns() + assert result['hour_of_day("a")'] == [23] + + def test_table_non_iso_datetime_string_parses_as_utc(self, util): + """The non-ISO fallback datetime parser must also interpret naive + strings as UTC, as documented — `mktime`-based parsing applied + the host timezone.""" + os.environ["TZ"] = "Europe/Amsterdam" + time.tzset() + + table = Table({"a": "datetime"}) + table.update({"a": ["01/31/2020 23:59"]}) + assert table.view().to_columns()["a"] == [ + util.to_timestamp(UTC.localize(datetime(2020, 1, 31, 23, 59))) + ] diff --git a/rust/perspective-python/perspective/tests/table/test_table_infer.py b/rust/perspective-python/perspective/tests/table/test_table_infer.py new file mode 100644 index 0000000..59356b1 --- /dev/null +++ b/rust/perspective-python/perspective/tests/table/test_table_infer.py @@ -0,0 +1,201 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 datetime import date, datetime +from pytest import mark +import perspective as psp + +client = psp.Server().new_local_client() +Table = client.table + + +class TestTableInfer(object): + def test_table_infer_int(self): + data = {"a": [None, None, None, None, 1, 0, 1, 1, 1]} + tbl = Table(data) + assert tbl.schema() == {"a": "integer"} + + def test_table_infer_float(self): + data = {"a": [None, None, None, None, 1.0, 2.0]} + tbl = Table(data) + assert tbl.schema() == {"a": "float"} + + def test_table_infer_bool(self): + bool_data = [{"a": True, "b": False}, {"a": True, "b": True}] + tbl = Table(bool_data) + assert tbl.size() == 2 + assert tbl.schema() == {"a": "boolean", "b": "boolean"} + + def test_table_infer_bool_str(self): + bool_data = [{"a": "True", "b": "False"}, {"a": "True", "b": "True"}] + tbl = Table(bool_data) + assert tbl.size() == 2 + assert tbl.schema() == {"a": "boolean", "b": "boolean"} + + @mark.skip(reason="Unsupported python specific behavior") + def test_table_bool_infer_str_all_formats_from_schema(self): + bool_data = [ + {"a": "True", "b": "False"}, + {"a": "t", "b": "f"}, + {"a": "true", "b": "false"}, + {"a": 1, "b": 0}, + {"a": "on", "b": "off"}, + ] + tbl = Table(bool_data) + assert tbl.schema() == {"a": "boolean", "b": "boolean"} + assert tbl.size() == 5 + assert tbl.view().to_columns() == { + "a": [True, True, True, True, True], + "b": [False, False, False, False, False], + } + + def test_table_infer_bool_variant(self): + data = {"a": [None, None, None, None, True, True, True]} + tbl = Table(data) + assert tbl.schema() == {"a": "boolean"} + + def test_table_infer_str(self): + data = {"a": [None, None, None, None, None, None, "abc"]} + tbl = Table(data) + assert tbl.schema() == {"a": "string"} + + @mark.skip(reason="Time is not a valid JSON type") + def test_table_infer_time_as_string(self): + # time objects are inferred as string + data = { + "a": [ + None, + None, + None, + None, + None, + None, + datetime(2019, 7, 11, 12, 30, 5).time(), + ] + } + tbl = Table(data) + assert tbl.schema() == {"a": "string"} + + @mark.skip(reason="Unsupported python specific behavior") + def test_table_infer_date_from_datetime(self): + # inferrence on non-pandas datasets defaults to datetime + data = {"a": [None, None, None, None, None, None, datetime(2019, 7, 11)]} + tbl = Table(data) + assert tbl.schema() == {"a": "datetime"} + + def test_table_infer_date_from_date(self): + # pass in a `date` to make sure it infers as date + data = {"a": [None, None, None, None, None, None, date(2019, 7, 11)]} + tbl = Table(data) + assert tbl.schema() == {"a": "date"} + + def test_table_infer_valid_date(self): + data = {"a": [None, None, None, None, None, None, "08/31/2019"]} + tbl = Table(data) + assert tbl.schema() == {"a": "date"} + + def test_table_infer_ambiguous_date(self): + data = {"a": [None, None, None, None, None, None, "01/03/2019"]} + tbl = Table(data) + assert tbl.schema() == {"a": "date"} + + def test_table_infer_ymd_date(self): + data = {"a": [None, None, None, None, None, None, "2019/01/03"]} + tbl = Table(data) + assert tbl.schema() == {"a": "date"} + + def test_table_infer_invalid_date(self): + data = {"a": [None, None, None, None, None, None, "08/55/2019"]} + tbl = Table(data) + assert tbl.schema() == {"a": "string"} + + def test_table_infer_date_edge(self): + data = {"a": [None, None, None, None, None, None, "08/31/2019 00:00:00"]} + tbl = Table(data) + assert tbl.schema() == {"a": "date"} + + def test_table_infer_datetime_edge(self): + data = {"a": [None, None, None, None, None, None, "08/31/2019 00:00:01"]} + tbl = Table(data) + assert tbl.schema() == {"a": "datetime"} + + def test_table_infer_valid_datetime(self): + data = {"a": [None, None, None, None, None, None, "08/31/2019 07:30:00"]} + tbl = Table(data) + assert tbl.schema() == {"a": "datetime"} + + def test_table_infer_iso_datetime(self): + data = {"a": [None, None, None, None, None, None, "2019/07/25T09:00:00"]} + tbl = Table(data) + assert tbl.schema() == {"a": "datetime"} + + def test_table_infer_datetime_separators(self): + data = { + "a": [ + None, + None, + None, + None, + None, + "2019-07-25T09:00:00", + "2019/07/25T09:00:00", + ] + } + tbl = Table(data) + assert tbl.schema() == {"a": "datetime"} + + def test_table_infer_datetime_tz(self): + data = {"a": [None, None, None, None, None, "2019-07-25T09:00:00-05:00"]} + tbl = Table(data) + assert tbl.schema() == {"a": "datetime"} + + def test_table_infer_invalid_datetime(self): + data = {"a": [None, None, None, None, None, None, "08/31/2019 25:30:00"]} + tbl = Table(data) + assert tbl.schema() == {"a": "string"} + + def test_table_infer_mixed_date(self): + data = {"a": [None, None, None, None, None, "08/11/2019"]} + tbl = Table(data) + assert tbl.schema() == {"a": "date"} + + def test_table_infer_mixed_datetime(self): + data = {"a": [None, None, None, None, None, "08/11/2019 13:14:15"]} + tbl = Table(data) + assert tbl.schema() == {"a": "datetime"} + + @mark.skip(reason="Numeric strings are not inferred as numbers") + def test_table_strict_datetime_infer(self): + data = {"a": ["10", "9", "8", "7", "6", "5", "4", "3", "2", "1"]} + tbl = Table(data) + assert tbl.schema() == {"a": "string"} + + def test_table_strict_date_infer(self): + data = {"a": ["2019 09 10"]} + tbl = Table(data) + assert tbl.schema() == {"a": "date"} + + def test_table_strict_datetime_separator_infer(self): + data = {"a": ["2019-10-01 7:30"]} + tbl = Table(data) + assert tbl.schema() == {"a": "datetime"} + + def test_table_datetime_infer_no_false_positive(self): + data = {"a": [" . - / but clearly not a date"]} + tbl = Table(data) + assert tbl.schema() == {"a": "string"} + + @mark.skip + def test_table_datetime_infer_from_string_with_time(self): + data = {"a": ["11:00 ABCD"]} + tbl = Table(data) + assert tbl.schema() == {"a": "string"} diff --git a/rust/perspective-python/perspective/tests/table/test_table_limit.py b/rust/perspective-python/perspective/tests/table/test_table_limit.py new file mode 100644 index 0000000..c1bfd6b --- /dev/null +++ b/rust/perspective-python/perspective/tests/table/test_table_limit.py @@ -0,0 +1,45 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 as psp + +client = psp.Server().new_local_client() +Table = client.table + + +class TestTableInfer(object): + def test_table_limit_wraparound_does_not_respect_partial_none(self): + t = Table({"a": "float", "b": "float"}, limit=3) + t.update([{"a": 10}, {"b": 1}, {"a": 20}, {"a": None, "b": 2}]) + d1 = t.view().to_json() + + t2 = Table({"a": "float", "b": "float"}, limit=3) + t2.update([{"a": 10}, {"b": 1}, {"a": 20}, {"b": 2}]) + d2 = t2.view().to_json() + + assert d1[0] == d2[0] + assert d1[1:] == d2[1:] + + def test_table_limit_wraparound_does_not_respect_partial(self): + t = Table({"a": "float", "b": "float"}, limit=3) + t.update([{"a": 10}, {"b": 1}, {"a": 20}, {"a": 10, "b": 2}]) + d1 = t.view().to_columns() + + t2 = Table({"a": "float", "b": "float"}, limit=3) + t2.update([{"a": 10}, {"b": 1}, {"a": 20}, {"b": 2}]) + d2 = t2.view().to_columns() + + assert d1 != d2 + + def test_table_limit_with_json(self): + t = Table({"a": [1, 2, 3]}, limit=1) + assert t.size() == 1 diff --git a/rust/perspective-python/perspective/tests/table/test_table_numpy.py b/rust/perspective-python/perspective/tests/table/test_table_numpy.py new file mode 100644 index 0000000..dfa3b40 --- /dev/null +++ b/rust/perspective-python/perspective/tests/table/test_table_numpy.py @@ -0,0 +1,1022 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 datetime import date, datetime +import numpy as np +import pandas as pd +from pytest import raises, mark +import perspective as psp + +client = psp.Server().new_local_client() +Table = client.table + + +@mark.skip(reason="We do not support numpy types in the Table constructor") +class TestTableNumpy(object): + def test_empty_table(self): + tbl = Table([]) + assert tbl.size() == 0 + + def test_table_int(self): + data = {"a": np.array([1, 2, 3]), "b": np.array([4, 5, 6])} + tbl = Table(data) + assert tbl.size() == 3 + assert tbl.view().to_columns() == {"a": [1, 2, 3], "b": [4, 5, 6]} + + def test_table_int_lots_of_columns(self): + data = { + "a": np.array([1, 2, 3]), + "b": np.array([4, 5, 6]), + "c": np.array([4, 5, 6]), + "d": np.array([4, 5, 6]), + "e": np.array([4, 5, 6]), + "f": np.array([4, 5, 6]), + } + tbl = Table(data) + assert tbl.size() == 3 + assert tbl.view().to_columns() == { + "a": [1, 2, 3], + "b": [4, 5, 6], + "c": [4, 5, 6], + "d": [4, 5, 6], + "e": [4, 5, 6], + "f": [4, 5, 6], + } + + def test_table_int_with_None(self): + data = { + "a": np.array([1, 2, 3, None, None]), + "b": np.array([4, 5, 6, None, None]), + } + tbl = Table(data) + assert tbl.size() == 5 + assert tbl.view().to_columns() == { + "a": [1, 2, 3, None, None], + "b": [4, 5, 6, None, None], + } + + def test_table_int8(self): + data = { + "a": np.array([1, 2, 3]).astype(np.int8), + "b": np.array([4, 5, 6]).astype(np.int8), + } + tbl = Table(data) + assert tbl.size() == 3 + assert tbl.view().to_columns() == {"a": [1, 2, 3], "b": [4, 5, 6]} + + def test_table_int16(self): + data = { + "a": np.array([1, 2, 3]).astype(np.int16), + "b": np.array([4, 5, 6]).astype(np.int16), + } + tbl = Table(data) + assert tbl.size() == 3 + assert tbl.view().to_columns() == {"a": [1, 2, 3], "b": [4, 5, 6]} + + def test_table_int32(self): + data = { + "a": np.array([1, 2, 3]).astype(np.int32), + "b": np.array([4, 5, 6]).astype(np.int32), + } + tbl = Table(data) + assert tbl.size() == 3 + assert tbl.view().to_columns() == {"a": [1, 2, 3], "b": [4, 5, 6]} + + def test_table_int64(self): + data = { + "a": np.array([1, 2, 3]).astype(np.int64), + "b": np.array([4, 5, 6]).astype(np.int64), + } + tbl = Table(data) + assert tbl.size() == 3 + assert tbl.view().to_columns() == {"a": [1, 2, 3], "b": [4, 5, 6]} + + def test_table_float(self): + data = {"a": np.array([1.1, 2.2]), "b": np.array([3.3, 4.4])} + tbl = Table(data) + assert tbl.size() == 2 + assert tbl.view().to_columns() == {"a": [1.1, 2.2], "b": [3.3, 4.4]} + + def test_table_float32(self): + data = { + "a": np.array([1.1, 2.2]).astype(np.float32), + "b": np.array([3.3, 4.4]).astype(np.float32), + } + tbl = Table(data) + assert tbl.size() == 2 + assert tbl.view().to_columns() == { + # py::cast automatically upcasts to 64-bit float + "a": [1.100000023841858, 2.200000047683716], + "b": [3.299999952316284, 4.400000095367432], + } + + def test_table_float64(self): + data = { + "a": np.array([1.1, 2.2]).astype(np.float64), + "b": np.array([3.3, 4.4]).astype(np.float64), + } + tbl = Table(data) + assert tbl.size() == 2 + assert tbl.view().to_columns() == {"a": [1.1, 2.2], "b": [3.3, 4.4]} + + # booleans + + def test_table_bool(self): + data = {"a": np.array([True, False]), "b": np.array([False, True])} + tbl = Table(data) + assert tbl.size() == 2 + assert tbl.view().to_columns() == {"a": [True, False], "b": [False, True]} + + def test_table_bool8(self): + data = { + "a": np.array([True, False]).astype(np.bool8), + "b": np.array([False, True]).astype(np.bool8), + } + tbl = Table(data) + assert tbl.size() == 2 + assert tbl.view().to_columns() == {"a": [True, False], "b": [False, True]} + + def test_table_bool_with_none(self): + data = { + "a": np.array([True, False, None, False]), + "b": np.array([False, True, None, False]), + } + tbl = Table(data) + assert tbl.size() == 4 + assert tbl.view().to_columns() == { + "a": [True, False, None, False], + "b": [False, True, None, False], + } + + def test_table_bool_with_dtype(self): + data = { + "a": np.array([True, False, False], dtype="?"), + "b": np.array([False, True, False], dtype="?"), + } + tbl = Table(data) + assert tbl.size() == 3 + assert tbl.view().to_columns() == { + "a": [True, False, False], + "b": [False, True, False], + } + + def test_table_bool_str(self): + data = {"a": np.array(["True", "False"]), "b": np.array(["False", "True"])} + tbl = Table(data) + assert tbl.size() == 2 + assert tbl.schema() == {"a": bool, "b": "boolean"} + assert tbl.view().to_columns() == {"a": [True, False], "b": [False, True]} + + # strings + + def test_table_str_object(self): + data = { + "a": np.array(["abc", "def"], dtype=object), + "b": np.array(["hij", "klm"], dtype=object), + } + tbl = Table(data) + assert tbl.size() == 2 + assert tbl.view().to_columns() == {"a": ["abc", "def"], "b": ["hij", "klm"]} + + def test_table_str_dtype(self): + dtype = "U3" + data = { + "a": np.array(["abc", "def"], dtype=dtype), + "b": np.array(["hij", "klm"], dtype=dtype), + } + tbl = Table(data) + assert tbl.size() == 2 + assert tbl.view().to_columns() == {"a": ["abc", "def"], "b": ["hij", "klm"]} + + # date and datetime + + def test_table_date(self): + data = {"a": np.array([date(2019, 7, 11)]), "b": np.array([date(2019, 7, 12)])} + tbl = Table(data) + assert tbl.size() == 1 + assert tbl.schema() == {"a": date, "b": "date"} + assert tbl.view().to_columns() == { + "a": [datetime(2019, 7, 11)], + "b": [datetime(2019, 7, 12)], + } + + def test_table_np_datetime(self): + data = { + "a": np.array([datetime(2019, 7, 11, 12, 13)], dtype="datetime64[ns]"), + "b": np.array([datetime(2019, 7, 11, 12, 14)], dtype="datetime64[ns]"), + } + tbl = Table(data) + assert tbl.size() == 1 + assert tbl.schema() == {"a": datetime, "b": "datetime"} + assert tbl.view().to_numpy() == { + "a": np.array([datetime(2019, 7, 11, 12, 13)], dtype=object), + "b": np.array([datetime(2019, 7, 11, 12, 14)], dtype=object), + } + + def test_table_np_datetime_mixed_dtype(self): + data = { + "a": np.array([datetime(2019, 7, 11, 12, 13)], dtype="datetime64[ns]"), + "b": np.array([datetime(2019, 7, 11, 12, 14)], dtype=object), + } + tbl = Table(data) + assert tbl.size() == 1 + assert tbl.schema() == {"a": datetime, "b": "datetime"} + assert tbl.view().to_numpy() == { + "a": np.array([datetime(2019, 7, 11, 12, 13)], dtype=object), + "b": np.array([datetime(2019, 7, 11, 12, 14)], dtype=object), + } + + def test_table_np_datetime_default(self, util): + tbl = Table( + {"a": np.array([datetime(2019, 7, 12, 11, 0)], dtype="datetime64[ns]")} + ) + + assert tbl.view().to_columns() == { + "a": [util.to_timestamp(datetime(2019, 7, 12, 11, 0))] + } + + def test_table_np_datetime_string_dtype(self): + data = ["2019/07/11 15:30:05", "2019/07/11 15:30:05"] + tbl = Table({"a": np.array(data)}) + + assert tbl.view().to_columns() == { + "a": [datetime(2019, 7, 11, 15, 30, 5), datetime(2019, 7, 11, 15, 30, 5)] + } + + def test_table_np_datetime_string_on_schema(self): + data = ["2019/07/11 15:30:05", "2019/07/11 15:30:05"] + tbl = Table({"a": "datetime"}) + + tbl.update({"a": data}) + + assert tbl.view().to_columns() == { + "a": [datetime(2019, 7, 11, 15, 30, 5), datetime(2019, 7, 11, 15, 30, 5)] + } + + def test_table_np_datetime_ns(self, util): + tbl = Table( + {"a": np.array([datetime(2019, 7, 12, 11, 0)], dtype="datetime64[ns]")} + ) + + assert tbl.view().to_columns() == { + "a": [util.to_timestamp(datetime(2019, 7, 12, 11, 0))] + } + + def test_table_np_datetime_us(self, util): + tbl = Table( + {"a": np.array([datetime(2019, 7, 12, 11, 0)], dtype="datetime64[us]")} + ) + + assert tbl.view().to_columns() == { + "a": [util.to_timestamp(datetime(2019, 7, 12, 11, 0))] + } + + def test_table_np_datetime_ms(self, util): + tbl = Table( + {"a": np.array([datetime(2019, 7, 12, 11, 0)], dtype="datetime64[ms]")} + ) + + assert tbl.view().to_columns() == { + "a": [util.to_timestamp(datetime(2019, 7, 12, 11, 0))] + } + + def test_table_np_datetime_s(self, util): + tbl = Table( + {"a": np.array([datetime(2019, 7, 12, 11, 0)], dtype="datetime64[s]")} + ) + + assert tbl.view().to_columns() == { + "a": [util.to_timestamp(datetime(2019, 7, 12, 11, 0))] + } + + def test_table_np_datetime_m(self, util): + tbl = Table( + {"a": np.array([datetime(2019, 7, 12, 11, 0)], dtype="datetime64[m]")} + ) + + assert tbl.view().to_columns() == { + "a": [util.to_timestamp(datetime(2019, 7, 12, 11, 0))] + } + + def test_table_np_datetime_h(self, util): + tbl = Table( + {"a": np.array([datetime(2019, 7, 12, 11, 0)], dtype="datetime64[h]")} + ) + + assert tbl.view().to_columns() == { + "a": [util.to_timestamp(datetime(2019, 7, 12, 11, 0))] + } + + def test_table_np_datetime_D(self, util): + tbl = Table( + {"a": np.array([datetime(2019, 7, 12, 11, 0)], dtype="datetime64[D]")} + ) + + assert tbl.schema() == {"a": "date"} + + assert tbl.view().to_columns() == { + "a": [util.to_timestamp(datetime(2019, 7, 12, 0, 0))] + } + + def test_table_np_datetime_W(self, util): + tbl = Table( + {"a": np.array([datetime(2019, 7, 12, 11, 0)], dtype="datetime64[W]")} + ) + + assert tbl.schema() == {"a": "date"} + + assert tbl.view().to_columns() == { + "a": [util.to_timestamp(datetime(2019, 7, 11, 0, 0))] + } + + def test_table_np_datetime_M(self): + tbl = Table( + { + "a": np.array( + [ + datetime(2019, 5, 12, 11, 0), + datetime(2019, 6, 12, 11, 0), + datetime(2019, 7, 12, 11, 0), + ], + dtype="datetime64[M]", + ) + } + ) + + assert tbl.schema() == {"a": "date"} + + assert tbl.view().to_columns() == { + "a": [ + datetime(2019, 5, 1, 0, 0), + datetime(2019, 6, 1, 0, 0), + datetime(2019, 7, 1, 0, 0), + ] + } + + def test_table_np_datetime_Y(self): + tbl = Table( + { + "a": np.array( + [ + datetime(2017, 5, 12, 11, 0), + datetime(2018, 6, 12, 11, 0), + datetime(2019, 7, 12, 11, 0), + ], + dtype="datetime64[Y]", + ) + } + ) + + assert tbl.schema() == {"a": "date"} + + assert tbl.view().to_columns() == { + "a": [ + datetime(2017, 1, 1, 0, 0), + datetime(2018, 1, 1, 0, 0), + datetime(2019, 1, 1, 0, 0), + ] + } + + def test_table_np_datetime_ms_nat(self, util): + tbl = Table( + { + "a": np.array( + [datetime(2019, 7, 12, 11, 0), np.datetime64("nat")], + dtype="datetime64[ms]", + ) + } + ) + + assert tbl.view().to_columns() == { + "a": [util.to_timestamp(datetime(2019, 7, 12, 11, 0)), None] + } + + def test_table_np_datetime_s_nat(self, util): + tbl = Table( + { + "a": np.array( + [datetime(2019, 7, 12, 11, 0), np.datetime64("nat")], + dtype="datetime64[s]", + ) + } + ) + + assert tbl.view().to_columns() == { + "a": [util.to_timestamp(datetime(2019, 7, 12, 11, 0)), None] + } + + def test_table_np_timedelta(self): + tbl = Table( + { + "a": np.array([datetime(2019, 7, 12, 11, 0)], dtype="datetime64[ns]") + - np.array([datetime(2019, 7, 1, 11, 0)], dtype="datetime64[ns]") + } + ) + + assert tbl.schema() == {"a": "string"} + + assert tbl.view().to_columns() == {"a": ["950400000000000 nanoseconds"]} + + def test_table_np_timedelta_us(self): + tbl = Table( + { + "a": np.array([datetime(2019, 7, 12, 11, 0)], dtype="datetime64[us]") + - np.array([datetime(2019, 7, 1, 11, 0)], dtype="datetime64[us]") + } + ) + + assert tbl.schema() == {"a": "string"} + + assert tbl.view().to_columns() == {"a": ["950400000000 microseconds"]} + + def test_table_np_timedelta_ms(self): + tbl = Table( + { + "a": np.array([datetime(2019, 7, 12, 11, 0)], dtype="datetime64[ms]") + - np.array([datetime(2019, 7, 1, 11, 0)], dtype="datetime64[ms]") + } + ) + + assert tbl.schema() == {"a": "string"} + + assert tbl.view().to_columns() == {"a": ["950400000 milliseconds"]} + + def test_table_np_timedelta_s(self): + tbl = Table( + { + "a": np.array([datetime(2019, 7, 12, 11, 0)], dtype="datetime64[s]") + - np.array([datetime(2019, 7, 1, 11, 0)], dtype="datetime64[s]") + } + ) + + assert tbl.schema() == {"a": "string"} + + assert tbl.view().to_columns() == {"a": ["950400 seconds"]} + + def test_table_np_timedelta_m(self): + tbl = Table( + { + "a": np.array([datetime(2019, 7, 12, 11, 0)], dtype="datetime64[m]") + - np.array([datetime(2019, 7, 1, 11, 0)], dtype="datetime64[m]") + } + ) + + assert tbl.schema() == {"a": "string"} + + assert tbl.view().to_columns() == {"a": ["15840 minutes"]} + + def test_table_np_timedelta_h(self): + tbl = Table( + { + "a": np.array([datetime(2019, 7, 12, 11, 0)], dtype="datetime64[h]") + - np.array([datetime(2019, 7, 1, 11, 0)], dtype="datetime64[h]") + } + ) + + assert tbl.schema() == {"a": "string"} + + assert tbl.view().to_columns() == {"a": ["264 hours"]} + + def test_table_np_timedelta_d(self): + tbl = Table( + { + "a": np.array([datetime(2019, 7, 12, 11, 0)], dtype="datetime64[D]") + - np.array([datetime(2019, 7, 1, 11, 0)], dtype="datetime64[D]") + } + ) + + assert tbl.schema() == {"a": "string"} + + assert tbl.view().to_columns() == {"a": ["11 days"]} + + def test_table_np_timedelta_with_none(self): + tbl = Table( + { + "a": np.array( + [None, datetime(2019, 7, 12, 11, 0)], dtype="datetime64[ns]" + ) + - np.array([datetime(2019, 7, 1, 11, 0), None], dtype="datetime64[ns]") + } + ) + + assert tbl.schema() == {"a": "string"} + + assert tbl.view().to_columns() == {"a": [None, None]} # two `NaT` values + + def test_table_np_mixed(self): + data = { + "a": np.arange(5), + "b": np.full(5, np.nan), + "c": ["a", "b", "c", "d", "e"], + } + + # should not be able to parse mixed dicts of numpy array with list + with raises(psp.PerspectiveError): + Table(data) + + def test_table_np_promote(self): + data = { + "a": np.arange(5), + "b": np.full(5, np.nan), + "c": np.array([1, 2, 3, 2147483648, 5]), + } + tbl = Table({"a": "integer", "b": "float", "c": "integer"}) + tbl.update(data) + assert tbl.size() == 5 + assert tbl.schema() == {"a": "integer", "b": "float", "c": "integer"} + + assert tbl.view().to_columns() == { + "a": [0, 1, 2, 3, 4], + "b": [None, None, None, None, None], + "c": [1.0, 2.0, 3.0, 2147483648.0, 5.0], + } + + def test_table_np_promote_to_string(self): + data = { + "a": np.arange(4), + "b": np.array([1, 2, "abc", "abc"]), + } + tbl = Table(data) + assert tbl.size() == 4 + assert tbl.schema() == { + "a": "integer", + "b": "string", + } + + assert tbl.view().to_columns() == { + "a": [0, 1, 2, 3], + "b": ["1", "2", "abc", "abc"], + } + + def test_table_np_implicit_index(self): + data = { + "a": np.array(["a", "b", "c", "d", "e"]), + "b": np.array([1, 2, 3, 4, 5]), + } + tbl = Table(data) + assert tbl.size() == 5 + assert tbl.schema() == {"a": "string", "b": "integer"} + tbl.update( + { + "__INDEX__": np.array([1, 2, 3, 4]), + "a": np.array(["bb", "cc", "dd", "ee"]), + } + ) + + assert tbl.view().to_columns() == { + "a": ["a", "bb", "cc", "dd", "ee"], + "b": [1, 2, 3, 4, 5], + } + + # from schema + + def test_table_numpy_from_schema_int(self): + df = {"a": np.array([1, None, 2, None, 3, 4])} + table = Table({"a": "integer"}) + table.update(df) + assert table.view().to_columns()["a"] == [1, None, 2, None, 3, 4] + + def test_table_numpy_from_schema_bool(self): + data = [True, False, True, False] + df = {"a": data} + table = Table({"a": "boolean"}) + table.update(df) + assert table.view().to_columns()["a"] == data + + def test_table_numpy_from_schema_float(self): + data = [1.5, None, 2.5, None, 3.5, 4.5] + df = {"a": np.array(data)} + table = Table({"a": "float"}) + table.update(df) + assert table.view().to_columns()["a"] == data + + def test_table_numpy_from_schema_float_all_nan(self): + data = [np.nan, np.nan, np.nan, np.nan] + df = {"a": np.array(data)} + table = Table({"a": "float"}) + table.update(df) + assert table.view().to_columns()["a"] == [None, None, None, None] + + def test_table_numpy_from_schema_float_to_int(self): + data = [None, 1.5, None, 2.5, None, 3.5, 4.5] + df = {"a": np.array(data)} + table = Table({"a": "integer"}) + table.update(df) + # truncates decimal + assert table.view().to_columns()["a"] == [None, 1, None, 2, None, 3, 4] + + def test_table_numpy_from_schema_float_to_int_with_nan(self): + df = {"a": np.array([np.nan, 1.5, np.nan, 2.5, np.nan, 3.5, 4.5])} + table = Table({"a": "integer"}) + table.update(df) + # truncates decimal + assert table.view().to_columns()["a"] == [None, 1, None, 2, None, 3, 4] + + def test_table_numpy_from_schema_float_to_int_with_nan_partial(self): + df = {"a": np.array([np.nan, 1.5, np.nan, 2.5, np.nan, 3.5, 4.5])} + table = Table({"a": "integer", "b": "integer"}) + table.update(df) + assert table.size() == 7 + # truncates decimal + assert table.view().to_columns() == { + "a": [None, 1, None, 2, None, 3, 4], + "b": [None, None, None, None, None, None, None], + } + + def test_table_numpy_from_schema_float_to_int_with_nan_partial_indexed(self): + """Assert that the null masking works even when primary keys + are being reordered.""" + df = { + "a": np.array([np.nan, 1.5, np.nan, 2.5, np.nan, 3.5, 4.5]), + "b": np.array([1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5]), + } + table = Table({"a": "integer", "b": "integer"}, index="b") + table.update(df) + + # truncates decimal + assert table.view().to_columns() == { + "a": [None, 1, None, 2, None, 3, 4], + "b": [1, 2, 3, 4, 5, 6, 7], + } + + table.update(pd.DataFrame({"a": [10, 9, 8], "b": [2, 3, 5]})) + + assert table.view().to_columns() == { + "a": [None, 10, 9, 2, 8, 3, 4], + "b": [1, 2, 3, 4, 5, 6, 7], + } + + table.update( + { + "a": np.array([100, np.nan], dtype=np.float64), + "b": np.array([-1, 6], dtype=np.float64), + } + ) + + assert table.view().to_columns() == { + "a": [100, None, 10, 9, 2, 8, None, 4], + "b": [-1, 1, 2, 3, 4, 5, 6, 7], + } + + table.update( + { + "a": np.array([100, 1000, np.nan], dtype=np.float64), + "b": np.array([100, 6, 97], dtype=np.float64), + } + ) + + assert table.view().to_columns() == { + "a": [100, None, 10, 9, 2, 8, 1000, 4, None, 100], + "b": [-1, 1, 2, 3, 4, 5, 6, 7, 97, 100], + } + + def test_table_numpy_from_schema_int_to_float(self): + data = [None, 1, None, 2, None, 3, 4] + df = {"a": np.array(data)} + table = Table({"a": "float"}) + table.update(df) + assert table.view().to_columns()["a"] == [None, 1.0, None, 2.0, None, 3.0, 4.0] + + def test_table_numpy_from_schema_date(self): + data = [date(2019, 8, 15), None, date(2019, 8, 16)] + df = {"a": np.array(data)} + table = Table({"a": "date"}) + table.update(df) + assert table.view().to_columns()["a"] == [ + datetime(2019, 8, 15), + None, + datetime(2019, 8, 16), + ] + + def test_table_numpy_from_schema_datetime(self): + data = [ + datetime(2019, 7, 11, 12, 30, 5), + None, + datetime(2019, 7, 11, 13, 30, 5), + None, + ] + df = {"a": np.array(data)} + table = Table({"a": "datetime"}) + table.update(df) + assert table.view().to_columns()["a"] == data + + def test_table_numpy_from_schema_datetime_timestamp_s(self, util): + data = [ + util.to_timestamp(datetime(2019, 7, 11, 12, 30, 5)), + np.nan, + util.to_timestamp(datetime(2019, 7, 11, 13, 30, 5)), + np.nan, + ] + df = {"a": np.array(data)} + table = Table({"a": "datetime"}) + table.update(df) + assert table.view().to_columns()["a"] == [ + datetime(2019, 7, 11, 12, 30, 5), + None, + datetime(2019, 7, 11, 13, 30, 5), + None, + ] + + def test_table_numpy_from_schema_datetime_timestamp_ms(self, util): + data = [ + util.to_timestamp(datetime(2019, 7, 11, 12, 30, 5)) * 1000, + np.nan, + util.to_timestamp(datetime(2019, 7, 11, 13, 30, 5)) * 1000, + np.nan, + ] + + df = {"a": np.array(data)} + table = Table({"a": "datetime"}) + table.update(df) + assert table.view().to_columns()["a"] == [ + datetime(2019, 7, 11, 12, 30, 5), + None, + datetime(2019, 7, 11, 13, 30, 5), + None, + ] + + def test_table_numpy_from_schema_str(self): + data = ["a", None, "b", None, "c"] + df = {"a": np.array(data)} + table = Table({"a": "string"}) + table.update(df) + assert table.view().to_columns()["a"] == data + + # partial update + + def test_table_numpy_partial_update(self): + data = ["a", None, "b", None, "c"] + df = {"a": np.array([1, 2, 3, 4, 5]), "b": np.array(data), "c": np.array(data)} + table = Table(df, index="a") + table.update({"a": np.array([2, 4, 5]), "b": np.array(["x", "y", "z"])}) + assert table.view().to_columns() == { + "a": [1, 2, 3, 4, 5], + "b": ["a", "x", "b", "y", "z"], + "c": ["a", None, "b", None, "c"], + } + + def test_table_numpy_partial_update_implicit(self): + data = ["a", None, "b", None, "c"] + df = {"a": np.array([1, 2, 3, 4, 5]), "b": np.array(data), "c": np.array(data)} + table = Table(df) + table.update({"__INDEX__": np.array([1, 3, 4]), "b": np.array(["x", "y", "z"])}) + assert table.view().to_columns() == { + "a": [1, 2, 3, 4, 5], + "b": ["a", "x", "b", "y", "z"], + "c": ["a", None, "b", None, "c"], + } + + # structured array + + def test_table_structured_array(self): + d = np.array([(1.0, 2), (3.0, 4)], dtype=[("x", "/perspective_/` directory. + """ + tmp = tempfile.gettempdir() + try: + entries = os.listdir(tmp) + except OSError: + return set() + + print(tmp) + return { + os.path.join(tmp, e) + for e in entries + if e.startswith("perspective_") and os.path.isdir(os.path.join(tmp, e)) + } + + +class TestTableOnDisk: + def test_page_to_disk_schema_and_view(self): + data = { + "x": [1, 2, 3, 4], + "y": ["a", "b", "c", "d"], + "z": [True, False, True, False], + } + + mem = Table(data) + disk = Table(data, page_to_disk=True) + assert disk.schema() == mem.schema() + assert disk.view().to_columns() == mem.view().to_columns() + + def test_page_to_disk_csv(self): + data = "x,y,z\n1,a,true\n2,b,false\n3,c,true\n4,d,false" + tbl = Table(data, page_to_disk=True) + assert tbl.schema() == {"x": "integer", "y": "string", "z": "boolean"} + assert tbl.view().to_columns() == { + "x": [1, 2, 3, 4], + "y": ["a", "b", "c", "d"], + "z": [True, False, True, False], + } + + def test_page_to_disk_update_indexed(self): + mem = Table({"x": [1, 2, 3], "y": [1.0, 2.0, 3.0]}, index="x") + disk = Table( + {"x": [1, 2, 3], "y": [1.0, 2.0, 3.0]}, index="x", page_to_disk=True + ) + update = {"x": [2, 4], "y": [20.0, 40.0]} + mem.update(update) + disk.update(update) + assert disk.view().to_columns() == mem.view().to_columns() + assert disk.size() == mem.size() + + def test_page_to_disk_group_by_aggregation(self): + data = {"g": ["a", "b", "a", "b", "a"], "v": [1, 2, 3, 4, 5]} + mem = Table(data) + disk = Table(data, page_to_disk=True) + config = {"group_by": ["g"], "columns": ["v"], "aggregates": {"v": "sum"}} + assert disk.view(**config).to_columns() == mem.view(**config).to_columns() + + def test_page_to_disk_arrow_roundtrip(self): + data = {"x": list(range(100)), "y": [float(i) / 2 for i in range(100)]} + mem = Table(data) + disk = Table(data, page_to_disk=True) + assert disk.view().to_arrow() == mem.view().to_arrow() + + def test_page_to_disk_creates_backing_files(self): + before = _perspective_temp_dirs() + tbl = Table({"x": [1, 2, 3], "y": ["a", "b", "c"]}, page_to_disk=True) + + # Touch the table so it is not optimized away before we inspect the FS. + assert tbl.size() == 3 + after = _perspective_temp_dirs() + new_dirs = after - before + assert new_dirs, "expected a new perspective_ directory on disk" + assert any(os.listdir(d) for d in new_dirs), "expected column files on disk" + + def test_memory_table_creates_no_backing_files(self): + before = _perspective_temp_dirs() + tbl = Table({"x": [1, 2, 3]}) + assert tbl.size() == 3 + after = _perspective_temp_dirs() + assert after == before, "in-memory table must not write to disk" + + def test_page_to_disk_growth_forces_resize(self): + tbl = Table({"x": [0], "y": [0.0]}, index="x", page_to_disk=True) + n = 50000 + tbl.update({"x": list(range(n)), "y": [float(i) for i in range(n)]}) + assert tbl.size() == n + cols = tbl.view().to_columns() + assert cols["x"][0] == 0 + assert cols["x"][-1] == n - 1 + assert cols["y"][-1] == float(n - 1) + + def test_page_to_disk_clone_keeps_master_files(self): + # Cloning a disk-backed column (e.g. the masked clone that serializing a + # table with removed rows performs) must give the clone its OWN backing + # file. Otherwise the clone aliases — and on teardown `rmfile`s — the + # master's file, silently unlinking the master's named backing store. + before = _perspective_temp_dirs() + tbl = Table( + {"x": [1, 2, 3, 4], "y": [10.0, 20.0, 30.0, 40.0]}, + index="x", + page_to_disk=True, + ) + view = tbl.view() + new_dirs = _perspective_temp_dirs() - before + master_dirs = [ + d + for d in new_dirs + if not os.path.basename(d).startswith("perspective_expr_") + ] + assert len(master_dirs) == 1 + master_dir = master_dirs[0] + master_files = set(os.listdir(master_dir)) + assert master_files, "expected master backing files on disk" + + # Removing rows triggers a masked clone of the disk master columns. + tbl.remove([2, 3]) + assert view.to_columns()["x"] == [1, 4] + # The master's own backing files must survive the clone's teardown. + survived = set(os.listdir(master_dir)) + assert master_files <= survived, ( + "clone teardown unlinked master backing files: {}".format( + master_files - survived + ) + ) + + # And the master must remain usable for subsequent updates. + tbl.update({"x": [5, 6], "y": [50.0, 60.0]}) + assert sorted(view.to_columns()["x"]) == [1, 4, 5, 6] + + def test_page_to_disk_larger_dataset_matches_memory(self): + n = 20000 + data = { + "i": list(range(n)), + "f": [float(i) * 1.5 for i in range(n)], + "s": ["row_{}".format(i % 97) for i in range(n)], + } + + mem = Table(data) + disk = Table(data, page_to_disk=True) + assert disk.view().to_arrow() == mem.view().to_arrow() + + +def _perspective_expr_dirs(): + """`perspective_expr_*` directories — the on-disk expression `m_master`.""" + tmp = tempfile.gettempdir() + try: + entries = os.listdir(tmp) + except OSError: + return set() + + return { + os.path.join(tmp, e) + for e in entries + if e.startswith("perspective_expr_") and os.path.isdir(os.path.join(tmp, e)) + } + + +class TestTableOnDiskExpressions: + def test_expression_numeric_equivalence(self): + data = {"x": [1, 2, 3, 4], "y": [10.0, 20.0, 30.0, 40.0]} + mem = Table(data) + disk = Table(data, page_to_disk=True) + exprs = {"sum": '"x" + "y"', "prod": '"x" * "y"'} + assert ( + disk.view(expressions=exprs).to_columns() + == mem.view(expressions=exprs).to_columns() + ) + + def test_expression_string_equivalence(self): + data = {"a": ["foo", "bar", "baz"], "b": ["AA", "BB", "CC"]} + mem = Table(data) + disk = Table(data, page_to_disk=True) + exprs = {"up": 'upper("a")', "lo": 'lower("b")'} + assert ( + disk.view(expressions=exprs).to_columns() + == mem.view(expressions=exprs).to_columns() + ) + + def test_expression_with_group_by_equivalence(self): + data = {"g": ["a", "b", "a", "b"], "v": [1, 2, 3, 4]} + mem = Table(data) + disk = Table(data, page_to_disk=True) + config = { + "expressions": {"v2": '"v" * 2'}, + "group_by": ["g"], + "columns": ["v2"], + "aggregates": {"v2": "sum"}, + } + + assert disk.view(**config).to_columns() == mem.view(**config).to_columns() + + def test_expression_master_is_page_to_disk(self): + before = _perspective_expr_dirs() + tbl = Table({"x": [1, 2, 3], "y": [10.0, 20.0, 30.0]}, page_to_disk=True) + view = tbl.view(expressions={"e": '"x" + "y"'}) + assert view.to_columns()["e"] == [11.0, 22.0, 33.0] + new_dirs = _perspective_expr_dirs() - before + assert new_dirs, ( + "expected a perspective_expr_ dir for the expression master" + ) + + assert any(os.listdir(d) for d in new_dirs), ( + "expected expression backing files on disk" + ) + + def test_memory_expression_creates_no_expr_dir(self): + before = _perspective_expr_dirs() + tbl = Table({"x": [1, 2, 3], "y": [10.0, 20.0, 30.0]}) + view = tbl.view(expressions={"e": '"x" + "y"'}) + assert view.to_columns()["e"] == [11.0, 22.0, 33.0] + assert _perspective_expr_dirs() == before, ( + "in-memory table must not write expression data to disk" + ) + + def test_expression_page_to_disk_update_and_larger(self): + n = 10000 + tbl = Table({"x": [0], "y": [0.0]}, index="x", page_to_disk=True) + tbl.update({"x": list(range(n)), "y": [float(i) for i in range(n)]}) + cols = tbl.view(expressions={"e": '"x" + "y"'}).to_columns() + assert cols["e"][0] == 0.0 + assert cols["e"][-1] == float((n - 1) + (n - 1)) + + +class TestResidency: + # The residency manager evicts disk-backed column buffers to their files + # when over the `PSP_MEMORY_BUDGET` and restores them transparently on + # access. Under a tiny budget, eviction fires aggressively and data must + # still round-trip identically to an in-memory table. + + @mark.skip(reason="No secret hooks in the engine") + def test_residency_evicts_and_data_is_correct(self): + stats_fd, stats_path = tempfile.mkstemp(prefix="psp_residency_") + os.close(stats_fd) + os.environ["PSP_MEMORY_BUDGET"] = "1024" + os.environ["PSP_RESIDENCY_STATS_FILE"] = stats_path + try: + n = 5000 + data = { + "x": list(range(n)), + "y": [float(i) * 1.5 for i in range(n)], + "s": ["row_{}".format(i % 50) for i in range(n)], + } + mem = Table(data) + disk = Table(data, page_to_disk=True) + + # Each request is a safepoint that trims to budget; data read back + # must be correct (round-tripped through evict -> restore). + assert disk.view().to_columns() == mem.view().to_columns() + assert disk.view().to_arrow() == mem.view().to_arrow() + + upd = { + "x": list(range(n, n + 200)), + "y": [float(i) for i in range(n, n + 200)], + "s": ["upd_{}".format(i) for i in range(200)], + } + mem.update(upd) + disk.update(upd) + assert disk.view().to_columns() == mem.view().to_columns() + + # An expression view on an evicted disk table must still compute. + exprs = {"e": '"x" + "y"'} + assert ( + disk.view(expressions=exprs).to_columns() + == mem.view(expressions=exprs).to_columns() + ) + + # Confirm eviction actually occurred (otherwise the test is vacuous). + with open(stats_path) as f: + stats = f.read() + evictions = int(stats.split("evictions=")[1].split()[0]) + assert evictions > 0, "expected evictions under a tiny budget: " + stats + finally: + os.environ.pop("PSP_MEMORY_BUDGET", None) + os.environ.pop("PSP_RESIDENCY_STATS_FILE", None) + os.remove(stats_path) + # Drain a safepoint with residency disabled so any evicted stores + # from other live tables are restored before subsequent tests. + Table({"_": [0]}).view().to_columns() diff --git a/rust/perspective-python/perspective/tests/table/test_table_pandas.py b/rust/perspective-python/perspective/tests/table/test_table_pandas.py new file mode 100644 index 0000000..23e8eb5 --- /dev/null +++ b/rust/perspective-python/perspective/tests/table/test_table_pandas.py @@ -0,0 +1,1018 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 datetime import date, datetime +from io import StringIO +import numpy as np +import pandas as pd +from pytest import mark +import perspective as psp + +client = psp.Server().new_local_client() +Table = client.table + + +def arrow_bytes_to_pandas(view): + import pyarrow + + with pyarrow.ipc.open_stream(pyarrow.BufferReader(view.to_arrow())) as reader: + return reader.read_pandas() + + +class TestTablePandas(object): + def test_empty_table(self): + tbl = Table([]) + assert tbl.size() == 0 + assert tbl.schema() == {} + + def test_table_dataframe(self): + d = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + data = pd.DataFrame(d) + tbl = Table(data) + assert tbl.size() == 2 + assert tbl.schema() == {"index": "integer", "a": "integer", "b": "integer"} + assert tbl.view().to_records() == [ + {"a": 1, "b": 2, "index": 0}, + {"a": 3, "b": 4, "index": 1}, + ] + + def test_table_dataframe_column_order(self): + d = [{"a": 1, "b": 2, "c": 3, "d": 4}, {"a": 3, "b": 4, "c": 5, "d": 6}] + data = pd.DataFrame(d, columns=["b", "c", "a", "d"]) + tbl = Table(data) + assert tbl.size() == 2 + assert tbl.columns() == ["index", "b", "c", "a", "d"] + + def test_table_dataframe_selective_column_order(self): + d = [{"a": 1, "b": 2, "c": 3, "d": 4}, {"a": 3, "b": 4, "c": 5, "d": 6}] + data = pd.DataFrame(d, columns=["b", "c", "a"]) + tbl = Table(data) + assert tbl.size() == 2 + assert tbl.columns() == ["index", "b", "c", "a"] + + def test_table_dataframe_does_not_mutate(self): + # make sure we don't mutate the dataframe that a user passes in + data = pd.DataFrame( + { + "a": np.array([None, 1, None, 2], dtype=object), + "b": np.array([1.5, None, 2.5, None], dtype=object), + } + ) + assert data["a"].tolist() == [None, 1, None, 2] + assert data["b"].tolist() == [1.5, None, 2.5, None] + + tbl = Table(data) + assert tbl.size() == 4 + assert tbl.schema() == {"index": "integer", "a": "integer", "b": "float"} + + assert data["a"].tolist() == [None, 1, None, 2] + assert data["b"].tolist() == [1.5, None, 2.5, None] + + @mark.skip(reason="Deprecated support for Series") + def test_table_date_series(self, util): + data = util.make_series(freq="D") + tbl = Table(data) + assert tbl.size() == 10 + assert tbl.schema() == {"index": "date", "0": "float"} + assert tbl.view().to_columns()["index"] == [ + util.to_timestamp(datetime(2000, 1, 1)), + util.to_timestamp(datetime(2000, 1, 2)), + util.to_timestamp(datetime(2000, 1, 3)), + util.to_timestamp(datetime(2000, 1, 4)), + util.to_timestamp(datetime(2000, 1, 5)), + util.to_timestamp(datetime(2000, 1, 6)), + util.to_timestamp(datetime(2000, 1, 7)), + util.to_timestamp(datetime(2000, 1, 8)), + util.to_timestamp(datetime(2000, 1, 9)), + util.to_timestamp(datetime(2000, 1, 10)), + ] + + @mark.skip(reason="Deprecated support for Series") + def test_table_time_series(self, util): + data = util.make_series(freq="H") + tbl = Table(data) + assert tbl.size() == 10 + assert tbl.schema() == {"index": "datetime", "0": "float"} + assert tbl.view().to_columns()["index"] == [ + util.to_timestamp(datetime(2000, 1, 1, 0, 0, 0)), + util.to_timestamp(datetime(2000, 1, 1, 1, 0, 0)), + util.to_timestamp(datetime(2000, 1, 1, 2, 0, 0)), + util.to_timestamp(datetime(2000, 1, 1, 3, 0, 0)), + util.to_timestamp(datetime(2000, 1, 1, 4, 0, 0)), + util.to_timestamp(datetime(2000, 1, 1, 5, 0, 0)), + util.to_timestamp(datetime(2000, 1, 1, 6, 0, 0)), + util.to_timestamp(datetime(2000, 1, 1, 7, 0, 0)), + util.to_timestamp(datetime(2000, 1, 1, 8, 0, 0)), + util.to_timestamp(datetime(2000, 1, 1, 9, 0, 0)), + ] + + @mark.skip(reason="pyarrow dataframe does not support date inference") + def test_table_dataframe_infer_date(self, util): + data = util.make_dataframe(freq="ME") + + tbl = Table(data) + assert tbl.size() == 10 + assert tbl.schema() == { + "index": "date", + "a": "float", + "b": "float", + "c": "float", + "d": "float", + } + + assert tbl.view().to_columns()["index"] == [ + util.to_timestamp(datetime(2000, 1, 31)), + util.to_timestamp(datetime(2000, 2, 29)), + util.to_timestamp(datetime(2000, 3, 31)), + util.to_timestamp(datetime(2000, 4, 30)), + util.to_timestamp(datetime(2000, 5, 31)), + util.to_timestamp(datetime(2000, 6, 30)), + util.to_timestamp(datetime(2000, 7, 31)), + util.to_timestamp(datetime(2000, 8, 31)), + util.to_timestamp(datetime(2000, 9, 30)), + util.to_timestamp(datetime(2000, 10, 31)), + ] + + def test_table_dataframe_infer_date_fixed(self, util): + data = util.make_dataframe(freq="ME") + + tbl = Table(data) + assert tbl.size() == 10 + assert tbl.schema() == { + "index": "datetime", + "a": "float", + "b": "float", + "c": "float", + "d": "float", + } + + assert tbl.view().to_columns()["index"] == [ + util.to_timestamp(datetime(2000, 1, 31)), + util.to_timestamp(datetime(2000, 2, 29)), + util.to_timestamp(datetime(2000, 3, 31)), + util.to_timestamp(datetime(2000, 4, 30)), + util.to_timestamp(datetime(2000, 5, 31)), + util.to_timestamp(datetime(2000, 6, 30)), + util.to_timestamp(datetime(2000, 7, 31)), + util.to_timestamp(datetime(2000, 8, 31)), + util.to_timestamp(datetime(2000, 9, 30)), + util.to_timestamp(datetime(2000, 10, 31)), + ] + + def test_table_dataframe_infer_time(self, util): + data = util.make_dataframe(freq="h") + + tbl = Table(data) + assert tbl.size() == 10 + assert tbl.schema() == { + "index": "datetime", + "a": "float", + "b": "float", + "c": "float", + "d": "float", + } + + assert tbl.view().to_columns()["index"] == [ + util.to_timestamp(datetime(2000, 1, 1, 0, 0, 0)), + util.to_timestamp(datetime(2000, 1, 1, 1, 0, 0)), + util.to_timestamp(datetime(2000, 1, 1, 2, 0, 0)), + util.to_timestamp(datetime(2000, 1, 1, 3, 0, 0)), + util.to_timestamp(datetime(2000, 1, 1, 4, 0, 0)), + util.to_timestamp(datetime(2000, 1, 1, 5, 0, 0)), + util.to_timestamp(datetime(2000, 1, 1, 6, 0, 0)), + util.to_timestamp(datetime(2000, 1, 1, 7, 0, 0)), + util.to_timestamp(datetime(2000, 1, 1, 8, 0, 0)), + util.to_timestamp(datetime(2000, 1, 1, 9, 0, 0)), + ] + + @mark.skip(reason="pyarrow dataframe does not support date inference") + def test_table_dataframe_year_start_index(self, util): + data = util.make_dataframe(freq="YS") + + tbl = Table(data) + assert tbl.size() == 10 + assert tbl.schema() == { + "index": "date", + "a": "float", + "b": "float", + "c": "float", + "d": "float", + } + + assert tbl.view().to_columns()["index"] == [ + util.to_timestamp(datetime(2000, 1, 1, 0, 0, 0)), + util.to_timestamp(datetime(2001, 1, 1, 0, 0, 0)), + util.to_timestamp(datetime(2002, 1, 1, 0, 0, 0)), + util.to_timestamp(datetime(2003, 1, 1, 0, 0, 0)), + util.to_timestamp(datetime(2004, 1, 1, 0, 0, 0)), + util.to_timestamp(datetime(2005, 1, 1, 0, 0, 0)), + util.to_timestamp(datetime(2006, 1, 1, 0, 0, 0)), + util.to_timestamp(datetime(2007, 1, 1, 0, 0, 0)), + util.to_timestamp(datetime(2008, 1, 1, 0, 0, 0)), + util.to_timestamp(datetime(2009, 1, 1, 0, 0, 0)), + ] + + def test_table_dataframe_year_start_index_fixed(self, util): + data = util.make_dataframe(freq="YS") + + tbl = Table(data) + assert tbl.size() == 10 + assert tbl.schema() == { + "index": "datetime", + "a": "float", + "b": "float", + "c": "float", + "d": "float", + } + + assert tbl.view().to_columns()["index"] == [ + util.to_timestamp(datetime(2000, 1, 1, 0, 0, 0)), + util.to_timestamp(datetime(2001, 1, 1, 0, 0, 0)), + util.to_timestamp(datetime(2002, 1, 1, 0, 0, 0)), + util.to_timestamp(datetime(2003, 1, 1, 0, 0, 0)), + util.to_timestamp(datetime(2004, 1, 1, 0, 0, 0)), + util.to_timestamp(datetime(2005, 1, 1, 0, 0, 0)), + util.to_timestamp(datetime(2006, 1, 1, 0, 0, 0)), + util.to_timestamp(datetime(2007, 1, 1, 0, 0, 0)), + util.to_timestamp(datetime(2008, 1, 1, 0, 0, 0)), + util.to_timestamp(datetime(2009, 1, 1, 0, 0, 0)), + ] + + @mark.skip(reason="pyarrow dataframe does not support date inference") + def test_table_dataframe_quarter_index(self, util): + data = util.make_dataframe(size=4, freq="QE") + + tbl = Table(data) + assert tbl.size() == 4 + assert tbl.schema() == { + "index": "date", + "a": "float", + "b": "float", + "c": "float", + "d": "float", + } + + assert tbl.view().to_columns()["index"] == [ + util.to_timestamp(datetime(2000, 3, 31, 0, 0, 0)), + util.to_timestamp(datetime(2000, 6, 30, 0, 0, 0)), + util.to_timestamp(datetime(2000, 9, 30, 0, 0, 0)), + util.to_timestamp(datetime(2000, 12, 31, 0, 0, 0)), + ] + + def test_table_dataframe_quarter_index_fixed(self, util): + data = util.make_dataframe(size=4, freq="QE") + + tbl = Table(data) + assert tbl.size() == 4 + assert tbl.schema() == { + "index": "datetime", + "a": "float", + "b": "float", + "c": "float", + "d": "float", + } + + assert tbl.view().to_columns()["index"] == [ + util.to_timestamp(datetime(2000, 3, 31, 0, 0, 0)), + util.to_timestamp(datetime(2000, 6, 30, 0, 0, 0)), + util.to_timestamp(datetime(2000, 9, 30, 0, 0, 0)), + util.to_timestamp(datetime(2000, 12, 31, 0, 0, 0)), + ] + + def test_table_dataframe_minute_index(self, util): + data = util.make_dataframe(size=5, freq="min") + + tbl = Table(data) + assert tbl.size() == 5 + assert tbl.schema() == { + "index": "datetime", + "a": "float", + "b": "float", + "c": "float", + "d": "float", + } + + assert tbl.view().to_columns()["index"] == [ + util.to_timestamp(datetime(2000, 1, 1, 0, 0)), + util.to_timestamp(datetime(2000, 1, 1, 0, 1)), + util.to_timestamp(datetime(2000, 1, 1, 0, 2)), + util.to_timestamp(datetime(2000, 1, 1, 0, 3)), + util.to_timestamp(datetime(2000, 1, 1, 0, 4)), + ] + + def test_table_pandas_periodindex(self, util): + df = util.make_period_dataframe(30) + tbl = Table(df) + + assert tbl.size() == 30 + assert tbl.schema() == { + "index": "integer", + "a": "float", + "b": "float", + "c": "float", + "d": "float", + } + + assert tbl.view().to_columns()["index"][:5] == [360, 361, 362, 363, 364] + + @mark.skip(reason="pyarrow does not support this") + def test_table_pandas_period(self, util): + df = pd.DataFrame( + { + "a": [ + pd.Period("1Q2019"), + pd.Period("2Q2019"), + pd.Period("3Q2019"), + pd.Period("4Q2019"), + ] + } + ) + tbl = Table(df) + assert tbl.size() == 4 + assert tbl.schema() == {"index": "integer", "a": "datetime"} + assert tbl.view().to_columns()["a"] == [ + util.to_timestamp(datetime(2019, 1, 1)), + util.to_timestamp(datetime(2019, 4, 1)), + util.to_timestamp(datetime(2019, 7, 1)), + util.to_timestamp(datetime(2019, 10, 1)), + ] + + def test_table_pandas_from_schema_int(self): + data = [None, 1, None, 2, None, 3, 4] + df = pd.DataFrame({"a": data}) + table = Table({"a": "integer"}) + table.update(df) + assert table.view().to_columns()["a"] == data + + def test_table_pandas_from_schema_bool(self): + data = [True, False, True, False] + df = pd.DataFrame({"a": data}) + table = Table({"a": "boolean"}) + table.update(df) + assert table.view().to_columns()["a"] == data + + @mark.skip(reason="pyarrow does not support this") + def test_table_pandas_from_schema_bool_str(self): + data = ["True", "False", "True", "False"] + df = pd.DataFrame({"a": data}) + table = Table({"a": "boolean"}) + table.update(df) + assert table.view().to_columns()["a"] == [True, False, True, False] + + def test_table_pandas_from_schema_float(self): + data = [None, 1.5, None, 2.5, None, 3.5, 4.5] + df = pd.DataFrame({"a": data}) + table = Table({"a": "float"}) + table.update(df) + assert table.view().to_columns()["a"] == data + + def test_table_pandas_from_schema_float_all_nan(self): + data = [np.nan, np.nan, np.nan, np.nan] + df = pd.DataFrame({"a": data}) + table = Table({"a": "float"}) + table.update(df) + assert table.view().to_columns()["a"] == [None, None, None, None] + + def test_table_pandas_from_schema_float_to_int(self): + data = [None, 1.5, None, 2.5, None, 3.5, 4.5] + df = pd.DataFrame({"a": data}) + table = Table({"a": "integer"}) + table.update(df) + # truncates decimal + assert table.view().to_columns()["a"] == [None, 1, None, 2, None, 3, 4] + + def test_table_pandas_from_schema_int_to_float(self): + data = [None, 1, None, 2, None, 3, 4] + df = pd.DataFrame({"a": data}) + table = Table({"a": "float"}) + table.update(df) + assert table.view().to_columns()["a"] == [None, 1.0, None, 2.0, None, 3.0, 4.0] + + def test_table_pandas_from_schema_date(self, util): + data = [date(2019, 8, 15), None, date(2019, 8, 16)] + df = pd.DataFrame({"a": data}) + table = Table({"a": "date"}) + table.update(df) + assert table.view().to_columns()["a"] == [ + util.to_timestamp(datetime(2019, 8, 15)), + None, + util.to_timestamp(datetime(2019, 8, 16)), + ] + + def test_table_pandas_from_schema_datetime(self, util): + data = [ + util.to_timestamp(datetime(2019, 7, 11, 12, 30, 5)), + None, + util.to_timestamp(datetime(2019, 7, 11, 13, 30, 5)), + None, + ] + df = pd.DataFrame({"a": pd.to_datetime(data, unit="ms")}) + table = Table({"a": "datetime"}) + table.update(df) + assert table.view().to_columns()["a"] == data + + def test_table_pandas_from_schema_datetime_timestamp_s(self, util): + data = [ + util.to_timestamp(datetime(2019, 7, 11, 12, 30, 5)), + np.nan, + util.to_timestamp(datetime(2019, 7, 11, 13, 30, 5)), + np.nan, + ] + df = pd.DataFrame({"a": pd.to_datetime(data, unit="ms")}) + table = Table({"a": "datetime"}) + table.update(df) + assert table.view().to_columns()["a"] == [ + util.to_timestamp(datetime(2019, 7, 11, 12, 30, 5)), + None, + util.to_timestamp(datetime(2019, 7, 11, 13, 30, 5)), + None, + ] + + @mark.skip(reason="This is no longer relevant") + def test_table_pandas_from_schema_datetime_timestamp_ms(self, util): + data = [ + util.to_timestamp(datetime(2019, 7, 11, 12, 30, 5)) * 1000, + np.nan, + util.to_timestamp(datetime(2019, 7, 11, 13, 30, 5)), + *1000, + np.nan, + ] + + df = pd.DataFrame({"a": pd.to_datetime(data, unit="ms")}) + table = Table({"a": "datetime"}) + table.update(df) + assert table.view().to_columns()["a"] == [ + util.to_timestamp(datetime(2019, 7, 11, 12, 30, 5)), + None, + util.to_timestamp(datetime(2019, 7, 11, 13, 30, 5)), + None, + ] + + def test_table_pandas_from_schema_str(self): + data = ["a", None, "b", None, "c"] + df = pd.DataFrame({"a": data}) + table = Table({"a": "string"}) + table.update(df) + assert table.view().to_columns()["a"] == data + + def test_table_pandas_none(self): + data = [None, None, None] + df = pd.DataFrame({"a": data}) + table = Table(df) + assert table.view().to_columns()["a"] == data + + def test_table_pandas_symmetric_table(self): + # make sure that updates are symmetric to table creation + df = pd.DataFrame({"a": [1, 2, 3, 4], "b": [1.5, 2.5, 3.5, 4.5]}) + t1 = Table(df) + t2 = Table({"a": "integer", "b": "float"}) + t2.update(df) + assert t1.view().to_columns() == { + "index": [0, 1, 2, 3], + "a": [1, 2, 3, 4], + "b": [1.5, 2.5, 3.5, 4.5], + } + + def test_table_pandas_symmetric_stacked_updates(self): + # make sure that updates are symmetric to table creation + df = pd.DataFrame({"a": [1, 2, 3, 4], "b": [1.5, 2.5, 3.5, 4.5]}) + + t1 = Table(df) + t1.update(df) + + t2 = Table({"a": "integer", "b": "float"}) + t2.update(df) + t2.update(df) + + assert t1.view().to_columns() == { + "index": [0, 1, 2, 3, 0, 1, 2, 3], + "a": [1, 2, 3, 4, 1, 2, 3, 4], + "b": [1.5, 2.5, 3.5, 4.5, 1.5, 2.5, 3.5, 4.5], + } + + def test_table_pandas_transitive(self): + # serialized output -> table -> serialized output + records = { + "a": [1, 2, 3, 4], + "b": [1.5, 2.5, 3.5, 4.5], + "c": [np.nan, np.nan, "abc", np.nan], + "d": [None, True, None, False], + "e": [ + float("nan"), + datetime(2019, 7, 11, 12, 30), + float("nan"), + datetime(2019, 7, 11, 12, 30), + ], + } + + df = pd.DataFrame(records) + t1 = Table(df) + out1 = arrow_bytes_to_pandas(t1.view(columns=["a", "b", "c", "d", "e"])) + t2 = Table(out1) + assert t1.schema() == t2.schema() + out2 = t2.view().to_columns() + assert t1.view().to_columns() == out2 + + # dtype=object should have correct inferred types + + def test_table_pandas_object_to_int(self): + df = pd.DataFrame({"a": np.array([1, 2, None, 2, None, 3, 4], dtype=object)}) + table = Table(df) + assert table.schema() == {"index": "integer", "a": "integer"} + assert table.view().to_columns()["a"] == [1, 2, None, 2, None, 3, 4] + + def test_table_pandas_object_to_float(self): + df = pd.DataFrame({"a": np.array([None, 1, None, 2, None, 3, 4], dtype=object)}) + table = Table(df) + assert table.schema() == {"index": "integer", "a": "integer"} + assert table.view().to_columns()["a"] == [None, 1.0, None, 2.0, None, 3.0, 4.0] + + def test_table_pandas_object_to_bool(self): + df = pd.DataFrame( + {"a": np.array([True, False, True, False, True, False], dtype=object)} + ) + table = Table(df) + assert table.schema() == {"index": "integer", "a": "boolean"} + assert table.view().to_columns()["a"] == [True, False, True, False, True, False] + + def test_table_pandas_object_to_date(self, util): + df = pd.DataFrame( + {"a": np.array([date(2019, 7, 11), date(2019, 7, 12), None], dtype=object)} + ) + table = Table(df) + assert table.schema() == {"index": "integer", "a": "date"} + assert table.view().to_columns()["a"] == [ + util.to_timestamp(datetime(2019, 7, 11)), + util.to_timestamp(datetime(2019, 7, 12)), + None, + ] + + def test_table_pandas_object_to_datetime(self, util): + df = pd.DataFrame( + { + "a": np.array( + [ + datetime(2019, 7, 11, 1, 2, 3), + datetime(2019, 7, 12, 1, 2, 3), + None, + ], + dtype=object, + ) + } + ) + table = Table(df) + assert table.schema() == {"index": "integer", "a": "datetime"} + assert table.view().to_columns()["a"] == [ + util.to_timestamp(datetime(2019, 7, 11, 1, 2, 3)), + util.to_timestamp(datetime(2019, 7, 12, 1, 2, 3)), + None, + ] + + def test_table_pandas_object_to_str(self): + df = pd.DataFrame({"a": np.array(["abc", "def", None, "ghi"], dtype=object)}) + table = Table(df) + assert table.schema() == {"index": "integer", "a": "string"} + assert table.view().to_columns()["a"] == ["abc", "def", None, "ghi"] + + # Type matching + + def test_table_pandas_update_float_schema_with_int(self): + df = pd.DataFrame({"a": [1.5, 2.5, 3.5, 4.5], "b": [1, 2, 3, 4]}) + + table = Table({"a": "float", "b": "float"}) + + table.update(df) + + assert table.view().to_columns() == { + "a": [1.5, 2.5, 3.5, 4.5], + "b": [1.0, 2.0, 3.0, 4.0], + } + + def test_table_pandas_update_int32_with_int64(self): + df = pd.DataFrame({"a": [1, 2, 3, 4]}) + + table = Table({"a": [1, 2, 3, 4]}) + + table.update(df) + + assert table.view().to_columns() == {"a": [1, 2, 3, 4, 1, 2, 3, 4]} + + def test_table_pandas_update_int64_with_float(self): + df = pd.DataFrame({"a": [1.5, 2.5, 3.5, 4.5]}) + + table = Table(pd.DataFrame({"a": [1, 2, 3, 4]})) + + table.update(df) + + assert table.view().to_columns()["a"] == [1, 2, 3, 4, 1, 2, 3, 4] + + def test_table_pandas_update_date_schema_with_datetime(self, util): + df = pd.DataFrame({"a": np.array([date(2019, 7, 11)])}) + + table = Table({"a": "date"}) + + table.update(df) + + assert table.schema() == {"a": "date"} + + assert table.view().to_columns() == { + "a": [util.to_timestamp(datetime(2019, 7, 11))] + } + + @mark.skip(reason="Not supported by pyarrow (?)") + def test_table_pandas_update_datetime_schema_with_date(self, util): + df = pd.DataFrame({"a": np.array([date(2019, 7, 11)])}) + table = Table({"a": "datetime"}) + table.update(df) + assert table.schema() == {"a": "datetime"} + assert table.view().to_columns() == { + "a": [util.to_timestamp(datetime(2019, 7, 11, 0, 0))] + } + + # Timestamps + + def test_table_pandas_timestamp_to_datetime(self, util): + data = [ + pd.Timestamp("2019-07-11 12:30:05"), + None, + pd.Timestamp("2019-07-11 13:30:05"), + None, + ] + df = pd.DataFrame({"a": data}) + table = Table(df) + assert table.view().to_columns()["a"] == [ + util.to_timestamp(datetime(2019, 7, 11, 12, 30, 5)), + None, + util.to_timestamp(datetime(2019, 7, 11, 13, 30, 5)), + None, + ] + + def test_table_pandas_timestamp_explicit_dtype(self, util): + data = [ + pd.Timestamp("2019-07-11 12:30:05"), + None, + pd.Timestamp("2019-07-11 13:30:05"), + None, + ] + df = pd.DataFrame({"a": np.array(data, dtype="datetime64[ns]")}) + table = Table(df) + assert table.view().to_columns()["a"] == [ + util.to_timestamp(datetime(2019, 7, 11, 12, 30, 5)), + None, + util.to_timestamp(datetime(2019, 7, 11, 13, 30, 5)), + None, + ] + + def test_table_pandas_update_datetime_with_timestamp(self, util): + data = [ + pd.Timestamp("2019-07-11 12:30:05"), + None, + pd.Timestamp("2019-07-11 13:30:05"), + None, + ] + df = pd.DataFrame({"a": data}) + df2 = pd.DataFrame({"a": data}) + table = Table(df) + table.update(df2) + assert table.view().to_columns()["a"] == [ + util.to_timestamp(datetime(2019, 7, 11, 12, 30, 5)), + None, + util.to_timestamp(datetime(2019, 7, 11, 13, 30, 5)), + None, + util.to_timestamp(datetime(2019, 7, 11, 12, 30, 5)), + None, + util.to_timestamp(datetime(2019, 7, 11, 13, 30, 5)), + None, + ] + + # NaN/NaT reading + + def test_table_pandas_nan(self): + data = [np.nan, np.nan, np.nan, np.nan] + df = pd.DataFrame({"a": data}) + table = Table(df) + assert table.view().to_columns()["a"] == [None, None, None, None] + + def test_table_pandas_int_nan(self): + data = [np.nan, 1, np.nan, 2] + df = pd.DataFrame({"a": data}) + table = Table(df) + assert table.view().to_columns()["a"] == [None, 1, None, 2] + + def test_table_pandas_float_nan(self): + data = [np.nan, 1.5, np.nan, 2.5] + df = pd.DataFrame({"a": data}) + table = Table(df) + assert table.view().to_columns()["a"] == [None, 1.5, None, 2.5] + + def test_table_read_nan_int_col(self): + data = pd.DataFrame( + {"str": ["abc", float("nan"), "def"], "int": [np.nan, 1, 2]} + ) + tbl = Table(data) + assert tbl.schema() == { + "index": "integer", + "str": "string", + "int": "float", + } # np.nan is float type - ints convert to floats when filled in + assert tbl.size() == 3 + assert tbl.view().to_columns() == { + "index": [0, 1, 2], + "str": ["abc", None, "def"], + "int": [None, 1.0, 2.0], + } + + def test_table_read_nan_float_col(self): + data = pd.DataFrame( + {"str": [float("nan"), "abc", float("nan")], "float": [np.nan, 1.5, 2.5]} + ) + tbl = Table(data) + assert tbl.schema() == { + "index": "integer", + "str": "string", + "float": "float", + } # can only promote to string or float + assert tbl.size() == 3 + assert tbl.view().to_columns() == { + "index": [0, 1, 2], + "str": [None, "abc", None], + "float": [None, 1.5, 2.5], + } + + def test_table_read_nan_bool_col(self): + data = pd.DataFrame( + {"bool": [np.nan, True, np.nan], "bool2": [False, np.nan, True]} + ) + tbl = Table(data) + # if np.nan begins a column, it is inferred as float and then can be promoted. if np.nan is in the values (but not at start), the column type is whatever is inferred. + assert tbl.schema() == { + "index": "integer", + "bool": "boolean", + "bool2": "boolean", + } + assert tbl.size() == 3 + # np.nans are always serialized as None + assert tbl.view().to_columns() == { + "index": [0, 1, 2], + "bool": [None, True, None], + "bool2": [False, None, True], + } + + def test_table_read_nan_date_col(self): + data = pd.DataFrame( + {"str": ["abc", "def"], "date": [float("nan"), date(2019, 7, 11)]} + ) + tbl = Table(data) + assert tbl.schema() == { + "index": "integer", + "str": "string", + "date": "date", + } # can only promote to string or float + assert tbl.size() == 2 + assert tbl.view().to_columns() == { + "index": [0, 1], + "str": ["abc", "def"], + "date": [None, 1562803200000], + } + + def test_table_read_nan_datetime_col(self, util): + data = pd.DataFrame( + { + "str": ["abc", "def"], + "datetime": [float("nan"), datetime(2019, 7, 11, 11, 0)], + } + ) + tbl = Table(data) + assert tbl.schema() == { + "index": "integer", + "str": "string", + "datetime": "datetime", + } # can only promote to string or float + assert tbl.size() == 2 + assert tbl.view().to_columns() == { + "index": [0, 1], + "str": ["abc", "def"], + "datetime": [None, util.to_timestamp(datetime(2019, 7, 11, 11, 0))], + } + + def test_table_read_nat_datetime_col(self, util): + data = pd.DataFrame( + {"str": ["abc", "def"], "datetime": ["NaT", datetime(2019, 7, 11, 11, 0)]} + ) + # datetime col is `datetime` in pandas<2, `object` in pandas>=2, so convert + data.datetime = pd.to_datetime(data.datetime) + tbl = Table(data) + assert tbl.schema() == { + "index": "integer", + "str": "string", + "datetime": "datetime", + } # can only promote to string or float + assert tbl.size() == 2 + assert tbl.view().to_columns() == { + "index": [0, 1], + "str": ["abc", "def"], + "datetime": [None, util.to_timestamp(datetime(2019, 7, 11, 11, 0))], + } + + def test_table_read_nan_datetime_as_date_col(self, util): + data = pd.DataFrame( + {"str": ["abc", "def"], "datetime": [float("nan"), datetime(2019, 7, 11)]} + ) + tbl = Table(data) + assert tbl.schema() == { + "index": "integer", + "str": "string", + "datetime": "datetime", + } # can only promote to string or float + assert tbl.size() == 2 + assert tbl.view().to_columns() == { + "index": [0, 1], + "str": ["abc", "def"], + "datetime": [None, util.to_timestamp(datetime(2019, 7, 11))], + } + + def test_table_read_nan_datetime_no_seconds(self, util): + data = pd.DataFrame( + { + "str": ["abc", "def"], + "datetime": [float("nan"), datetime(2019, 7, 11, 11, 0)], + } + ) + tbl = Table(data) + assert tbl.schema() == { + "index": "integer", + "str": "string", + "datetime": "datetime", + } # can only promote to string or float + assert tbl.size() == 2 + assert tbl.view().to_columns() == { + "index": [0, 1], + "str": ["abc", "def"], + "datetime": [None, util.to_timestamp(datetime(2019, 7, 11, 11, 0))], + } + + def test_table_read_nan_datetime_milliseconds(self, util): + data = pd.DataFrame( + { + "str": ["abc", "def"], + "datetime": [np.nan, datetime(2019, 7, 11, 10, 30, 55)], + } + ) + tbl = Table(data) + assert tbl.schema() == { + "index": "integer", + "str": "string", + "datetime": "datetime", + } # can only promote to string or float + assert tbl.size() == 2 + assert tbl.view().to_columns() == { + "index": [0, 1], + "str": ["abc", "def"], + "datetime": [None, util.to_timestamp(datetime(2019, 7, 11, 10, 30, 55))], + } + + @mark.skip(reason="lol wtf") + def test_table_pandas_correct_csv_nan_end(self): + s = "string,\nint\n,1\n,2\nabc,3" + csv = StringIO(s) + data = pd.read_csv(csv) + tbl = Table(data) + assert tbl.schema() == {"index": "integer", "str": "string", "int": "integer"} + assert tbl.size() == 3 + assert tbl.view().to_columns() == { + "index": [0, 1, 2], + "str": [None, None, "abc"], + "int": [1, 2, 3], + } + + @mark.skip(reason="lol wtf") + def test_table_pandas_correct_csv_nan_intermittent(self): + s = "string,\nfloat\nabc,\n,2\nghi," + csv = StringIO(s) + data = pd.read_csv(csv) + tbl = Table(data) + assert tbl.schema() == {"index": "integer", "str": "string", "float": "float"} + assert tbl.size() == 3 + assert tbl.view().to_columns() == { + "index": [0, 1, 2], + "str": ["abc", None, "ghi"], + "float": [None, 2, None], + } + + @mark.skip(reason="pyarrow does not support series") + def test_table_series(self): + import pandas as pd + + data = pd.Series([1, 2, 3], name="a") + tbl = Table(data) + assert tbl.size() == 3 + + @mark.skip(reason="pyarrow does not support series") + def test_table_indexed_series(self): + import pandas as pd + + data = pd.Series([1, 2, 3], index=["a", "b", "c"], name="a") + tbl = Table(data) + assert tbl.schema() == {"index": "string", "a": "integer"} + assert tbl.size() == 3 + + def test_groupbys(self, superstore): + df_pivoted = superstore.set_index(["Country", "Region"]) + table = Table(df_pivoted) + columns = table.columns() + assert table.size() == 100 + assert "Country" in columns + assert "Region" in columns + + def test_pivottable(self, superstore): + pt = pd.pivot_table( + superstore, + values="Discount", + index=["Country", "Region"], + columns="Category", + ) + table = Table(pt) + columns = table.columns() + assert "Country" in columns + assert "Region" in columns + + @mark.skip(reason="TODO move this to Python") + def test_splitbys(self): + arrays = [ + np.array( + [ + "bar", + "bar", + "bar", + "bar", + "baz", + "baz", + "baz", + "baz", + "foo", + "foo", + "foo", + "foo", + "qux", + "qux", + "qux", + "qux", + ] + ), + np.array( + [ + "one", + "one", + "two", + "two", + "one", + "one", + "two", + "two", + "one", + "one", + "two", + "two", + "one", + "one", + "two", + "two", + ] + ), + np.array( + [ + "X", + "Y", + "X", + "Y", + "X", + "Y", + "X", + "Y", + "X", + "Y", + "X", + "Y", + "X", + "Y", + "X", + "Y", + ] + ), + ] + tuples = list(zip(*arrays)) + index = pd.MultiIndex.from_tuples(tuples, names=["first", "second", "third"]) + df_both = pd.DataFrame( + np.random.randn(3, 16), index=["A", "B", "C"], columns=index + ) + table = Table(df_both) + assert table.size() == 48 diff --git a/rust/perspective-python/perspective/tests/table/test_table_polars.py b/rust/perspective-python/perspective/tests/table/test_table_polars.py new file mode 100644 index 0000000..8fe2cf2 --- /dev/null +++ b/rust/perspective-python/perspective/tests/table/test_table_polars.py @@ -0,0 +1,251 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 datetime import date, datetime +import numpy as np +import polars as pl +from pytest import mark +import perspective as psp + +client = psp.Server().new_local_client() +Table = client.table + + +def arrow_bytes_to_polars(view): + import pyarrow + + with pyarrow.ipc.open_stream(pyarrow.BufferReader(view.to_arrow())) as reader: + return pl.from_dataframe(reader.read_pandas()) + + +class TestTablePolars(object): + def test_empty_table(self): + tbl = Table([]) + assert tbl.size() == 0 + assert tbl.schema() == {} + + def test_table_dataframe(self): + d = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + data = pl.DataFrame(d) + tbl = Table(data) + assert tbl.size() == 2 + assert tbl.schema() == {"a": "integer", "b": "integer"} + assert tbl.view().to_records() == [ + {"a": 1, "b": 2}, + {"a": 3, "b": 4}, + ] + + def test_table_lazyframe(self): + d = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + data = pl.DataFrame(d).lazy() + tbl = Table(data) + assert tbl.size() == 2 + assert tbl.schema() == {"a": "integer", "b": "integer"} + assert tbl.view().to_records() == [ + {"a": 1, "b": 2}, + {"a": 3, "b": 4}, + ] + + def test_table_dataframe_column_order(self): + d = [{"a": 1, "b": 2, "c": 3, "d": 4}, {"a": 3, "b": 4, "c": 5, "d": 6}] + data = pl.DataFrame(d).select(["b", "c", "a", "d"]) + tbl = Table(data) + assert tbl.size() == 2 + assert tbl.columns() == ["b", "c", "a", "d"] + + def test_table_dataframe_selective_column_order(self): + d = [{"a": 1, "b": 2, "c": 3, "d": 4}, {"a": 3, "b": 4, "c": 5, "d": 6}] + data = pl.DataFrame(d).select(["b", "c", "a"]) + tbl = Table(data) + assert tbl.size() == 2 + assert tbl.columns() == ["b", "c", "a"] + + def test_table_dataframe_does_not_mutate(self): + # make sure we don't mutate the dataframe that a user passes in + data = pl.DataFrame( + { + "a": [None, 1, None, 2], + "b": [1.5, None, 2.5, None], + } + ) + assert data["a"].to_list() == [None, 1, None, 2] + assert data["b"].to_list() == [1.5, None, 2.5, None] + + tbl = Table(data) + assert tbl.size() == 4 + assert tbl.schema() == {"a": "integer", "b": "float"} + + assert data["a"].to_list() == [None, 1, None, 2] + assert data["b"].to_list() == [1.5, None, 2.5, None] + + def test_table_polars_from_schema_int(self): + data = [None, 1, None, 2, None, 3, 4] + df = pl.DataFrame({"a": data}) + table = Table({"a": "integer"}) + table.update(df) + assert table.view().to_columns()["a"] == data + + def test_table_polars_from_schema_bool(self): + data = [True, False, True, False] + df = pl.DataFrame({"a": data}) + table = Table({"a": "boolean"}) + table.update(df) + assert table.view().to_columns()["a"] == data + + def test_table_polars_from_schema_float(self): + data = [None, 1.5, None, 2.5, None, 3.5, 4.5] + df = pl.DataFrame({"a": data}) + table = Table({"a": "float"}) + table.update(df) + assert table.view().to_columns()["a"] == data + + def test_table_polars_from_schema_float_all_nan(self): + data = [np.nan, np.nan, np.nan, np.nan] + df = pl.DataFrame({"a": data}) + table = Table({"a": "float"}) + table.update(df) + assert table.view().to_columns()["a"] == [None, None, None, None] + + def test_table_polars_from_schema_float_to_int(self): + data = [None, 1.5, None, 2.5, None, 3.5, 4.5] + df = pl.DataFrame({"a": data}) + table = Table({"a": "integer"}) + table.update(df) + # truncates decimal + assert table.view().to_columns()["a"] == [None, 1, None, 2, None, 3, 4] + + def test_table_polars_from_schema_int_to_float(self): + data = [None, 1, None, 2, None, 3, 4] + df = pl.DataFrame({"a": data}) + table = Table({"a": "float"}) + table.update(df) + assert table.view().to_columns()["a"] == [None, 1.0, None, 2.0, None, 3.0, 4.0] + + def test_table_polars_from_schema_date(self, util): + data = [date(2019, 8, 15), None, date(2019, 8, 16)] + df = pl.DataFrame({"a": data}) + table = Table({"a": "date"}) + table.update(df) + assert table.view().to_columns()["a"] == [ + util.to_timestamp(datetime(2019, 8, 15)), + None, + util.to_timestamp(datetime(2019, 8, 16)), + ] + + def test_table_polars_from_schema_str(self): + data = ["a", None, "b", None, "c"] + df = pl.DataFrame({"a": data}) + table = Table({"a": "string"}) + table.update(df) + assert table.view().to_columns()["a"] == data + + def test_table_polars_none(self): + data = [None, None, None] + df = pl.DataFrame({"a": data}) + table = Table(df) + assert table.view().to_columns()["a"] == data + + def test_table_polars_symmetric_table(self): + # make sure that updates are symmetric to table creation + df = pl.DataFrame({"a": [1, 2, 3, 4], "b": [1.5, 2.5, 3.5, 4.5]}) + t1 = Table(df) + t2 = Table({"a": "integer", "b": "float"}) + t2.update(df) + assert t1.view().to_columns() == { + "a": [1, 2, 3, 4], + "b": [1.5, 2.5, 3.5, 4.5], + } + + def test_table_polars_symmetric_stacked_updates(self): + # make sure that updates are symmetric to table creation + df = pl.DataFrame({"a": [1, 2, 3, 4], "b": [1.5, 2.5, 3.5, 4.5]}) + + t1 = Table(df) + t1.update(df) + + t2 = Table({"a": "integer", "b": "float"}) + t2.update(df) + t2.update(df) + + assert t1.view().to_columns() == { + "a": [1, 2, 3, 4, 1, 2, 3, 4], + "b": [1.5, 2.5, 3.5, 4.5, 1.5, 2.5, 3.5, 4.5], + } + + @mark.skip(reason="Not supported, polars doesnt like input") + def test_table_polars_transitive(self): + # serialized output -> table -> serialized output + records = { + "a": [1, 2, 3, 4], + "b": [1.5, 2.5, 3.5, 4.5], + "c": [np.nan, np.nan, "abc", np.nan], + "d": [None, True, None, False], + "e": [ + float("nan"), + datetime(2019, 7, 11, 12, 30), + float("nan"), + datetime(2019, 7, 11, 12, 30), + ], + } + + df = pl.DataFrame(records, strict=False) + t1 = Table(df) + out1 = arrow_bytes_to_polars(t1.view(columns=["a", "b", "c", "d", "e"])) + t2 = Table(out1) + assert t1.schema() == t2.schema() + out2 = t2.view().to_columns() + assert t1.view().to_columns() == out2 + + # dtype=object should have correct inferred types + + def test_table_polars_object_to_int(self): + df = pl.DataFrame({"a": [1, 2, None, 2, None, 3, 4]}) + table = Table(df) + assert table.schema() == {"a": "integer"} + assert table.view().to_columns()["a"] == [1, 2, None, 2, None, 3, 4] + + def test_table_polars_object_to_float(self): + df = pl.DataFrame({"a": [None, 1, None, 2, None, 3, 4]}) + table = Table(df) + assert table.schema() == {"a": "integer"} + assert table.view().to_columns()["a"] == [None, 1.0, None, 2.0, None, 3.0, 4.0] + + def test_table_polars_object_to_bool(self): + df = pl.DataFrame({"a": [True, False, True, False, True, False]}) + table = Table(df) + assert table.schema() == {"a": "boolean"} + assert table.view().to_columns()["a"] == [True, False, True, False, True, False] + + def test_table_polars_object_to_datetime(self): + df = pl.DataFrame( + { + "a": [ + datetime(2019, 7, 11, 1, 2, 3), + datetime(2019, 7, 12, 1, 2, 3), + None, + ] + } + ) + + table = Table(df) + assert table.schema() == {"a": "datetime"} + assert table.view().to_columns()["a"] == [ + datetime(2019, 7, 11, 1, 2, 3).timestamp() * 1000, + datetime(2019, 7, 12, 1, 2, 3).timestamp() * 1000, + None, + ] + + def test_table_polars_object_to_str(self): + df = pl.DataFrame({"a": np.array(["abc", "def", None, "ghi"], dtype=object)}) + table = Table(df) + assert table.schema() == {"a": "string"} + assert table.view().to_columns()["a"] == ["abc", "def", None, "ghi"] diff --git a/rust/perspective-python/perspective/tests/table/test_table_view_table.py b/rust/perspective-python/perspective/tests/table/test_table_view_table.py new file mode 100644 index 0000000..e6f1c9b --- /dev/null +++ b/rust/perspective-python/perspective/tests/table/test_table_view_table.py @@ -0,0 +1,130 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 pandas as pd +import numpy as np +from perspective import PerspectiveError +from datetime import date, datetime +from pytest import approx, mark, raises + +import perspective as psp + +client = psp.Server().new_local_client() +Table = client.table + + +def date_timestamp(date): + return int(datetime.combine(date, datetime.min.time()).timestamp()) * 1000 + + +def compare_delta(received, expected): + """Compare an arrow-serialized row delta by constructing a Table.""" + tbl = Table(received) + assert tbl.view().to_columns() == expected + + +class TestView(object): + def test_view_zero(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view() + tbl2 = Table(view) + view2 = tbl2.view() + dimms = view2.dimensions() + assert dimms["num_view_rows"] == 2 + assert dimms["num_view_columns"] == 2 + assert view2.schema() == {"a": "integer", "b": "integer"} + assert view2.to_records() == data + + def test_view_one(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(group_by=["a"]) + tbl2 = Table(view) + view2 = tbl2.view() + + dimms = view2.dimensions() + assert dimms["num_view_rows"] == 3 + assert dimms["num_view_columns"] == 3 + assert view2.schema() == { + "a (Group by 1)": "integer", + "a": "integer", + "b": "integer", + } + + assert view2.to_records() == [ + {"a (Group by 1)": None, "a": 4, "b": 6}, + {"a (Group by 1)": 1, "a": 1, "b": 2}, + {"a (Group by 1)": 3, "a": 3, "b": 4}, + ] + + def test_view_two(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(group_by=["a"], split_by=["b"]) + tbl2 = Table(view) + view2 = tbl2.view() + dimms = view2.dimensions() + assert dimms["num_view_rows"] == 3 + assert dimms["num_view_columns"] == 5 + assert view2.schema() == { + "a (Group by 1)": "integer", + "2|a": "integer", + "2|b": "integer", + "4|a": "integer", + "4|b": "integer", + } + + assert view2.to_records() == [ + { + "a (Group by 1)": None, + "2|a": 1, + "2|b": 2, + "4|a": 3, + "4|b": 4, + }, + { + "a (Group by 1)": 1, + "2|a": 1, + "2|b": 2, + "4|a": None, + "4|b": None, + }, + { + "a (Group by 1)": 3, + "2|a": None, + "2|b": None, + "4|a": 3, + "4|b": 4, + }, + ] + + def test_view_two_column_only(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(split_by=["b"]) + tbl2 = Table(view) + view2 = tbl2.view() + dimms = view2.dimensions() + assert dimms["num_view_rows"] == 2 + assert dimms["num_view_columns"] == 4 + assert view2.schema() == { + "2|a": "integer", + "2|b": "integer", + "4|a": "integer", + "4|b": "integer", + } + + assert view2.to_records() == [ + {"2|a": 1, "2|b": 2, "4|a": None, "4|b": None}, + {"2|a": None, "2|b": None, "4|a": 3, "4|b": 4}, + ] diff --git a/rust/perspective-python/perspective/tests/table/test_to_arrow.py b/rust/perspective-python/perspective/tests/table/test_to_arrow.py new file mode 100644 index 0000000..22748cc --- /dev/null +++ b/rust/perspective-python/perspective/tests/table/test_to_arrow.py @@ -0,0 +1,417 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 pyarrow as pa +from datetime import date, datetime +import perspective as psp + +client = psp.Server().new_local_client() +Table = client.table + + +class TestToArrow(object): + def test_to_arrow_nones_symmetric(self): + data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]} + tbl = Table(data) + assert tbl.schema() == {"a": "integer", "b": "float"} + arr = tbl.view().to_arrow() + tbl2 = Table(arr) + assert tbl2.view().to_columns() == data + + def test_to_arrow_big_numbers_symmetric(self): + data = { + "a": [1, 2, 3, 4], + "b": [ + 1.7976931348623157e308, + 1.7976931348623157e308, + 1.7976931348623157e308, + 1.7976931348623157e308, + ], + } + tbl = Table(data) + assert tbl.schema() == {"a": "integer", "b": "float"} + arr = tbl.view().to_arrow() + tbl2 = Table(arr) + assert tbl2.view().to_columns() == data + + def test_to_arrow_boolean_symmetric(self): + data = {"a": [True, False, None, False, True, None]} + tbl = Table(data) + assert tbl.schema() == {"a": "boolean"} + arr = tbl.view().to_arrow() + tbl2 = Table(arr) + assert tbl2.view().to_columns() == data + + def test_to_arrow_str_symmetric(self): + data = {"a": ["a", "b", "c", "d", "e", None]} + tbl = Table(data) + assert tbl.schema() == {"a": "string"} + arr = tbl.view().to_arrow() + tbl2 = Table(arr) + assert tbl2.view().to_columns() == data + + def test_to_arrow_str_dict(self): + data = { + "a": ["abcdefg", "abcdefg", "h"], + "b": ["aaa", "bbb", "bbb"], + "c": ["hello", "world", "world"], + } + tbl = Table(data) + assert tbl.schema() == {"a": "string", "b": "string", "c": "string"} + arr = tbl.view().to_arrow() + + # assert that we are actually generating dict arrays + buf = pa.BufferReader(arr) + reader = pa.ipc.open_stream(buf) + arrow_table = reader.read_all() + arrow_schema = arrow_table.schema + + for name in ("a", "b", "c"): + arrow_type = arrow_schema.field(name).type + assert pa.types.is_dictionary(arrow_type) + + # assert that data is symmetrical + tbl2 = Table(arr) + assert tbl2.view().to_columns() == data + + def test_to_arrow_date_symmetric(self): + # data = {"a": [date(2019, 7, 11), date(2016, 2, 29), date(2019, 12, 10)]} + # tbl = Table(data) + tbl = Table({"a": "date"}) + # data = {"a": map(lambda x: x.getTime(), [date(2019, 7, 11), date(2016, 2, 29), date(2019, 12, 10)])} + data = {"a": [date(2019, 7, 11), date(2016, 2, 29), date(2019, 12, 10)]} + tbl.update(data) + assert tbl.schema() == {"a": "date"} + arr = tbl.view().to_arrow() + tbl2 = Table(arr) + + def ts(x): + return int(datetime.timestamp(x) * 1000) + + assert tbl2.schema() == tbl.schema() + assert tbl2.view().to_columns() == { + "a": [ + ts(datetime(2019, 7, 11)), + ts(datetime(2016, 2, 29)), + ts(datetime(2019, 12, 10)), + ] + } + + def test_to_arrow_date_symmetric_january(self, util): + data = {"a": [date(2019, 1, 1), date(2016, 1, 1), date(2019, 1, 1)]} + tbl = Table(data) + assert tbl.schema() == {"a": "date"} + arr = tbl.view().to_arrow() + tbl2 = Table(arr) + assert tbl2.schema() == tbl.schema() + assert tbl2.view().to_columns() == { + "a": [ + util.to_timestamp(x) + for x in [ + datetime(2019, 1, 1), + datetime(2016, 1, 1), + datetime(2019, 1, 1), + ] + ] + } + + def test_to_arrow_datetime_symmetric(self, util): + data = { + "a": [ + datetime(2019, 7, 11, 12, 30), + datetime(2016, 2, 29, 11, 0), + datetime(2019, 12, 10, 12, 0), + ] + } + tbl = Table(data) + assert tbl.schema() == {"a": "datetime"} + arr = tbl.view().to_arrow() + tbl2 = Table(arr) + assert tbl2.schema() == tbl.schema() + assert tbl2.view().to_columns() == { + "a": [ + util.to_timestamp(x) + for x in [ + datetime(2019, 7, 11, 12, 30), + datetime(2016, 2, 29, 11, 0), + datetime(2019, 12, 10, 12, 0), + ] + ] + } + + def test_to_arrow_one_symmetric(self): + data = { + "a": [1, 2, 3, 4], + "b": ["a", "b", "c", "d"], + "c": [ + datetime(2019, 7, 11, 12, 0), + datetime(2019, 7, 11, 12, 10), + datetime(2019, 7, 11, 12, 20), + datetime(2019, 7, 11, 12, 30), + ], + } + tbl = Table(data) + view = tbl.view(group_by=["a"]) + arrow = view.to_arrow() + tbl2 = Table(arrow) + assert tbl2.schema() == { + "a (Group by 1)": "integer", + "a": "integer", + "b": "integer", + "c": "integer", + } + d = view.to_columns() + d["a (Group by 1)"] = [ + x[0] if len(x) > 0 else None for x in d.pop("__ROW_PATH__") + ] + assert tbl2.view().to_columns() == d + + def test_to_arrow_two_symmetric(self): + data = { + "a": [1, 2, 3, 4], + "b": ["hello", "world", "hello2", "world2"], + "c": [datetime(2019, 7, 11, 12, i) for i in range(0, 40, 10)], + } + tbl = Table(data) + view = tbl.view(group_by=["a"], split_by=["b"]) + arrow = view.to_arrow() + tbl2 = Table(arrow) + assert tbl2.schema() == { + "a (Group by 1)": "integer", + "hello|a": "integer", + "hello|b": "integer", + "hello|c": "integer", + "world|a": "integer", + "world|b": "integer", + "world|c": "integer", + "hello2|a": "integer", + "hello2|b": "integer", + "hello2|c": "integer", + "world2|a": "integer", + "world2|b": "integer", + "world2|c": "integer", + } + d = view.to_columns() + d["a (Group by 1)"] = [ + x[0] if len(x) > 0 else None for x in d.pop("__ROW_PATH__") + ] + assert tbl2.view().to_columns() == d + + def test_to_arrow_column_only_symmetric(self): + data = { + "a": [1, 2, 3, 4], + "b": ["a", "b", "c", "d"], + "c": [datetime(2019, 7, 11, 12, i) for i in range(0, 40, 10)], + } + tbl = Table(data) + view = tbl.view(split_by=["a"]) + arrow = view.to_arrow() + tbl2 = Table(arrow) + assert tbl2.schema() == { + "1|a": "integer", + "1|b": "string", + "1|c": "datetime", + "2|a": "integer", + "2|b": "string", + "2|c": "datetime", + "3|a": "integer", + "3|b": "string", + "3|c": "datetime", + "4|a": "integer", + "4|b": "string", + "4|c": "datetime", + } + d = view.to_columns() + assert tbl2.view().to_columns() == d + + # start and end row + def test_to_arrow_start_row(self): + data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]} + tbl = Table(data) + assert tbl.schema() == {"a": "integer", "b": "float"} + arr = tbl.view().to_arrow(start_row=3) + tbl2 = Table(arr) + assert tbl2.view().to_columns() == {"a": data["a"][3:], "b": data["b"][3:]} + + def test_to_arrow_end_row(self): + data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]} + tbl = Table(data) + assert tbl.schema() == {"a": "integer", "b": "float"} + arr = tbl.view().to_arrow(end_row=2) + tbl2 = Table(arr) + assert tbl2.view().to_columns() == {"a": data["a"][:2], "b": data["b"][:2]} + + def test_to_arrow_start_end_row(self): + data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]} + tbl = Table(data) + assert tbl.schema() == {"a": "integer", "b": "float"} + arr = tbl.view().to_arrow(start_row=2, end_row=3) + tbl2 = Table(arr) + assert tbl2.view().to_columns() == {"a": data["a"][2:3], "b": data["b"][2:3]} + + def test_to_arrow_start_end_row_equiv(self): + data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]} + tbl = Table(data) + assert tbl.schema() == {"a": "integer", "b": "float"} + arr = tbl.view().to_arrow(start_row=2, end_row=2) + tbl2 = Table(arr) + assert tbl2.view().to_columns() == {"a": [], "b": []} + + def test_to_arrow_start_row_invalid(self): + data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]} + tbl = Table(data) + assert tbl.schema() == {"a": "integer", "b": "float"} + arr = tbl.view().to_arrow(start_row=-1) + tbl2 = Table(arr) + assert tbl2.view().to_columns() == data + + def test_to_arrow_end_row_invalid(self): + data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]} + tbl = Table(data) + assert tbl.schema() == {"a": "integer", "b": "float"} + arr = tbl.view().to_arrow(end_row=6) + tbl2 = Table(arr) + assert tbl2.view().to_columns() == data + + def test_to_arrow_start_end_row_invalid(self): + data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]} + tbl = Table(data) + assert tbl.schema() == {"a": "integer", "b": "float"} + arr = tbl.view().to_arrow(start_row=-1, end_row=6) + tbl2 = Table(arr) + assert tbl2.view().to_columns() == data + + def test_to_arrow_start_col(self): + data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]} + tbl = Table(data) + assert tbl.schema() == {"a": "integer", "b": "float"} + arr = tbl.view().to_arrow(start_col=1) + tbl2 = Table(arr) + assert tbl2.view().to_columns() == {"b": data["b"]} + + def test_to_arrow_end_col(self): + data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]} + tbl = Table(data) + assert tbl.schema() == {"a": "integer", "b": "float"} + arr = tbl.view().to_arrow(end_col=1) + tbl2 = Table(arr) + assert tbl2.view().to_columns() == {"a": data["a"]} + + def test_to_arrow_start_end_col(self): + data = { + "a": [None, 1, None, 2, 3], + "b": [1.5, 2.5, None, 3.5, None], + "c": [None, 1, None, 2, 3], + "d": [1.5, 2.5, None, 3.5, None], + } + tbl = Table(data) + assert tbl.schema() == { + "a": "integer", + "b": "float", + "c": "integer", + "d": "float", + } + arr = tbl.view().to_arrow(start_col=1, end_col=3) + tbl2 = Table(arr) + assert tbl2.view().to_columns() == {"b": data["b"], "c": data["c"]} + + def test_to_arrow_start_col_invalid(self): + data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]} + tbl = Table(data) + assert tbl.schema() == {"a": "integer", "b": "float"} + arr = tbl.view().to_arrow(start_col=-1) + tbl2 = Table(arr) + assert tbl2.view().to_columns() == data + + def test_to_arrow_end_col_invalid(self): + data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]} + tbl = Table(data) + assert tbl.schema() == {"a": "integer", "b": "float"} + arr = tbl.view().to_arrow(end_col=6) + tbl2 = Table(arr) + assert tbl2.view().to_columns() == data + + def test_to_arrow_start_end_col_invalid(self): + data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]} + tbl = Table(data) + assert tbl.schema() == {"a": "integer", "b": "float"} + arr = tbl.view().to_arrow(start_col=-1, end_col=6) + tbl2 = Table(arr) + assert tbl2.view().to_columns() == data + + def test_to_arrow_start_end_col_equiv_row(self): + data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]} + tbl = Table(data) + assert tbl.schema() == {"a": "integer", "b": "float"} + arr = tbl.view().to_arrow(start_col=1, end_col=1, start_row=2, end_row=3) + tbl2 = Table(arr) + # start/end col is a range - thus start=end provides no columns + assert tbl2.view().to_columns() == {} + + def test_to_arrow_start_end_col_equiv(self): + data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]} + tbl = Table(data) + assert tbl.schema() == {"a": "integer", "b": "float"} + arr = tbl.view().to_arrow(start_col=1, end_col=1) + tbl2 = Table(arr) + assert tbl2.view().to_columns() == {} + + def test_to_arrow_start_end_row_end_col(self): + data = {"a": [None, 1, None, 2, 3], "b": [1.5, 2.5, None, 3.5, None]} + tbl = Table(data) + assert tbl.schema() == {"a": "integer", "b": "float"} + arr = tbl.view().to_arrow(end_col=1, start_row=2, end_row=3) + tbl2 = Table(arr) + assert tbl2.view().to_columns() == tbl.view().to_columns( + end_col=1, start_row=2, end_row=3 + ) + + def test_to_arrow_start_end_col_start_row(self): + data = { + "a": [None, 1, None, 2, 3], + "b": [1.5, 2.5, None, 3.5, None], + "c": [1.5, 2.5, None, 4.5, None], + } + tbl = Table(data) + assert tbl.schema() == {"a": "integer", "b": "float", "c": "float"} + arr = tbl.view().to_arrow(start_col=1, end_col=2, start_row=2) + tbl2 = Table(arr) + assert tbl2.view().to_columns() == tbl.view().to_columns( + start_col=1, end_col=2, start_row=2 + ) + + def test_to_arrow_start_end_col_end_row(self): + data = { + "a": [None, 1, None, 2, 3], + "b": [1.5, 2.5, None, 3.5, None], + "c": [1.5, 2.5, None, 4.5, None], + } + tbl = Table(data) + assert tbl.schema() == {"a": "integer", "b": "float", "c": "float"} + arr = tbl.view().to_arrow(start_col=1, end_col=2, end_row=2) + tbl2 = Table(arr) + assert tbl2.view().to_columns() == tbl.view().to_columns( + start_col=1, end_col=2, end_row=2 + ) + + def test_to_arrow_one_mean(self): + data = {"a": [1, 2, 3, 4], "b": ["a", "a", "b", "b"]} + + table = Table(data) + view = table.view(group_by=["b"], columns=["a"], aggregates={"a": "mean"}) + arrow = view.to_arrow() + + table2 = Table(arrow) + view2 = table2.view() + result = view2.to_columns() + + assert result == {"b (Group by 1)": [None, "a", "b"], "a": [2.5, 1.5, 3.5]} diff --git a/rust/perspective-python/perspective/tests/table/test_to_arrow_lz4.py b/rust/perspective-python/perspective/tests/table/test_to_arrow_lz4.py new file mode 100644 index 0000000..4da9972 --- /dev/null +++ b/rust/perspective-python/perspective/tests/table/test_to_arrow_lz4.py @@ -0,0 +1,32 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 as psp + +client = psp.Server().new_local_client() +Table = client.table + + +class TestToArrowLZ4(object): + def test_to_arrow_lz4_roundtrip(self, superstore): + original_tbl = Table(superstore.to_dict(orient="records")) + arrow_uncompressed = original_tbl.view().to_arrow(compression=None) + + tbl = Table(arrow_uncompressed) + arr = tbl.view().to_arrow(compression="lz4") + assert len(arr) < len(arrow_uncompressed) + tbl2 = Table(arr) + arr2 = tbl2.view().to_arrow(compression=None) + assert len(arr2) > len(arr) + tbl3 = Table(arr) + arr3 = tbl3.view().to_arrow(compression="lz4") + assert len(arr3) == len(arr) diff --git a/rust/perspective-python/perspective/tests/table/test_to_format.py b/rust/perspective-python/perspective/tests/table/test_to_format.py new file mode 100644 index 0000000..7bca866 --- /dev/null +++ b/rust/perspective-python/perspective/tests/table/test_to_format.py @@ -0,0 +1,1024 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 os +from datetime import date, datetime +from io import StringIO + +import numpy as np +import pandas as pd +import pytz +from pytest import mark +import perspective as psp + +client = psp.Server().new_local_client() +Table = client.table + +IS_WIN = os.name == "nt" + + +class TestToFormat(object): + # to_records + + def test_to_records_int(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view() + assert view.to_records() == data + + def test_to_records_float(self): + data = [{"a": 1.5, "b": 2.5}, {"a": 3.5, "b": 4.5}] + tbl = Table(data) + view = tbl.view() + assert view.to_records() == data + + def test_to_records_string(self): + data = [{"a": "string1", "b": "string2"}, {"a": "string3", "b": "string4"}] + tbl = Table(data) + view = tbl.view() + assert view.to_records() == data + + def test_to_records_date(self, util): + today = date.today() + dt = datetime(today.year, today.month, today.day) + data = [{"a": today, "b": "string2"}, {"a": today, "b": "string4"}] + tbl = Table(data) + view = tbl.view() + assert view.to_records() == [ + {"a": util.to_timestamp(dt), "b": "string2"}, + {"a": util.to_timestamp(dt), "b": "string4"}, + ] + + def test_to_records_date_no_dst(self, util): + # make sure that DST does not affect the way we read dates - if tm_dst in `t_date::get_tm()` isn't set to -1, it could reverse 1hr by assuming DST is not in effect. + today = date.today() + dt = datetime(today.year, today.month, today.day) + data = [{"a": today, "b": "string2"}, {"a": today, "b": "string4"}] + tbl = Table(data) + view = tbl.view() + assert view.to_records() == [ + {"a": util.to_timestamp(dt), "b": "string2"}, + {"a": util.to_timestamp(dt), "b": "string4"}, + ] + + def test_to_records_date_str(self, util): + data = [ + {"a": "03/11/2019", "b": "string2"}, + {"a": "03/12/2019", "b": "string4"}, + ] + tbl = Table(data) + view = tbl.view() + assert view.to_records() == [ + {"a": util.to_timestamp(datetime(2019, 3, 11)), "b": "string2"}, + {"a": util.to_timestamp(datetime(2019, 3, 12)), "b": "string4"}, + ] + + def test_to_records_date_str_month_first(self, util): + data = [{"a": "1/2/2019", "b": "string2"}, {"a": "3/4/2019", "b": "string4"}] + tbl = Table(data) + view = tbl.view() + assert view.schema() == {"a": "date", "b": "string"} + assert view.to_records() == [ + {"a": util.to_timestamp(datetime(2019, 1, 2)), "b": "string2"}, + {"a": util.to_timestamp(datetime(2019, 3, 4)), "b": "string4"}, + ] + + def test_to_records_date_str_month_ymd(self, util): + tbl = Table({"a": "date", "b": "string"}) + data = [ + {"a": "2019/01/02", "b": "string2"}, + {"a": "2019/03/04", "b": "string4"}, + ] + tbl.update(data) + view = tbl.view() + assert view.schema() == {"a": "date", "b": "string"} + assert view.to_records() == [ + {"a": util.to_timestamp(datetime(2019, 1, 2)), "b": "string2"}, + {"a": util.to_timestamp(datetime(2019, 3, 4)), "b": "string4"}, + ] + + def test_to_records_datetime(self, util): + dt = datetime(2019, 9, 10, 19, 30, 59, 515000) + data = [{"a": str(dt), "b": "string2"}, {"a": str(dt), "b": "string4"}] + tbl = Table({"a": "datetime", "b": "string"}) + tbl.update(data) + view = tbl.view() + data_out = [ + {"a": util.to_timestamp(dt), "b": "string2"}, + {"a": util.to_timestamp(dt), "b": "string4"}, + ] + assert view.to_records() == data_out # should have symmetric input/output + + @mark.skip(reason="No longer supported format") + def test_to_records_datetime_str(self, util): + data = [ + {"a": "03/11/2019 3:15PM", "b": "string2"}, + {"a": "3/11/2019 3:20PM", "b": "string4"}, + ] + tbl = Table({"a": "datetime", "b": "string"}) + tbl.update(data) + view = tbl.view() + assert view.schema()["a"] == "datetime" + assert view.to_records() == [ + {"a": util.to_timestamp(datetime(2019, 3, 11, 15, 15)), "b": "string2"}, + {"a": util.to_timestamp(datetime(2019, 3, 11, 15, 20)), "b": "string4"}, + ] + + @mark.skip(reason="No longer supported format") + def test_to_records_datetime_str_tz(self, util): + dt = "2019/07/25T15:30:00+00:00" + data = [{"a": dt}, {"a": dt}] + tbl = Table({"a": "datetime"}) + tbl.update(data) + + view = tbl.view() + assert view.schema()["a"] == "datetime" + records = view.to_records() + for r in records: + r["a"] = r["a"].replace(tzinfo=pytz.utc) + assert records == [ + {"a": (datetime(2019, 7, 25, 15, 30, tzinfo=pytz.utc))}, + {"a": (datetime(2019, 7, 25, 15, 30, tzinfo=pytz.utc))}, + ] + + @mark.skip(reason="No longer supported format") + def test_to_records_datetime_ms_str(self, util): + data = [{"a": "03/11/2019 3:15:15.999PM"}, {"a": "3/11/2019 3:15:16.001PM"}] + tbl = Table({"a": "datetime"}) + tbl.update(data) + + view = tbl.view() + assert view.schema()["a"] == "datetime" + assert view.to_records() == [ + {"a": util.to_timestamp(datetime(2019, 3, 11, 15, 15, 15, 999000))}, + {"a": util.to_timestamp(datetime(2019, 3, 11, 15, 15, 16, 1000))}, + ] + + def test_to_records_none(self): + data = [{"a": None, "b": 1}, {"a": None, "b": 2}] + tbl = Table(data) + view = tbl.view() + assert view.to_records() == data + + def test_to_records_one(self): + data = [{"a": 1, "b": "string1"}, {"a": 1, "b": "string2"}] + tbl = Table(data) + view = tbl.view(group_by=["a"]) + assert view.to_records() == [ + {"__ROW_PATH__": [], "a": 2, "b": 2}, + {"__ROW_PATH__": [1], "a": 2, "b": 2}, + ] + + def test_to_records_two(self): + data = [{"a": 1, "b": "string1"}, {"a": 1, "b": "string2"}] + tbl = Table(data) + view = tbl.view(group_by=["a"], split_by=["b"]) + assert view.to_records() == [ + { + "__ROW_PATH__": [], + "string1|a": 1, + "string1|b": 1, + "string2|a": 1, + "string2|b": 1, + }, + { + "__ROW_PATH__": [1], + "string1|a": 1, + "string1|b": 1, + "string2|a": 1, + "string2|b": 1, + }, + ] + + def test_to_records_column_only(self): + data = [{"a": 1, "b": "string1"}, {"a": 1, "b": "string2"}] + tbl = Table(data) + view = tbl.view(split_by=["b"]) + assert view.to_records() == [ + { + "string1|a": 1, + "string1|b": "string1", + "string2|a": None, + "string2|b": None, + }, + { + "string1|a": None, + "string1|b": None, + "string2|a": 1, + "string2|b": "string2", + }, + ] + + # to_columns + + def test_to_columns_int(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view() + assert view.to_columns() == {"a": [1, 3], "b": [2, 4]} + + def test_to_columns_float(self): + data = [{"a": 1.5, "b": 2.5}, {"a": 3.5, "b": 4.5}] + tbl = Table(data) + view = tbl.view() + assert view.to_columns() == {"a": [1.5, 3.5], "b": [2.5, 4.5]} + + def test_to_columns_date(self, util): + today = date.today() + dt = datetime(today.year, today.month, today.day) + data = [{"a": today, "b": 2}, {"a": today, "b": 4}] + tbl = Table(data) + view = tbl.view() + assert view.to_columns() == { + "a": [util.to_timestamp(dt), util.to_timestamp(dt)], + "b": [2, 4], + } + + def test_to_columns_datetime(self, util): + dt = datetime(2019, 3, 15, 20, 30, 59, 6000) + data = [{"a": dt, "b": 2}, {"a": dt, "b": 4}] + tbl = Table(data) + view = tbl.view() + assert view.to_columns() == { + "a": [util.to_timestamp(dt), util.to_timestamp(dt)], + "b": [2, 4], + } + + def test_to_columns_bool(self): + data = [{"a": True, "b": False}, {"a": True, "b": False}] + tbl = Table(data) + view = tbl.view() + assert view.to_columns() == {"a": [True, True], "b": [False, False]} + + def test_to_columns_string(self): + data = [{"a": "string1", "b": "string2"}, {"a": "string3", "b": "string4"}] + tbl = Table(data) + view = tbl.view() + assert view.to_columns() == { + "a": ["string1", "string3"], + "b": ["string2", "string4"], + } + + def test_to_columns_none(self): + data = [{"a": None, "b": None}, {"a": None, "b": None}] + tbl = Table(data) + view = tbl.view() + assert view.to_columns() == {"a": [None, None], "b": [None, None]} + + def test_to_columns_one(self): + data = [{"a": 1, "b": 2}, {"a": 1, "b": 2}] + tbl = Table(data) + view = tbl.view(group_by=["a"]) + assert view.to_columns() == { + "__ROW_PATH__": [[], [1]], + "a": [2, 2], + "b": [4, 4], + } + + def test_to_columns_two(self): + data = [{"a": 1, "b": 2}, {"a": 1, "b": 2}] + tbl = Table(data) + view = tbl.view(group_by=["a"], split_by=["b"]) + assert view.to_columns() == { + "__ROW_PATH__": [[], [1]], + "2|a": [2, 2], + "2|b": [4, 4], + } + + def test_to_columns_column_only(self): + data = [{"a": 1, "b": 2}, {"a": 1, "b": 2}] + tbl = Table(data) + view = tbl.view(split_by=["b"]) + assert view.to_columns() == { + "2|a": [1, 1], + "2|b": [2, 2], + } + + def test_to_columns_one_no_columns(self): + data = [{"a": 1, "b": 2}, {"a": 1, "b": 2}] + tbl = Table(data) + view = tbl.view(group_by=["a"], columns=[]) + assert view.to_columns() == {"__ROW_PATH__": [[], [1]]} + + def test_to_columns_two_no_columns(self): + data = [{"a": 1, "b": 2}, {"a": 1, "b": 2}] + tbl = Table(data) + view = tbl.view(group_by=["a"], split_by=["b"], columns=[]) + assert view.to_columns() == {"__ROW_PATH__": [[], [1]]} + + def test_to_columns_column_only_no_columns(self): + data = [{"a": 1, "b": 2}, {"a": 1, "b": 2}] + tbl = Table(data) + view = tbl.view(split_by=["b"], columns=[]) + assert view.to_columns() == {} + + # to_numpy + + @mark.skip(reason="No numpy support") + def test_to_numpy_int(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view() + v = view.to_numpy() + assert np.array_equal(v["a"], np.array([1, 3])) + assert np.array_equal(v["b"], np.array([2, 4])) + + @mark.skip(reason="No numpy support") + def test_to_numpy_float(self): + data = [{"a": 1.5, "b": 2.5}, {"a": 3.5, "b": 4.5}] + tbl = Table(data) + view = tbl.view() + v = view.to_numpy() + assert np.array_equal(v["a"], np.array([1.5, 3.5])) + assert np.array_equal(v["b"], np.array([2.5, 4.5])) + + @mark.skip(reason="No numpy support") + def test_to_numpy_bool(self): + data = [{"a": True, "b": False}, {"a": True, "b": False}] + tbl = Table(data) + view = tbl.view() + v = view.to_numpy() + assert np.array_equal(v["a"], np.array([True, True])) + assert np.array_equal(v["b"], np.array([False, False])) + + @mark.skip(reason="No numpy support") + def test_to_numpy_date(self): + today = date.today() + dt = datetime(today.year, today.month, today.day) + data = [{"a": dt, "b": 2}, {"a": dt, "b": 4}] + tbl = Table(data) + view = tbl.view() + v = view.to_numpy() + assert np.array_equal(v["a"], np.array([dt, dt])) + + @mark.skip(reason="No numpy support") + def test_to_numpy_datetime(self): + dt = datetime(2019, 3, 15, 20, 30, 59, 6000) + data = [{"a": dt}, {"a": dt}] + tbl = Table(data) + view = tbl.view() + v = view.to_numpy() + assert np.array_equal(v["a"], np.array([dt, dt])) + + @mark.skip(reason="No numpy support") + def test_to_numpy_string(self): + data = [{"a": "string1", "b": "string2"}, {"a": "string3", "b": "string4"}] + tbl = Table(data) + view = tbl.view() + v = view.to_numpy() + assert np.array_equal(v["a"], np.array(["string1", "string3"])) + assert np.array_equal(v["b"], np.array(["string2", "string4"])) + + @mark.skip(reason="No numpy support") + def test_to_numpy_none(self): + data = [{"a": None, "b": None}, {"a": None, "b": None}] + tbl = Table(data) + view = tbl.view() + v = view.to_numpy() + assert np.array_equal(v["a"], np.array([None, None])) + assert np.array_equal(v["b"], np.array([None, None])) + + @mark.skip(reason="No numpy support") + def test_to_numpy_one(self): + data = [{"a": 1, "b": 2}, {"a": 1, "b": 2}] + tbl = Table(data) + view = tbl.view(group_by=["a"]) + v = view.to_numpy() + assert np.array_equal(v["__ROW_PATH__"], np.array([[], [1]], dtype="object")) + assert np.array_equal(v["a"], np.array([2, 2])) + assert np.array_equal(v["b"], np.array([4, 4])) + + @mark.skip(reason="No numpy support") + def test_to_numpy_two(self): + data = [{"a": 1, "b": 2}, {"a": 1, "b": 2}] + tbl = Table(data) + view = tbl.view(group_by=["a"], split_by=["b"]) + v = view.to_numpy() + assert np.array_equal(v["__ROW_PATH__"], np.array([[], [1]], dtype="object")) + assert np.array_equal(v["2|a"], np.array([2, 2])) + assert np.array_equal(v["2|b"], np.array([4, 4])) + + @mark.skip(reason="No numpy support") + def test_to_numpy_column_only(self): + data = [{"a": 1, "b": 2}, {"a": 1, "b": 2}] + tbl = Table(data) + view = tbl.view(split_by=["b"]) + v = view.to_numpy() + assert np.array_equal(v["2|a"], np.array([1, 1])) + assert np.array_equal(v["2|b"], np.array([2, 2])) + + @mark.skip(reason="No numpy support") + def test_to_pandas_df_simple(self): + data = [{"a": 1, "b": 2}, {"a": 1, "b": 2}] + df = pd.DataFrame(data) + tbl = Table(df) + view = tbl.view() + df2 = view.to_df() + assert np.array_equal(df2.columns, pd.Index(["index", "a", "b"], dtype=object)) + assert np.array_equal(df2["a"].values, df["a"].values) + assert np.array_equal(df2["b"].values, df["b"].values) + + @mark.skip(reason="No numpy support") + def test_to_pandas_df_simple_series(self): + inp = pd.Series([1, 2, 3], name="a") + df = pd.DataFrame() + df["a"] = pd.Series([1, 2, 3]) + tbl = Table(inp) + view = tbl.view() + df2 = view.to_df() + assert np.array_equal(df2.columns, pd.Index(["index", "a"], dtype=object)) + assert np.array_equal(df2["a"].values, df["a"].values) + + # start_row/end_row + def test_to_records_zero_over_max_row(self): + data = [{"a": 1.5, "b": 2.5}, {"a": 3.5, "b": 4.5}] + tbl = Table(data) + view = tbl.view() + records = view.to_records(end_row=1000) + assert records == data + + def test_to_records_one_over_max_row(self): + data = [{"a": 1.5, "b": 2.5}, {"a": 3.5, "b": 4.5}] + tbl = Table(data) + view = tbl.view(group_by=["a"]) + records = view.to_records(end_row=1000) + assert records == [ + {"__ROW_PATH__": [], "a": 5, "b": 7}, + {"__ROW_PATH__": [1.5], "a": 1.5, "b": 2.5}, + {"__ROW_PATH__": [3.5], "a": 3.5, "b": 4.5}, + ] + + def test_to_records_two_over_max_row(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(group_by=["a"], split_by=["b"]) + records = view.to_records(end_row=1000) + assert records == [ + {"2|a": 1, "2|b": 2, "4|a": 3, "4|b": 4, "__ROW_PATH__": []}, + {"2|a": 1, "2|b": 2, "4|a": None, "4|b": None, "__ROW_PATH__": [1]}, + {"2|a": None, "2|b": None, "4|a": 3, "4|b": 4, "__ROW_PATH__": [3]}, + ] + + def test_to_records_start_row(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view() + records = view.to_records(start_row=1) + assert records == [{"a": 3, "b": 4}] + + def test_to_records_end_row(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view() + records = view.to_records(end_row=1) + assert records == [{"a": 1, "b": 2}] + + def test_to_records_start_row_end_row(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}, {"a": 5, "b": 6}] + tbl = Table(data) + view = tbl.view() + records = view.to_records(start_row=1, end_row=2) + assert records == [{"a": 3, "b": 4}] + + def test_to_records_start_row_end_row_equiv(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}, {"a": 5, "b": 6}] + tbl = Table(data) + view = tbl.view() + records = view.to_records(start_row=1, end_row=1) + assert records == [] + + def test_to_records_floor_start_row(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view() + records = view.to_records(start_row=1.5) + assert records == [{"a": 3, "b": 4}] + + def test_to_records_ceil_end_row(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view() + records = view.to_records(end_row=0.5) + assert records == [{"a": 1, "b": 2}] + + def test_to_records_floor_start_row_ceil_end_row(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}, {"a": 5, "b": 6}] + tbl = Table(data) + view = tbl.view() + records = view.to_records(start_row=1.5, end_row=1.5) + assert records == [{"a": 3, "b": 4}] + + def test_to_records_floor_start_row_ceil_end_row_equiv(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}, {"a": 5, "b": 6}] + tbl = Table(data) + view = tbl.view() + records = view.to_records(start_row=1.5, end_row=0.5) + assert records == [] + + # start_col/end_col + + def test_to_records_zero_over_max_col(self): + data = [{"a": 1.5, "b": 2.5}, {"a": 3.5, "b": 4.5}] + tbl = Table(data) + view = tbl.view() + records = view.to_records(end_col=1000) + assert records == data + + def test_to_records_zero_start_gt_end_col(self): + data = [{"a": 1.5, "b": 2.5}, {"a": 3.5, "b": 4.5}] + tbl = Table(data) + view = tbl.view() + records = view.to_records(start_col=2, end_col=1) + assert records == [] + + def test_to_records_zero_start_eq_end_col(self): + data = [{"a": 1.5, "b": 2.5}, {"a": 3.5, "b": 4.5}] + tbl = Table(data) + view = tbl.view() + records = view.to_records(start_col=1, end_col=1) + assert records == [] + + def test_to_records_one_over_max_col(self): + data = [{"a": 1.5, "b": 2.5}, {"a": 3.5, "b": 4.5}] + tbl = Table(data) + view = tbl.view(group_by=["a"]) + records = view.to_records(end_col=1000) + assert records == [ + {"__ROW_PATH__": [], "a": 5, "b": 7}, + {"__ROW_PATH__": [1.5], "a": 1.5, "b": 2.5}, + {"__ROW_PATH__": [3.5], "a": 3.5, "b": 4.5}, + ] + + def test_to_records_one_start_gt_end_col(self): + data = [{"a": 1.5, "b": 2.5}, {"a": 3.5, "b": 4.5}] + tbl = Table(data) + view = tbl.view(group_by=["a"]) + records = view.to_records(start_col=2, end_col=1) + assert records == [ + {"__ROW_PATH__": []}, + {"__ROW_PATH__": [1.5]}, + {"__ROW_PATH__": [3.5]}, + ] + + def test_to_records_one_start_gt_end_col_large(self): + data = [{"a": 1.5, "b": 2.5}, {"a": 3.5, "b": 4.5}] + tbl = Table(data) + view = tbl.view(group_by=["a"]) + records = view.to_records(start_col=20, end_col=19) + assert records == [ + {"__ROW_PATH__": []}, + {"__ROW_PATH__": [1.5]}, + {"__ROW_PATH__": [3.5]}, + ] + + def test_to_records_one_start_eq_end_col(self): + data = [{"a": 1.5, "b": 2.5}, {"a": 3.5, "b": 4.5}] + tbl = Table(data) + view = tbl.view(group_by=["a"]) + records = view.to_records(start_col=0, end_col=0) + assert records == [ + {"__ROW_PATH__": []}, + {"__ROW_PATH__": [1.5]}, + {"__ROW_PATH__": [3.5]}, + ] + + def test_to_records_two_over_max_col(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(group_by=["a"], split_by=["b"]) + records = view.to_records(end_col=1000) + assert records == [ + {"2|a": 1, "2|b": 2, "4|a": 3, "4|b": 4, "__ROW_PATH__": []}, + {"2|a": 1, "2|b": 2, "4|a": None, "4|b": None, "__ROW_PATH__": [1]}, + {"2|a": None, "2|b": None, "4|a": 3, "4|b": 4, "__ROW_PATH__": [3]}, + ] + + def test_to_records_start_col(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view() + records = view.to_records(start_col=1) + assert records == [{"b": 2}, {"b": 4}] + + def test_to_records_end_col(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view() + records = view.to_records(end_col=1) + assert records == [{"a": 1}, {"a": 3}] + + def test_to_records_two_end_col(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(group_by=["a"], split_by=["b"]) + records = view.to_records(end_row=12, end_col=5) + assert records == [ + {"2|a": 1, "2|b": 2, "4|a": 3, "4|b": 4, "__ROW_PATH__": []}, + {"2|a": 1, "2|b": 2, "4|a": None, "4|b": None, "__ROW_PATH__": [1]}, + {"2|a": None, "2|b": None, "4|a": 3, "4|b": 4, "__ROW_PATH__": [3]}, + ] + + def test_to_records_two_start_gt_end_col(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(group_by=["a"], split_by=["b"]) + records = view.to_records(end_row=12, start_col=5, end_col=4) + assert records == [ + {"__ROW_PATH__": []}, + {"__ROW_PATH__": [1]}, + {"__ROW_PATH__": [3]}, + ] + + def test_to_records_two_start_gt_end_col_large_overage(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(group_by=["a"], split_by=["b"]) + records = view.to_records(end_row=12, start_col=50, end_col=49) + assert records == [ + {"__ROW_PATH__": []}, + {"__ROW_PATH__": [1]}, + {"__ROW_PATH__": [3]}, + ] + + def test_to_records_two_start_end_col_equiv(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(group_by=["a"], split_by=["b"]) + records = view.to_records(end_row=12, start_col=5, end_col=5) + assert records == [ + {"__ROW_PATH__": []}, + {"__ROW_PATH__": [1]}, + {"__ROW_PATH__": [3]}, + ] + + def test_to_records_two_sorted_start_gt_end_col(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(group_by=["a"], split_by=["b"], sort=[["a", "desc"]]) + records = view.to_records(end_row=12, start_col=5, end_col=4) + assert records == [ + {"__ROW_PATH__": []}, + {"__ROW_PATH__": [3]}, + {"__ROW_PATH__": [1]}, + ] + + def test_to_records_two_sorted_start_gt_end_col_large_overage(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(group_by=["a"], split_by=["b"], sort=[["a", "desc"]]) + records = view.to_records(end_row=12, start_col=20, end_col=30) + assert records == [ + {"__ROW_PATH__": []}, + {"__ROW_PATH__": [3]}, + {"__ROW_PATH__": [1]}, + ] + + def test_to_records_two_sorted_start_gt_end_col_overage(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view( + columns=[], group_by=["a"], split_by=["b"], sort=[["a", "desc"]] + ) + records = view.to_records(end_row=12, start_col=1, end_col=3) + assert records == [ + {"__ROW_PATH__": []}, + {"__ROW_PATH__": [3]}, + {"__ROW_PATH__": [1]}, + ] + + def test_to_records_two_sorted_start_end_col(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(group_by=["a"], split_by=["b"], sort=[["a", "desc"]]) + records = view.to_records(start_col=1, end_col=2) + assert records == [ + {"2|b": 2, "__ROW_PATH__": []}, + {"2|b": None, "__ROW_PATH__": [3]}, + {"2|b": 2, "__ROW_PATH__": [1]}, + ] + + def test_to_records_two_sorted_start_end_col_equiv(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(group_by=["a"], split_by=["b"], sort=[["a", "desc"]]) + records = view.to_records(end_row=12, start_col=5, end_col=5) + assert records == [ + {"__ROW_PATH__": []}, + {"__ROW_PATH__": [3]}, + {"__ROW_PATH__": [1]}, + ] + + def test_to_records_start_col_end_col(self): + data = [{"a": 1, "b": 2, "c": 3}, {"a": 3, "b": 4, "c": 5}] + tbl = Table(data) + view = tbl.view() + records = view.to_records(start_col=1, end_col=2) + # start_col and end_col access columns at that index - dict key order not guaranteed in python2 + assert records == [{"b": 2}, {"b": 4}] + + def test_to_records_start_col_end_col_equiv(self): + data = [{"a": 1, "b": 2, "c": 3}, {"a": 3, "b": 4, "c": 5}] + tbl = Table(data) + view = tbl.view() + records = view.to_records(start_col=1, end_col=1) + assert records == [] + + def test_to_records_floor_start_col(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view() + records = view.to_records(start_col=1.5) + assert records == [{"b": 2}, {"b": 4}] + + def test_to_records_ceil_end_col(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view() + records = view.to_records(end_col=1) + assert records == [{"a": 1}, {"a": 3}] + + def test_to_records_two_ceil_end_col(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(group_by=["a"], split_by=["b"]) + records = view.to_records(end_row=12, end_col=4.5) + assert records == [ + {"2|a": 1, "2|b": 2, "4|a": 3, "4|b": 4, "__ROW_PATH__": []}, + {"2|a": 1, "2|b": 2, "4|a": None, "4|b": None, "__ROW_PATH__": [1]}, + {"2|a": None, "2|b": None, "4|a": 3, "4|b": 4, "__ROW_PATH__": [3]}, + ] + + def test_to_records_floor_start_col_ceil_end_col(self): + data = [{"a": 1, "b": 2, "c": 3}, {"a": 3, "b": 4, "c": 5}] + tbl = Table(data) + view = tbl.view() + records = view.to_records(start_col=1.5, end_col=1.5) + # start_col and end_col access columns at that index - dict key order not guaranteed in python2 + assert records == [{"b": 2}, {"b": 4}] + + def test_to_columns_start_col_end_col(self): + data = [{"a": 1, "b": 2, "c": 3, "d": 4}, {"a": 3, "b": 4, "c": 5, "d": 6}] + tbl = Table(data) + view = tbl.view() + d = view.to_columns(start_col=1, end_col=3) + assert d == {"b": [2, 4], "c": [3, 5]} + + # to csv + + # XXX: Table(pandas_df) does not include the index column anymore. + def test_to_csv_symmetric(self): + csv = "a,b\n1,2\n3,4" + df = pd.read_csv(StringIO(csv)) + tbl = Table(df) + view = tbl.view() + assert view.to_csv() == '"index","a","b"\n0,1,2\n1,3,4\n' + + def test_to_csv_int(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view() + assert view.to_csv() == '"a","b"\n1,2\n3,4\n' + + def test_to_csv_float(self): + data = [{"a": 1.5, "b": 2.5}, {"a": 3.5, "b": 4.5}] + tbl = Table(data) + view = tbl.view() + assert view.to_csv() == '"a","b"\n1.5,2.5\n3.5,4.5\n' + + def test_to_csv_date(self): + today = date.today() + dt_str = today.strftime("%Y-%m-%d") + data = [{"a": today, "b": 2}, {"a": today, "b": 4}] + tbl = Table(data) + assert tbl.schema()["a"] == "date" + view = tbl.view() + assert view.to_csv() == '"a","b"\n{},2\n{},4\n'.format(dt_str, dt_str) + + # XXX: Broken because our datetime parsing is broken + def test_to_csv_datetime(self): + dt = datetime(2019, 3, 15, 20, 30, 59, 6000) + dt_str = dt.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + data = [{"a": dt, "b": 2}, {"a": dt, "b": 4}] + tbl = Table(data) + view = tbl.view() + assert view.to_csv() == '"a","b"\n{},2\n{},4\n'.format(dt_str, dt_str) + + def test_to_csv_bool(self): + data = [{"a": True, "b": False}, {"a": True, "b": False}] + tbl = Table(data) + view = tbl.view() + assert view.to_csv() == '"a","b"\ntrue,false\ntrue,false\n' + + def test_to_csv_string(self): + data = [{"a": "string1", "b": "string2"}, {"a": "string3", "b": "string4"}] + tbl = Table(data) + view = tbl.view() + assert view.to_csv() == '"a","b"\n"string1","string2"\n"string3","string4"\n' + + def test_to_csv_none(self): + data = [{"a": None, "b": None}, {"a": None, "b": None}] + tbl = Table(data) + view = tbl.view() + assert view.to_csv() == '"a","b"\n,\n,\n' + + def test_to_csv_custom_rows(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view() + assert view.to_csv(start_row=1) == '"a","b"\n3,4\n' + + def test_to_csv_custom_cols(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view() + assert view.to_csv(start_col=1) == '"b"\n2\n4\n' + + def test_to_csv_custom_rows_cols(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view() + assert view.to_csv(start_row=1, start_col=1) == '"b"\n4\n' + + def test_to_csv_one(self): + data = [{"a": 1, "b": 2}, {"a": 1, "b": 2}] + tbl = Table(data) + view = tbl.view(group_by=["a"]) + assert view.to_csv() == '"a (Group by 1)","a","b"\n,2,4\n1,2,4\n' + + def test_to_csv_two(self): + data = [{"a": 1, "b": 2}, {"a": 1, "b": 2}] + tbl = Table(data) + view = tbl.view(group_by=["a"], split_by=["b"]) + assert view.to_csv() == '"a (Group by 1)","2|a","2|b"\n,2,4\n1,2,4\n' + + def test_to_csv_column_only(self): + data = [{"a": 1, "b": 2}, {"a": 1, "b": 2}] + tbl = Table(data) + view = tbl.view(split_by=["b"]) + assert view.to_csv() == '"2|a","2|b"\n1,2\n1,2\n' + + def test_to_csv_one_no_columns(self): + data = [{"a": 1, "b": 2}, {"a": 1, "b": 2}] + tbl = Table(data) + view = tbl.view(group_by=["a"], columns=[]) + assert view.to_csv() == '"a (Group by 1)"\n\n1\n' + + def test_to_csv_two_no_columns(self): + data = [{"a": 1, "b": 2}, {"a": 1, "b": 2}] + tbl = Table(data) + view = tbl.view(group_by=["a"], split_by=["b"], columns=[]) + assert view.to_csv() == '"a (Group by 1)"\n\n1\n' + + def test_to_csv_column_only_no_columns(self): + data = [{"a": 1, "b": 2}, {"a": 1, "b": 2}] + tbl = Table(data) + view = tbl.view(split_by=["b"], columns=[]) + + assert view.to_csv() == "" + + # implicit index + + def test_to_format_implicit_index_records(self): + data = [{"a": 1.5, "b": 2.5}, {"a": 3.5, "b": 4.5}] + tbl = Table(data) + view = tbl.view() + assert view.to_records(index=True) == [ + {"__INDEX__": [0], "a": 1.5, "b": 2.5}, + {"__INDEX__": [1], "a": 3.5, "b": 4.5}, + ] + + def test_to_format_implicit_index_dict(self): + data = [{"a": 1.5, "b": 2.5}, {"a": 3.5, "b": 4.5}] + tbl = Table(data) + view = tbl.view() + assert view.to_columns(index=True) == { + "__INDEX__": [[0], [1]], + "a": [1.5, 3.5], + "b": [2.5, 4.5], + } + + def test_to_format_implicit_id_records(self): + data = [{"a": 1.5, "b": 2.5}, {"a": 3.5, "b": 4.5}] + tbl = Table(data) + view = tbl.view() + assert view.to_records(id=True) == [ + {"__ID__": [0], "a": 1.5, "b": 2.5}, + {"__ID__": [1], "a": 3.5, "b": 4.5}, + ] + + def test_to_format_implicit_id_dict(self): + data = [{"a": 1.5, "b": 2.5}, {"a": 3.5, "b": 4.5}] + tbl = Table(data) + view = tbl.view() + assert view.to_columns(id=True) == { + "__ID__": [[0], [1]], + "a": [1.5, 3.5], + "b": [2.5, 4.5], + } + + def test_to_format_implicit_index_two_dict_1(self): + data = [{"a": 1.5, "b": 2.5}, {"a": 3.5, "b": 4.5}] + tbl = Table(data) + view = tbl.view(group_by=["a"], split_by=["b"]) + assert view.to_columns(index=True) == { + "2.5|a": [1.5, 1.5, None], + "2.5|b": [2.5, 2.5, None], + "4.5|a": [3.5, None, 3.5], + "4.5|b": [4.5, None, 4.5], + "__INDEX__": [ + [], + [], + [], + ], # index needs to be the same length as each column + "__ROW_PATH__": [[], [1.5], [3.5]], + } + + def test_to_format_implicit_index_two_dict_2(self): + data = [{"a": 1.5, "b": 2.5}, {"a": 3.5, "b": 4.5}] + tbl = Table(data) + view = tbl.view(group_by=["a"], split_by=["b"]) + assert view.to_columns(id=True) == { + "2.5|a": [1.5, 1.5, None], + "2.5|b": [2.5, 2.5, None], + "4.5|a": [3.5, None, 3.5], + "4.5|b": [4.5, None, 4.5], + "__ID__": [ + [], + [1.5], + [3.5], + ], # index needs to be the same length as each column + "__ROW_PATH__": [[], [1.5], [3.5]], + } + + @mark.skip(reason="No numpy support") + def test_to_format_implicit_index_np(self): + data = [{"a": 1.5, "b": 2.5}, {"a": 3.5, "b": 4.5}] + tbl = Table(data) + view = tbl.view() + cols = view.to_numpy(index=True) + assert np.array_equal(cols["__INDEX__"], np.array([[0], [1]])) + + def test_to_format_explicit_index_records(self): + data = [{"a": 1.5, "b": 2.5}, {"a": 3.5, "b": 4.5}] + tbl = Table(data, index="a") + view = tbl.view() + assert view.to_records(index=True) == [ + {"__INDEX__": [1.5], "a": 1.5, "b": 2.5}, + {"__INDEX__": [3.5], "a": 3.5, "b": 4.5}, + ] + + def test_to_format_explicit_index_dict(self): + data = [{"a": 1.5, "b": 2.5}, {"a": 3.5, "b": 4.5}] + tbl = Table(data, index="a") + view = tbl.view() + assert view.to_columns(index=True) == { + "__INDEX__": [[1.5], [3.5]], + "a": [1.5, 3.5], + "b": [2.5, 4.5], + } + + @mark.skip(reason="No numpy supprt") + def test_to_format_explicit_index_np(self): + data = [{"a": 1.5, "b": 2.5}, {"a": 3.5, "b": 4.5}] + tbl = Table(data, index="a") + view = tbl.view() + cols = view.to_numpy(index=True) + assert np.array_equal(cols["__INDEX__"], np.array([[1.5], [3.5]])) + + def test_to_format_explicit_index_str_records(self): + data = [{"a": "a", "b": 2.5}, {"a": "b", "b": 4.5}] + tbl = Table(data, index="a") + view = tbl.view() + assert view.to_records(index=True) == [ + {"__INDEX__": ["a"], "a": "a", "b": 2.5}, + {"__INDEX__": ["b"], "a": "b", "b": 4.5}, + ] + + def test_to_format_explicit_index_datetime_records(self, util): + data = [ + {"a": datetime(2019, 7, 11, 9, 0), "b": 2.5}, + {"a": datetime(2019, 7, 11, 9, 1), "b": 4.5}, + ] + tbl = Table(data, index="a") + view = tbl.view() + assert view.to_records(index=True) == [ + { + "__INDEX__": [util.to_timestamp(datetime(2019, 7, 11, 9, 0))], + "a": util.to_timestamp(datetime(2019, 7, 11, 9, 0)), + "b": 2.5, + }, + { + "__INDEX__": [util.to_timestamp(datetime(2019, 7, 11, 9, 1))], + "a": util.to_timestamp(datetime(2019, 7, 11, 9, 1)), + "b": 4.5, + }, + ] diff --git a/rust/perspective-python/perspective/tests/table/test_to_polars.py b/rust/perspective-python/perspective/tests/table/test_to_polars.py new file mode 100644 index 0000000..1cb5d8d --- /dev/null +++ b/rust/perspective-python/perspective/tests/table/test_to_polars.py @@ -0,0 +1,26 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 as psp + +client = psp.Server().new_local_client() +Table = client.table + + +class TestToPolars(object): + def test_to_polars(self, superstore): + original_tbl = Table(superstore.to_dict(orient="records")) + df = original_tbl.view().to_polars() + tbl = Table(df) + assert tbl.size() == original_tbl.size() + df2 = tbl.view().to_polars() + assert df.equals(df2) diff --git a/rust/perspective-python/perspective/tests/table/test_update.py b/rust/perspective-python/perspective/tests/table/test_update.py new file mode 100644 index 0000000..50b63e1 --- /dev/null +++ b/rust/perspective-python/perspective/tests/table/test_update.py @@ -0,0 +1,536 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 numpy as np +import pyarrow as pa +import pandas as pd +from datetime import date, datetime +from pytest import mark +import perspective as psp + +client = psp.Server().new_local_client() +Table = client.table + + +class TestUpdate(object): + @mark.skip(reason="Fix for null values update yet to be implemented") + def test_update_with_missing_or_null_values(self): + tbl = Table([{"a": "1", "b": "2"}, {"a": "3", "b": "4"}], index="a") + tbl.update([{"a": "1", "b": None}]) + assert tbl.view().to_records() == [{"a": "1", "b": None}, {"a": "3", "b": "4"}] + + data = pd.DataFrame({"a": ["1"], "b": [None]}) + + arrow_table = pa.Table.from_pandas(data, preserve_index=False) + + tbl.update(arrow_table) + assert tbl.size() == 2 + assert tbl.view().to_records() == [{"a": "1", "b": ""}, {"a": "3", "b": "4"}] + + def test_update_from_schema(self): + tbl = Table({"a": "string", "b": "integer"}) + tbl.update([{"a": "abc", "b": 123}]) + assert tbl.view().to_records() == [{"a": "abc", "b": 123}] + + def test_update_columnar_from_schema(self): + tbl = Table({"a": "string", "b": "integer"}) + tbl.update({"a": ["abc"], "b": [123]}) + assert tbl.view().to_records() == [{"a": "abc", "b": 123}] + + def test_update_csv(self): + tbl = Table({"a": "string", "b": "integer"}) + + view = tbl.view() + tbl.update("a,b\nxyz,123\ndef,100000000") + + assert view.to_columns() == {"a": ["xyz", "def"], "b": [123, 100000000]} + + def test_update_csv_indexed(self): + tbl = Table({"a": "string", "b": "float"}, index="a") + + view = tbl.view() + tbl.update("a,b\nxyz,1.23456718\ndef,100000000.1") + + assert view.to_columns() == { + "a": ["def", "xyz"], + "b": [100000000.1, 1.23456718], + } + + tbl.update("a,b\nxyz,0.00000001\ndef,1234.5678\nefg,100.2") + + assert view.to_columns() == { + "a": ["def", "efg", "xyz"], + "b": [1234.5678, 100.2, 0.00000001], + } + + def test_update_append(self): + tbl = Table([{"a": "abc", "b": 123}]) + tbl.update([{"a": "def", "b": 456}]) + assert tbl.view().to_records() == [ + {"a": "abc", "b": 123}, + {"a": "def", "b": 456}, + ] + + def test_update_partial(self): + tbl = Table([{"a": "abc", "b": 123}], index="a") + tbl.update([{"a": "abc", "b": 456}]) + assert tbl.view().to_records() == [{"a": "abc", "b": 456}] + + def test_update_partial_add_row(self): + tbl = Table([{"a": "abc", "b": 123}], index="a") + tbl.update([{"a": "abc", "b": 456}, {"a": "def"}]) + assert tbl.view().to_records() == [ + {"a": "abc", "b": 456}, + {"a": "def", "b": None}, + ] + + def test_update_partial_noop(self): + tbl = Table([{"a": "abc", "b": 1, "c": 2}], index="a") + tbl.update([{"a": "abc", "b": 456}]) + assert tbl.view().to_records() == [{"a": "abc", "b": 456, "c": 2}] + + def test_update_partial_unset(self): + tbl = Table( + [{"a": "abc", "b": 1, "c": 2}, {"a": "def", "b": 3, "c": 4}], index="a" + ) + tbl.update([{"a": "abc"}, {"a": "def", "c": None}]) + assert tbl.view().to_records() == [ + {"a": "abc", "b": 1, "c": 2}, + {"a": "def", "b": 3, "c": None}, + ] + + def test_update_columnar_partial_add_row(self): + tbl = Table([{"a": "abc", "b": 123}], index="a") + + tbl.update({"a": ["abc", "def"], "b": [456, None]}) + + assert tbl.view().to_records() == [ + {"a": "abc", "b": 456}, + {"a": "def", "b": None}, + ] + + def test_update_columnar_partial_noop(self): + tbl = Table([{"a": "abc", "b": 1, "c": 2}], index="a") + + # no-op because "c" is not in the update dataset + tbl.update({"a": ["abc"], "b": [456]}) + + assert tbl.view().to_records() == [{"a": "abc", "b": 456, "c": 2}] + + def test_update_columnar_partial_unset(self): + tbl = Table( + [{"a": "abc", "b": 1, "c": 2}, {"a": "def", "b": 3, "c": 4}], index="a" + ) + + tbl.update({"a": ["abc"], "b": [None]}) + + assert tbl.view().to_records() == [ + {"a": "abc", "b": None, "c": 2}, + {"a": "def", "b": 3, "c": 4}, + ] + + def test_update_partial_subcolumn(self): + tbl = Table([{"a": "abc", "b": 123, "c": 456}], index="a") + tbl.update([{"a": "abc", "c": 1234}]) + assert tbl.view().to_records() == [{"a": "abc", "b": 123, "c": 1234}] + + def test_update_partial_subcolumn_dict(self): + tbl = Table([{"a": "abc", "b": 123, "c": 456}], index="a") + tbl.update({"a": ["abc"], "c": [1234]}) + assert tbl.view().to_records() == [{"a": "abc", "b": 123, "c": 1234}] + + def test_update_partial_cols_more_columns_than_table(self): + tbl = Table([{"a": "abc", "b": 123}], index="a") + tbl.update([{"a": "abc", "b": 456, "c": 789}]) + assert tbl.view().to_records() == [{"a": "abc", "b": 456}] + + def test_update_columnar_append(self): + tbl = Table({"a": ["abc"], "b": [123]}) + tbl.update({"a": ["def"], "b": [456]}) + assert tbl.view().to_records() == [ + {"a": "abc", "b": 123}, + {"a": "def", "b": 456}, + ] + + def test_update_columnar_partial(self): + tbl = Table({"a": ["abc"], "b": [123]}, index="a") + tbl.update({"a": ["abc"], "b": [456]}) + assert tbl.view().to_records() == [{"a": "abc", "b": 456}] + + # make sure already created views are notified properly + def test_update_from_schema_notify(self): + tbl = Table({"a": "string", "b": "integer"}) + view = tbl.view() + assert view.num_rows() == 0 + tbl.update([{"a": "abc", "b": 123}]) + assert view.to_records() == [{"a": "abc", "b": 123}] + + def test_update_columnar_from_schema_notify(self): + tbl = Table({"a": "string", "b": "integer"}) + view = tbl.view() + assert view.num_rows() == 0 + tbl.update({"a": ["abc"], "b": [123]}) + assert view.to_records() == [{"a": "abc", "b": 123}] + + def test_update_append_notify(self): + tbl = Table([{"a": "abc", "b": 123}]) + view = tbl.view() + assert view.num_rows() == 1 + tbl.update([{"a": "def", "b": 456}]) + assert view.to_records() == [{"a": "abc", "b": 123}, {"a": "def", "b": 456}] + + def test_update_partial_notify(self): + tbl = Table([{"a": "abc", "b": 123}], index="a") + view = tbl.view() + assert view.num_rows() == 1 + tbl.update([{"a": "abc", "b": 456}]) + assert view.to_records() == [{"a": "abc", "b": 456}] + + def test_update_partial_cols_not_in_schema_notify(self): + tbl = Table([{"a": "abc", "b": 123}], index="a") + view = tbl.view() + assert view.num_rows() == 1 + tbl.update([{"a": "abc", "b": 456, "c": 789}]) + assert view.to_records() == [{"a": "abc", "b": 456}] + + def test_update_columnar_append_notify(self): + tbl = Table({"a": ["abc"], "b": [123]}) + view = tbl.view() + assert view.num_rows() == 1 + tbl.update({"a": ["def"], "b": [456]}) + assert view.to_records() == [{"a": "abc", "b": 123}, {"a": "def", "b": 456}] + + def test_update_columnar_partial_notify(self): + tbl = Table({"a": ["abc"], "b": [123]}, index="a") + view = tbl.view() + assert view.num_rows() == 1 + tbl.update({"a": ["abc"], "b": [456]}) + assert view.to_records() == [{"a": "abc", "b": 456}] + + # bool + + def test_update_bool_from_schema(self): + bool_data = [{"a": True, "b": False}, {"a": True, "b": True}] + tbl = Table({"a": "boolean", "b": "boolean"}) + tbl.update(bool_data) + assert tbl.size() == 2 + assert tbl.view().to_records() == bool_data + + def test_update_bool_str_from_schema(self): + bool_data = [{"a": "True", "b": "False"}, {"a": "True", "b": "True"}] + tbl = Table({"a": "boolean", "b": "boolean"}) + tbl.update(bool_data) + assert tbl.size() == 2 + assert tbl.view().to_records() == [ + {"a": True, "b": False}, + {"a": True, "b": True}, + ] + + @mark.skip(reason="We may not want to support this") + def test_update_bool_str_all_formats_from_schema(self): + bool_data = [ + {"a": "True", "b": "False"}, + {"a": "t", "b": "f"}, + {"a": "true", "b": "false"}, + {"a": 1, "b": 0}, + {"a": "on", "b": "off"}, + ] + tbl = Table({"a": "boolean", "b": "boolean"}) + tbl.update(bool_data) + assert tbl.size() == 5 + assert tbl.view().to_columns() == { + "a": [True, True, True, True, True], + "b": [False, False, False, False, False], + } + + def test_update_bool_int_from_schema(self): + bool_data = [{"a": 1, "b": 0}, {"a": 1, "b": 0}] + tbl = Table({"a": "boolean", "b": "boolean"}) + tbl.update(bool_data) + assert tbl.size() == 2 + assert tbl.view().to_columns() == {"a": [True, True], "b": [False, False]} + + # dates and datetimes + def test_update_date(self, util): + tbl = Table({"a": [date(2019, 7, 11)]}) + tbl.update([{"a": date(2019, 7, 12)}]) + assert tbl.view().to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 11))}, + {"a": util.to_timestamp(datetime(2019, 7, 12))}, + ] + + @mark.skip # We do not support numpy. + def test_update_date_np(self, util): + tbl = Table({"a": [date(2019, 7, 11)]}) + tbl.update([{"a": np.datetime64(date(2019, 7, 12))}]) + assert tbl.view().to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 11))}, + {"a": util.to_timestamp(datetime(2019, 7, 12))}, + ] + + def test_update_datetime(self, util): + tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)]}) + tbl.update([{"a": datetime(2019, 7, 12, 11, 0)}]) + assert tbl.view().to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 11, 11, 0))}, + {"a": util.to_timestamp(datetime(2019, 7, 12, 11, 0))}, + ] + + @mark.skip # We do not support numpy. + def test_update_datetime_np(self, util): + tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)]}) + tbl.update([{"a": np.datetime64(datetime(2019, 7, 12, 11, 0))}]) + assert tbl.view().to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 11, 11, 0))}, + {"a": util.to_timestamp(datetime(2019, 7, 12, 11, 0))}, + ] + + @mark.skip # We do not support numpy. + def test_update_datetime_np_ts(self, util): + tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)]}) + tbl.update([{"a": np.datetime64("2019-07-12T11:00")}]) + assert tbl.view().to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 11, 11, 0))}, + {"a": util.to_timestamp(datetime(2019, 7, 12, 11, 0))}, + ] + + def test_update_datetime_timestamp_seconds(self, util): + ts = util.to_timestamp(datetime(2019, 7, 12, 11, 0, 0)) + tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)]}) + tbl.update([{"a": ts}]) + assert tbl.view().to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 11, 11, 0))}, + {"a": util.to_timestamp(datetime(2019, 7, 12, 11, 0))}, + ] + + def test_update_datetime_timestamp_ms(self, util): + ts = util.to_timestamp(datetime(2019, 7, 12, 11, 0, 0)) + tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)]}) + tbl.update([{"a": ts}]) + assert tbl.view().to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 11, 11, 0))}, + {"a": util.to_timestamp(datetime(2019, 7, 12, 11, 0))}, + ] + + # partial date & datetime updates + + def test_update_date_partial(self, util): + tbl = Table({"a": [date(2019, 7, 11)], "b": [1]}, index="b") + tbl.update([{"a": date(2019, 7, 12), "b": 1}]) + assert tbl.view().to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 12)), "b": 1} + ] + + @mark.skip # We do not support numpy anymore. + def test_update_date_np_partial(self, util): + tbl = Table({"a": [date(2019, 7, 11)], "b": [1]}, index="b") + tbl.update([{"a": np.datetime64(date(2019, 7, 12)), "b": 1}]) + assert tbl.view().to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 12)), "b": 1} + ] + + def test_update_datetime_partial(self, util): + tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)], "b": [1]}, index="b") + tbl.update([{"a": datetime(2019, 7, 12, 11, 0), "b": 1}]) + assert tbl.view().to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 12, 11, 0)), "b": 1} + ] + + @mark.skip # We do not support numpy anymore. + def test_update_datetime_np_partial(self, util): + tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)], "b": [1]}, index="b") + tbl.update([{"a": np.datetime64(datetime(2019, 7, 12, 11, 0)), "b": 1}]) + assert tbl.view().to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 12, 11, 0)), "b": 1} + ] + + @mark.skip # We do not support numpy anymore. + def test_update_datetime_np_ts_partial(self, util): + tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)], "b": [1]}, index="b") + tbl.update([{"a": np.datetime64("2019-07-12T11:00"), "b": 1}]) + assert tbl.view().to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 12, 11, 0)), "b": 1} + ] + + def test_update_datetime_timestamp_seconds_partial(self, util): + ts = util.to_timestamp(datetime(2019, 7, 12, 11, 0, 0)) + tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)], "idx": [1]}, index="idx") + tbl.update([{"a": ts, "idx": 1}]) + assert tbl.view().to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 12, 11, 0)), "idx": 1} + ] + + def test_update_datetime_timestamp_ms_partial(self, util): + ts = util.to_timestamp(datetime(2019, 7, 12, 11, 0, 0)) + tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)], "idx": [1]}, index="idx") + tbl.update([{"a": ts, "idx": 1}]) + assert tbl.view().to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 12, 11, 0)), "idx": 1} + ] + + # updating dates using implicit index + + def test_update_date_partial_implicit(self, util): + tbl = Table({"a": [date(2019, 7, 11)]}) + tbl.update([{"a": date(2019, 7, 12), "__INDEX__": 0}]) + assert tbl.view().to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 12))} + ] + + @mark.skip # We do not support numpy anymore. + def test_update_date_np_partial_implicit(self, util): + tbl = Table({"a": [date(2019, 7, 11)]}) + tbl.update([{"a": np.datetime64(date(2019, 7, 12)), "__INDEX__": 0}]) + assert tbl.view().to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 12))} + ] + + def test_update_datetime_partial_implicit(self, util): + tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)]}) + tbl.update([{"a": datetime(2019, 7, 12, 11, 0), "__INDEX__": 0}]) + assert tbl.view().to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 12, 11, 0))} + ] + + @mark.skip # We do not support numpy anymore. + def test_update_datetime_np_partial_implicit(self, util): + tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)]}) + tbl.update([{"a": np.datetime64(datetime(2019, 7, 12, 11, 0)), "__INDEX__": 0}]) + assert tbl.view().to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 12, 11, 0))} + ] + + @mark.skip # We do not support numpy anymore. + def test_update_datetime_np_ts_partial_implicit(self, util): + tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)]}) + tbl.update([{"a": np.datetime64("2019-07-12T11:00"), "__INDEX__": 0}]) + assert tbl.view().to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 12, 11, 0))} + ] + + # implicit index + + def test_update_implicit_index(self): + data = [{"a": 1, "b": 2}, {"a": 2, "b": 3}] + tbl = Table(data) + view = tbl.view() + tbl.update([{"__INDEX__": 0, "a": 3, "b": 15}]) + assert view.to_records() == [{"a": 3, "b": 15}, {"a": 2, "b": 3}] + + def test_update_implicit_index_dict_noop(self): + data = [{"a": 1, "b": 2}, {"a": 2, "b": 3}] + tbl = Table(data) + view = tbl.view() + + # "b" does not exist in dataset, so no-op + tbl.update( + { + "__INDEX__": [0], + "a": [3], + } + ) + assert view.to_records() == [{"a": 3, "b": 2}, {"a": 2, "b": 3}] + + def test_update_implicit_index_dict_unset_with_null(self): + data = [{"a": 1, "b": 2}, {"a": 2, "b": 3}] + tbl = Table(data) + view = tbl.view() + + # unset because "b" is null + tbl.update({"__INDEX__": [0], "a": [3], "b": [None]}) + assert view.to_records() == [{"a": 3, "b": None}, {"a": 2, "b": 3}] + + def test_update_implicit_index_multi(self): + data = [{"a": 1, "b": 2}, {"a": 2, "b": 3}, {"a": 4, "b": 5}] + tbl = Table(data) + view = tbl.view() + tbl.update( + [ + { + "__INDEX__": 0, + "a": 3, + }, + {"__INDEX__": 2, "a": 5}, + ] + ) + assert view.to_records() == [ + {"a": 3, "b": 2}, + {"a": 2, "b": 3}, + {"a": 5, "b": 5}, + ] + + def test_update_implicit_index_symmetric(self): + data = [{"a": 1, "b": 2}, {"a": 2, "b": 3}] + tbl = Table(data) + view = tbl.view() + records = view.to_records(index=True) + idx = records[0]["__INDEX__"][0] # XXX: How should we grab this? + tbl.update([{"__INDEX__": idx, "a": 3}]) + assert view.to_records() == [{"a": 3, "b": 2}, {"a": 2, "b": 3}] + + def test_update_explicit_index(self): + data = [{"a": 1, "b": 2}, {"a": 2, "b": 3}] + tbl = Table(data, index="a") + view = tbl.view() + tbl.update([{"a": 1, "b": 3}]) + assert view.to_records() == [{"a": 1, "b": 3}, {"a": 2, "b": 3}] + + def test_update_explicit_index_multi(self): + data = [{"a": 1, "b": 2}, {"a": 2, "b": 3}, {"a": 3, "b": 4}] + tbl = Table(data, index="a") + view = tbl.view() + tbl.update([{"a": 1, "b": 3}, {"a": 3, "b": 5}]) + assert view.to_records() == [ + {"a": 1, "b": 3}, + {"a": 2, "b": 3}, + {"a": 3, "b": 5}, + ] + + def test_update_explicit_index_multi_append(self): + data = [{"a": 1, "b": 2}, {"a": 2, "b": 3}, {"a": 3, "b": 4}] + tbl = Table(data, index="a") + view = tbl.view() + tbl.update([{"a": 1, "b": 3}, {"a": 12, "b": 5}]) + assert view.to_records() == [ + {"a": 1, "b": 3}, + {"a": 2, "b": 3}, + {"a": 3, "b": 4}, + {"a": 12, "b": 5}, + ] + + def test_update_explicit_index_multi_append_noindex(self): + data = [{"a": 1, "b": 2}, {"a": 2, "b": 3}, {"a": 3, "b": 4}] + tbl = Table(data, index="a") + view = tbl.view() + tbl.update([{"a": 1, "b": 3}, {"b": 5}]) + assert view.to_records() == [ + {"a": None, "b": 5}, + {"a": 1, "b": 3}, + {"a": 2, "b": 3}, + {"a": 3, "b": 4}, + ] + + def test_update_implicit_index_with_explicit_unset(self): + data = [{"a": 1, "b": 2}, {"a": 2, "b": 3}] + tbl = Table(data, index="a") + view = tbl.view() + tbl.update([{"__INDEX__": 1, "b": 3}]) + assert view.to_records() == [{"a": 1, "b": 3}, {"a": 2, "b": 3}] + + def test_update_implicit_index_with_explicit_set(self): + data = [{"a": 1, "b": 2}, {"a": 2, "b": 3}] + tbl = Table(data, index="a") + view = tbl.view() + tbl.update( + [{"__INDEX__": 1, "a": 1, "b": 3}] + ) # should ignore re-specification of pkey + assert view.to_records() == [{"a": 1, "b": 3}, {"a": 2, "b": 3}] diff --git a/rust/perspective-python/perspective/tests/table/test_update_arrow.py b/rust/perspective-python/perspective/tests/table/test_update_arrow.py new file mode 100644 index 0000000..1b20ed9 --- /dev/null +++ b/rust/perspective-python/perspective/tests/table/test_update_arrow.py @@ -0,0 +1,980 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 os +import random +import uuid +import pyarrow as pa +import pandas as pd +import numpy as np +from datetime import date, datetime +from pytest import mark +import perspective as psp + +client = psp.Server().new_local_client() +Table = client.table + +SOURCE_STREAM_ARROW = os.path.join( + os.path.dirname(__file__), "arrow", "int_float_str.arrow" +) +SOURCE_FILE_ARROW = os.path.join( + os.path.dirname(__file__), "arrow", "int_float_str.arrow" +) +PARTIAL_ARROW = os.path.join( + os.path.dirname(__file__), "arrow", "int_float_str_update.arrow" +) +DICT_ARROW = os.path.join(os.path.dirname(__file__), "arrow", "dict.arrow") +DICT_UPDATE_ARROW = os.path.join( + os.path.dirname(__file__), "arrow", "dict_update.arrow" +) + +names = ["a", "b", "c", "d"] + + +class TestUpdateArrow(object): + # files + + def test_update_arrow_updates_stream_file(self): + tbl = Table({"a": "integer", "b": "float", "c": "string"}) + + with open(SOURCE_STREAM_ARROW, mode="rb") as file: # b is important -> binary + tbl.update(file.read()) + assert tbl.size() == 4 + assert tbl.schema() == {"a": "integer", "b": "float", "c": "string"} + + with open(SOURCE_FILE_ARROW, mode="rb") as file: + tbl.update(file.read()) + assert tbl.size() == 8 + assert tbl.view().to_columns() == { + "a": [1, 2, 3, 4] * 2, + "b": [1.5, 2.5, 3.5, 4.5] * 2, + "c": ["a", "b", "c", "d"] * 2, + } + + def test_update_arrow_partial_updates_file(self): + tbl = Table({"a": "integer", "b": "float", "c": "string"}, index="a") + + with open(SOURCE_STREAM_ARROW, mode="rb") as src: + tbl.update(src.read()) + assert tbl.size() == 4 + + with open(PARTIAL_ARROW, mode="rb") as partial: + tbl.update(partial.read()) + assert tbl.size() == 4 + assert tbl.view().to_columns() == { + "a": [1, 2, 3, 4], + "b": [100.5, 2.5, 3.5, 400.5], + "c": ["x", "b", "c", "y"], + } + + def test_update_arrow_updates_dict_file(self): + tbl = Table({"a": "string", "b": "string"}) + + with open(DICT_ARROW, mode="rb") as src: + tbl.update(src.read()) + assert tbl.size() == 5 + + with open(DICT_UPDATE_ARROW, mode="rb") as partial: + tbl.update(partial.read()) + assert tbl.size() == 8 + assert tbl.view().to_columns() == { + "a": ["abc", "def", "def", None, "abc", None, "update1", "update2"], + "b": ["klm", "hij", None, "hij", "klm", "update3", None, "update4"], + } + + @mark.skip + def test_update_arrow_updates_dict_partial_file(self): + tbl = None + v = None + + with open(DICT_ARROW, mode="rb") as src: + tbl = Table(src.read(), index="a") + v = tbl.view() + assert v.num_rows() == 2 + assert v.to_columns() == {"a": ["abc", "def"], "b": ["klm", "hij"]} + + with open(DICT_UPDATE_ARROW, mode="rb") as partial: + tbl.update(partial.read()) + v.num_rows() == 4 + assert v.to_columns() == { + "a": ["abc", "def", "update1", "update2"], + "b": ["klm", "hij", None, "update4"], + } + + # update with file arrow with more columns than in schema + + def test_update_arrow_updates_more_columns_stream_file(self): + tbl = Table( + { + "a": "integer", + "b": "float", + } + ) + + with open(SOURCE_STREAM_ARROW, mode="rb") as file: # b is important -> binary + tbl.update(file.read()) + assert tbl.size() == 4 + assert tbl.schema() == {"a": "integer", "b": "float"} + + with open(SOURCE_FILE_ARROW, mode="rb") as file: + tbl.update(file.read()) + assert tbl.size() == 8 + assert tbl.view().to_columns() == { + "a": [1, 2, 3, 4] * 2, + "b": [1.5, 2.5, 3.5, 4.5] * 2, + } + + def test_update_arrow_partial_updates_more_columns_file(self): + tbl = Table({"a": "integer", "c": "string"}, index="a") + + with open(SOURCE_STREAM_ARROW, mode="rb") as src: + tbl.update(src.read()) + assert tbl.size() == 4 + + with open(PARTIAL_ARROW, mode="rb") as partial: + tbl.update(partial.read()) + assert tbl.size() == 4 + assert tbl.view().to_columns() == { + "a": [1, 2, 3, 4], + "c": ["x", "b", "c", "y"], + } + + def test_update_arrow_updates_dict_more_columns_file(self): + tbl = Table( + { + "a": "string", + } + ) + + with open(DICT_ARROW, mode="rb") as src: + tbl.update(src.read()) + assert tbl.size() == 5 + + with open(DICT_UPDATE_ARROW, mode="rb") as partial: + tbl.update(partial.read()) + assert tbl.size() == 8 + assert tbl.view().to_columns() == { + "a": ["abc", "def", "def", None, "abc", None, "update1", "update2"] + } + + @mark.skip + def test_update_arrow_updates_dict_more_columns_partial_file(self): + tbl = Table({"a": "string"}, index="a") + + with open(DICT_ARROW, mode="rb") as src: + tbl.update(src.read()) + assert tbl.size() == 4 + + with open(DICT_UPDATE_ARROW, mode="rb") as partial: + tbl.update(partial.read()) + assert tbl.size() == 4 + assert tbl.view().to_columns() == { + "a": ["abc", "def", "update1", "update2"] + } + + # update with file arrow with less columns than in schema + + def test_update_arrow_updates_less_columns_stream_file(self): + tbl = Table( + { + "a": "integer", + "x": "float", + } + ) + + with open(SOURCE_STREAM_ARROW, mode="rb") as file: # b is important -> binary + tbl.update(file.read()) + assert tbl.size() == 4 + assert tbl.schema() == {"a": "integer", "x": "float"} + + with open(SOURCE_FILE_ARROW, mode="rb") as file: + tbl.update(file.read()) + assert tbl.size() == 8 + assert tbl.view().to_columns() == { + "a": [1, 2, 3, 4] * 2, + "x": [None for i in range(8)], + } + + def test_update_arrow_partial_updates_less_columns_file(self): + tbl = Table({"a": "integer", "x": "string"}, index="a") + + with open(SOURCE_STREAM_ARROW, mode="rb") as src: + tbl.update(src.read()) + assert tbl.size() == 4 + + with open(PARTIAL_ARROW, mode="rb") as partial: + tbl.update(partial.read()) + assert tbl.size() == 4 + assert tbl.view().to_columns() == { + "a": [1, 2, 3, 4], + "x": [None for i in range(4)], + } + + def test_update_arrow_updates_dict_less_columns_file(self): + tbl = Table({"a": "string", "x": "string"}) + + with open(DICT_ARROW, mode="rb") as src: + tbl.update(src.read()) + assert tbl.size() == 5 + + with open(DICT_UPDATE_ARROW, mode="rb") as partial: + tbl.update(partial.read()) + assert tbl.size() == 8 + assert tbl.view().to_columns() == { + "a": ["abc", "def", "def", None, "abc", None, "update1", "update2"], + "x": [None for i in range(8)], + } + + @mark.skip + def test_update_arrow_updates_dict_less_columns_partial_file(self): + tbl = Table({"a": "string", "x": "string"}, index="a") + + with open(DICT_ARROW, mode="rb") as src: + tbl.update(src.read()) + assert tbl.size() == 4 + + with open(DICT_UPDATE_ARROW, mode="rb") as partial: + tbl.update(partial.read()) + assert tbl.size() == 4 + assert tbl.view().to_columns() == { + "a": ["abc", "def", "update1", "update2"], + "x": [None for i in range(4)], + } + + # update int schema with int + + def test_update_arrow_update_int_schema_with_uint8(self, util): + array = [random.randint(0, 127) for i in range(100)] + data = pd.DataFrame({"a": np.array(array, dtype=np.uint8)}) + + schema = pa.schema({"a": pa.uint8()}) + + arrow = util.make_arrow_from_pandas(data, schema) + tbl = Table({"a": "integer"}) + tbl.update(arrow) + assert tbl.view().to_columns()["a"] == array + + def test_update_arrow_update_int_schema_with_uint16(self, util): + array = [random.randint(0, 32767) for i in range(100)] + data = pd.DataFrame({"a": np.array(array, dtype=np.uint16)}) + + schema = pa.schema({"a": pa.uint16()}) + + arrow = util.make_arrow_from_pandas(data, schema) + tbl = Table({"a": "integer"}) + tbl.update(arrow) + assert tbl.view().to_columns()["a"] == array + + def test_update_arrow_update_int_schema_with_uint32(self, util): + array = [random.randint(0, 2000000) for i in range(100)] + data = pd.DataFrame({"a": np.array(array, dtype=np.uint32)}) + + schema = pa.schema({"a": pa.uint32()}) + + arrow = util.make_arrow_from_pandas(data, schema) + tbl = Table({"a": "integer"}) + tbl.update(arrow) + assert tbl.view().to_columns()["a"] == array + + def test_update_arrow_update_int_schema_with_uint64(self, util): + array = [random.randint(0, 20000000) for i in range(100)] + data = pd.DataFrame({"a": np.array(array, dtype=np.uint64)}) + + schema = pa.schema({"a": pa.uint64()}) + + arrow = util.make_arrow_from_pandas(data, schema) + tbl = Table({"a": "integer"}) + tbl.update(arrow) + assert tbl.view().to_columns()["a"] == array + + def test_update_arrow_update_int_schema_with_int8(self, util): + array = [random.randint(-127, 127) for i in range(100)] + data = pd.DataFrame({"a": np.array(array, dtype=np.int8)}) + + schema = pa.schema({"a": pa.int8()}) + + arrow = util.make_arrow_from_pandas(data, schema) + tbl = Table({"a": "integer"}) + tbl.update(arrow) + assert tbl.view().to_columns()["a"] == array + + def test_update_arrow_update_int_schema_with_int16(self, util): + array = [random.randint(-32767, 32767) for i in range(100)] + data = pd.DataFrame({"a": np.array(array, dtype=np.int16)}) + + schema = pa.schema({"a": pa.int16()}) + + arrow = util.make_arrow_from_pandas(data, schema) + tbl = Table({"a": "integer"}) + tbl.update(arrow) + assert tbl.view().to_columns()["a"] == array + + def test_update_arrow_update_int_schema_with_int32(self, util): + array = [random.randint(-2000000, 2000000) for i in range(100)] + data = pd.DataFrame({"a": np.array(array, dtype=np.int32)}) + + schema = pa.schema({"a": pa.int32()}) + + arrow = util.make_arrow_from_pandas(data, schema) + tbl = Table({"a": "integer"}) + tbl.update(arrow) + assert tbl.view().to_columns()["a"] == array + + def test_update_arrow_update_int_schema_with_int64(self, util): + array = [random.randint(-20000000, 20000000) for i in range(100)] + data = pd.DataFrame({"a": np.array(array, dtype=np.int64)}) + + schema = pa.schema({"a": pa.int64()}) + + arrow = util.make_arrow_from_pandas(data, schema) + tbl = Table({"a": "integer"}) + tbl.update(arrow) + assert tbl.view().to_columns()["a"] == [x * 1.0 for x in array] + + # updating float schema with int + + def test_update_arrow_update_float_schema_with_uint8(self, util): + array = [random.randint(0, 127) for i in range(100)] + data = pd.DataFrame({"a": np.array(array, dtype=np.uint8)}) + + schema = pa.schema({"a": pa.uint8()}) + + arrow = util.make_arrow_from_pandas(data, schema) + tbl = Table({"a": "float"}) + tbl.update(arrow) + assert tbl.view().to_columns()["a"] == array + + def test_update_arrow_update_float_schema_with_uint16(self, util): + array = [random.randint(0, 32767) for i in range(100)] + data = pd.DataFrame({"a": np.array(array, dtype=np.uint16)}) + + schema = pa.schema({"a": pa.uint16()}) + + arrow = util.make_arrow_from_pandas(data, schema) + tbl = Table({"a": "float"}) + tbl.update(arrow) + assert tbl.view().to_columns()["a"] == array + + def test_update_arrow_update_float_schema_with_uint32(self, util): + array = [random.randint(0, 2000000) for i in range(100)] + data = pd.DataFrame({"a": np.array(array, dtype=np.uint32)}) + + schema = pa.schema({"a": pa.uint32()}) + + arrow = util.make_arrow_from_pandas(data, schema) + tbl = Table({"a": "float"}) + tbl.update(arrow) + assert tbl.view().to_columns()["a"] == array + + def test_update_arrow_update_float_schema_with_uint64(self, util): + array = [random.randint(0, 20000000) for i in range(100)] + data = pd.DataFrame({"a": np.array(array, dtype=np.uint64)}) + + schema = pa.schema({"a": pa.uint64()}) + + arrow = util.make_arrow_from_pandas(data, schema) + tbl = Table({"a": "float"}) + tbl.update(arrow) + assert tbl.view().to_columns()["a"] == array + + def test_update_arrow_update_float_schema_with_int8(self, util): + array = [random.randint(-127, 127) for i in range(100)] + data = pd.DataFrame({"a": np.array(array, dtype=np.int8)}) + + schema = pa.schema({"a": pa.int8()}) + + arrow = util.make_arrow_from_pandas(data, schema) + tbl = Table({"a": "float"}) + tbl.update(arrow) + assert tbl.view().to_columns()["a"] == array + + def test_update_arrow_update_float_schema_with_int16(self, util): + array = [random.randint(-32767, 32767) for i in range(100)] + data = pd.DataFrame({"a": np.array(array, dtype=np.int16)}) + + schema = pa.schema({"a": pa.int16()}) + + arrow = util.make_arrow_from_pandas(data, schema) + tbl = Table({"a": "float"}) + tbl.update(arrow) + assert tbl.view().to_columns()["a"] == array + + def test_update_arrow_update_float_schema_with_int32(self, util): + array = [random.randint(-2000000, 2000000) for i in range(100)] + data = pd.DataFrame({"a": np.array(array, dtype=np.int32)}) + + schema = pa.schema({"a": pa.int32()}) + + arrow = util.make_arrow_from_pandas(data, schema) + tbl = Table({"a": "float"}) + tbl.update(arrow) + assert tbl.view().to_columns()["a"] == array + + def test_update_arrow_update_float_schema_with_int64(self, util): + array = [random.randint(-20000000, 20000000) for i in range(100)] + data = pd.DataFrame({"a": np.array(array, dtype=np.int64)}) + + schema = pa.schema({"a": pa.int64()}) + + arrow = util.make_arrow_from_pandas(data, schema) + tbl = Table({"a": "float"}) + tbl.update(arrow) + assert tbl.view().to_columns()["a"] == array + + # updating int schema with float + def test_update_arrow_update_int_schema_with_float32(self, util): + array = [random.randint(-2000000, 2000000) * 0.5 for i in range(100)] + data = pd.DataFrame({"a": np.array(array, dtype=np.float32)}) + + schema = pa.schema({"a": pa.float32()}) + + arrow = util.make_arrow_from_pandas(data, schema) + tbl = Table({"a": "integer"}) + tbl.update(arrow) + assert tbl.view().to_columns()["a"] == [int(x) for x in array] + + def test_update_arrow_update_int_schema_with_float64(self, util): + array = [ + random.randint(-20000000, 20000000) * random.random() for i in range(100) + ] + data = pd.DataFrame({"a": np.array(array, dtype=np.float64)}) + + schema = pa.schema({"a": pa.float64()}) + + arrow = util.make_arrow_from_pandas(data, schema) + tbl = Table({"a": "integer"}) + tbl.update(arrow) + assert tbl.view().to_columns()["a"] == [int(x) for x in array] + + # updating float schema with float + + def test_update_arrow_update_float_schema_with_float32(self, util): + array = [random.randint(-2000000, 2000000) * 0.5 for i in range(100)] + data = pd.DataFrame({"a": np.array(array, dtype=np.float32)}) + + schema = pa.schema({"a": pa.float32()}) + + arrow = util.make_arrow_from_pandas(data, schema) + tbl = Table({"a": "float"}) + tbl.update(arrow) + assert tbl.view().to_columns()["a"] == array + + def test_update_arrow_update_float_schema_with_float64(self, util): + array = [ + random.randint(-20000000, 20000000) * random.random() for i in range(100) + ] + data = pd.DataFrame({"a": np.array(array, dtype=np.float64)}) + + schema = pa.schema({"a": pa.float64()}) + + arrow = util.make_arrow_from_pandas(data, schema) + tbl = Table({"a": "float"}) + tbl.update(arrow) + assert tbl.view().to_columns()["a"] == array + + # updating date schema + + def test_update_arrow_update_date_schema_with_date32(self, util): + array = [date(2019, 2, i) for i in range(1, 11)] + data = pd.DataFrame({"a": array}) + + schema = pa.schema({"a": pa.date32()}) + + arrow = util.make_arrow_from_pandas(data, schema) + + tbl = Table({"a": "date"}) + + tbl.update(arrow) + + assert tbl.view().to_columns()["a"] == [ + util.to_timestamp(datetime(2019, 2, i)) for i in range(1, 11) + ] + + def test_update_arrow_update_date_schema_with_date64(self, util): + array = [date(2019, 2, i) for i in range(1, 11)] + data = pd.DataFrame({"a": array}) + + schema = pa.schema({"a": pa.date64()}) + + arrow = util.make_arrow_from_pandas(data, schema) + + tbl = Table({"a": "date"}) + + tbl.update(arrow) + + assert tbl.view().to_columns()["a"] == [ + util.to_timestamp(datetime(2019, 2, i)) for i in range(1, 11) + ] + + def test_update_arrow_update_datetime_schema_with_timestamp(self, util): + data = [ + [datetime(2019, 2, i, 9) for i in range(1, 11)], + [datetime(2019, 2, i, 10) for i in range(1, 11)], + [datetime(2019, 2, i, 11) for i in range(1, 11)], + [datetime(2019, 2, i, 12) for i in range(1, 11)], + ] + + arrow_data = util.make_arrow( + names, + data, + types=[ + pa.timestamp("s"), + pa.timestamp("ms"), + pa.timestamp("us"), + pa.timestamp("ns"), + ], + ) + + tbl = Table( + { + "a": "datetime", + "b": "datetime", + "c": "datetime", + "d": "datetime", + } + ) + tbl.update(arrow_data) + assert tbl.size() == 10 + assert tbl.view().to_columns() == { + "a": [util.to_timestamp(d) for d in data[0]], + "b": [util.to_timestamp(d) for d in data[1]], + "c": [util.to_timestamp(d) for d in data[2]], + "d": [util.to_timestamp(d) for d in data[3]], + } + + # streams + + def test_update_arrow_updates_int_stream(self, util): + data = [list(range(10)) for i in range(4)] + arrow_data = util.make_arrow(names, data) + tbl = Table({"a": "integer", "b": "integer", "c": "integer", "d": "integer"}) + tbl.update(arrow_data) + assert tbl.size() == 10 + assert tbl.view().to_columns() == { + "a": data[0], + "b": data[1], + "c": data[2], + "d": data[3], + } + + def test_update_arrow_updates_float_stream(self, util): + data = [[i for i in range(10)], [i * 1.5 for i in range(10)]] + arrow_data = util.make_arrow(["a", "b"], data) + tbl = Table( + { + "a": "integer", + "b": "float", + } + ) + tbl.update(arrow_data) + assert tbl.size() == 10 + assert tbl.view().to_columns() == {"a": data[0], "b": data[1]} + + @mark.skip(reason="Decimal128 isn't part of our schema yet") + def test_update_arrow_updates_decimal128_stream(self, util): + data = [[i * 1000000000 for i in range(10)]] + arrow_data = util.make_arrow(["a"], data, types=[pa.decimal128(10)]) + tbl = Table({"a": "integer"}) + tbl.update(arrow_data) + assert tbl.size() == 10 + assert tbl.view().to_columns() == {"a": data[0]} + + def test_update_arrow_updates_bool_stream(self, util): + data = [[True if i % 2 == 0 else False for i in range(10)]] + arrow_data = util.make_arrow(["a"], data) + tbl = Table({"a": "boolean"}) + tbl.update(arrow_data) + assert tbl.size() == 10 + assert tbl.view().to_columns() == {"a": data[0]} + + def test_update_arrow_updates_date32_stream(self, util): + data = [[date(2019, 2, i) for i in range(1, 11)]] + arrow_data = util.make_arrow(["a"], data, types=[pa.date32()]) + tbl = Table({"a": "date"}) + tbl.update(arrow_data) + assert tbl.size() == 10 + assert tbl.view().to_columns() == { + "a": [int(1000 * datetime(2019, 2, i).timestamp()) for i in range(1, 11)] + } + + def test_update_arrow_updates_date64_stream(self, util): + data = [[date(2019, 2, i) for i in range(1, 11)]] + arrow_data = util.make_arrow(["a"], data, types=[pa.date64()]) + tbl = Table({"a": "date"}) + tbl.update(arrow_data) + assert tbl.size() == 10 + assert tbl.view().to_columns() == { + "a": [util.to_timestamp(datetime(2019, 2, i)) for i in range(1, 11)] + } + + def test_update_arrow_updates_timestamp_all_formats_stream(self, util): + data = [ + [datetime(2019, 2, i, 9) for i in range(1, 11)], + [datetime(2019, 2, i, 10) for i in range(1, 11)], + [datetime(2019, 2, i, 11) for i in range(1, 11)], + [datetime(2019, 2, i, 12) for i in range(1, 11)], + ] + arrow_data = util.make_arrow( + names, + data, + types=[ + pa.timestamp("s"), + pa.timestamp("ms"), + pa.timestamp("us"), + pa.timestamp("ns"), + ], + ) + tbl = Table( + {"a": "datetime", "b": "datetime", "c": "datetime", "d": "datetime"} + ) + tbl.update(arrow_data) + assert tbl.size() == 10 + assert tbl.view().to_columns() == { + "a": [util.to_timestamp(d) for d in data[0]], + "b": [util.to_timestamp(d) for d in data[1]], + "c": [util.to_timestamp(d) for d in data[2]], + "d": [util.to_timestamp(d) for d in data[3]], + } + + def test_update_arrow_updates_string_stream(self, util): + data = [[str(i) for i in range(10)]] + arrow_data = util.make_arrow(["a"], data, types=[pa.string()]) + tbl = Table({"a": "string"}) + tbl.update(arrow_data) + assert tbl.size() == 10 + assert tbl.view().to_columns() == {"a": data[0]} + + def test_update_arrow_updates_dictionary_stream(self, util): + data = [([0, 1, 1, None], ["a", "b"]), ([0, 1, None, 2], ["x", "y", "z"])] + arrow_data = util.make_dictionary_arrow(["a", "b"], data) + tbl = Table({"a": "string", "b": "string"}) + tbl.update(arrow_data) + + assert tbl.size() == 4 + assert tbl.view().to_columns() == { + "a": ["a", "b", "b", None], + "b": ["x", "y", None, "z"], + } + + @mark.skip(reason="Arrow no longer supports partial updates per row") + def test_update_arrow_partial_updates_dictionary_stream(self, util): + data = [([0, 1, 1, None], ["a", "b"]), ([0, 1, None, 2], ["x", "y", "z"])] + arrow_data = util.make_dictionary_arrow(["a", "b"], data) + tbl = Table({"a": "string", "b": "string"}, index="a") + tbl.update(arrow_data) + assert tbl.size() == 3 + assert tbl.view().to_columns() == {"a": [None, "a", "b"], "b": ["z", "x", "y"]} + + @mark.skip + def test_update_arrow_partial_updates_dictionary_stream_duplicates(self, util): + """If there are duplicate values in the dictionary, primary keys + may be duplicated if the column is used as an index. Skip this test + for now - still looking for the best way to fix.""" + data = [ + ([0, 1, 1, None, 2], ["a", "b", "a"]), + ([0, 1, None, 2, 1], ["x", "y", "z"]), + ] + arrow_data = util.make_dictionary_arrow(["a", "b"], data) + + tbl = Table({"a": "string", "b": "string"}, index="a") + + tbl.update(arrow_data) + + assert tbl.size() == 3 + assert tbl.view().to_columns() == {"a": [None, "a", "b"], "b": ["z", "x", "y"]} + + def test_update_arrow_partial_updates_more_columns_dictionary_stream(self, util): + data = [([0, 1, 1, None], ["a", "b"]), ([0, 1, None, 2], ["x", "y", "z"])] + arrow_data = util.make_dictionary_arrow(["a", "b"], data) + + tbl = Table({"a": "string"}, index="a") + + tbl.update(arrow_data) + + assert tbl.size() == 3 + assert tbl.view().to_columns() == {"a": [None, "a", "b"]} + + @mark.skip(reason="Arrow no longer supports partial updates per row") + def test_update_arrow_partial_updates_less_columns_dictionary_stream(self, util): + data = [([0, 1, 1, None], ["a", "b"]), ([0, 1, None, 2], ["x", "y", "z"])] + arrow_data = util.make_dictionary_arrow(["a", "b"], data) + tbl = Table({"a": "string", "b": "string", "x": "string"}, index="a") + tbl.update(arrow_data) + + assert tbl.size() == 3 + assert tbl.view().to_columns() == { + "a": [None, "a", "b"], + "b": ["z", "x", "y"], + "x": [None, None, None], + } + + def test_update_arrow_arbitary_order(self, util): + data = [[1, 2, 3, 4], ["a", "b", "c", "d"], [1, 2, 3, 4], ["a", "b", "c", "d"]] + update_data = [[5, 6], ["e", "f"], [5, 6], ["e", "f"]] + arrow = util.make_arrow(["a", "b", "c", "d"], data) + update_arrow = util.make_arrow(["c", "b", "a", "d"], update_data) + tbl = Table(arrow) + assert tbl.schema() == { + "a": "integer", + "b": "string", + "c": "integer", + "d": "string", + } + tbl.update(update_arrow) + assert tbl.size() == 6 + assert tbl.view().to_columns() == { + "a": [1, 2, 3, 4, 5, 6], + "b": ["a", "b", "c", "d", "e", "f"], + "c": [1, 2, 3, 4, 5, 6], + "d": ["a", "b", "c", "d", "e", "f"], + } + + # append + + def test_update_arrow_updates_append_int_stream(self, util): + data = [list(range(10)) for i in range(4)] + arrow_data = util.make_arrow(names, data) + tbl = Table(arrow_data) + tbl.update(arrow_data) + assert tbl.size() == 20 + assert tbl.view().to_columns() == { + "a": data[0] + data[0], + "b": data[1] + data[1], + "c": data[2] + data[2], + "d": data[3] + data[3], + } + + def test_update_arrow_updates_append_float_stream(self, util): + data = [[i for i in range(10)], [i * 1.5 for i in range(10)]] + arrow_data = util.make_arrow(["a", "b"], data) + tbl = Table(arrow_data) + tbl.update(arrow_data) + assert tbl.size() == 20 + assert tbl.view().to_columns() == { + "a": data[0] + data[0], + "b": data[1] + data[1], + } + + def test_update_arrow_updates_append_decimal_stream(self, util): + data = [[i * 1000 for i in range(10)]] + arrow_data = util.make_arrow(["a"], data, types=[pa.decimal128(4)]) + tbl = Table(arrow_data) + tbl.update(arrow_data) + assert tbl.size() == 20 + assert tbl.view().to_columns() == {"a": data[0] + data[0]} + + def test_update_arrow_updates_append_bool_stream(self, util): + data = [[True if i % 2 == 0 else False for i in range(10)]] + arrow_data = util.make_arrow(["a"], data) + tbl = Table(arrow_data) + tbl.update(arrow_data) + assert tbl.size() == 20 + assert tbl.view().to_columns() == {"a": data[0] + data[0]} + + def test_update_arrow_updates_append_date32_stream(self, util): + data = [[date(2019, 2, i) for i in range(1, 11)]] + out_data = [datetime(2019, 2, i) for i in range(1, 11)] + arrow_data = util.make_arrow(["a"], data, types=[pa.date32()]) + tbl = Table(arrow_data) + tbl.update(arrow_data) + assert tbl.size() == 20 + assert tbl.view().to_columns() == { + "a": [util.to_timestamp(d) for d in out_data + out_data] + } + + def test_update_arrow_updates_append_date64_stream(self, util): + data = [[date(2019, 2, i) for i in range(1, 11)]] + out_data = [datetime(2019, 2, i) for i in range(1, 11)] + arrow_data = util.make_arrow(["a"], data, types=[pa.date64()]) + tbl = Table(arrow_data) + tbl.update(arrow_data) + assert tbl.size() == 20 + assert tbl.view().to_columns() == { + "a": [util.to_timestamp(d) for d in out_data + out_data] + } + + def test_update_arrow_updates_append_timestamp_all_formats_stream(self, util): + data = [ + [datetime(2019, 2, i, 9) for i in range(1, 11)], + [datetime(2019, 2, i, 10) for i in range(1, 11)], + [datetime(2019, 2, i, 11) for i in range(1, 11)], + [datetime(2019, 2, i, 12) for i in range(1, 11)], + ] + arrow_data = util.make_arrow( + names, + data, + types=[ + pa.timestamp("s"), + pa.timestamp("ms"), + pa.timestamp("us"), + pa.timestamp("ns"), + ], + ) + tbl = Table(arrow_data) + tbl.update(arrow_data) + assert tbl.size() == 20 + assert tbl.view().to_columns() == { + "a": [util.to_timestamp(d) for d in data[0] + data[0]], + "b": [util.to_timestamp(d) for d in data[1] + data[1]], + "c": [util.to_timestamp(d) for d in data[2] + data[2]], + "d": [util.to_timestamp(d) for d in data[3] + data[3]], + } + + def test_update_arrow_updates_append_string_stream(self, util): + data = [[str(i) for i in range(10)]] + arrow_data = util.make_arrow(["a"], data, types=[pa.string()]) + tbl = Table(arrow_data) + tbl.update(arrow_data) + assert tbl.size() == 20 + assert tbl.view().to_columns() == {"a": data[0] + data[0]} + + def test_update_arrow_updates_append_dictionary_stream(self, util): + data = [([0, 1, 1, None], ["a", "b"]), ([0, 1, None, 2], ["x", "y", "z"])] + arrow_data = util.make_dictionary_arrow(["a", "b"], data) + tbl = Table(arrow_data) + tbl.update(arrow_data) + + assert tbl.size() == 8 + assert tbl.view().to_columns() == { + "a": ["a", "b", "b", None, "a", "b", "b", None], + "b": ["x", "y", None, "z", "x", "y", None, "z"], + } + + def test_update_arrow_updates_append_dictionary_stream_legacy(self, util): + data = [([0, 1, 1, None], ["a", "b"]), ([0, 1, None, 2], ["x", "y", "z"])] + arrow_data = util.make_dictionary_arrow(["a", "b"], data, legacy=True) + tbl = Table(arrow_data) + tbl.update(arrow_data) + + assert tbl.size() == 8 + assert tbl.view().to_columns() == { + "a": ["a", "b", "b", None, "a", "b", "b", None], + "b": ["x", "y", None, "z", "x", "y", None, "z"], + } + + # indexed + + def test_update_arrow_partial_indexed(self, util): + data = [[1, 2, 3, 4], ["a", "b", "c", "d"]] + update_data = [[2, 4], ["x", "y"]] + arrow = util.make_arrow(["a", "b"], data) + update_arrow = util.make_arrow(["a", "b"], update_data) + tbl = Table(arrow, index="a") + assert tbl.schema() == {"a": "integer", "b": "string"} + tbl.update(update_arrow) + assert tbl.size() == 4 + assert tbl.view().to_columns() == {"a": [1, 2, 3, 4], "b": ["a", "x", "c", "y"]} + + # update specific columns + + def test_update_arrow_specific_column(self, util): + data = [[1, 2, 3, 4], ["a", "b", "c", "d"]] + update_data = [[2, 3, 4]] + arrow = util.make_arrow(["a", "b"], data) + update_arrow = util.make_arrow(["a"], update_data) + tbl = Table(arrow) + assert tbl.schema() == {"a": "integer", "b": "string"} + tbl.update(update_arrow) + assert tbl.size() == 7 + assert tbl.view().to_columns() == { + "a": [1, 2, 3, 4, 2, 3, 4], + "b": ["a", "b", "c", "d", None, None, None], + } + + # try to fuzz column order + + def test_update_arrow_column_order_str(self, util): + # use str so it doesn't get promoted + data = [["a", "b", "c"] for i in range(10)] + names = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"] + names_scrambled = names[::-1] + arrow = util.make_arrow(names_scrambled, data) + tbl = Table({name: "string" for name in names}) + tbl.update(arrow) + assert tbl.size() == 3 + assert tbl.view().to_columns() == {name: data[0] for name in names} + + def test_update_arrow_column_order_int(self, util): + data = [[1, 2, 3] for i in range(10)] + names = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"] + names_scrambled = names[::-1] + arrow = util.make_arrow(names_scrambled, data) + tbl = Table({name: "integer" for name in names}) + tbl.update(arrow) + assert tbl.size() == 3 + assert tbl.view().to_columns() == {name: data[0] for name in names} + + def test_update_arrow_thread_safe_int_index(self, util): + data = [["a", "b", "c"] for i in range(10)] + data += [[1, 2, 3]] + names = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "uid"] + arrow = util.make_arrow(names, data) + tbl = Table(arrow, index="uid") + + for i in range(100): + idx = (1, 2, 3)[random.randint(0, 2)] + update_data = [ + [str(uuid.uuid4()) + str(random.randint(100, 1000000000))], + [idx], + ] + update_names = [names[random.randint(0, 9)], "uid"] + update_arrow = util.make_arrow(update_names, update_data) + tbl.update(update_arrow) + + assert tbl.size() == 3 + + def test_update_arrow_thread_safe_datetime_index(self, util): + data = [["a", "b", "c"] for i in range(10)] + data += [ + [ + datetime(2020, 1, 15, 12, 17), + datetime(2020, 1, 15, 12, 18), + datetime(2020, 1, 15, 12, 19), + ] + ] + names = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "uid"] + arrow = util.make_arrow(names, data) + tbl = Table(arrow, index="uid") + + for i in range(100): + idx = ( + datetime(2020, 1, 15, 12, 17), + datetime(2020, 1, 15, 12, 18), + datetime(2020, 1, 15, 12, 19), + )[random.randint(0, 2)] + update_data = [ + [str(uuid.uuid4()) + str(random.randint(100, 1000000000))], + [idx], + ] + update_names = [names[random.randint(0, 9)], "uid"] + update_arrow = util.make_arrow(update_names, update_data) + tbl.update(update_arrow) + + assert tbl.size() == 3 + + def test_update_arrow_thread_safe_str_index(self, util): + data = [["a", "b", "c"] for i in range(11)] + names = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "uid"] + arrow = util.make_arrow(names, data) + tbl = Table(arrow, index="uid") + + for i in range(100): + idx = ("a", "b", "c")[random.randint(0, 2)] + update_data = [ + [str(uuid.uuid4()) + str(random.randint(100, 1000000000))], + [idx], + ] + update_names = [names[random.randint(0, 9)], "uid"] + update_arrow = util.make_arrow(update_names, update_data) + tbl.update(update_arrow) + + assert tbl.size() == 3 diff --git a/rust/perspective-python/perspective/tests/table/test_update_pandas.py b/rust/perspective-python/perspective/tests/table/test_update_pandas.py new file mode 100644 index 0000000..55d305d --- /dev/null +++ b/rust/perspective-python/perspective/tests/table/test_update_pandas.py @@ -0,0 +1,211 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 numpy as np +import pandas as pd +from datetime import date, datetime +from pytest import mark +import pytest +import perspective as psp + +client = psp.Server().new_local_client() +Table = client.table + + +class TestUpdatePandas(object): + def test_update_df(self): + tbl = Table({"a": [1, 2, 3, 4]}) + + update_data = pd.DataFrame({"a": [5, 6, 7, 8]}) + + tbl.update(update_data) + + assert tbl.view().to_columns() == {"a": [1, 2, 3, 4, 5, 6, 7, 8]} + + def test_update_df_i32_vs_i64(self): + tbl = Table({"a": "integer"}) + + update_data = pd.DataFrame({"a": np.array([5, 6, 7, 8], dtype="int64")}) + + tbl.update(update_data) + + assert tbl.view().to_columns() == {"a": [5, 6, 7, 8]} + + def test_update_df_bool(self): + tbl = Table({"a": [True, False, True, False]}) + + update_data = pd.DataFrame({"a": [True, False, True, False]}) + + tbl.update(update_data) + + assert tbl.view().to_columns() == { + "a": [True, False, True, False, True, False, True, False] + } + + def test_update_df_str(self): + tbl = Table({"a": ["a", "b", "c", "d"]}) + + update_data = pd.DataFrame({"a": ["a", "b", "c", "d"]}) + + tbl.update(update_data) + + assert tbl.view().to_columns() == { + "a": ["a", "b", "c", "d", "a", "b", "c", "d"] + } + + def test_update_df_date(self, util): + # set_global_serializer(serializer) + tbl = Table({"a": [date(2019, 7, 11)]}) + + assert tbl.schema() == {"a": "date"} + + update_data = pd.DataFrame({"a": [date(2019, 7, 12)]}) + + tbl.update(update_data) + assert tbl.view().to_columns() == { + "a": [ + util.to_timestamp(d) + for d in [datetime(2019, 7, 11), datetime(2019, 7, 12)] + ] + } + + @mark.skip(reason="this test relies on a lossy conversion from datetime -> date.") + def test_update_df_date_timestamp(self, util): + tbl = Table({"a": [date(2019, 7, 11)]}) + + assert tbl.schema() == {"a": "date"} + + update_data = pd.DataFrame({"a": [datetime(2019, 7, 12, 0, 0, 0)]}) + + tbl.update(update_data) + assert tbl.view().to_columns() == { + "a": [ + util.to_timestamp(datetime(2019, 7, 11)), + util.to_timestamp(datetime(2019, 7, 12)), + ] + } + + def test_update_df_datetime(self, util): + tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)]}) + + update_data = pd.DataFrame({"a": [datetime(2019, 7, 12, 11, 0)]}) + + tbl.update(update_data) + assert tbl.view().to_columns() == { + "a": [ + util.to_timestamp(datetime(2019, 7, 11, 11, 0)), + util.to_timestamp(datetime(2019, 7, 12, 11, 0)), + ] + } + + def test_update_df_datetime_timestamp_seconds(self, util): + tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)]}) + + update_data = pd.DataFrame({"a": [datetime(2019, 7, 12, 11, 0)]}) + + tbl.update(update_data) + assert tbl.view().to_columns() == { + "a": [ + util.to_timestamp(datetime(2019, 7, 11, 11, 0)), + util.to_timestamp(datetime(2019, 7, 12, 11, 0)), + ] + } + + def test_update_df_datetime_timestamp_ms(self, util): + tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)]}) + + update_data = pd.DataFrame({"a": [datetime(2019, 7, 12, 11, 0)]}) + + tbl.update(update_data) + assert tbl.view().to_columns() == { + "a": [ + util.to_timestamp(datetime(2019, 7, 11, 11, 0)), + util.to_timestamp(datetime(2019, 7, 12, 11, 0)), + ] + } + + def test_update_df_partial(self): + tbl = Table({"a": [1, 2, 3, 4], "b": ["a", "b", "c", "d"]}, index="b") + + update_data = pd.DataFrame({"a": [5, 6, 7, 8], "b": ["a", "b", "c", "d"]}) + + tbl.update(update_data) + + assert tbl.view().to_columns() == {"a": [5, 6, 7, 8], "b": ["a", "b", "c", "d"]} + + def test_df_update_index(self): + tbl = Table(pd.DataFrame({"a": [1, 2, 3, 4]})) + update_data = pd.DataFrame({"a": [5, 6, 7, 8], "__INDEX__": [0, 1, 2, 3]}) + tbl.update(update_data) + assert tbl.view().to_columns() == {"a": [5, 6, 7, 8], "index": [0, 1, 2, 3]} + + def test_update_df_partial_implicit(self): + tbl = Table({"a": [1, 2, 3, 4]}) + + update_data = pd.DataFrame({"a": [5, 6, 7, 8], "__INDEX__": [0, 1, 2, 3]}) + + tbl.update(update_data) + + assert tbl.view().to_columns() == {"a": [5, 6, 7, 8]} + + def test_update_df_datetime_partial(self, util): + tbl = Table({"a": [datetime(2019, 7, 11, 11, 0)], "b": [1]}, index="b") + + update_data = pd.DataFrame({"a": [datetime(2019, 7, 12, 11, 0)], "b": [1]}) + + tbl.update(update_data) + + assert tbl.view().to_columns() == { + "a": [util.to_timestamp(datetime(2019, 7, 12, 11, 0))], + "b": [1], + } + + def test_update_df_one_col(self): + tbl = Table({"a": [1, 2, 3, 4], "b": ["a", "b", "c", "d"]}) + update_data = pd.DataFrame({"a": [5, 6, 7]}) + tbl.update(update_data) + assert tbl.view().to_columns() == { + "a": [1, 2, 3, 4, 5, 6, 7], + "b": ["a", "b", "c", "d", None, None, None], + } + + def test_update_df_nonseq_partial(self): + tbl = Table({"a": [1, 2, 3, 4], "b": ["a", "b", "c", "d"]}, index="b") + update_data = pd.DataFrame({"a": [5, 6, 7], "b": ["a", "c", "d"]}) + tbl.update(update_data) + assert tbl.view().to_columns() == {"a": [5, 2, 6, 7], "b": ["a", "b", "c", "d"]} + + @pytest.mark.skip + def test_update_df_with_none_partial(self): + tbl = Table({"a": [1, float("nan"), 3], "b": ["a", None, "d"]}, index="b") + update_data = pd.DataFrame({"a": [4, 5], "b": ["a", "d"]}) + tbl.update(update_data) + assert tbl.view().to_columns() == { + "a": [None, 4, 5], + "b": [None, "a", "d"], + } # pkeys are ordered + + def test_update_df_unset_partial(self): + tbl = Table({"a": [1, 2, 3], "b": ["a", "b", "c"]}, index="b") + update_data = pd.DataFrame( + {"a": pd.Series([None, None], dtype=pd.Int32Dtype()), "b": ["a", "c"]} + ) + tbl.update(update_data) + assert tbl.view().to_columns() == {"a": [None, 2, None], "b": ["a", "b", "c"]} + + def test_update_df_nan_partial(self): + tbl = Table({"a": [1, 2, 3], "b": ["a", "b", "c"]}, index="b") + update_data = pd.DataFrame( + {"a": pd.Series([None, None], dtype=pd.Int32Dtype()), "b": ["a", "c"]} + ) + tbl.update(update_data) + assert tbl.view().to_columns() == {"a": [None, 2, None], "b": ["a", "b", "c"]} diff --git a/rust/perspective-python/perspective/tests/table/test_view.py b/rust/perspective-python/perspective/tests/table/test_view.py new file mode 100644 index 0000000..e59d13d --- /dev/null +++ b/rust/perspective-python/perspective/tests/table/test_view.py @@ -0,0 +1,2444 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 pandas as pd +import numpy as np +from perspective import PerspectiveError +from datetime import date, datetime +from pytest import approx, mark, raises + +import perspective as psp + +client = psp.Server().new_local_client() +Table = client.table + + +def date_timestamp(date): + return int(datetime.combine(date, datetime.min.time()).timestamp()) * 1000 + + +def compare_delta(received, expected): + """Compare an arrow-serialized row delta by constructing a Table.""" + tbl = Table(received) + assert tbl.view().to_columns() == expected + + +class TestView(object): + def test_view_zero(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view() + dimms = view.dimensions() + assert dimms["num_view_rows"] == 2 + assert dimms["num_view_columns"] == 2 + assert view.schema() == {"a": "integer", "b": "integer"} + assert view.to_records() == data + + def test_view_one(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(group_by=["a"]) + dimms = view.dimensions() + assert dimms["num_view_rows"] == 3 + assert dimms["num_view_columns"] == 2 + assert view.schema() == {"a": "integer", "b": "integer"} + assert view.to_records() == [ + {"__ROW_PATH__": [], "a": 4, "b": 6}, + {"__ROW_PATH__": [1], "a": 1, "b": 2}, + {"__ROW_PATH__": [3], "a": 3, "b": 4}, + ] + + def test_view_two(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(group_by=["a"], split_by=["b"]) + dimms = view.dimensions() + assert dimms["num_view_rows"] == 3 + assert dimms["num_view_columns"] == 4 + assert view.schema() == {"a": "integer", "b": "integer"} + assert view.to_records() == [ + {"2|a": 1, "2|b": 2, "4|a": 3, "4|b": 4, "__ROW_PATH__": []}, + {"2|a": 1, "2|b": 2, "4|a": None, "4|b": None, "__ROW_PATH__": [1]}, + {"2|a": None, "2|b": None, "4|a": 3, "4|b": 4, "__ROW_PATH__": [3]}, + ] + + def test_view_two_column_only(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(split_by=["b"]) + dimms = view.dimensions() + assert dimms["num_view_rows"] == 2 + assert dimms["num_view_columns"] == 4 + assert view.schema() == {"a": "integer", "b": "integer"} + assert view.to_records() == [ + {"2|a": 1, "2|b": 2, "4|a": None, "4|b": None}, + {"2|a": None, "2|b": None, "4|a": 3, "4|b": 4}, + ] + + # column path + + def test_view_column_path_zero(self): + data = {"a": [1, 2, 3], "b": [1.5, 2.5, 3.5]} + tbl = Table(data) + view = tbl.view() + paths = view.column_paths() + assert paths == ["a", "b"] + + def test_view_column_path_zero_schema(self): + data = {"a": "integer", "b": "float"} + tbl = Table(data) + view = tbl.view() + paths = view.column_paths() + assert paths == ["a", "b"] + + def test_view_column_path_zero_hidden(self): + data = {"a": [1, 2, 3], "b": [1.5, 2.5, 3.5]} + tbl = Table(data) + view = tbl.view(columns=["b"]) + paths = view.column_paths() + assert paths == ["b"] + + def test_view_column_path_zero_respects_order(self): + data = {"a": [1, 2, 3], "b": [1.5, 2.5, 3.5]} + tbl = Table(data) + view = tbl.view(columns=["b", "a"]) + paths = view.column_paths() + assert paths == ["b", "a"] + + def test_view_column_path_one(self): + data = {"a": [1, 2, 3], "b": [1.5, 2.5, 3.5]} + tbl = Table(data) + view = tbl.view(group_by=["a"]) + paths = view.column_paths() + assert paths == ["a", "b"] + + def test_view_column_path_one_numeric_names(self): + data = {"a": [1, 2, 3], "b": [1.5, 2.5, 3.5], "1234": [5, 6, 7]} + tbl = Table(data) + view = tbl.view(group_by=["a"], columns=["b", "1234", "a"]) + paths = view.column_paths() + assert paths == ["b", "1234", "a"] + + def test_view_column_path_two(self): + data = {"a": [1, 2, 3], "b": [1.5, 2.5, 3.5]} + tbl = Table(data) + view = tbl.view(group_by=["a"], split_by=["b"]) + paths = view.column_paths() + assert paths == [ + "1.5|a", + "1.5|b", + "2.5|a", + "2.5|b", + "3.5|a", + "3.5|b", + ] + + def test_view_column_path_two_column_only(self): + data = {"a": [1, 2, 3], "b": [1.5, 2.5, 3.5]} + tbl = Table(data) + view = tbl.view(split_by=["b"]) + paths = view.column_paths() + assert paths == ["1.5|a", "1.5|b", "2.5|a", "2.5|b", "3.5|a", "3.5|b"] + + def test_view_column_path_hidden_sort(self): + data = {"a": [1, 2, 3], "b": [1.5, 2.5, 3.5], "c": [3, 2, 1]} + tbl = Table(data) + view = tbl.view(columns=["a", "b"], sort=[["c", "desc"]]) + paths = view.column_paths() + assert paths == ["a", "b"] + + def test_view_column_path_hidden_col_sort(self): + data = {"a": [1, 2, 3], "b": [1.5, 2.5, 3.5], "c": [3, 2, 1]} + tbl = Table(data) + view = tbl.view(split_by=["a"], columns=["a", "b"], sort=[["c", "col desc"]]) + paths = view.column_paths() + assert paths == ["1|a", "1|b", "2|a", "2|b", "3|a", "3|b"] + + def test_view_column_path_pivot_by_bool(self): + data = {"a": [1, 2, 3], "b": [True, False, True], "c": [3, 2, 1]} + tbl = Table(data) + view = tbl.view(split_by=["b"], columns=["a", "b", "c"]) + paths = view.column_paths() + assert paths == ["false|a", "false|b", "false|c", "true|a", "true|b", "true|c"] + + # schema correctness + + def test_string_view_schema(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view() + assert view.schema() == {"a": "integer", "b": "integer"} + + def test_zero_view_schema(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view() + assert view.schema() == {"a": "integer", "b": "integer"} + + def test_one_view_schema(self): + data = [{"a": "abc", "b": 2}, {"a": "abc", "b": 4}] + tbl = Table(data) + view = tbl.view(group_by=["a"], aggregates={"a": "distinct count"}) + assert view.schema() == {"a": "integer", "b": "integer"} + + def test_two_view_schema(self): + data = [{"a": "abc", "b": "def"}, {"a": "abc", "b": "def"}] + tbl = Table(data) + view = tbl.view( + group_by=["a"], split_by=["b"], aggregates={"a": "count", "b": "count"} + ) + assert view.schema() == {"a": "integer", "b": "integer"} + + # aggregates and column specification + + def test_view_no_columns(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(columns=[]) + assert view.dimensions()["num_view_columns"] == 0 + assert view.to_records() == [] + + def test_view_no_columns_pivoted(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(group_by=["a"], columns=[]) + assert view.dimensions()["num_view_columns"] == 0 + assert view.to_records() == [ + {"__ROW_PATH__": []}, + {"__ROW_PATH__": [1]}, + {"__ROW_PATH__": [3]}, + ] + + def test_view_specific_column(self): + data = [{"a": 1, "b": 2, "c": 3, "d": 4}, {"a": 3, "b": 4, "c": 5, "d": 6}] + tbl = Table(data) + view = tbl.view(columns=["a", "c", "d"]) + assert view.dimensions()["num_view_columns"] == 3 + assert view.to_records() == [{"a": 1, "c": 3, "d": 4}, {"a": 3, "c": 5, "d": 6}] + + def test_view_column_order(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(columns=["b", "a"]) + assert view.to_records() == [{"b": 2, "a": 1}, {"b": 4, "a": 3}] + + def test_view_dataframe_column_order(self): + table = Table( + pd.DataFrame( + { + "0.1": [5, 6, 7, 8], + "-0.05": [5, 6, 7, 8], + "0.0": [1, 2, 3, 4], + "-0.1": [1, 2, 3, 4], + "str": ["a", "b", "c", "d"], + } + ) + ) + view = table.view(columns=["-0.1", "-0.05", "0.0", "0.1"], group_by=["str"]) + assert view.column_paths() == ["-0.1", "-0.05", "0.0", "0.1"] + + def test_view_aggregate_order_with_columns(self): + """If `columns` is provided, order is always guaranteed.""" + data = [{"a": 1, "b": 2, "c": 3, "d": 4}, {"a": 3, "b": 4, "c": 5, "d": 6}] + tbl = Table(data) + view = tbl.view( + group_by=["a"], + columns=["a", "b", "c", "d"], + aggregates={"d": "avg", "c": "avg", "b": "last", "a": "last"}, + ) + + order = ["a", "b", "c", "d"] + assert view.column_paths() == order + + def test_view_df_aggregate_order_with_columns(self): + """If `columns` is provided, order is always guaranteed.""" + data = pd.DataFrame( + {"a": [1, 2, 3], "b": [2, 3, 4], "c": [3, 4, 5], "d": [4, 5, 6]}, + columns=["d", "a", "c", "b"], + ) + tbl = Table(data) + view = tbl.view( + group_by=["a"], + aggregates={"d": "avg", "c": "avg", "b": "last", "a": "last"}, + ) + + order = ["index", "d", "a", "c", "b"] + assert view.column_paths() == order + + def test_view_aggregates_with_no_columns(self): + data = [{"a": 1, "b": 2, "c": 3, "d": 4}, {"a": 3, "b": 4, "c": 5, "d": 6}] + tbl = Table(data) + view = tbl.view( + group_by=["a"], aggregates={"c": "avg", "a": "last"}, columns=[] + ) + assert view.column_paths() == [] + assert view.to_records() == [ + {"__ROW_PATH__": []}, + {"__ROW_PATH__": [1]}, + {"__ROW_PATH__": [3]}, + ] + + def test_view_aggregates_default_column_order(self): + """Order of columns are entirely determined by the `columns` kwarg. If + it is not provided, order of columns is default based on the order + of table.columns().""" + data = [{"a": 1, "b": 2, "c": 3, "d": 4}, {"a": 3, "b": 4, "c": 5, "d": 6}] + tbl = Table(data) + cols = tbl.columns() + view = tbl.view(group_by=["a"], aggregates={"c": "avg", "a": "last"}) + + assert view.column_paths() == cols + + # check that default aggregates have been applied + result = view.to_columns() + assert result["b"] == [6, 2, 4] + assert result["d"] == [10, 4, 6] + + # and that specified aggregates are applied + assert result["a"] == [3, 1, 3] + assert result["c"] == [4, 3, 5] + + # row and split by paths + def test_view_group_by_datetime_row_paths_are_same_as_data(self, util): + """Tests row paths for datetimes in UTC. Timezone-related tests are + in the `test_table_datetime` file.""" + data = {"a": [datetime(2019, 7, 11, 12, 30)], "b": [1]} + tbl = Table(data) + view = tbl.view(group_by=["a"]) + data = view.to_columns() + + for rp in data["__ROW_PATH__"]: + if len(rp) > 0: + assert rp[0] == util.to_timestamp(datetime(2019, 7, 11, 12, 30)) + + assert tbl.view().to_columns() == { + "a": [util.to_timestamp(datetime(2019, 7, 11, 12, 30))], + "b": [1], + } + + def test_view_split_by_datetime_names_utc(self): + """Tests column paths for datetimes in UTC. Timezone-related tests are + in the `test_table_datetime` file.""" + data = {"a": [datetime(2019, 7, 11, 12, 30)], "b": [1]} + tbl = Table(data) + view = tbl.view(split_by=["a"]) + cols = view.column_paths() + assert cols == ["2019-07-11 12:30:00.000|a", "2019-07-11 12:30:00.000|b"] + + # TODO: time slightly off! thinks its NYE 1969 + @mark.skip # We do not support python datetimes. + def test_view_split_by_datetime_names_min(self): + """Tests column paths for datetimes in UTC. Timezone-related tests are + in the `test_table_datetime` file.""" + import os + + os.environ["TZ"] = "UTC" + data = {"a": [datetime.min], "b": [1]} + tbl = Table({"a": "datetime", "b": "integer"}) + tbl.update(data) + view = tbl.view(split_by=["a"]) + cols = view.column_paths() + assert cols == ["1970-01-01 00:00:00.000|a", "1970-01-01 00:00:00.000|b"] + + @mark.skip # We dont support python datetimes. + def test_view_split_by_datetime_names_max(self): + """Tests column paths for datetimes in UTC. Timezone-related tests are + in the `test_table_datetime` file.""" + data = {"a": [datetime.max], "b": [1]} + tbl = Table(data) + view = tbl.view(split_by=["a"]) + cols = view.column_paths() + assert cols == ["10000-01-01 00:00:00.000|a", "10000-01-01 00:00:00.000|b"] + + # aggregate + + def test_view_aggregate_int(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(aggregates={"a": "avg"}, group_by=["a"]) + assert view.to_records() == [ + {"__ROW_PATH__": [], "a": 2.0, "b": 6}, + {"__ROW_PATH__": [1], "a": 1.0, "b": 2}, + {"__ROW_PATH__": [3], "a": 3.0, "b": 4}, + ] + + def test_view_aggregate_str(self): + data = [{"a": "abc", "b": 2}, {"a": "def", "b": 4}] + tbl = Table(data) + view = tbl.view(aggregates={"a": "count"}, group_by=["a"]) + assert view.to_records() == [ + {"__ROW_PATH__": [], "a": 2, "b": 6}, + {"__ROW_PATH__": ["abc"], "a": 1, "b": 2}, + {"__ROW_PATH__": ["def"], "a": 1, "b": 4}, + ] + + def test_view_aggregate_datetime(self, util): + data = [ + {"a": datetime(2019, 10, 1, 11, 30)}, + {"a": datetime(2019, 10, 1, 11, 30)}, + ] + tbl = Table(data) + view = tbl.view(aggregates={"a": "distinct count"}, group_by=["a"]) + assert view.to_records() == [ + {"__ROW_PATH__": [], "a": 1}, + { + "__ROW_PATH__": [util.to_timestamp(datetime(2019, 10, 1, 11, 30))], + "a": 1, + }, + ] + + def test_view_aggregate_datetime_leading_zeroes(self, util): + data = [ + {"a": datetime(2019, 1, 1, 5, 5, 5)}, + {"a": datetime(2019, 1, 1, 5, 5, 5)}, + ] + tbl = Table(data) + view = tbl.view(aggregates={"a": "distinct count"}, group_by=["a"]) + assert view.to_records() == [ + {"__ROW_PATH__": [], "a": 1}, + { + "__ROW_PATH__": [util.to_timestamp(datetime(2019, 1, 1, 5, 5, 5))], + "a": 1, + }, + ] + + def test_view_aggregate_mean(self): + data = [ + {"a": "a", "x": 1, "y": 200}, + {"a": "a", "x": 2, "y": 100}, + {"a": "a", "x": 3, "y": None}, + ] + tbl = Table(data) + view = tbl.view(aggregates={"y": "mean"}, group_by=["a"], columns=["y"]) + assert view.to_records() == [ + {"__ROW_PATH__": [], "y": 300 / 2}, + {"__ROW_PATH__": ["a"], "y": 300 / 2}, + ] + + def test_view_aggregate_gmv(self): + data = { + "division": [ + "D1", + "D2", + "D1", + "D2", + "D1", + "D2", + "D1", + "D2", + "D1", + "D2", + "D1", + "D2", + "D1", + "D2", + "D1", + "D2", + "D1", + "D2", + "D1", + "D2", + ], + "trading area": [ + "A", + "B", + "C", + "D", + "E", + "A", + "B", + "C", + "D", + "E", + "A", + "B", + "C", + "D", + "E", + "A", + "B", + "C", + "D", + "E", + ], + "symbol": [ + "AAPL", + "GOOG", + "MSFT", + "AAPL", + "GOOG", + "MSFT", + "AAPL", + "GOOG", + "MSFT", + "AAPL", + "GOOG", + "MSFT", + "AAPL", + "GOOG", + "MSFT", + "AAPL", + "GOOG", + "MSFT", + "AAPL", + "GOOG", + ], + "MV": [ + 1500, + 1200, + 1300, + 1400, + 1600, + 1100, + 1700, + 1800, + 1900, + 2000, + -2100, + -2200, + -2300, + -2400, + -2500, + -2600, + -2700, + -2800, + -2900, + -3000, + ], + } + + tbl = Table(data) + view = tbl.view( + aggregates={"MV": "gmv"}, group_by=["division", "symbol"], columns=["MV"] + ) + + assert view.to_columns() == { + "__ROW_PATH__": [ + [], + ["D1"], + ["D1", "AAPL"], + ["D1", "GOOG"], + ["D1", "MSFT"], + ["D2"], + ["D2", "AAPL"], + ["D2", "GOOG"], + ["D2", "MSFT"], + ], + "MV": [10000, 5900, -2000, -3200, 700, 7100, 800, -2400, -3900], + } + + def test_view_aggregate_gmv_split_by(self): + data = { + "division": [ + "D1", "D2", "D1", "D2", "D1", "D2", "D1", "D2", "D1", "D2", + "D1", "D2", "D1", "D2", "D1", "D2", "D1", "D2", "D1", "D2", + ], + "symbol": [ + "AAPL", "GOOG", "MSFT", "AAPL", "GOOG", "MSFT", "AAPL", + "GOOG", "MSFT", "AAPL", "GOOG", "MSFT", "AAPL", "GOOG", + "MSFT", "AAPL", "GOOG", "MSFT", "AAPL", "GOOG", + ], + "MV": [ + 1500, 1200, 1300, 1400, 1600, 1100, 1700, 1800, 1900, 2000, + -2100, -2200, -2300, -2400, -2500, -2600, -2700, -2800, + -2900, -3000, + ], + } + + tbl = Table(data) + view = tbl.view( + aggregates={"MV": "gmv"}, + group_by=["division"], + split_by=["symbol"], + columns=["MV"], + ) + + assert view.to_columns() == { + "__ROW_PATH__": [[], ["D1"], ["D2"]], + "AAPL|MV": [2800, -2000, 800], + "GOOG|MV": [5600, -3200, -2400], + "MSFT|MV": [4600, 700, -3900], + } + + def test_view_aggregate_mean_from_schema(self): + data = [ + {"a": "a", "x": 1, "y": 200}, + {"a": "a", "x": 2, "y": 100}, + {"a": "a", "x": 3, "y": None}, + ] + tbl = Table({"a": "string", "x": "integer", "y": "float"}) + view = tbl.view(aggregates={"y": "mean"}, group_by=["a"], columns=["y"]) + tbl.update(data) + assert view.to_records() == [ + {"__ROW_PATH__": [], "y": 300 / 2}, + {"__ROW_PATH__": ["a"], "y": 300 / 2}, + ] + + def test_view_aggregate_weighted_mean(self): + data = [ + {"a": "a", "x": 1, "y": 200}, + {"a": "a", "x": 2, "y": 100}, + {"a": "a", "x": 3, "y": None}, + ] + tbl = Table(data) + view = tbl.view( + aggregates={"y": ("weighted mean", ["x"])}, + group_by=["a"], + columns=["y"], + ) + assert view.to_records() == [ + {"__ROW_PATH__": [], "y": (1.0 * 200 + 2 * 100) / (1.0 + 2)}, + {"__ROW_PATH__": ["a"], "y": (1.0 * 200 + 2 * 100) / (1.0 + 2)}, + ] + + def test_view_aggregate_weighted_mean_by_expression(self): + data = [ + {"a": "a", "x": 1, "y": 200}, + {"a": "a", "x": 2, "y": 100}, + {"a": "a", "x": 3, "y": None}, + ] + tbl = Table(data) + view = tbl.view( + aggregates={"y": ("weighted mean", ["z"])}, + group_by=["a"], + columns=["y", "z"], + expressions={"z": '"x"'}, + ) + + assert view.to_records() == [ + {"__ROW_PATH__": [], "y": (1.0 * 200 + 2 * 100) / (1.0 + 2), "z": 6}, + {"__ROW_PATH__": ["a"], "y": (1.0 * 200 + 2 * 100) / (1.0 + 2), "z": 6}, + ] + + def test_view_aggregate_weighted_mean_by_expression_without_column_ref(self): + data = [ + {"a": "a", "x": 1, "y": 200}, + {"a": "a", "x": 2, "y": 100}, + {"a": "a", "x": 3, "y": None}, + ] + tbl = Table(data) + view = tbl.view( + aggregates={"y": ("weighted mean", ["z"])}, + group_by=["a"], + columns=["y"], + expressions={"z": '"x" + 1'}, + ) + + assert view.to_records() == [ + {"__ROW_PATH__": [], "y": (2 * 200 + 3 * 100) / (2 + 3)}, + {"__ROW_PATH__": ["a"], "y": (2 * 200 + 3 * 100) / (2 + 3)}, + ] + + def test_view_aggregate_weighted_mean_with_negative_weights(self): + data = [ + {"a": "a", "x": 1, "y": 200}, + {"a": "a", "x": -2, "y": 100}, + {"a": "a", "x": 3, "y": None}, + ] + tbl = Table(data) + view = tbl.view( + aggregates={"y": ("weighted mean", ["x"])}, + group_by=["a"], + columns=["y"], + ) + assert view.to_records() == [ + {"__ROW_PATH__": [], "y": (1 * 200 + (-2) * 100) / (1 - 2)}, + {"__ROW_PATH__": ["a"], "y": (1 * 200 + (-2) * 100) / (1 - 2)}, + ] + + def test_view_variance(self): + data = {"x": list(np.random.rand(10)), "y": ["a" for _ in range(10)]} + + table = Table(data) + view = table.view(aggregates={"x": "var"}, group_by=["y"]) + + result = view.to_columns() + expected = np.var(data["x"]) + + assert result["x"] == approx([expected, expected]) + + def test_view_variance_multi(self): + data = { + "a": [ + 91.96, + 258.576, + 29.6, + 243.16, + 36.24, + 25.248, + 79.99, + 206.1, + 31.5, + 55.6, + ], + "b": [1 if i % 2 == 0 else 0 for i in range(10)], + } + table = Table(data) + view = table.view(aggregates={"a": "var"}, group_by=["b"]) + + result = view.to_columns() + expected_total = np.var(data["a"]) + expected_zero = np.var( + [data["a"][1], data["a"][3], data["a"][5], data["a"][7], data["a"][9]] + ) + expected_one = np.var( + [data["a"][0], data["a"][2], data["a"][4], data["a"][6], data["a"][8]] + ) + + assert result["a"] == approx([expected_total, expected_zero, expected_one]) + + def test_view_variance_update_none(self): + data = {"a": [0.1, 0.5, None, 0.8], "b": [0, 1, 0, 1], "c": [1, 2, 3, 4]} + table = Table(data, index="c") + view = table.view(columns=["a"], group_by=["b"], aggregates={"a": "var"}) + result = view.to_columns() + assert result["a"][0] == approx(np.var([0.1, 0.5, 0.8])) + assert result["a"][1] is None + assert result["a"][2] == approx(np.var([0.5, 0.8])) + + table.update({"a": [0.3], "c": [3]}) + + result = view.to_columns() + assert result["a"] == approx( + [np.var([0.1, 0.5, 0.3, 0.8]), np.var([0.1, 0.3]), np.var([0.5, 0.8])] + ) + + table.update({"a": [None], "c": [1]}) + + result = view.to_columns() + assert result["a"][0] == approx(np.var([0.5, 0.3, 0.8])) + assert result["a"][1] is None + assert result["a"][2] == approx(np.var([0.5, 0.8])) + + def test_view_variance_multi_update(self): + data = { + "a": [ + 91.96, + 258.576, + 29.6, + 243.16, + 36.24, + 25.248, + 79.99, + 206.1, + 31.5, + 55.6, + ], + "b": [1 if i % 2 == 0 else 0 for i in range(10)], + } + table = Table(data) + view = table.view(aggregates={"a": "var"}, group_by=["b"]) + + result = view.to_columns() + expected_total = data["a"] + expected_zero = [ + data["a"][1], + data["a"][3], + data["a"][5], + data["a"][7], + data["a"][9], + ] + expected_one = [ + data["a"][0], + data["a"][2], + data["a"][4], + data["a"][6], + data["a"][8], + ] + + assert result["a"] == approx( + [np.var(expected_total), np.var(expected_zero), np.var(expected_one)] + ) + + # 2 here should result in null var because the group size is 1 + update_data = {"a": [15.12, 9.102, 0.99, 12.8], "b": [1, 0, 1, 2]} + table.update(update_data) + + result = view.to_columns() + expected_total += update_data["a"] + expected_zero += [update_data["a"][1]] + expected_one += [update_data["a"][0], update_data["a"][2]] + + assert result["__ROW_PATH__"] == [[], [0], [1], [2]] + assert result["a"][:-1] == approx( + [np.var(expected_total), np.var(expected_zero), np.var(expected_one)] + ) + assert result["a"][-1] is None + + def test_view_variance_multi_update_delta(self): + data = { + "a": [ + 91.96, + 258.576, + 29.6, + 243.16, + 36.24, + 25.248, + 79.99, + 206.1, + 31.5, + 55.6, + ], + "b": [1 if i % 2 == 0 else 0 for i in range(10)], + } + table = Table(data) + view = table.view(aggregates={"a": "var"}, group_by=["b"]) + + result = view.to_columns() + expected_total = data["a"] + expected_zero = [ + data["a"][1], + data["a"][3], + data["a"][5], + data["a"][7], + data["a"][9], + ] + expected_one = [ + data["a"][0], + data["a"][2], + data["a"][4], + data["a"][6], + data["a"][8], + ] + + assert result["a"] == approx( + [np.var(expected_total), np.var(expected_zero), np.var(expected_one)] + ) + + # 2 here should result in null var because the group size is 1 + update_data = {"a": [15.12, 9.102, 0.99, 12.8], "b": [1, 0, 1, 2]} + + def cb1(port_id, delta): + table2 = Table(delta) + view2 = table2.view() + result = view2.to_columns() + + flat_view = table.view() + flat_data = flat_view.to_columns() + result = view.to_columns() + + expected_total = flat_data["a"] + expected_zero = [] + expected_one = [] + + for i, num in enumerate(expected_total): + if flat_data["b"][i] == 1: + expected_one.append(num) + elif flat_data["b"][i] == 0: + expected_zero.append(num) + + assert result["a"][0] == approx(np.var(expected_total)) + assert result["a"][1] == approx(np.var(expected_zero)) + assert result["a"][2] == approx(np.var(expected_one)) + assert result["a"][3] is None + + view.on_update(cb1, mode="row") + + table.update(update_data) + + def test_view_variance_multi_update_indexed(self): + data = { + "a": [ + 91.96, + 258.576, + 29.6, + 243.16, + 36.24, + 25.248, + 79.99, + 206.1, + 31.5, + 55.6, + ], + "b": [1 if i % 2 == 0 else 0 for i in range(10)], + "c": [i for i in range(10)], + } + table = Table(data, index="c") + view = table.view(aggregates={"a": "var"}, group_by=["b"]) + + result = view.to_columns() + expected_total = data["a"] + expected_zero = [ + data["a"][1], + data["a"][3], + data["a"][5], + data["a"][7], + data["a"][9], + ] + expected_one = [ + data["a"][0], + data["a"][2], + data["a"][4], + data["a"][6], + data["a"][8], + ] + + assert result["a"] == approx( + [np.var(expected_total), np.var(expected_zero), np.var(expected_one)] + ) + + # "b" = 2 here should result in null var because the group size is 1 + update_data = { + "a": [15.12, 9.102, 0.99, 12.8], + "b": [1, 0, 1, 2], + "c": [1, 5, 2, 7], + } + + table.update(update_data) + + result = view.to_columns() + + view2 = table.view() + flat_data = view2.to_columns() + expected_total = flat_data["a"] + + expected_zero = [] + expected_one = [] + + for i, val in enumerate(flat_data["a"]): + if flat_data["b"][i] == 1: + expected_one.append(val) + elif flat_data["b"][i] == 0: + expected_zero.append(val) + + assert result["__ROW_PATH__"] == [[], [0], [1], [2]] + assert result["a"][:-1] == approx( + [np.var(expected_total), np.var(expected_zero), np.var(expected_one)] + ) + assert result["a"][-1] is None + + def test_view_variance_multi_update_indexed_delta(self): + data = { + "a": [ + 91.96, + 258.576, + 29.6, + 243.16, + 36.24, + 25.248, + 79.99, + 206.1, + 31.5, + 55.6, + ], + "b": [1 if i % 2 == 0 else 0 for i in range(10)], + "c": [i for i in range(10)], + } + table = Table(data, index="c") + view = table.view( + aggregates={"a": "var", "b": "last", "c": "last"}, group_by=["b"] + ) + + result = view.to_columns() + expected_total = data["a"] + expected_zero = [ + data["a"][1], + data["a"][3], + data["a"][5], + data["a"][7], + data["a"][9], + ] + expected_one = [ + data["a"][0], + data["a"][2], + data["a"][4], + data["a"][6], + data["a"][8], + ] + + assert result["a"] == approx( + [np.var(expected_total), np.var(expected_zero), np.var(expected_one)] + ) + + # 2 here should result in null var because the group size is 1 + update_data = { + "a": [15.12, 9.102, 0.99, 12.8], + "b": [1, 0, 1, 2], + "c": [0, 4, 1, 6], + } + + def cb1(port_id, delta): + table2 = Table(delta) + view2 = table2.view() + result = view2.to_columns() + + flat_view = table.view() + flat_result = flat_view.to_columns() + + new_a = flat_result["a"] + b = flat_result["b"] + expected_zero = [] + expected_one = [] + + for i, num in enumerate(new_a): + if b[i] == 0: + expected_zero.append(num) + elif b[i] == 1: + expected_one.append(num) + + assert result["a"][0] == approx(np.var(new_a)) + assert result["a"][1] == approx(np.var(expected_zero)) + assert result["a"][2] == approx(np.var(expected_one)) + assert result["a"][3] is None + assert result["b"] == [2, 0, 1, 2] + assert result["c"] == [6, 9, 8, 6] + + view.on_update(cb1, mode="row") + + table.update(update_data) + + def test_view_variance_less_than_two(self): + data = {"a": list(np.random.rand(10)), "b": [i for i in range(10)]} + + table = Table(data) + view = table.view(aggregates={"a": "var"}, group_by=["b"]) + + result = view.to_columns() + assert result["a"][0] == approx(np.var(data["a"])) + assert result["a"][1:] == [None] * 10 + + def test_view_variance_normal_distribution(self): + data = {"a": list(np.random.standard_normal(100)), "b": [1] * 100} + + table = Table(data) + view = table.view(aggregates={"a": "var"}, group_by=["b"]) + + result = view.to_columns() + assert result["a"] == approx([np.var(data["a"]), np.var(data["a"])]) + + def test_view_standard_deviation(self): + data = {"x": list(np.random.rand(10)), "y": ["a" for _ in range(10)]} + + table = Table(data) + view = table.view(aggregates={"x": "stddev"}, group_by=["y"]) + + result = view.to_columns() + expected = np.std(data["x"]) + + assert result["x"] == approx([expected, expected]) + + def test_view_standard_deviation_multi(self): + data = { + "a": [ + 91.96, + 258.576, + 29.6, + 243.16, + 36.24, + 25.248, + 79.99, + 206.1, + 31.5, + 55.6, + ], + "b": [1 if i % 2 == 0 else 0 for i in range(10)], + } + table = Table(data) + view = table.view(aggregates={"a": "stddev"}, group_by=["b"]) + + result = view.to_columns() + expected_total = np.std(data["a"]) + expected_zero = np.std( + [data["a"][1], data["a"][3], data["a"][5], data["a"][7], data["a"][9]] + ) + expected_one = np.std( + [data["a"][0], data["a"][2], data["a"][4], data["a"][6], data["a"][8]] + ) + + assert result["a"] == approx([expected_total, expected_zero, expected_one]) + + def test_view_standard_deviation_update_none(self): + data = {"a": [0.1, 0.5, None, 0.8], "b": [0, 1, 0, 1], "c": [1, 2, 3, 4]} + table = Table(data, index="c") + view = table.view(columns=["a"], group_by=["b"], aggregates={"a": "stddev"}) + result = view.to_columns() + assert result["a"][0] == approx(np.std([0.1, 0.5, 0.8])) + assert result["a"][1] is None + assert result["a"][2] == approx(np.std([0.5, 0.8])) + + table.update({"a": [0.3], "c": [3]}) + + result = view.to_columns() + assert result["a"] == approx( + [np.std([0.1, 0.5, 0.3, 0.8]), np.std([0.1, 0.3]), np.std([0.5, 0.8])] + ) + + table.update({"a": [None], "c": [1]}) + + result = view.to_columns() + assert result["a"][0] == approx(np.std([0.5, 0.3, 0.8])) + assert result["a"][1] is None + assert result["a"][2] == approx(np.std([0.5, 0.8])) + + def test_view_standard_deviation_multi_update(self): + data = { + "a": [ + 91.96, + 258.576, + 29.6, + 243.16, + 36.24, + 25.248, + 79.99, + 206.1, + 31.5, + 55.6, + ], + "b": [1 if i % 2 == 0 else 0 for i in range(10)], + } + table = Table(data) + view = table.view(aggregates={"a": "stddev"}, group_by=["b"]) + + result = view.to_columns() + expected_total = data["a"] + expected_zero = [ + data["a"][1], + data["a"][3], + data["a"][5], + data["a"][7], + data["a"][9], + ] + expected_one = [ + data["a"][0], + data["a"][2], + data["a"][4], + data["a"][6], + data["a"][8], + ] + + assert result["a"] == approx( + [np.std(expected_total), np.std(expected_zero), np.std(expected_one)] + ) + + # 2 here should result in null stddev because the group size is 1 + update_data = {"a": [15.12, 9.102, 0.99, 12.8], "b": [1, 0, 1, 2]} + table.update(update_data) + + result = view.to_columns() + expected_total += update_data["a"] + expected_zero += [update_data["a"][1]] + expected_one += [update_data["a"][0], update_data["a"][2]] + + assert result["__ROW_PATH__"] == [[], [0], [1], [2]] + assert result["a"][:-1] == approx( + [np.std(expected_total), np.std(expected_zero), np.std(expected_one)] + ) + assert result["a"][-1] is None + + def test_view_standard_deviation_multi_update_delta(self): + data = { + "a": [ + 91.96, + 258.576, + 29.6, + 243.16, + 36.24, + 25.248, + 79.99, + 206.1, + 31.5, + 55.6, + ], + "b": [1 if i % 2 == 0 else 0 for i in range(10)], + } + table = Table(data) + view = table.view(aggregates={"a": "stddev"}, group_by=["b"]) + + result = view.to_columns() + expected_total = data["a"] + expected_zero = [ + data["a"][1], + data["a"][3], + data["a"][5], + data["a"][7], + data["a"][9], + ] + expected_one = [ + data["a"][0], + data["a"][2], + data["a"][4], + data["a"][6], + data["a"][8], + ] + + assert result["a"] == approx( + [np.std(expected_total), np.std(expected_zero), np.std(expected_one)] + ) + + # 2 here should result in null stddev because the group size is 1 + update_data = {"a": [15.12, 9.102, 0.99, 12.8], "b": [1, 0, 1, 2]} + + def cb1(port_id, delta): + table2 = Table(delta) + view2 = table2.view() + result = view2.to_columns() + + flat_view = table.view() + flat_data = flat_view.to_columns() + result = view.to_columns() + + expected_total = flat_data["a"] + expected_zero = [] + expected_one = [] + + for i, num in enumerate(expected_total): + if flat_data["b"][i] == 1: + expected_one.append(num) + elif flat_data["b"][i] == 0: + expected_zero.append(num) + + assert result["a"][0] == approx(np.std(expected_total)) + assert result["a"][1] == approx(np.std(expected_zero)) + assert result["a"][2] == approx(np.std(expected_one)) + assert result["a"][3] is None + + view.on_update(cb1, mode="row") + + table.update(update_data) + + def test_view_standard_deviation_multi_update_indexed(self): + data = { + "a": [ + 91.96, + 258.576, + 29.6, + 243.16, + 36.24, + 25.248, + 79.99, + 206.1, + 31.5, + 55.6, + ], + "b": [1 if i % 2 == 0 else 0 for i in range(10)], + "c": [i for i in range(10)], + } + table = Table(data, index="c") + view = table.view(aggregates={"a": "stddev"}, group_by=["b"]) + + result = view.to_columns() + expected_total = data["a"] + expected_zero = [ + data["a"][1], + data["a"][3], + data["a"][5], + data["a"][7], + data["a"][9], + ] + expected_one = [ + data["a"][0], + data["a"][2], + data["a"][4], + data["a"][6], + data["a"][8], + ] + + assert result["a"] == approx( + [np.std(expected_total), np.std(expected_zero), np.std(expected_one)] + ) + + # "b" = 2 here should result in null stddev because the group size is 1 + update_data = { + "a": [15.12, 9.102, 0.99, 12.8], + "b": [1, 0, 1, 2], + "c": [1, 5, 2, 7], + } + + table.update(update_data) + + result = view.to_columns() + + view2 = table.view() + flat_data = view2.to_columns() + expected_total = flat_data["a"] + + expected_zero = [] + expected_one = [] + + for i, val in enumerate(flat_data["a"]): + if flat_data["b"][i] == 1: + expected_one.append(val) + elif flat_data["b"][i] == 0: + expected_zero.append(val) + + assert result["__ROW_PATH__"] == [[], [0], [1], [2]] + assert result["a"][:-1] == approx( + [np.std(expected_total), np.std(expected_zero), np.std(expected_one)] + ) + assert result["a"][-1] is None + + def test_view_standard_deviation_multi_update_indexed_delta(self): + data = { + "a": [ + 91.96, + 258.576, + 29.6, + 243.16, + 36.24, + 25.248, + 79.99, + 206.1, + 31.5, + 55.6, + ], + "b": [1 if i % 2 == 0 else 0 for i in range(10)], + "c": [i for i in range(10)], + } + table = Table(data, index="c") + view = table.view( + aggregates={"a": "stddev", "b": "last", "c": "last"}, group_by=["b"] + ) + + result = view.to_columns() + expected_total = data["a"] + expected_zero = [ + data["a"][1], + data["a"][3], + data["a"][5], + data["a"][7], + data["a"][9], + ] + expected_one = [ + data["a"][0], + data["a"][2], + data["a"][4], + data["a"][6], + data["a"][8], + ] + + assert result["a"] == approx( + [np.std(expected_total), np.std(expected_zero), np.std(expected_one)] + ) + + # 2 here should result in null stddev because the group size is 1 + update_data = { + "a": [15.12, 9.102, 0.99, 12.8], + "b": [1, 0, 1, 2], + "c": [0, 4, 1, 6], + } + + def cb1(port_id, delta): + table2 = Table(delta) + view2 = table2.view() + result = view2.to_columns() + + flat_view = table.view() + flat_result = flat_view.to_columns() + + new_a = flat_result["a"] + b = flat_result["b"] + expected_zero = [] + expected_one = [] + + for i, num in enumerate(new_a): + if b[i] == 0: + expected_zero.append(num) + elif b[i] == 1: + expected_one.append(num) + + assert result["a"][0] == approx(np.std(new_a)) + assert result["a"][1] == approx(np.std(expected_zero)) + assert result["a"][2] == approx(np.std(expected_one)) + assert result["a"][3] is None + assert result["b"] == [2, 0, 1, 2] + assert result["c"] == [6, 9, 8, 6] + + view.on_update(cb1, mode="row") + + table.update(update_data) + + def test_view_standard_deviation_less_than_two(self): + data = {"a": list(np.random.rand(10)), "b": [i for i in range(10)]} + + table = Table(data) + view = table.view(aggregates={"a": "stddev"}, group_by=["b"]) + + result = view.to_columns() + assert result["a"][0] == approx(np.std(data["a"])) + assert result["a"][1:] == [None] * 10 + + def test_view_standard_deviation_normal_distribution(self): + data = {"a": list(np.random.standard_normal(100)), "b": [1] * 100} + + table = Table(data) + view = table.view(aggregates={"a": "stddev"}, group_by=["b"]) + + result = view.to_columns() + assert result["a"] == approx([np.std(data["a"]), np.std(data["a"])]) + + # sort + + def test_view_sort_int(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(sort=[["a", "desc"]]) + assert view.to_records() == [{"a": 3, "b": 4}, {"a": 1, "b": 2}] + + def test_view_sort_float(self): + data = [{"a": 1.1, "b": 2}, {"a": 1.2, "b": 4}] + tbl = Table(data) + view = tbl.view(sort=[["a", "desc"]]) + assert view.to_records() == [{"a": 1.2, "b": 4}, {"a": 1.1, "b": 2}] + + def test_view_sort_string(self): + data = [{"a": "abc", "b": 2}, {"a": "def", "b": 4}] + tbl = Table(data) + view = tbl.view(sort=[["a", "desc"]]) + assert view.to_records() == [{"a": "def", "b": 4}, {"a": "abc", "b": 2}] + + def test_view_sort_date(self, util): + data = [{"a": date(2019, 7, 11), "b": 2}, {"a": date(2019, 7, 12), "b": 4}] + tbl = Table(data) + view = tbl.view(sort=[["a", "desc"]]) + assert view.to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 12)), "b": 4}, + {"a": util.to_timestamp(datetime(2019, 7, 11)), "b": 2}, + ] + + def test_view_sort_datetime(self, util): + data = [ + {"a": datetime(2019, 7, 11, 8, 15), "b": 2}, + {"a": datetime(2019, 7, 11, 8, 16), "b": 4}, + ] + tbl = Table(data) + view = tbl.view(sort=[["a", "desc"]]) + assert view.to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 11, 8, 16)), "b": 4}, + {"a": util.to_timestamp(datetime(2019, 7, 11, 8, 15)), "b": 2}, + ] + + def test_view_sort_hidden(self): + data = [{"a": 1.1, "b": 2}, {"a": 1.2, "b": 4}] + tbl = Table(data) + view = tbl.view(sort=[["a", "desc"]], columns=["b"]) + assert view.to_records() == [{"b": 4}, {"b": 2}] + + def test_view_sort_avg_nan(self): + data = { + "w": [3.5, 4.5, None, None, None, None, 1.5, 2.5], + "x": [1, 2, 3, 4, 4, 3, 2, 1], + "y": ["a", "b", "c", "d", "e", "f", "g", "h"], + } + tbl = Table(data) + view = tbl.view( + columns=["x", "w"], + group_by=["y"], + sort=[["w", "asc"]], + aggregates={"w": "avg", "x": "unique"}, + ) + assert view.to_columns() == { + "__ROW_PATH__": [ + [], + ["c"], + ["d"], + ["e"], + ["f"], + ["g"], + ["h"], + ["a"], + ["b"], + ], + "w": [3, None, None, None, None, 1.5, 2.5, 3.5, 4.5], + "x": [None, 3, 4, 4, 3, 2, 1, 1, 2], + } + + def test_view_sort_sum_nan(self): + data = { + "w": [3.5, 4.5, None, None, None, None, 1.5, 2.5], + "x": [1, 2, 3, 4, 4, 3, 2, 1], + "y": ["a", "b", "c", "d", "e", "f", "g", "h"], + } + tbl = Table(data) + view = tbl.view( + columns=["x", "w"], + group_by=["y"], + sort=[["w", "asc"]], + aggregates={"w": "sum", "x": "unique"}, + ) + assert view.to_columns() == { + "__ROW_PATH__": [ + [], + ["c"], + ["d"], + ["e"], + ["f"], + ["g"], + ["h"], + ["a"], + ["b"], + ], + "w": [12, 0, 0, 0, 0, 1.5, 2.5, 3.5, 4.5], + "x": [None, 3, 4, 4, 3, 2, 1, 1, 2], + } + + def test_view_sort_unique_nan(self): + data = { + "w": [3.5, 4.5, None, None, None, None, 1.5, 2.5], + "x": [1, 2, 3, 4, 4, 3, 2, 1], + "y": ["a", "b", "c", "d", "e", "f", "g", "h"], + } + tbl = Table(data) + view = tbl.view( + columns=["x", "w"], + group_by=["y"], + sort=[["w", "asc"]], + aggregates={"w": "unique", "x": "unique"}, + ) + assert view.to_columns() == { + "__ROW_PATH__": [ + [], + ["c"], + ["d"], + ["e"], + ["f"], + ["g"], + ["h"], + ["a"], + ["b"], + ], + "w": [None, None, None, None, None, 1.5, 2.5, 3.5, 4.5], + "x": [None, 3, 4, 4, 3, 2, 1, 1, 2], + } + + # filter + + def test_view_filter_int_eq(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(filter=[["a", "==", 1]]) + assert view.to_records() == [{"a": 1, "b": 2}] + + def test_view_filter_int_neq(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(filter=[["a", "!=", 1]]) + assert view.to_records() == [{"a": 3, "b": 4}] + + def test_view_filter_int_gt(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(filter=[["a", ">", 1]]) + assert view.to_records() == [{"a": 3, "b": 4}] + + def test_view_filter_int_lt(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(filter=[["a", "<", 3]]) + assert view.to_records() == [{"a": 1, "b": 2}] + + def test_view_filter_float_eq(self): + data = [{"a": 1.1, "b": 2}, {"a": 1.2, "b": 4}] + tbl = Table(data) + view = tbl.view(filter=[["a", "==", 1.2]]) + assert view.to_records() == [{"a": 1.2, "b": 4}] + + def test_view_filter_float_neq(self): + data = [{"a": 1.1, "b": 2}, {"a": 1.2, "b": 4}] + tbl = Table(data) + view = tbl.view(filter=[["a", "!=", 1.2]]) + assert view.to_records() == [{"a": 1.1, "b": 2}] + + def test_view_filter_string_eq(self): + data = [{"a": "abc", "b": 2}, {"a": "def", "b": 4}] + tbl = Table(data) + view = tbl.view(filter=[["a", "==", "def"]]) + assert view.to_records() == [{"a": "def", "b": 4}] + + def test_view_filter_string_neq(self): + data = [{"a": "abc", "b": 2}, {"a": "def", "b": 4}] + tbl = Table(data) + view = tbl.view(filter=[["a", "!=", "def"]]) + assert view.to_records() == [{"a": "abc", "b": 2}] + + def test_view_filter_string_gt(self): + data = [{"a": "abc", "b": 2}, {"a": "def", "b": 4}] + tbl = Table(data) + view = tbl.view(filter=[["a", ">", "abc"]]) + assert view.to_records() == [{"a": "def", "b": 4}] + + def test_view_filter_string_lt(self): + data = [{"a": "abc", "b": 2}, {"a": "def", "b": 4}] + tbl = Table(data) + view = tbl.view(filter=[["a", "<", "def"]]) + assert view.to_records() == [{"a": "abc", "b": 2}] + + # @mark.skip # We do not support using `datetime.date` in operators. + def test_view_filter_date_eq(self, util): + data = [{"a": date(2019, 7, 11), "b": 2}, {"a": date(2019, 7, 12), "b": 4}] + tbl = Table(data) + view = tbl.view(filter=[["a", "==", str(date(2019, 7, 12))]]) + assert view.to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 12)), "b": 4} + ] + + # @mark.skip # We do not support using `datetime.date` in operators. + def test_view_filter_date_neq(self, util): + data = [{"a": date(2019, 7, 11), "b": 2}, {"a": date(2019, 7, 12), "b": 4}] + tbl = Table(data) + view = tbl.view(filter=[["a", "!=", str(date(2019, 7, 12))]]) + assert view.to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 11)), "b": 2} + ] + + def test_view_filter_date_str_eq(self, util): + data = [{"a": date(2019, 7, 11), "b": 2}, {"a": date(2019, 7, 12), "b": 4}] + tbl = Table(data) + view = tbl.view(filter=[["a", "==", "2019/7/12"]]) + assert view.to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 12)), "b": 4} + ] + + def test_view_filter_date_str_neq(self, util): + data = [{"a": date(2019, 7, 11), "b": 2}, {"a": date(2019, 7, 12), "b": 4}] + tbl = Table(data) + view = tbl.view(filter=[["a", "!=", "2019/7/12"]]) + assert view.to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 11)), "b": 2} + ] + + @mark.skip # We do not support using `datetime.datetime` in operators + def test_view_filter_datetime_eq(self, util): + data = [ + {"a": datetime(2019, 7, 11, 8, 15), "b": 2}, + {"a": datetime(2019, 7, 11, 8, 16), "b": 4}, + ] + tbl = Table(data) + view = tbl.view(filter=[["a", "==", datetime(2019, 7, 11, 8, 15)]]) + assert view.to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 11, () * 1000)), "b": 2} + ] + + @mark.skip # We do not support using `datetime.datetime` in operators + def test_view_filter_datetime_neq(self, util): + data = [ + {"a": datetime(2019, 7, 11, 8, 15), "b": 2}, + {"a": datetime(2019, 7, 11, 8, 16), "b": 4}, + ] + tbl = Table(data) + view = tbl.view(filter=[["a", "!=", datetime(2019, 7, 11, 8, 15)]]) + assert view.to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 11, () * 1000)), "b": 4} + ] + + @mark.skip # We do not support numpy anymore + def test_view_filter_datetime_np_eq(self, util): + data = [ + {"a": datetime(2019, 7, 11, 8, 15), "b": 2}, + {"a": datetime(2019, 7, 11, 8, 16), "b": 4}, + ] + tbl = Table(data) + view = tbl.view( + filter=[["a", "==", np.datetime64(datetime(2019, 7, 11, 8, 15))]] + ) + assert view.to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 11, () * 1000)), "b": 2} + ] + + @mark.skip # We do not support numpy anymore. + def test_view_filter_datetime_np_neq(self, util): + data = [ + {"a": datetime(2019, 7, 11, 8, 15), "b": 2}, + {"a": datetime(2019, 7, 11, 8, 16), "b": 4}, + ] + tbl = Table(data) + view = tbl.view( + filter=[["a", "!=", np.datetime64(datetime(2019, 7, 11, 8, 15))]] + ) + assert view.to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 11, () * 1000)), "b": 4} + ] + + def test_view_filter_datetime_as_number_eq(self, util): + data = [ + {"a": datetime(2019, 7, 11, 8, 15).timestamp(), "b": 2}, + {"a": datetime(2019, 7, 11, 8, 16).timestamp(), "b": 4}, + ] + + tbl = Table({"a": "datetime", "b": "integer"}) + tbl.update(data) + view = tbl.view(filter=[["a", "==", datetime(2019, 7, 11, 8, 15).timestamp()]]) + assert view.to_records() == [ + {"a": datetime(2019, 7, 11, 8, 15).timestamp(), "b": 2} + ] + + def test_view_filter_datetime_as_number_neq(self, util): + data = [ + {"a": datetime(2019, 7, 11, 8, 15).timestamp(), "b": 2}, + {"a": datetime(2019, 7, 11, 8, 16).timestamp(), "b": 4}, + ] + tbl = Table({"a": "datetime", "b": "integer"}) + tbl.update(data) + view = tbl.view(filter=[["a", "!=", datetime(2019, 7, 11, 8, 15).timestamp()]]) + assert view.to_records() == [ + {"a": datetime(2019, 7, 11, 8, 16).timestamp(), "b": 4} + ] + + def test_view_filter_datetime_str_eq(self, util): + data = [ + {"a": datetime(2019, 7, 11, 8, 15), "b": 2}, + {"a": datetime(2019, 7, 11, 8, 16), "b": 4}, + ] + tbl = Table(data) + view = tbl.view(filter=[["a", "==", "2019/7/11 8:15"]]) + assert view.to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 11, 8, 15)), "b": 2} + ] + + def test_view_filter_datetime_str_neq(self, util): + data = [ + {"a": datetime(2019, 7, 11, 8, 15), "b": 2}, + {"a": datetime(2019, 7, 11, 8, 16), "b": 4}, + ] + tbl = Table(data) + view = tbl.view(filter=[["a", "!=", "2019/7/11 8:15"]]) + assert view.to_records() == [ + {"a": util.to_timestamp(datetime(2019, 7, 11, 8, 16)), "b": 4} + ] + + def test_view_filter_string_is_none(self): + data = [{"a": None, "b": 2}, {"a": "abc", "b": 4}] + tbl = Table(data) + view = tbl.view( + filter=[["a", "is null", ""]] + ) # XXX: Having to add this "" is not soooo great. + assert view.to_records() == [{"a": None, "b": 2}] + + def test_view_filter_string_is_not_none(self): + data = [{"a": None, "b": 2}, {"a": "abc", "b": 4}] + tbl = Table(data) + view = tbl.view( + filter=[["a", "is not null", ""]] + ) # XXX: Having to add this "" is not soooo great. + assert view.to_records() == [{"a": "abc", "b": 4}] + + # on_update + def test_view_on_update(self, sentinel): + s = sentinel(False) + + def callback(port_id): + s.set(True) + + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view() + view.on_update(callback) + tbl.update(data) + assert s.get() is True + + def test_view_on_update_multiple_callback(self, sentinel): + s = sentinel(0) + + def callback(port_id): + s.set(s.get() + 1) + + def callback1(port_id): + s.set(s.get() - 1) + + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view() + view.on_update(callback) + view.on_update(callback1) + tbl.update(data) + assert s.get() == 0 + + # on_delete + + def test_view_on_delete(self, sentinel): + s = sentinel(False) + + def callback(): + s.set(True) + + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view() + view.on_delete(callback) + view.delete() + assert s.get() is True + + # delete + + def test_view_delete(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view() + with raises(PerspectiveError): + tbl.delete() + view.delete() + tbl.delete() + + def test_view_delete_multiple_callbacks(self, sentinel): + # make sure that callbacks on views get filtered + s1 = sentinel(0) + s2 = sentinel(0) + + def cb1(port_id): + s1.set(s1.get() + 1) + + def cb2(port_id): + s2.set(s2.get() + 1) + + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + v1 = tbl.view() + v2 = tbl.view() + v1.on_update(cb1) + v2.on_update(cb2) + tbl.update(data) + assert s1.get() == 1 + assert s2.get() == 1 + v1.delete() + tbl.update(data) + assert s1.get() == 1 + assert s2.get() == 2 + + def test_view_delete_full_cleanup(self, sentinel): + s = sentinel(0) + + def cb1(port_id): + s.set(s.get() + 1) + + def cb2(port_id): + s.set(s.get() + 2) + + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + v1 = tbl.view() + v2 = tbl.view() + v1.on_update(cb1) + v2.on_update(cb2) + v1.delete() + v2.delete() + tbl.update(data) + assert s.get() == 0 + + # remove_update + + def test_view_remove_update(self, sentinel): + s = sentinel(0) + + def cb1(port_id): + s.set(s.get() + 1) + + def cb2(port_id): + s.set(s.get() + 2) + + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view() + t1 = view.on_update(cb1) + view.on_update(cb2) + view.remove_update(t1) + tbl.update(data) + assert s.get() == 2 + + def test_view_remove_multiple_update(self, sentinel): + s1 = sentinel(0) + s2 = sentinel(0) + + def cb1(port_id): + s1.set(s1.get() + 1) + + def cb2(port_id): + s2.set(s2.get() + 1) + + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view() + t1 = view.on_update(cb1) + view.on_update(cb2) + view.on_update(cb1) + tbl.update(data) + assert s1.get() == 2 + assert s2.get() == 1 + view.remove_update(t1) + tbl.update(data) + assert s1.get() == 3 + assert s2.get() == 2 + + # row delta + + def test_view_row_delta_zero(self, util): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + update_data = {"a": [5], "b": [6]} + + def cb1(port_id, delta): + compare_delta(delta, update_data) + + tbl = Table(data) + view = tbl.view() + view.on_update(cb1, mode="row") + tbl.update(update_data) + + def test_view_row_delta_zero_column_subset(self, util): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + update_data = {"a": [5], "b": [6]} + + def cb1(port_id, delta): + compare_delta(delta, {"b": [6]}) + + tbl = Table(data) + view = tbl.view(columns=["b"]) + view.on_update(cb1, mode="row") + tbl.update(update_data) + + def test_view_row_delta_zero_from_schema(self, util): + update_data = {"a": [5], "b": [6]} + + def cb1(port_id, delta): + compare_delta(delta, update_data) + + tbl = Table({"a": "integer", "b": "integer"}) + view = tbl.view() + view.on_update(cb1, mode="row") + tbl.update(update_data) + + def test_view_row_delta_zero_from_schema_column_subset(self, util): + update_data = {"a": [5], "b": [6]} + + def cb1(port_id, delta): + compare_delta(delta, {"b": [6]}) + + tbl = Table({"a": "integer", "b": "integer"}) + + view = tbl.view(columns=["b"]) + view.on_update(cb1, mode="row") + tbl.update(update_data) + + def test_view_row_delta_zero_from_schema_filtered(self, util): + update_data = {"a": [8, 9, 10, 11], "b": [1, 2, 3, 4]} + + def cb1(port_id, delta): + compare_delta(delta, {"a": [11], "b": [4]}) + + tbl = Table({"a": "integer", "b": "integer"}) + view = tbl.view(filter=[["a", ">", 10]]) + view.on_update(cb1, mode="row") + tbl.update(update_data) + + def test_view_row_delta_zero_from_schema_indexed(self, util): + update_data = {"a": ["a", "b", "a"], "b": [1, 2, 3]} + + def cb1(port_id, delta): + compare_delta(delta, {"a": ["a", "b"], "b": [3, 2]}) + + tbl = Table({"a": "string", "b": "integer"}, index="a") + + view = tbl.view() + view.on_update(cb1, mode="row") + + tbl.update(update_data) + + def test_view_row_delta_zero_from_schema_indexed_filtered(self, util): + update_data = {"a": [8, 9, 10, 11, 11], "b": [1, 2, 3, 4, 5]} + + def cb1(port_id, delta): + compare_delta(delta, {"a": [11], "b": [5]}) + + tbl = Table({"a": "integer", "b": "integer"}, index="a") + view = tbl.view(filter=[["a", ">", 10]]) + view.on_update(cb1, mode="row") + tbl.update(update_data) + + def test_view_row_delta_one(self, util): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + update_data = {"a": [5], "b": [6]} + + def cb1(port_id, delta): + compare_delta(delta, {"a": [9, 5], "b": [12, 6]}) + + tbl = Table(data) + view = tbl.view(group_by=["a"]) + assert view.to_columns() == { + "__ROW_PATH__": [[], [1], [3]], + "a": [4, 1, 3], + "b": [6, 2, 4], + } + view.on_update(cb1, mode="row") + tbl.update(update_data) + + def test_view_row_delta_one_from_schema(self, util): + update_data = {"a": [1, 2, 3, 4, 5], "b": [6, 7, 8, 9, 10]} + + def cb1(port_id, delta): + compare_delta(delta, {"a": [15, 1, 2, 3, 4, 5], "b": [40, 6, 7, 8, 9, 10]}) + + tbl = Table({"a": "integer", "b": "integer"}) + view = tbl.view(group_by=["a"]) + view.on_update(cb1, mode="row") + tbl.update(update_data) + + def test_view_row_delta_one_from_schema_sorted(self, util): + update_data = {"a": [1, 2, 3, 4, 5], "b": [6, 7, 8, 9, 10]} + + def cb1(port_id, delta): + compare_delta(delta, {"a": [15, 5, 4, 3, 2, 1], "b": [40, 10, 9, 8, 7, 6]}) + + tbl = Table({"a": "integer", "b": "integer"}) + view = tbl.view(group_by=["a"], sort=[["a", "desc"]]) + view.on_update(cb1, mode="row") + tbl.update(update_data) + + def test_view_row_delta_one_from_schema_filtered(self, util): + update_data = {"a": [1, 2, 3, 4, 5], "b": [6, 7, 8, 9, 10]} + + def cb1(port_id, delta): + compare_delta(delta, {"a": [9, 4, 5], "b": [19, 9, 10]}) + + tbl = Table({"a": "integer", "b": "integer"}) + view = tbl.view(group_by=["a"], filter=[["a", ">", 3]]) + view.on_update(cb1, mode="row") + tbl.update(update_data) + + def test_view_row_delta_one_from_schema_sorted_filtered(self, util): + update_data = {"a": [1, 2, 3, 4, 5], "b": [6, 7, 8, 9, 10]} + + def cb1(port_id, delta): + compare_delta(delta, {"a": [9, 5, 4], "b": [19, 10, 9]}) + + tbl = Table({"a": "integer", "b": "integer"}) + view = tbl.view(group_by=["a"], sort=[["a", "desc"]], filter=[["a", ">", 3]]) + view.on_update(cb1, mode="row") + tbl.update(update_data) + + def test_view_row_delta_one_from_schema_indexed(self, util): + update_data = {"a": [1, 2, 3, 4, 5, 5, 4], "b": [6, 7, 8, 9, 10, 11, 12]} + + def cb1(port_id, delta): + compare_delta(delta, {"a": [15, 1, 2, 3, 4, 5], "b": [44, 6, 7, 8, 12, 11]}) + + tbl = Table({"a": "integer", "b": "integer"}, index="a") + + view = tbl.view(group_by=["a"]) + view.on_update(cb1, mode="row") + + tbl.update(update_data) + + def test_view_row_delta_one_from_schema_sorted_indexed(self, util): + update_data = {"a": [1, 2, 3, 4, 5, 5, 4], "b": [6, 7, 8, 9, 10, 11, 12]} + + def cb1(port_id, delta): + compare_delta(delta, {"a": [15, 4, 5, 3, 2, 1], "b": [44, 12, 11, 8, 7, 6]}) + + tbl = Table({"a": "integer", "b": "integer"}, index="a") + + view = tbl.view(group_by=["a"], sort=[["b", "desc"]]) + view.on_update(cb1, mode="row") + + tbl.update(update_data) + + def test_view_row_delta_one_from_schema_filtered_indexed(self, util): + update_data = {"a": [1, 2, 3, 4, 5, 5, 4], "b": [6, 7, 8, 9, 10, 11, 12]} + + def cb1(port_id, delta): + compare_delta(delta, {"a": [9, 4, 5], "b": [23, 12, 11]}) + + tbl = Table({"a": "integer", "b": "integer"}, index="a") + + view = tbl.view(group_by=["a"], filter=[["a", ">", 3]]) + view.on_update(cb1, mode="row") + + tbl.update(update_data) + + def test_view_row_delta_two(self, util): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + update_data = {"a": [5], "b": [6]} + + def cb1(port_id, delta): + compare_delta( + delta, + { + "2|a": [1, None], + "2|b": [2, None], + "4|a": [3, None], + "4|b": [4, None], + "6|a": [5, 5], + "6|b": [6, 6], + }, + ) + + tbl = Table(data) + view = tbl.view(group_by=["a"], split_by=["b"]) + assert view.to_columns() == { + "__ROW_PATH__": [[], [1], [3]], + "2|a": [1, 1, None], + "2|b": [2, 2, None], + "4|a": [3, None, 3], + "4|b": [4, None, 4], + } + view.on_update(cb1, mode="row") + tbl.update(update_data) + + def test_view_row_delta_two_from_schema(self, util): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + + def cb1(port_id, delta): + compare_delta( + delta, + { + "2|a": [1, 1, None], + "2|b": [2, 2, None], + "4|a": [3, None, 3], + "4|b": [4, None, 4], + }, + ) + + tbl = Table({"a": "integer", "b": "integer"}) + view = tbl.view(group_by=["a"], split_by=["b"]) + view.on_update(cb1, mode="row") + tbl.update(data) + + def test_view_row_delta_two_from_schema_indexed(self, util): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}, {"a": 3, "b": 5}] + + def cb1(port_id, delta): + compare_delta( + delta, + { + "2|a": [1, 1, None], + "2|b": [2, 2, None], + "5|a": [3, None, 3], + "5|b": [5, None, 5], + }, + ) + + tbl = Table({"a": "integer", "b": "integer"}, index="a") + view = tbl.view(group_by=["a"], split_by=["b"]) + view.on_update(cb1, mode="row") + tbl.update(data) + + def test_view_row_delta_two_column_only(self, util): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + update_data = {"a": [5], "b": [6]} + + def cb1(port_id, delta): + compare_delta( + delta, + { + "2|a": [1, None], + "2|b": [2, None], + "4|a": [3, None], + "4|b": [4, None], + "6|a": [5, 5], + "6|b": [6, 6], + }, + ) + + tbl = Table(data) + view = tbl.view(split_by=["b"]) + assert view.to_columns() == { + "2|a": [1, None], + "2|b": [2, None], + "4|a": [None, 3], + "4|b": [None, 4], + } + view.on_update(cb1, mode="row") + tbl.update(update_data) + + def test_view_row_delta_two_column_only_indexed(self, util): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}, {"a": 3, "b": 5}] + update_data = {"a": [5], "b": [6]} + + def cb1(port_id, delta): + compare_delta( + delta, + { + "2|a": [1, None], + "2|b": [2, None], + "5|a": [3, None], + "5|b": [5, None], + "6|a": [5, 5], + "6|b": [6, 6], + }, + ) + + tbl = Table(data, index="a") + view = tbl.view(split_by=["b"]) + assert view.to_columns() == { + "2|a": [1, None], + "2|b": [2, None], + "5|a": [None, 3], + "5|b": [None, 5], + } + view.on_update(cb1, mode="row") + tbl.update(update_data) + + def test_view_row_delta_two_column_only_from_schema(self, util): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + + def cb1(port_id, delta): + compare_delta( + delta, + { + "2|a": [1, 1, None], + "2|b": [2, 2, None], + "4|a": [3, None, 3], + "4|b": [4, None, 4], + }, + ) + + tbl = Table({"a": "integer", "b": "integer"}) + view = tbl.view(split_by=["b"]) + view.on_update(cb1, mode="row") + tbl.update(data) + + def test_view_row_delta_two_column_only_from_schema_indexed(self, util): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}, {"a": 3, "b": 5}] + + def cb1(port_id, delta): + compare_delta( + delta, + { + "2|a": [1, 1, None], + "2|b": [2, 2, None], + "5|a": [3, None, 3], + "5|b": [5, None, 5], + }, + ) + + tbl = Table({"a": "integer", "b": "integer"}, index="a") + view = tbl.view(split_by=["b"]) + view.on_update(cb1, mode="row") + tbl.update(data) + + # hidden cols + + def test_view_num_hidden_cols(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(columns=["a"], sort=[["b", "desc"]]) + cols = view.to_columns() + assert cols == {"a": [3, 1]} + + def test_view_context_two_update_clears_column_regression(self, util): + """Tests that, when a 2-sided View() is updated to a state where one of + the column groups is empty, an infinite loop is not encountered. + """ + data = [ + {"a": "a", "b": 1, "c": 1.5, "i": 0}, + {"a": "a", "b": 2, "c": 2.5, "i": 1}, + {"a": "a", "b": 3, "c": 3.5, "i": 2}, + {"a": "b", "b": 1, "c": 4.5, "i": 3}, + {"a": "b", "b": 2, "c": 5.5, "i": 4}, + {"a": "b", "b": 3, "c": 6.5, "i": 5}, + ] + + tbl = Table(data, index="i") + view = tbl.view( + group_by=["b"], + split_by=["a"], + columns=["c"], + filter=[["c", ">", 0]], + sort=[["c", "asc"], ["a", "col asc"]], + ) + + assert view.to_records() == [ + {"__ROW_PATH__": [], "a|c": 7.5, "b|c": 16.5}, + {"__ROW_PATH__": [1], "a|c": 1.5, "b|c": 4.5}, + {"__ROW_PATH__": [2], "a|c": 2.5, "b|c": 5.5}, + {"__ROW_PATH__": [3], "a|c": 3.5, "b|c": 6.5}, + ] + + tbl.update( + [ + {"c": -1, "i": 0}, + {"c": -1, "i": 1}, + {"c": -1, "i": 2}, + ] + ) + + assert view.to_records() == [ + {"__ROW_PATH__": [], "b|c": 16.5}, + {"__ROW_PATH__": [1], "b|c": 4.5}, + {"__ROW_PATH__": [2], "b|c": 5.5}, + {"__ROW_PATH__": [3], "b|c": 6.5}, + ] + + tbl.update( + [ + {"a": "a", "b": 1, "c": 1.5, "i": 6}, + {"a": "a", "b": 2, "c": 2.5, "i": 7}, + {"a": "a", "b": 3, "c": 3.5, "i": 8}, + ] + ) + + assert view.to_records() == [ + {"__ROW_PATH__": [], "a|c": 7.5, "b|c": 16.5}, + {"__ROW_PATH__": [1], "a|c": 1.5, "b|c": 4.5}, + {"__ROW_PATH__": [2], "a|c": 2.5, "b|c": 5.5}, + {"__ROW_PATH__": [3], "a|c": 3.5, "b|c": 6.5}, + ] + + assert tbl.size() == 9 + + # expand/collapse + + def test_view_collapse_one(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(group_by=["a"]) + assert view.collapse(0) == 2 + + def test_view_collapse_two(self): + data = [{"a": 1, "b": 2, "c": "a"}, {"a": 3, "b": 4, "c": "b"}] + tbl = Table(data) + view = tbl.view(group_by=["a"], split_by=["c"]) + assert view.collapse(0) == 2 + + # TODO collapse/espand should be no-ops on column only contexts, but + # the concept of "column only" is not yet implemented in C++ + @mark.skip + def test_view_collapse_two_column_only(self): + data = [{"a": 1, "b": 2, "c": "a"}, {"a": 3, "b": 4, "c": "b"}] + tbl = Table(data) + view = tbl.view(split_by=["c"]) + assert view.collapse(0) == 0 + + def test_view_expand_one(self): + data = [{"a": 1, "b": 2}, {"a": 3, "b": 4}] + tbl = Table(data) + view = tbl.view(group_by=["a"]) + assert view.expand(0) == 0 + + def test_view_expand_two(self): + data = [{"a": 1, "b": 2, "c": "a"}, {"a": 3, "b": 4, "c": "b"}] + tbl = Table(data) + view = tbl.view(group_by=["a"], split_by=["c"]) + assert view.expand(1) == 1 + + def test_view_expand_two_column_only(self): + data = [{"a": 1, "b": 2, "c": "a"}, {"a": 3, "b": 4, "c": "b"}] + tbl = Table(data) + view = tbl.view(split_by=["c"]) + assert view.expand(0) == 0 + + # view config validation + + def test_invalid_column_should_throw(self): + data = [{"a": 1, "b": 2, "c": "a"}, {"a": 3, "b": 4, "c": "b"}] + tbl = Table(data) + with raises(PerspectiveError) as ex: + tbl.view(columns=["x"]) + assert str(ex.value) == "Abort(): Invalid column 'x' found in View columns.\n" + + def test_invalid_column_should_throw_and_updates_should_work(self): + data = [{"a": 1, "b": 2, "c": "a"}, {"a": 3, "b": 4, "c": "b"}] + tbl = Table(data) + + with raises(PerspectiveError) as ex: + tbl.view(columns=["x"]) + assert str(ex.value) == "Abort(): Invalid column 'x' found in View columns.\n" + + for i in range(100): + tbl.update(data) + # force call to _process which should shake out invalid column ptrs + tbl.size() + + view2 = tbl.view() + assert view2.num_rows() == 202 + + def test_invalid_column_aggregate_should_throw(self): + data = [{"a": 1, "b": 2, "c": "a"}, {"a": 3, "b": 4, "c": "b"}] + tbl = Table(data) + + with raises(PerspectiveError) as ex: + tbl.view(columns=["x"], aggregates={"x": "sum"}) + + assert str(ex.value) == "Abort(): Invalid column 'x' found in View columns.\n" + + def test_invalid_column_aggregate_should_throw_and_updates_should_work(self): + data = [{"a": 1, "b": 2, "c": "a"}, {"a": 3, "b": 4, "c": "b"}] + tbl = Table(data) + + with raises(PerspectiveError) as ex: + tbl.view(columns=["x"], aggregates={"x": "sum"}) + + assert str(ex.value) == "Abort(): Invalid column 'x' found in View columns.\n" + + for i in range(100): + tbl.update(data) + # force call to _process which should shake out invalid column ptrs + tbl.size() + + view2 = tbl.view() + assert view2.num_rows() == 202 + + def test_invalid_group_by_should_throw(self): + data = [{"a": 1, "b": 2, "c": "a"}, {"a": 3, "b": 4, "c": "b"}] + tbl = Table(data) + with raises(PerspectiveError) as ex: + tbl.view(group_by=["x"]) + assert str(ex.value) == "Abort(): Invalid column 'x' found in View group_by.\n" + + def test_invalid_split_by_should_throw(self): + data = [{"a": 1, "b": 2, "c": "a"}, {"a": 3, "b": 4, "c": "b"}] + tbl = Table(data) + with raises(PerspectiveError) as ex: + tbl.view(split_by=["x"]) + assert str(ex.value) == "Abort(): Invalid column 'x' found in View split_by.\n" + + def test_invalid_filters_should_throw(self): + data = [{"a": 1, "b": 2, "c": "a"}, {"a": 3, "b": 4, "c": "b"}] + tbl = Table(data) + with raises(PerspectiveError) as ex: + tbl.view(filter=[["x", "==", "abc"]]) + assert str(ex.value) == "Abort(): Filter column not in schema: x" + + def test_invalid_sorts_should_throw(self): + data = [{"a": 1, "b": 2, "c": "a"}, {"a": 3, "b": 4, "c": "b"}] + tbl = Table(data) + with raises(PerspectiveError) as ex: + tbl.view(sort=[["x", "desc"]]) + assert str(ex.value) == "Abort(): Invalid column 'x' found in View sorts.\n" + + def test_should_throw_on_first_invalid(self): + data = [{"a": 1, "b": 2, "c": "a"}, {"a": 3, "b": 4, "c": "b"}] + tbl = Table(data) + with raises(PerspectiveError) as ex: + tbl.view( + group_by=["a"], + split_by=["c"], + filter=[["a", ">", 1]], + aggregates={"a": "avg"}, + sort=[["x", "desc"]], + ) + assert str(ex.value) == "Abort(): Invalid column 'x' found in View sorts.\n" + + def test_invalid_columns_not_in_expression_should_throw(self): + data = [{"a": 1, "b": 2, "c": "a"}, {"a": 3, "b": 4, "c": "b"}] + tbl = Table(data) + with raises(PerspectiveError) as ex: + tbl.view(columns=["abc", "x"], expressions={"abc": "1 + 2"}) + assert str(ex.value) == "Abort(): Invalid column 'x' found in View columns.\n" + + def test_should_not_throw_valid_expression(self): + data = [{"a": 1, "b": 2, "c": "a"}, {"a": 3, "b": 4, "c": "b"}] + tbl = Table(data) + view = tbl.view(columns=["abc"], expressions={"abc": "'hello!'"}) + + assert view.schema() == {"abc": "string"} + + def test_should_not_throw_valid_expression_config(self): + data = [{"a": 1, "b": 2, "c": "a"}, {"a": 3, "b": 4, "c": "b"}] + tbl = Table(data) + view = tbl.view( + aggregates={"abc": "dominant"}, + columns=["abc"], + sort=[["abc", "desc"]], + filter=[["abc", "==", "A"]], + group_by=["abc"], + split_by=["abc"], + expressions={"abc": "'hello!'"}, + ) + + assert view.schema() == {"abc": "string"} diff --git a/rust/perspective-python/perspective/tests/table/test_view_expression.py b/rust/perspective-python/perspective/tests/table/test_view_expression.py new file mode 100644 index 0000000..06bf083 --- /dev/null +++ b/rust/perspective-python/perspective/tests/table/test_view_expression.py @@ -0,0 +1,1940 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 re +from random import random, randint, choices +from string import ascii_letters +from pytest import raises +from datetime import date, datetime +from time import mktime +from perspective import PerspectiveError +from .test_view import compare_delta +import perspective as psp + +client = psp.Server().new_local_client() +Table = client.table + + +def randstr(length, input=ascii_letters): + return "".join(choices(input, k=length)) + + +class TestViewExpression(object): + def test_table_validate_expressions_empty(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + validate = table.validate_expressions([]) + assert validate["expression_schema"] == {} + assert validate["expression_alias"] == {} + assert validate["errors"] == {} + + def test_view_expression_schema_empty(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + view = table.view() + assert view.to_columns() == {"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]} + assert view.expression_schema() == {} + + def test_view_validate_expressions_alias_map_errors(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + expressions = { + "x": '"a"', + "y": '"b" * 0.5', + "c": "'abcdefg'", + "d": "true and false", + "e": 'float("a") > 2 ? null : 1', + "f": "today()", + "g": "now()", + "h": "length(123)", + } + + validated = table.validate_expressions(expressions) + aliases = ["x", "y", "c", "d", "e", "f", "g", "h"] + + # Errored should also be in aliases + for alias in aliases: + assert validated["expression_alias"][alias] == expressions[alias] + + def test_view_validate_expressions_alias_map(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + expressions = { + "x": '"a"', + "y": '"b" * 0.5', + "c": "'abcdefg'", + "d": "true and false", + "e": 'float("a") > 2 ? null : 1', + "f": "today()", + "g": "now()", + "h": "length('abcd')", + } + + validated = table.validate_expressions(expressions) + aliases = ["x", "y", "c", "d", "e", "f", "g", "h"] + for alias in aliases: + assert validated["expression_alias"][alias] == expressions[alias] + + def test_view_expression_schema_all_types(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + expressions = [ + '"a"', + '"b" * 0.5', + "'abcdefg'", + "true and false", + 'float("a") > 2 ? null : 1', + "today()", + "now()", + "length('abcd')", + ] + + view = table.view(expressions=expressions) + assert view.expression_schema() == { + '"a"': "integer", + '"b" * 0.5': "float", + "'abcdefg'": "string", + "true and false": "boolean", + 'float("a") > 2 ? null : 1': "float", + "today()": "date", + "now()": "datetime", + "length('abcd')": "float", + } + + result = view.to_columns() + today = datetime(date.today().year, date.today().month, date.today().day) + del result["now()"] # no need to match datetime.now() + + assert result == { + "a": [1, 2, 3, 4], + "b": [5, 6, 7, 8], + '"a"': [1, 2, 3, 4], + '"b" * 0.5': [2.5, 3, 3.5, 4], + "'abcdefg'": ["abcdefg" for _ in range(4)], + "true and false": [False for _ in range(4)], + 'float("a") > 2 ? null : 1': [1, 1, None, None], + "today()": [int(today.timestamp()) * 1000 for _ in range(4)], + "length('abcd')": [4 for _ in range(4)], + } + + validated = table.validate_expressions(expressions) + for expr in expressions: + assert validated["expression_alias"][expr] == expr + + def test_table_validate_expressions_with_errors(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + expressions = ['"Sales" + "a"', "datetime()", "string()", "for () {}"] + validate = table.validate_expressions(expressions) + assert validate["expression_schema"] == {} + assert validate["expression_alias"] == {expr: expr for expr in expressions} + assert validate["errors"] == { + '"Sales" + "a"': { + "column": 0, + "error_message": 'Value Error - Input column "Sales" does not exist.', + "line": 0, + }, + "datetime()": { + "column": 10, + "error_message": "Zero parameter call to generic function: datetime not allowed", + "line": 0, + }, + "for () {}": { + "column": 5, + "error_message": "Premature end of expression[2]", + "line": 0, + }, + "string()": { + "column": 8, + "error_message": "Zero parameter call to generic function: string not allowed", + "line": 0, + }, + } + + def test_view_expression_create(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + view = table.view(expressions={"computed": ' "a" + "b"'}) + assert view.to_columns() == { + "a": [1, 2, 3, 4], + "b": [5, 6, 7, 8], + "computed": [6, 8, 10, 12], + } + assert view.expression_schema() == {"computed": "float"} + + def test_view_expression_string_per_page(self): + table = Table({"a": [i for i in range(100)]}) + big_strings = [randstr(6400) for _ in range(4)] + view = table.view( + expressions={ + "computed{}".format(i): "var x := '{}'; lower(x)".format(big_strings[i]) + for i in range(4) + } + ) + + result = view.to_columns() + schema = view.expression_schema() + + for i in range(4): + name = "computed{}".format(i) + res = big_strings[i].lower() + assert schema[name] == "string" + assert result[name] == [res for _ in range(100)] + + def test_view_expression_string_page_stress(self): + table = Table({"a": [i for i in range(100)]}) + big_strings = [ + "".join(["a" for _ in range(640)]), + "".join(["b" for _ in range(640)]), + "".join(["c" for _ in range(640)]), + "".join(["d" for _ in range(640)]), + ] + + view = table.view( + expressions={ + "computed": "var a := '{}'; var b := '{}'; var c := '{}'; var d := '{}'; concat(a, b, c, d)".format( + *big_strings + ) + } + ) + + result = view.to_columns() + schema = view.expression_schema() + assert schema == {"computed": "string"} + assert result["computed"] == ["".join(big_strings) for _ in range(100)] + + def test_view_expression_new_vocab_page(self): + table = Table({"a": [randstr(100) for _ in range(100)]}) + + def make_expression(idx): + expr = ["//computed{}".format(idx)] + num_vars = randint(1, 26) + concat_cols = [] + concat_result = [] + + for i in range(num_vars): + name = ascii_letters[i] + string_literal = randstr(randint(100, 1000)) + + if random() > 0.5: + result = string_literal.upper() + string_literal = "upper('{}')".format(string_literal) + else: + result = string_literal.lower() + string_literal = "lower('{}')".format(string_literal) + + concat_cols.append(name) + concat_result.append(result) + + expr.append("var {} := {};".format(name, string_literal)) + + expr.append('concat("a", {})'.format(", ".join(concat_cols))) + + return { + "expression_name": expr[0][2:], + "expression": "\n".join(expr), + "output": "".join(concat_result), + } + + expressions = [make_expression(i) for i in range(10)] + + view = table.view(expressions=[expr["expression"] for expr in expressions]) + + result = view.to_columns() + schema = view.expression_schema() + + for expr in expressions: + name = expr["expression_name"] + assert schema[name] == "string" + + for i in range(100): + val = result["a"][i] + assert result[name][i] == val + expr["output"] + + def test_view_expression_collide_local_var(self): + """Make sure that strings declared under the same var name in + different expressions do not collide.""" + table = Table({"a": [1, 2, 3, 4]}) + strings = [randstr(50) for _ in range(8)] + + view = table.view( + expressions={ + "computed": " var w := '{}'; var x := '{}'; var y := '{}'; var z := '{}'; concat(w, x, y, z)".format( + *strings[:4] + ), + "computed2": " var w := '{}'; var x := '{}'; var y := '{}'; var z := '{}'; concat(w, x, y, z)".format( + *strings[4:] + ), + } + ) + + result = view.to_columns() + schema = view.expression_schema() + assert schema == {"computed": "string", "computed2": "string"} + assert result["computed"] == ["".join(strings[:4]) for _ in range(4)] + assert result["computed2"] == ["".join(strings[4:]) for _ in range(4)] + + def test_view_random_expressions(self): + def make_expression(): + """Create a random expression with a few local string vars that + are too long to be stored in-place.""" + expression_name = randstr(10) + expression = "" + num_vars = randint(1, 26) + output_var_name = "" + output_str = "" + for i in range(num_vars): + name = ascii_letters[i] + string_literal = randstr(randint(15, 100)) + expression += "var {} := '{}';\n".format(name, string_literal) + if i == num_vars - 1: + output_var_name = name + output_str = string_literal + + expression += output_var_name + return { + "expression_name": expression_name, + "expression": expression, + "output": output_str, + } + + table = Table({"a": [1, 2, 3, 4]}) + + for _ in range(5): + exprs = [make_expression() for _ in range(5)] + output_map = {expr["expression_name"]: expr["output"] for expr in exprs} + view = table.view( + expressions={ + expr["expression_name"]: expr["expression"] for expr in exprs + } + ) + expression_schema = view.expression_schema() + result = view.to_columns() + for expr in output_map.keys(): + assert expression_schema[expr] == "string" + assert result[expr] == [output_map[expr] for _ in range(4)] + + def test_view_expression_string_literal_compare(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + validated = table.validate_expressions({"computed": " 'a' == 'a'"}) + + assert validated["expression_schema"] == {"computed": "boolean"} + + view = table.view(expressions={"computed": " 'a' == 'a'"}) + + assert view.to_columns() == { + "a": [1, 2, 3, 4], + "b": [5, 6, 7, 8], + "computed": [True, True, True, True], + } + + assert view.expression_schema() == {"computed": "boolean"} + + def test_view_expression_string_literal_compare_null(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + validated = table.validate_expressions({"computed": " 'a' == null"}) + + assert validated["expression_schema"] == {"computed": "float"} + + view = table.view(expressions={"computed": " 'a' == null"}) + + assert view.to_columns() == { + "a": [1, 2, 3, 4], + "b": [5, 6, 7, 8], + "computed": [0, 0, 0, 0], + } + + assert view.expression_schema() == {"computed": "float"} + + def test_view_expression_string_literal_compare_column(self): + table = Table({"a": ["a", "a", "b", "c"]}) + validated = table.validate_expressions({"computed": " \"a\" == 'a'"}) + assert validated["expression_schema"] == {"computed": "boolean"} + view = table.view(expressions={"computed": " \"a\" == 'a'"}) + assert view.to_columns() == { + "a": ["a", "a", "b", "c"], + "computed": [True, True, False, False], + } + + assert view.expression_schema() == {"computed": "boolean"} + + def test_view_expression_string_literal_compare_column_null(self): + table = Table({"a": ["a", None, "b", "c", None]}) + validated = table.validate_expressions({"computed": " \"a\" == 'a'"}) + assert validated["expression_schema"] == {"computed": "boolean"} + view = table.view(expressions={"computed": " \"a\" == 'a'"}) + assert view.to_columns() == { + "a": ["a", None, "b", "c", None], + "computed": [True, False, False, False, False], + } + + assert view.expression_schema() == {"computed": "boolean"} + + def test_view_expression_string_literal_compare_column_null_long(self): + table = Table( + { + "a": [ + "abcdefghijklmnopqrstuvwxyz", + None, + "abcdefghijklmnopqrstuvwxyz", + "aabcdefghijklmnopqrstuvwxyz", + None, + ] + } + ) + validated = table.validate_expressions( + {"computed": " \"a\" == 'abcdefghijklmnopqrstuvwxyz'"} + ) + assert validated["expression_schema"] == {"computed": "boolean"} + view = table.view( + expressions={"computed": "\"a\" == 'abcdefghijklmnopqrstuvwxyz'"} + ) + result = view.to_columns() + assert result["computed"] == [True, False, True, False, False] + assert view.expression_schema() == {"computed": "boolean"} + + def test_view_expression_string_literal_compare_column_null_long_var(self): + table = Table( + { + "a": [ + "abcdefghijklmnopqrstuvwxyz", + None, + "abcdefghijklmnopqrstuvwxyz", + "aabcdefghijklmnopqrstuvwxyz", + None, + ] + } + ) + validated = table.validate_expressions( + {"computed": " var xyz := 'abcdefghijklmnopqrstuvwxyz'; \"a\" == xyz"} + ) + assert validated["expression_schema"] == {"computed": "boolean"} + view = table.view( + expressions={ + "computed": "var xyz := 'abcdefghijklmnopqrstuvwxyz'; \"a\" == xyz" + } + ) + result = view.to_columns() + assert result["computed"] == [True, False, True, False, False] + assert view.expression_schema() == {"computed": "boolean"} + + def test_view_expression_string_literal_compare_if(self): + table = Table({"a": ["a", "a", "b", "c"]}) + validated = table.validate_expressions({"computed": " if(\"a\" == 'a', 1, 2)"}) + assert validated["expression_schema"] == {"computed": "float"} + view = table.view(expressions={"computed": "if(\"a\" == 'a', 1, 2)"}) + assert view.to_columns() == { + "a": ["a", "a", "b", "c"], + "computed": [1, 1, 2, 2], + } + + assert view.expression_schema() == {"computed": "float"} + + def test_view_expression_string_literal_var(self): + table = Table({"a": [1, 2, 3]}) + + for _ in range(10): + view = table.view( + expressions=[ + "var x := 'Eabcdefghijklmn'; var y := '0123456789'; concat(x, y)" + ] + ) + assert view.to_columns() == { + "a": [1, 2, 3], + "var x := 'Eabcdefghijklmn'; var y := '0123456789'; concat(x, y)": [ + "Eabcdefghijklmn0123456789", + "Eabcdefghijklmn0123456789", + "Eabcdefghijklmn0123456789", + ], + } + + def test_view_streaming_expression(self): + def data(): + return [{"a": random()} for _ in range(50)] + + table = Table(data()) + view = table.view(expressions=["123"]) + + for _ in range(5): + table.update(data()) + + assert table.size() == 300 + result = view.to_columns() + assert result["123"] == [123 for _ in range(300)] + + def test_view_streaming_expression_limit(self): + def data(): + return [{"a": random()} for _ in range(55)] + + table = Table(data(), limit=50) + view = table.view(expressions=["123"]) + + for _ in range(5): + table.update(data()) + + assert table.size() == 50 + result = view.to_columns() + assert result["123"] == [123 for _ in range(50)] + + def test_view_streaming_expression_one(self): + def data(): + return [{"a": random()} for _ in range(50)] + + table = Table(data()) + view = table.view(group_by=["c0"], expressions={"c0": '"a" * 2'}) + for _ in range(5): + table.update(data()) + + assert table.size() == 300 + assert view.expression_schema() == {"c0": "float"} + + def test_view_streaming_expression_two(self): + def data(): + return [{"a": random()} for _ in range(50)] + + table = Table(data()) + view = table.view( + group_by=["c0"], + split_by=["c1"], + expressions={"c0": '"a" * 2', "c1": "'new string'"}, + ) + for i in range(5): + table.update(data()) + + assert table.size() == 300 + assert view.expression_schema() == {"c0": "float", "c1": "integer"} # pivoted + + def test_view_expression_create_no_alias(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + view = table.view(expressions=['"a" + "b"']) + assert view.to_columns() == { + "a": [1, 2, 3, 4], + "b": [5, 6, 7, 8], + '"a" + "b"': [6, 8, 10, 12], + } + + assert view.expression_schema() == {'"a" + "b"': "float"} + + def test_view_expression_should_not_overwrite_real(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + with raises(PerspectiveError) as ex: + table.view(expressions={"a": 'upper("a")'}) + + assert ( + str(ex.value) + == 'Abort(): Value Error - expression "a" cannot overwrite an existing column.' + ) + + def test_legacy_view_duplicate_expression_should_resolve_to_last_alias(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + view = table.view( + columns=["abc"], + expressions=['//abc\n"a" + "b"', '//abc\n"a" - "b"'], + ) + + assert view.to_columns() == {"abc": [-4, -4, -4, -4]} + + def test_view_expression_multiple_alias( + self, + ): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + view = table.view( + expressions={ + "computed": '"a" + "b"', + "computed2": '"a" + "b"', + "computed3": '"a" + "b"', + "computed4": '"a" + "b"', + } + ) + + assert view.schema() == { + "a": "integer", + "b": "integer", + "computed": "float", + "computed2": "float", + "computed3": "float", + "computed4": "float", + } + + assert view.expression_schema() == { + "computed": "float", + "computed2": "float", + "computed3": "float", + "computed4": "float", + } + + def test_view_expression_multiple_views_with_the_same_alias_should_not_overwrite( + self, + ): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + view = table.view(expressions={"computed": ' "a" + "b"'}) + view2 = table.view(expressions={"computed": ' "a" * "b"'}) + assert view.expression_schema() == {"computed": "float"} + assert view2.expression_schema() == { + "computed": "float", + } + + assert view.to_columns()["computed"] == [6, 8, 10, 12] + assert view2.to_columns()["computed"] == [5, 12, 21, 32] + + def test_view_expression_multiple_views_with_the_same_alias_pivoted( + self, + ): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + view = table.view( + group_by=["computed"], + aggregates={"computed": ("weighted mean", ["b"])}, + expressions={"computed": ' "a" + "b"'}, + ) + view2 = table.view( + group_by=["computed"], + aggregates={"computed": "last"}, + expressions={"computed": "concat('abc', ' ', 'def')"}, + ) + assert view.expression_schema() == {"computed": "float"} + assert view2.expression_schema() == { + "computed": "string", + } + + result = view.to_columns() + result2 = view2.to_columns() + + assert result["__ROW_PATH__"] == [[], [6], [8], [10], [12]] + assert result2["__ROW_PATH__"] == [[], ["abc def"]] + + assert result["computed"] == [9.384615384615385, 6, 8, 10, 12] + assert result2["computed"] == ["abc def", "abc def"] + + def test_view_expression_multiple_views_with_the_same_alias_all_types( + self, + ): + now = datetime.now() + today = date.today() + + month_bucketed = datetime(today.year, today.month, 1).timestamp() * 1000 + minute_bucketed = ( + datetime( + now.year, now.month, now.day, now.hour, now.minute, 0, 0 + ).timestamp() + * 1000 + ) + + table = Table( + { + "a": "integer", + "b": "float", + "c": "datetime", + "d": "date", + "e": "boolean", + "f": "string", + } + ) + + table.update( + { + "a": [1, 2, 3, 4], + "b": [5.5, 6.5, 7.5, 8.5], + "c": [str(datetime.now()) for _ in range(4)], + "d": [str(date.today()) for _ in range(4)], + "e": [True, False, True, False], + "f": ["a", "b", "c", "d"], + } + ) + + view = table.view( + expressions={ + "computed": '"a" + "b"', + "computed2": "bucket(\"c\", 'M')", + "computed3": "concat('a', 'b', 'c')", + "computed4": "'new string'", + } + ) + + view2 = table.view( + expressions={ + "computed": 'upper("f")', + "computed2": '20 + ("b" * "a")', + "computed4": "bucket(\"c\", 'm')", + } + ) + + assert view.expression_schema() == { + "computed": "float", + "computed2": "date", + "computed3": "string", + "computed4": "string", + } + + assert view2.expression_schema() == { + "computed": "string", + "computed2": "float", + "computed4": "datetime", + } + + result = view.to_columns() + result2 = view2.to_columns() + + assert result["computed"] == [6.5, 8.5, 10.5, 12.5] + assert result2["computed"] == ["A", "B", "C", "D"] + + assert result["computed2"] == [month_bucketed for _ in range(4)] + assert result2["computed2"] == [25.5, 33, 42.5, 54] + + assert result["computed3"] == ["abc", "abc", "abc", "abc"] + assert "computed3" not in result2 + + assert result["computed4"] == ["new string" for _ in range(4)] + assert result2["computed4"] == [minute_bucketed for _ in range(4)] + + def test_view_expression_create_no_columns(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + view = table.view(columns=[], expressions={"computed": ' "a" + "b"'}) + assert view.to_columns() == {} + assert view.schema() == {} + + # computed column should still exist + assert view.expression_schema() == {"computed": "float"} + + def test_view_expression_create_columns(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + view = table.view(columns=["computed"], expressions={"computed": ' "a" + "b"'}) + assert view.to_columns() == {"computed": [6, 8, 10, 12]} + assert view.schema() == {"computed": "float"} + # computed column should still exist + assert view.expression_schema() == {"computed": "float"} + + def test_view_expression_create_clear(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + view = table.view(expressions={"computed": ' "a" + "b"'}) + assert view.to_columns() == { + "a": [1, 2, 3, 4], + "b": [5, 6, 7, 8], + "computed": [6, 8, 10, 12], + } + table.clear() + assert view.schema() == {"a": "integer", "b": "integer", "computed": "float"} + assert view.to_columns() == {"a": [], "b": [], "computed": []} + + def test_view_expression_create_replace(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + view = table.view(expressions={"computed": ' "a" + "b"'}) + assert view.to_columns() == { + "a": [1, 2, 3, 4], + "b": [5, 6, 7, 8], + "computed": [6, 8, 10, 12], + } + table.replace({"a": [10, 20, 30, 40], "b": [50, 60, 70, 80]}) + assert view.schema() == {"a": "integer", "b": "integer", "computed": "float"} + assert view.to_columns() == { + "a": [10, 20, 30, 40], + "b": [50, 60, 70, 80], + "computed": [60, 80, 100, 120], + } + + def test_view_expression_multiple_dependents_replace(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + view = table.view( + expressions={"computed": '"a" + "b"', "final": '("a" + "b") ^ 2'} + ) + assert view.to_columns() == { + "a": [1, 2, 3, 4], + "b": [5, 6, 7, 8], + "computed": [6, 8, 10, 12], + "final": [36, 64, 100, 144], + } + table.replace({"a": [10, 20, 30, 40], "b": [50, 60, 70, 80]}) + assert view.schema() == { + "a": "integer", + "b": "integer", + "computed": "float", + "final": "float", + } + assert view.to_columns() == { + "a": [10, 20, 30, 40], + "b": [50, 60, 70, 80], + "computed": [60, 80, 100, 120], + "final": [3600, 6400, 10000, 14400], + } + + def test_view_expression_multiple_views_should_not_conflate(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + view = table.view( + expressions={ + "computed": '"a" + "b"', + } + ) + + view2 = table.view(expressions={"computed2": ' "a" - "b"'}) + assert view.schema() == {"a": "integer", "b": "integer", "computed": "float"} + assert view2.schema() == {"a": "integer", "b": "integer", "computed2": "float"} + assert view.to_columns() == { + "a": [1, 2, 3, 4], + "b": [5, 6, 7, 8], + "computed": [6, 8, 10, 12], + } + + assert view2.to_columns() == { + "a": [1, 2, 3, 4], + "b": [5, 6, 7, 8], + "computed2": [-4, -4, -4, -4], + } + + def test_view_expression_multiple_views_should_all_clear(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + + view = table.view( + expressions={ + "computed": '"a" + "b"', + } + ) + + view2 = table.view(expressions={"computed2": ' "a" - "b"'}) + assert view.schema() == {"a": "integer", "b": "integer", "computed": "float"} + assert view2.schema() == {"a": "integer", "b": "integer", "computed2": "float"} + assert view.to_columns() == { + "a": [1, 2, 3, 4], + "b": [5, 6, 7, 8], + "computed": [6, 8, 10, 12], + } + + assert view2.to_columns() == { + "a": [1, 2, 3, 4], + "b": [5, 6, 7, 8], + "computed2": [-4, -4, -4, -4], + } + + table.clear() + + assert view.schema() == {"a": "integer", "b": "integer", "computed": "float"} + + assert view2.schema() == {"a": "integer", "b": "integer", "computed2": "float"} + + assert view.to_columns() == {"a": [], "b": [], "computed": []} + + assert view2.to_columns() == {"a": [], "b": [], "computed2": []} + + def test_view_expression_multiple_views_should_all_replace(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + view = table.view( + expressions={ + "computed": '"a" + "b"', + } + ) + + view2 = table.view(expressions={"computed2": ' "a" - "b"'}) + assert view.schema() == {"a": "integer", "b": "integer", "computed": "float"} + assert view2.schema() == {"a": "integer", "b": "integer", "computed2": "float"} + assert view.to_columns() == { + "a": [1, 2, 3, 4], + "b": [5, 6, 7, 8], + "computed": [6, 8, 10, 12], + } + + assert view2.to_columns() == { + "a": [1, 2, 3, 4], + "b": [5, 6, 7, 8], + "computed2": [-4, -4, -4, -4], + } + + table.replace({"a": [10, 20, 30, 40], "b": [50, 60, 70, 80]}) + assert view.to_columns() == { + "a": [10, 20, 30, 40], + "b": [50, 60, 70, 80], + "computed": [60, 80, 100, 120], + } + + assert view2.to_columns() == { + "a": [10, 20, 30, 40], + "b": [50, 60, 70, 80], + "computed2": [-40, -40, -40, -40], + } + + def test_view_expression_delete_and_create(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + view = table.view( + expressions={ + "computed": '"a" + "b"', + } + ) + + assert view.schema() == {"a": "integer", "b": "integer", "computed": "float"} + assert view.to_columns() == { + "a": [1, 2, 3, 4], + "b": [5, 6, 7, 8], + "computed": [6, 8, 10, 12], + } + + view.delete() + view2 = table.view(expressions={"computed": ' "a" - "b"'}) + assert view2.schema() == {"a": "integer", "b": "integer", "computed": "float"} + assert view2.to_columns() == { + "a": [1, 2, 3, 4], + "b": [5, 6, 7, 8], + "computed": [-4, -4, -4, -4], + } + + def test_view_expression_delete_and_create_with_updates(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + view = table.view( + expressions={ + "computed": ' "a" + "b"', + "upper(concat('abc', 'def'))": "upper(concat('abc', 'def'))", + } + ) + assert view.schema() == { + "a": "integer", + "b": "integer", + "computed": "float", + "upper(concat('abc', 'def'))": "string", + } + table.update({"a": [5, 6], "b": [9, 10]}) + assert view.to_columns() == { + "a": [1, 2, 3, 4, 5, 6], + "b": [5, 6, 7, 8, 9, 10], + "computed": [6, 8, 10, 12, 14, 16], + "upper(concat('abc', 'def'))": ["ABCDEF" for _ in range(6)], + } + view.delete() + view2 = table.view( + expressions={ + "computed2": '"a" - "b"', + } + ) + + assert view2.schema() == {"a": "integer", "b": "integer", "computed2": "float"} + table.update({"a": [5, 6], "b": [9, 10]}) + table.update({"a": [5, 6], "b": [9, 10]}) + assert view2.to_columns() == { + "a": [1, 2, 3, 4, 5, 6, 5, 6, 5, 6], + "b": [5, 6, 7, 8, 9, 10, 9, 10, 9, 10], + "computed2": [-4, -4, -4, -4, -4, -4, -4, -4, -4, -4], + } + + def test_view_expression_append(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + + view = table.view( + expressions={ + "computed": '"a" + "b"', + } + ) + + assert view.schema() == {"a": "integer", "b": "integer", "computed": "float"} + assert view.to_columns() == { + "a": [1, 2, 3, 4], + "b": [5, 6, 7, 8], + "computed": [6, 8, 10, 12], + } + + table.update({"a": [5, 6], "b": [9, 10]}) + + assert view.to_columns() == { + "a": [1, 2, 3, 4, 5, 6], + "b": [5, 6, 7, 8, 9, 10], + "computed": [6, 8, 10, 12, 14, 16], + } + + def test_view_expression_delta_zero(self, util): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + + view = table.view( + expressions={ + "computed": '"a" + "b"', + } + ) + + assert view.schema() == {"a": "integer", "b": "integer", "computed": "float"} + + assert view.to_columns() == { + "a": [1, 2, 3, 4], + "b": [5, 6, 7, 8], + "computed": [6, 8, 10, 12], + } + + def updater(port, delta): + compare_delta(delta, {"a": [5, 6], "b": [9, 10]}) + + table.update({"a": [5, 6], "b": [9, 10]}) + + assert view.to_columns() == { + "a": [1, 2, 3, 4, 5, 6], + "b": [5, 6, 7, 8, 9, 10], + "computed": [6, 8, 10, 12, 14, 16], + } + + def test_view_delete_with_scope(self): + """Tests that `View`'s `__del__` method, when called by the Python + reference counter, leaves an empty `Table` in a clean state. + """ + table = Table( + {"id": "integer", "msg": "string", "val": "float"}, + index="id", + ) + + table.view( + expressions={ + "inverted": '1 / "val"', + }, + columns=["inverted"], + ) + table.update( + [ + { + "id": 1, + "msg": "test", + "val": 1.0, + } + ] + ) + + def test_view_expression_with_custom_columns(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + view = table.view( + columns=["computed", "b"], + expressions={ + "computed": '"a" + "b"', + }, + ) + assert view.to_columns() == { + "b": [5, 6, 7, 8], + "computed": [6, 8, 10, 12], + } + + def test_view_expression_with_group_by(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + view = table.view( + group_by=["computed"], + expressions={ + "computed": '"a" + "b"', + }, + ) + assert view.to_columns() == { + "__ROW_PATH__": [[], [6], [8], [10], [12]], + "a": [10, 1, 2, 3, 4], + "b": [26, 5, 6, 7, 8], + "computed": [36.0, 6.0, 8.0, 10.0, 12.0], + } + + def test_view_expression_with_group_by_clear(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + + view = table.view( + group_by=["computed"], + expressions={ + "computed": '"a" + "b"', + }, + ) + + assert view.to_columns() == { + "__ROW_PATH__": [[], [6], [8], [10], [12]], + "a": [10, 1, 2, 3, 4], + "b": [26, 5, 6, 7, 8], + "computed": [36.0, 6.0, 8.0, 10.0, 12.0], + } + + table.clear() + + assert view.to_columns() == { + "__ROW_PATH__": [[]], + "a": [None], + "b": [None], + "computed": [None], + } + + def test_view_expression_with_group_by_replace(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + + view = table.view( + group_by=["computed"], + expressions={ + "computed": '"a" + "b"', + }, + ) + + assert view.to_columns() == { + "__ROW_PATH__": [[], [6], [8], [10], [12]], + "a": [10, 1, 2, 3, 4], + "b": [26, 5, 6, 7, 8], + "computed": [36.0, 6.0, 8.0, 10.0, 12.0], + } + + table.replace({"a": [10, 20, 30, 40], "b": [50, 60, 70, 80]}) + + assert view.to_columns() == { + "__ROW_PATH__": [[], [60], [80], [100], [120]], + "a": [100, 10, 20, 30, 40], + "b": [260, 50, 60, 70, 80], + "computed": [360.0, 60.0, 80.0, 100.0, 120.0], + } + + def test_view_expression_with_split_by(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + view = table.view( + split_by=["computed"], + expressions={ + "computed": '"a" + "b"', + }, + ) + assert view.to_columns() == { + "6|a": [1, None, None, None], + "6|b": [5, None, None, None], + "6|computed": [6, None, None, None], + "8|a": [None, 2, None, None], + "8|b": [None, 6, None, None], + "8|computed": [None, 8, None, None], + "10|a": [None, None, 3, None], + "10|b": [None, None, 7, None], + "10|computed": [None, None, 10.0, None], + "12|a": [None, None, None, 4], + "12|b": [None, None, None, 8], + "12|computed": [None, None, None, 12.0], + } + + def test_view_expression_with_row_split_by(self): + table = Table({"a": [1, 2, 3, 4], "b": [5, 6, 7, 8]}) + view = table.view( + split_by=["computed"], + expressions={ + "computed": '"a" + "b"', + }, + ) + assert view.to_columns() == { + "6|a": [1, None, None, None], + "6|b": [5, None, None, None], + "6|computed": [6.0, None, None, None], + "8|a": [None, 2, None, None], + "8|b": [None, 6, None, None], + "8|computed": [None, 8.0, None, None], + "10|a": [None, None, 3, None], + "10|b": [None, None, 7, None], + "10|computed": [None, None, 10.0, None], + "12|a": [None, None, None, 4], + "12|b": [None, None, None, 8], + "12|computed": [None, None, None, 12.0], + } + + def test_view_expression_with_sort(self): + table = Table({"a": ["a", "ab", "abc", "abcd"]}) + view = table.view( + sort=[["computed", "desc"]], expressions={"computed": 'length("a")'} + ) + assert view.to_columns() == { + "a": ["abcd", "abc", "ab", "a"], + "computed": [4, 3, 2, 1], + } + + def test_view_expression_with_filter(self): + table = Table({"a": ["a", "ab", "abc", "abcd"]}) + view = table.view( + filter=[["computed", ">=", 3]], expressions={"computed": 'length("a")'} + ) + assert view.to_columns() == {"a": ["abc", "abcd"], "computed": [3, 4]} + + def test_view_day_of_week_date(self, util): + table = Table({"a": [date(2020, 3, i) for i in range(9, 14)]}) + view = table.view(expressions={"bucket": 'day_of_week("a")'}) + assert view.schema() == {"a": "date", "bucket": "string"} + assert view.to_columns() == { + "a": [util.to_timestamp(datetime(2020, 3, i)) for i in range(9, 14)], + "bucket": [ + "2 Monday", + "3 Tuesday", + "4 Wednesday", + "5 Thursday", + "6 Friday", + ], + } + + def test_view_day_of_week_datetime(self, util): + table = Table({"a": [datetime(2020, 3, i, 12, 30) for i in range(9, 14)]}) + view = table.view(expressions={"bucket": 'day_of_week("a")'}) + assert view.schema() == {"a": "datetime", "bucket": "string"} + assert view.to_columns() == { + "a": [ + util.to_timestamp(datetime(2020, 3, i, 12, 30)) for i in range(9, 14) + ], + "bucket": [ + "2 Monday", + "3 Tuesday", + "4 Wednesday", + "5 Thursday", + "6 Friday", + ], + } + + def test_view_month_of_year_date(self, util): + table = Table({"a": [date(2020, i, 15) for i in range(1, 13)]}) + view = table.view(expressions={"bucket": 'month_of_year("a")'}) + assert view.schema() == {"a": "date", "bucket": "string"} + assert view.to_columns() == { + "a": [util.to_timestamp(datetime(2020, i, 15)) for i in range(1, 13)], + "bucket": [ + "01 January", + "02 February", + "03 March", + "04 April", + "05 May", + "06 June", + "07 July", + "08 August", + "09 September", + "10 October", + "11 November", + "12 December", + ], + } + + # XXX: these datetimes are being interpreted as dates!! + # to get around this, I explicitly gave a scheme to `table.` + def test_view_month_of_year_datetime(self, util): + table = Table({"a": "datetime"}) + table.update( + { + "a": [datetime(2020, i, 15) for i in range(1, 13)], + } + ) + view = table.view(expressions={"bucket": 'month_of_year("a")'}) + assert view.schema() == {"a": "datetime", "bucket": "string"} + assert view.to_columns() == { + "a": [util.to_timestamp(datetime(2020, i, 15)) for i in range(1, 13)], + "bucket": [ + "01 January", + "02 February", + "03 March", + "04 April", + "05 May", + "06 June", + "07 July", + "08 August", + "09 September", + "10 October", + "11 November", + "12 December", + ], + } + + # bucketing + def test_view_day_bucket_date(self, util): + table = Table( + { + "a": [ + date(2020, 1, 1), + date(2020, 1, 1), + date(2020, 2, 29), + date(2020, 3, 1), + ], + } + ) + view = table.view(expressions={"bucket": "bucket(\"a\", 'D')"}) + assert view.schema() == {"a": "date", "bucket": "date"} + assert view.to_columns() == { + "a": [ + util.to_timestamp(datetime(2020, 1, 1)), + util.to_timestamp(datetime(2020, 1, 1)), + util.to_timestamp(datetime(2020, 2, 29)), + util.to_timestamp(datetime(2020, 3, 1)), + ], + "bucket": [ + util.to_timestamp(datetime(2020, 1, 1)), + util.to_timestamp(datetime(2020, 1, 1)), + util.to_timestamp(datetime(2020, 2, 29)), + util.to_timestamp(datetime(2020, 3, 1)), + ], + } + + def test_view_day_bucket_date_with_null(self, util): + table = Table( + { + "a": [ + date(2020, 1, 1), + None, + date(2020, 2, 29), + date(2020, 3, 15), + ], + } + ) + view = table.view(expressions={"bucket": "bucket(\"a\", 'D')"}) + assert view.schema() == {"a": "date", "bucket": "date"} + assert view.to_columns() == { + "a": [ + util.to_timestamp(datetime(2020, 1, 1)), + None, + util.to_timestamp(datetime(2020, 2, 29)), + util.to_timestamp(datetime(2020, 3, 15)), + ], + "bucket": [ + util.to_timestamp(datetime(2020, 1, 1)), + None, + util.to_timestamp(datetime(2020, 2, 29)), + util.to_timestamp(datetime(2020, 3, 15)), + ], + } + + def test_view_day_bucket_datetime(self, util): + table = Table( + { + "a": [ + datetime(2020, 1, 1, 5), + datetime(2020, 1, 1, 23), + datetime(2020, 2, 29, 1), + datetime(2020, 3, 1, 0), + ], + } + ) + view = table.view(expressions={"bucket": "bucket(\"a\", 'D')"}) + assert view.schema() == {"a": "datetime", "bucket": "date"} + assert view.to_columns() == { + "a": [ + util.to_timestamp(datetime(2020, 1, 1, 5)), + util.to_timestamp(datetime(2020, 1, 1, 23)), + util.to_timestamp(datetime(2020, 2, 29, 1)), + util.to_timestamp(datetime(2020, 3, 1, 0)), + ], + "bucket": [ + util.to_timestamp(datetime(2020, 1, 1)), + util.to_timestamp(datetime(2020, 1, 1)), + util.to_timestamp(datetime(2020, 2, 29)), + util.to_timestamp(datetime(2020, 3, 1)), + ], + } + + def test_view_month_bucket_date(self, util): + table = Table({"a": "date"}) + table.update( + { + "a": [ + str(date(2020, 1, 1)), + str(date(2020, 1, 28)), + str(date(2020, 2, 29)), + str(date(2020, 3, 15)), + ], + } + ) + view = table.view(expressions={"bucket": "bucket(\"a\", 'M')"}) + assert view.schema() == {"a": "date", "bucket": "date"} + assert view.to_columns() == { + "a": [ + (datetime(2020, 1, 1).timestamp() * 1000), + (datetime(2020, 1, 28).timestamp() * 1000), + (datetime(2020, 2, 29).timestamp() * 1000), + (datetime(2020, 3, 15).timestamp() * 1000), + ], + "bucket": [ + (datetime(2020, 1, 1).timestamp() * 1000), + (datetime(2020, 1, 1).timestamp() * 1000), + (datetime(2020, 2, 1).timestamp() * 1000), + (datetime(2020, 3, 1).timestamp() * 1000), + ], + } + + def test_view_month_bucket_date_with_null(self, util): + table = Table( + { + "a": [ + date(2020, 1, 1), + None, + date(2020, 2, 29), + date(2020, 3, 15), + ], + } + ) + view = table.view(expressions={"bucket": "bucket(\"a\", 'M')"}) + assert view.schema() == {"a": "date", "bucket": "date"} + assert view.to_columns() == { + "a": [ + util.to_timestamp(datetime(2020, 1, 1)), + None, + util.to_timestamp(datetime(2020, 2, 29)), + util.to_timestamp(datetime(2020, 3, 15)), + ], + "bucket": [ + util.to_timestamp(datetime(2020, 1, 1)), + None, + util.to_timestamp(datetime(2020, 2, 1)), + util.to_timestamp(datetime(2020, 3, 1)), + ], + } + + def test_view_month_bucket_datetime(self, util): + table = Table({"a": "datetime"}) + table.update( + { + "a": [ + datetime(2020, 1, 1), + datetime(2020, 1, 28), + datetime(2020, 2, 29), + datetime(2020, 3, 15), + ], + } + ) + view = table.view(expressions={"bucket": "bucket(\"a\", 'M')"}) + assert view.schema() == {"a": "datetime", "bucket": "date"} + assert view.to_columns() == { + "a": [ + util.to_timestamp(datetime(2020, 1, 1)), + util.to_timestamp(datetime(2020, 1, 28)), + util.to_timestamp(datetime(2020, 2, 29)), + util.to_timestamp(datetime(2020, 3, 15)), + ], + "bucket": [ + util.to_timestamp(datetime(2020, 1, 1)), + util.to_timestamp(datetime(2020, 1, 1)), + util.to_timestamp(datetime(2020, 2, 1)), + util.to_timestamp(datetime(2020, 3, 1)), + ], + } + + def test_view_month_bucket_datetime_with_null(self, util): + table = Table({"a": "datetime"}) + table.update( + { + "a": [datetime(2020, 1, 1), None, None, datetime(2020, 3, 15)], + } + ) + view = table.view(expressions={"bucket": "bucket(\"a\", 'M')"}) + assert view.schema() == {"a": "datetime", "bucket": "date"} + assert view.to_columns() == { + "a": [ + util.to_timestamp(datetime(2020, 1, 1)), + None, + None, + util.to_timestamp(datetime(2020, 3, 15)), + ], + "bucket": [ + util.to_timestamp(datetime(2020, 1, 1)), + None, + None, + util.to_timestamp(datetime(2020, 3, 1)), + ], + } + + def test_view_integer_expression(self): + table = Table({"x": "integer", "y": "date", "z": "float"}) + + view = table.view( + expressions={ + "computed": "integer(2147483648)", + "computed2": "integer(-2147483649)", + "computed3": "integer(123.456)", + "computed4": 'integer("x")', + "computed5": 'integer("y")', + "computed6": 'integer("z")', + } + ) + + table.update({"x": [12136582], "y": [date(2020, 6, 30)], "z": [1.23456]}) + + assert view.expression_schema() == { + "computed": "integer", + "computed2": "integer", + "computed3": "integer", + "computed4": "integer", + "computed5": "integer", + "computed6": "integer", + } + + result = view.to_columns() + + assert result["computed"] == [2147483648] + assert result["computed2"] == [-2147483649] + assert result["computed3"] == [123] + assert result["computed4"] == [12136582] + assert result["computed5"] == [132384030] + assert result["computed6"] == [1] + + def test_view_float_expression(self): + table = Table({"w": "datetime", "x": "integer", "y": "date", "z": "float"}) + + view = table.view( + expressions={ + "computed": "float(2147483648)", + "computed2": "float(-2147483649)", + "computed3": "float(123.456789123)", + "computed4": 'float("x")', + "computed5": 'float("y")', + "computed6": 'float("z")', + "computed7": 'float("w")', + } + ) + + dt = datetime(2018, 8, 12, 15, 32, 55) + + table.update( + {"w": [dt], "x": [12136582], "y": [date(2020, 6, 30)], "z": [1.23456]} + ) + + assert view.expression_schema() == { + "computed": "float", + "computed2": "float", + "computed3": "float", + "computed4": "float", + "computed5": "float", + "computed6": "float", + "computed7": "float", + } + + result = view.to_columns() + + seconds_timestamp = mktime(dt.timetuple()) + dt.microsecond / 1000000.0 + ms_timestamp = int(seconds_timestamp * 1000) + + assert result["computed"] == [2147483648] + assert result["computed2"] == [-2147483649] + assert result["computed3"] == [123.456789123] + assert result["computed4"] == [12136582] + assert result["computed5"] == [132384030] + assert result["computed6"] == [1.23456] + assert result["computed7"] == [ms_timestamp] + + def test_view_date_expression(self, util): + table = Table({"x": [1]}) + view = table.view( + expressions={ + "computed": " date(2020, 5, 30)", + "computed2": "date(1997, 8, 31)", + } + ) + assert view.expression_schema() == {"computed": "date", "computed2": "date"} + result = view.to_columns() + assert result["computed"] == [util.to_timestamp(datetime(2020, 5, 30))] + assert result["computed2"] == [util.to_timestamp(datetime(1997, 8, 31))] + + def test_view_datetime_expression(self, util): + table = Table({"x": [1]}) + + dt = datetime(2015, 11, 29, 23, 59, 59) + seconds_timestamp = mktime(dt.timetuple()) + dt.microsecond / 1000000.0 + ms_timestamp = int(seconds_timestamp * 1000) + view = table.view(expressions={"computed": "datetime({})".format(ms_timestamp)}) + assert view.expression_schema() == {"computed": "datetime"} + result = view.to_columns() + assert result["computed"] == [ + util.to_timestamp(datetime(2015, 11, 29, 23, 59, 59)) + ] + + def test_view_datetime_expression_roundtrip(self, util): + table = Table({"x": [datetime(2015, 11, 29, 23, 59, 59)]}) + view = table.view(expressions={"computed": 'datetime(float("x"))'}) + assert view.expression_schema() == {"computed": "datetime"} + result = view.to_columns() + assert result["computed"] == [ + util.to_timestamp(datetime(2015, 11, 29, 23, 59, 59)) + ] + + def test_view_string_expression(self): + table = Table( + { + "a": "date", + "b": "datetime", + "c": "integer", + "d": "float", + "e": "string", + "f": "boolean", + } + ) + view = table.view( + expressions={ + "computed": 'string("a")', + "computed2": 'string("b")', + "computed3": 'string("c")', + "computed4": 'string("d")', + "computed5": 'string("e")', + "computed6": 'string("f")', + "computed7": "string(1234.5678)", + } + ) + + table.update( + { + "a": [date(2020, 5, 30), date(2021, 7, 13)], + "b": [ + datetime(2015, 11, 29, 23, 59, 59), + datetime(2016, 11, 29, 23, 59, 59), + ], + "c": [12345678, 1293879852], + "d": [1.2792013981, 19.218975981], + "e": ["abcdefghijklmnop", "def"], + "f": [False, True], + } + ) + + assert view.expression_schema() == { + "computed": "string", + "computed2": "string", + "computed3": "string", + "computed4": "string", + "computed5": "string", + "computed6": "string", + "computed7": "string", + } + result = view.to_columns() + assert result["computed"] == ["2020-05-30", "2021-07-13"] + assert result["computed2"] == [ + "2015-11-29 23:59:59.000", + "2016-11-29 23:59:59.000", + ] + assert result["computed3"] == ["12345678", "1293879852"] + assert result["computed4"] == ["1.2792", "19.219"] + assert result["computed5"] == ["abcdefghijklmnop", "def"] + assert result["computed6"] == ["false", "true"] + assert result["computed7"] == ["1234.57"] * 2 + + def test_view_expession_multicomment(self): + table = Table({"a": [1, 2, 3, 4]}) + view = table.view(expressions=["var x := 1 + 2;\n// def\nx + 100 // cdefghijk"]) + assert view.expression_schema() == { + "var x := 1 + 2;\n// def\nx + 100 // cdefghijk": "float" + } + assert view.to_columns() == { + "var x := 1 + 2;\n// def\nx + 100 // cdefghijk": [103, 103, 103, 103], + "a": [1, 2, 3, 4], + } + + def test_view_regex_email(self): + endings = ["com", "net", "co.uk", "ie", "me", "io", "co"] + data = [ + "{}@{}.{}".format( + randstr(30, ascii_letters + "0123456789" + "._-"), + randstr(10), + choices(endings, k=1)[0], + ) + for _ in range(100) + ] + table = Table({"a": data}) + expressions = { + "address": "search(\"a\", '^([a-zA-Z0-9._-]+)@')", + "domain": "search(\"a\", '@([a-zA-Z.]+)$')", + "is_email?": "match_all(\"a\", '^([a-zA-Z0-9._-]+)@([a-zA-Z.]+)$')", + "has_at?": "match(\"a\", '@')", + } + + view = table.view(expressions=expressions) + schema = view.expression_schema() + assert schema == { + "address": "string", + "domain": "string", + "is_email?": "boolean", + "has_at?": "boolean", + } + + results = view.to_columns() + + for i in range(100): + source = results["a"][i] + expected_address = re.match(r"^([a-zA-Z0-9._-]+)@", source).group(1) + expected_domain = re.search(r"@([a-zA-Z.]+)$", source).group(1) + assert results["address"][i] == expected_address + assert results["domain"][i] == expected_domain + assert results["is_email?"][i] + assert results["has_at?"][i] + + def test_view_expression_number(self): + def digits(): + return randstr(4, "0123456789") + + data = [] + + for _ in range(1000): + separator = "-" if random() > 0.5 else " " + data.append( + "{}{}{}{}{}{}{}".format( + digits(), + separator, + digits(), + separator, + digits(), + separator, + digits(), + ) + ) + + table = Table({"a": data}) + view = table.view( + expressions={ + "parsed": """ + var parts[4]; + parts[0] := search("a", '^([0-9]{4})[ -][0-9]{4}[ -][0-9]{4}[ -][0-9]{4}'); + parts[1] := search("a", '^[0-9]{4}[ -]([0-9]{4})[ -][0-9]{4}[ -][0-9]{4}'); + parts[2] := search("a", '^[0-9]{4}[ -][0-9]{4}[ -]([0-9]{4})[ -][0-9]{4}'); + parts[3] := search("a", '^[0-9]{4}[ -][0-9]{4}[ -][0-9]{4}[ -]([0-9]{4})'); + concat(parts[0], parts[1], parts[2], parts[3]) + """, + "is_number?": "match_all(\"a\", '^[0-9]{4}[ -][0-9]{4}[ -][0-9]{4}[ -][0-9]{4}')", + } + ) + schema = view.expression_schema() + assert schema == {"parsed": "string", "is_number?": "boolean"} + results = view.to_columns() + + for i in range(1000): + source = results["a"][i] + expected = re.sub(r"[ -]", "", source) + assert results["parsed"][i] == expected + assert results["is_number?"][i] + + def test_view_expression_newlines(self): + table = Table( + { + "a": [ + "abc\ndef", + "\n\n\n\nabc\ndef", + "abc\n\n\n\n\n\nabc\ndef\n\n\n\n", + None, + "def", + ], + "b": [ + "hello\tworld", + "\n\n\n\n\nhello\n\n\n\n\n\tworld", + "\tworld", + "world", + None, + ], + } + ) + + view = table.view( + expressions={ + "c1": "search(\"a\", '(\ndef)')", + "c2": "search(\"b\", '(\tworld)')", + "c3": "match(\"a\", '\\n')", + "c4": "match(\"b\", '\\n')", + } + ) + + assert view.expression_schema() == { + "c1": "string", + "c2": "string", + "c3": "boolean", + "c4": "boolean", + } + + results = view.to_columns() + assert results["c1"] == ["\ndef", "\ndef", "\ndef", None, None] + assert results["c2"] == ["\tworld", "\tworld", "\tworld", None, None] + assert results["c3"] == [True, True, True, None, False] + assert results["c4"] == [False, True, False, False, None] + + def test_view_regex_substring(self): + data = ["abc, def", "efg", "", None, "aaaaaaaaaaaaa"] + table = Table({"x": data}) + view = table.view( + expressions={ + "a": "substring('abcdef', 0)", + "abc": "substring('abcdef', 3)", + "b": 'substring("x", 0)', + "c": 'substring("x", 5, 1)', + "d": 'substring("x", 100)', + "e": 'substring("x", 0, 10000)', + "f": 'substring("x", 5, 0)', + } + ) + results = view.to_columns() + + assert results["a"] == ["abcdef" for _ in data] + assert results["abc"] == ["def" for _ in data] + assert results["b"] == [d if d else None for d in data] + assert results["c"] == ["d", None, None, None, "a"] + assert results["d"] == [None for _ in data] + assert results["e"] == [None for _ in data] + assert results["f"] == ["", None, None, None, ""] + + # FIXME: // ending\nvar domain := search(\"a\", '@([a-zA-Z.]+)$'); length(domain) > 0 ? search(domain, '[.](.*)$') : null + # is a broken expression without the newline after var domain + def test_view_regex_email_substr(self): + endings = ["com", "net", "co.uk", "ie", "me", "io", "co"] + data = [ + "{}@{}.{}".format( + randstr(30, ascii_letters + "0123456789" + "._-"), + randstr(10), + choices(endings, k=1)[0], + ) + for _ in range(100) + ] + table = Table({"a": data}) + expressions = { + "address": 'var vec[2]; indexof("a", \'^([a-zA-Z0-9._-]+)@\', vec) ? substring("a", vec[0], vec[1] - vec[0] + 1) : null', + "ending": """ + var domain := search(\"a\", '@([a-zA-Z.]+)$'); + var len := length(domain); + if (len > 0 and is_not_null(domain)) { + search(domain, '[.](.*)$'); + } else { + 'not found'; + }""", + } + + view = table.view(expressions=expressions) + schema = view.expression_schema() + assert schema == { + "address": "string", + "ending": "string", + } + + results = view.to_columns() + + for i in range(100): + source = results["a"][i] + address = re.match(r"^([a-zA-Z0-9._-]+)@", source).group(1) + domain = re.search(r"@([a-zA-Z.]+)$", source).group(1) + ending = re.search(r"[.](.*)$", domain).group(1) + assert results["address"][i] == address + assert results["ending"][i] == ending + + def test_view_expressions_replace(self): + def digits(): + return randstr(4, "0123456789") + + data = [] + + for _ in range(1000): + separator = "-" if random() > 0.5 else " " + data.append( + "{}{}{}{}{}{}{}".format( + digits(), + separator, + digits(), + separator, + digits(), + separator, + digits(), + ) + ) + + # XXX: This test was broken because it thought `b` was an integer column. + table = Table({"a": "string", "b": "string"}) + table.update({"a": data, "b": [str(i) for i in range(1000)]}) + expressions = [ + """//w + replace('abc-def-hijk', '-', '')""", + """//x + replace("a", '[0-9]{4}$', "b")""", + """//y + replace("a", '[a-z]{4}$', "b")""", + """//z + var x := 'long string, very cool!'; replace("a", '^[0-9]{4}', x)""", + ] + + validate = table.validate_expressions(expressions) + assert validate["expression_schema"] == { + "w": "string", + "x": "string", + "y": "string", + "z": "string", + } + + view = table.view(expressions=expressions) + schema = view.expression_schema() + assert schema == { + "w": "string", + "x": "string", + "y": "string", + "z": "string", + } + results = view.to_columns() + + for i in range(1000): + source = results["a"][i] + idx = results["b"][i] + assert results["w"][i] == "abcdef-hijk" + assert results["x"][i] == re.sub(r"[0-9]{4}$", idx, source, count=1) + assert results["y"][i] == source + assert results["z"][i] == re.sub( + r"^[0-9]{4}", "long string, very cool!", source, count=1 + ) + + def test_view_replace_invalid(self): + table = Table({"a": "string", "b": "string"}) + expressions = [ + """//v + replace('abc-def-hijk', '-', 123)""", + """//w + replace('', '-', today())""", + """//x + replace("a", '[0-9]{4}$', today())""", + """//y + replace("a", '[a-z]{4}$', null)""", + """//z + var x := 123; replace("a", '^[0-9]{4}', x)""", + ] + validate = table.validate_expressions(expressions) + assert validate["expression_schema"] == {} + + def test_view_expressions_replace_all(self): + def digits(): + return randstr(4, "0123456789") + + data = [] + + for _ in range(1000): + separator = "-" if random() > 0.5 else " " + data.append( + "{}{}{}{}{}{}{}".format( + digits(), + separator, + digits(), + separator, + digits(), + separator, + digits(), + ) + ) + table = Table({"a": "string", "b": "string"}) + table.update({"a": data, "b": [str(i) for i in range(1000)]}) + expressions = [ + """//w + replace_all('abc-def-hijk', '-', '')""", + """//x + replace_all("a", '[0-9]{4}$', "b")""", + """//y + replace_all("a", '[a-z]{4}$', "b")""", + """//z + var x := 'long string, very cool!'; replace_all("a", '^[0-9]{4}', x)""", + ] + + validate = table.validate_expressions(expressions) + assert validate["expression_schema"] == { + "w": "string", + "x": "string", + "y": "string", + "z": "string", + } + + view = table.view(expressions=expressions) + schema = view.expression_schema() + assert schema == { + "w": "string", + "x": "string", + "y": "string", + "z": "string", + } + + results = view.to_columns() + + for i in range(1000): + source = results["a"][i] + idx = results["b"][i] + assert results["w"][i] == "abcdefhijk" + assert results["x"][i] == re.sub(r"[0-9]{4}$", idx, source) + assert results["y"][i] == source + assert results["z"][i] == re.sub( + r"^[0-9]{4}", "long string, very cool!", source + ) + + def test_view_replace_invalid_variation(self): + table = Table({"a": "string", "b": "string"}) + expressions = [ + """//v + replace_all('abc-def-hijk', '-', 123)""", + """//w + replace_all('', '-', today())""", + """//x + replace_all("a", '[0-9]{4}$', today())""", + """//y + replace_all("a", '[a-z]{4}$', null)""", + """//z + var x := 123; replace_all("a", '^[0-9]{4}', x)""", + ] + validate = table.validate_expressions(expressions) + assert validate["expression_schema"] == {} diff --git a/rust/perspective-python/perspective/tests/test_dependencies.py b/rust/perspective-python/perspective/tests/test_dependencies.py new file mode 100644 index 0000000..1a54d5f --- /dev/null +++ b/rust/perspective-python/perspective/tests/test_dependencies.py @@ -0,0 +1,53 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 pytest + + +# test that loading perspective and calling a common constructor code path does +# not load various "expensive" modules. "Expensive" is in quotes because this is +# utter nonsense. +@pytest.mark.skip(reason="https://github.com/pyca/bcrypt/issues/694") +def test_lazy_modules(): + import sys + + cache = {} + for key in list(sys.modules.keys()): + if ( + key.startswith("perspective") + or key.startswith("test") + or key.startswith("pandas") + or key.startswith("pyarrow") + or key.startswith("tornado") + ): + cache[key] = sys.modules[key] + del sys.modules[key] + + import perspective + + t1 = perspective.table("x\n1") + t1.delete() + + assert "perspective" in sys.modules + assert "pandas" not in sys.modules + assert "pyarrow" not in sys.modules + assert "tornado" not in sys.modules + + for k, v in cache.items(): + sys.modules[k] = v + + +def test_all(): + import perspective + + for key in perspective.__all__: + assert hasattr(perspective, key) diff --git a/rust/perspective-python/perspective/tests/viewer/__init__.py b/rust/perspective-python/perspective/tests/viewer/__init__.py new file mode 100644 index 0000000..284e708 --- /dev/null +++ b/rust/perspective-python/perspective/tests/viewer/__init__.py @@ -0,0 +1,11 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ diff --git a/rust/perspective-python/perspective/tests/viewer/test_viewer.py b/rust/perspective-python/perspective/tests/viewer/test_viewer.py new file mode 100644 index 0000000..bc23f6a --- /dev/null +++ b/rust/perspective-python/perspective/tests/viewer/test_viewer.py @@ -0,0 +1,246 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 numpy as np +import pandas as pd +import pytest +from perspective.widget.viewer import PerspectiveViewer +import perspective as psp + +client = psp.Server().new_local_client() +Table = client.table + + +class TestViewer: + def test_viewer_get_table(self): + table = Table({"a": [1, 2, 3]}) + viewer = PerspectiveViewer() + viewer.load(table) + assert viewer.table == table + + # loading + + def test_viewer_load_table(self): + table = Table({"a": [1, 2, 3]}) + viewer = PerspectiveViewer() + viewer.load(table) + assert viewer.columns == ["a"] + + def test_viewer_load_named_table(self): + table = Table({"a": [1, 2, 3]}, name="data_1") + viewer = PerspectiveViewer() + viewer.load(table) + assert viewer.columns == ["a"] + assert viewer.table_name == "data_1" + assert viewer.table == table + + def test_viewer_load_data(self): + viewer = PerspectiveViewer() + viewer.load({"a": [1, 2, 3]}) + assert viewer.columns == ["a"] + + def test_viewer_load_named_data(self): + viewer = PerspectiveViewer() + viewer.load({"a": [1, 2, 3]}, name="data_1") + assert viewer.columns == ["a"] + assert viewer.table_name == "data_1" + + def test_viewer_load_schema(self): + viewer = PerspectiveViewer() + viewer.load({"a": "string", "b": "integer", "c": "boolean", "d": "string"}) + for col in viewer.columns: + assert col in ["a", "b", "c", "d"] + + def test_viewer_load_table_with_options(self): + table = Table({"a": [1, 2, 3]}) + viewer = PerspectiveViewer() + # options should be disregarded when loading Table + viewer.load(table, limit=1) + assert viewer.columns == ["a"] + assert viewer.table.size() == 3 + + def test_viewer_load_data_with_options(self): + viewer = PerspectiveViewer() + # options should be forwarded to the Table constructor + viewer.load({"a": [1, 2, 3]}, limit=1) + assert viewer.columns == ["a"] + assert viewer.table.size() == 1 + + def test_viewer_load_clears_state(self): + table = Table({"a": [1, 2, 3]}) + viewer = PerspectiveViewer(theme="Pro Dark", group_by=["a"]) + viewer.load(table) + assert viewer.group_by == ["a"] + viewer.load({"b": [1, 2, 3]}) + assert viewer.group_by == [] + assert viewer.theme == "Pro Dark" # should not break UI + + @pytest.mark.skip + def test_viewer_load_np(self): + table = Table({"a": np.arange(1, 100)}) + viewer = PerspectiveViewer() + viewer.load(table) + assert viewer.columns == ["a"] + + @pytest.mark.skip + def test_viewer_load_np_data(self): + viewer = PerspectiveViewer() + viewer.load({"a": np.arange(1, 100)}) + assert viewer.columns == ["a"] + assert viewer.table.size() == 99 + + @pytest.mark.skip + def test_viewer_load_df(self): + table = Table(pd.DataFrame({"a": np.arange(1, 100)})) + viewer = PerspectiveViewer() + viewer.load(table) + for col in viewer.columns: + assert col in ["index", "a"] + assert viewer.table.size() == 99 + + def test_viewer_load_df_data(self): + viewer = PerspectiveViewer() + viewer.load(pd.DataFrame({"a": np.arange(1, 100)})) + for col in viewer.columns: + assert col in ["index", "a"] + + # update + + def test_viewer_update_dict(self): + table = Table({"a": [1, 2, 3]}) + viewer = PerspectiveViewer() + viewer.load(table) + viewer.update({"a": [4, 5, 6]}) + assert table.size() == 6 + assert viewer.table.size() == 6 + assert viewer.table.view().to_columns() == {"a": [1, 2, 3, 4, 5, 6]} + + def test_viewer_update_list(self): + table = Table({"a": [1, 2, 3]}) + viewer = PerspectiveViewer() + viewer.load(table) + viewer.update([{"a": 4}, {"a": 5}, {"a": 6}]) + assert table.size() == 6 + assert viewer.table.size() == 6 + assert viewer.table.view().to_columns() == {"a": [1, 2, 3, 4, 5, 6]} + + def test_viewer_update_df(self): + table = Table({"a": [1, 2, 3]}) + viewer = PerspectiveViewer() + viewer.load(table) + viewer.update(pd.DataFrame({"a": [4, 5, 6]})) + assert table.size() == 6 + assert viewer.table.size() == 6 + assert viewer.table.view().to_columns() == {"a": [1, 2, 3, 4, 5, 6]} + + def test_viewer_update_dict_partial(self): + table = Table({"a": [1, 2, 3], "b": [5, 6, 7]}, index="a") + viewer = PerspectiveViewer() + viewer.load(table) + viewer.update({"a": [1, 2, 3], "b": [8, 9, 10]}) + assert table.size() == 3 + assert viewer.table.size() == 3 + assert viewer.table.view().to_columns() == {"a": [1, 2, 3], "b": [8, 9, 10]} + + # clear + + def test_viewer_clear(self): + table = Table({"a": [1, 2, 3]}) + viewer = PerspectiveViewer() + viewer.load(table) + viewer.clear() + assert viewer.table.size() == 0 + assert viewer.table.schema() == {"a": "integer"} + + # replace + + def test_viewer_replace(self): + table = Table({"a": [1, 2, 3]}) + viewer = PerspectiveViewer() + viewer.load(table) + viewer.replace({"a": [4, 5, 6]}) + assert viewer.table.size() == 3 + assert viewer.table.schema() == {"a": "integer"} + assert viewer.table.view().to_columns() == {"a": [4, 5, 6]} + + # reset + + def test_viewer_reset(self): + table = Table({"a": [1, 2, 3]}) + viewer = PerspectiveViewer(plugin="X Bar", filter=[["a", "==", 2]]) + viewer.load(table) + assert viewer.filter == [["a", "==", 2]] + viewer.reset() + assert viewer.plugin == "Datagrid" + assert viewer.filter == [] + + # delete + + def test_viewer_delete(self): + table = Table({"a": [1, 2, 3]}) + viewer = PerspectiveViewer(plugin="X Bar", filter=[["a", "==", 2]]) + viewer.load(table) + assert viewer.filter == [["a", "==", 2]] + viewer.delete() + assert viewer.table_name is None + assert viewer.table is None + + def test_viewer_delete_without_table(self): + table = Table({"a": [1, 2, 3]}) + viewer = PerspectiveViewer(plugin="X Bar", filter=[["a", "==", 2]]) + viewer.load(table) + assert viewer.filter == [["a", "==", 2]] + viewer.delete(delete_table=False) + assert viewer.table_name is not None + assert viewer.table is not None + assert viewer.filter == [] + + def test_save_restore(self): + table = Table({"a": [1, 2, 3]}) + viewer = PerspectiveViewer( + plugin="X Bar", filter=[["a", "==", 2]], expressions={'"a" * 2': '"a" * 2'} + ) + viewer.load(table) + + # Save config + config = viewer.save() + assert viewer.filter == [["a", "==", 2]] + assert config["filter"] == [["a", "==", 2]] + assert viewer.plugin == "X Bar" + assert config["plugin"] == "X Bar" + assert config["expressions"] == {'"a" * 2': '"a" * 2'} + + # reset configuration + viewer.reset() + assert viewer.plugin == "Datagrid" + assert viewer.filter == [] + assert viewer.expressions == {} + + # restore configuration + viewer.restore(**config) + assert viewer.filter == [["a", "==", 2]] + assert viewer.plugin == "X Bar" + assert viewer.expressions == {'"a" * 2': '"a" * 2'} + + def test_save_restore_plugin_config(self): + viewer = PerspectiveViewer( + plugin="Datagrid", plugin_config={"columns": {"a": {"fixed": 4}}} + ) + config = viewer.save() + + assert config["plugin_config"] == {"columns": {"a": {"fixed": 4}}} + + viewer.reset() + assert viewer.plugin_config == {} + + viewer.restore(**config) + assert viewer.plugin_config == config["plugin_config"] diff --git a/rust/perspective-python/perspective/tests/virtual_servers/__init__.py b/rust/perspective-python/perspective/tests/virtual_servers/__init__.py new file mode 100644 index 0000000..08a51ce --- /dev/null +++ b/rust/perspective-python/perspective/tests/virtual_servers/__init__.py @@ -0,0 +1,12 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + diff --git a/rust/perspective-python/perspective/tests/virtual_servers/test_coerce_types.py b/rust/perspective-python/perspective/tests/virtual_servers/test_coerce_types.py new file mode 100644 index 0000000..5280e25 --- /dev/null +++ b/rust/perspective-python/perspective/tests/virtual_servers/test_coerce_types.py @@ -0,0 +1,209 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 io +import json +from decimal import Decimal + +import pyarrow as pa +import pyarrow.ipc as ipc + +from perspective import VirtualDataSlice + + +def round_trip(arrow_table): + """Serialize a PyArrow table to IPC, feed through VirtualDataSlice, read back as JSON.""" + buf = io.BytesIO() + with ipc.new_stream(buf, arrow_table.schema) as writer: + writer.write_table(arrow_table) + ds = VirtualDataSlice() + ds.from_arrow_ipc(buf.getvalue()) + return json.loads(ds.render_to_columns_json()) + + +class TestCoerceSmallIntegers: + def test_coerce_int8(self): + table = pa.table({"col": pa.array([-1, 127, None], type=pa.int8())}) + result = round_trip(table) + assert result["col"] == [-1, 127, None] + + def test_coerce_int16(self): + table = pa.table({"col": pa.array([-300, 32000, None], type=pa.int16())}) + result = round_trip(table) + assert result["col"] == [-300, 32000, None] + + +class TestCoerceUnsignedIntegers: + def test_coerce_uint8(self): + table = pa.table({"col": pa.array([0, 255, None], type=pa.uint8())}) + result = round_trip(table) + assert result["col"] == [0, 255, None] + + def test_coerce_uint16(self): + table = pa.table({"col": pa.array([0, 65535, None], type=pa.uint16())}) + result = round_trip(table) + assert result["col"] == [0, 65535, None] + + def test_coerce_uint32(self): + table = pa.table({"col": pa.array([0, 4_294_967_295, None], type=pa.uint32())}) + result = round_trip(table) + assert result["col"] == [0.0, 4_294_967_295.0, None] + + def test_coerce_uint64(self): + table = pa.table({"col": pa.array([0, 1 << 53, None], type=pa.uint64())}) + result = round_trip(table) + assert result["col"] == [0.0, 9_007_199_254_740_992.0, None] + + +class TestCoerceFloat32: + def test_coerce_float32(self): + table = pa.table({"col": pa.array([3.14, -0.0, None], type=pa.float32())}) + result = round_trip(table) + assert abs(result["col"][0] - 3.14) < 0.001 + assert result["col"][1] == 0.0 + assert result["col"][2] is None + + +class TestCoerceDate64: + def test_coerce_date64(self): + day = 19738 + table = pa.table({"col": pa.array([day * 86_400_000, None], type=pa.date64())}) + result = round_trip(table) + assert result["col"] == [day * 86_400_000, None] + + +class TestCoerceTime: + def test_coerce_time32_second(self): + table = pa.table({"col": pa.array([49530, None], type=pa.time32("s"))}) + result = round_trip(table) + assert result["col"] == [49_530_000, None] + + def test_coerce_time32_millisecond(self): + table = pa.table({"col": pa.array([49_530_000, None], type=pa.time32("ms"))}) + result = round_trip(table) + assert result["col"] == [49_530_000, None] + + def test_coerce_time64_microsecond(self): + table = pa.table( + {"col": pa.array([49_530_000_000, None], type=pa.time64("us"))} + ) + result = round_trip(table) + assert result["col"] == [49_530_000, None] + + def test_coerce_time64_nanosecond(self): + table = pa.table( + {"col": pa.array([49_530_000_000_000, None], type=pa.time64("ns"))} + ) + result = round_trip(table) + assert result["col"] == [49_530_000, None] + + +class TestCoerceLargeUtf8: + def test_coerce_large_utf8(self): + table = pa.table({"col": pa.array(["hello", "", None], type=pa.large_utf8())}) + result = round_trip(table) + assert result["col"] == ["hello", "", None] + + +class TestCoerceDecimal128: + def test_coerce_decimal128(self): + arr = pa.array([Decimal("1234.5678"), None], type=pa.decimal128(10, 4)) + table = pa.table({"col": arr}) + result = round_trip(table) + assert result["col"] == [1234.5678, None] + + +class TestCoerceInt64: + def test_coerce_int64(self): + table = pa.table({"col": pa.array([1, -1, None], type=pa.int64())}) + result = round_trip(table) + assert result["col"] == [1.0, -1.0, None] + + +class TestCoerceTimestamp: + def test_coerce_timestamp_second(self): + table = pa.table({"col": pa.array([1000, None], type=pa.timestamp("s"))}) + result = round_trip(table) + assert result["col"] == [1_000_000, None] + + def test_coerce_timestamp_microsecond(self): + table = pa.table({"col": pa.array([1_000_000, None], type=pa.timestamp("us"))}) + result = round_trip(table) + assert result["col"] == [1000, None] + + def test_coerce_timestamp_nanosecond(self): + table = pa.table( + {"col": pa.array([1_000_000_000, None], type=pa.timestamp("ns"))} + ) + result = round_trip(table) + assert result["col"] == [1000, None] + + +class TestPassthrough: + def test_passthrough_bool(self): + table = pa.table({"col": pa.array([True, False, None], type=pa.bool_())}) + result = round_trip(table) + assert result["col"] == [True, False, None] + + def test_passthrough_utf8(self): + table = pa.table({"col": pa.array(["a", "b", None], type=pa.utf8())}) + result = round_trip(table) + assert result["col"] == ["a", "b", None] + + def test_passthrough_float64(self): + table = pa.table({"col": pa.array([1.5, -2.5, None], type=pa.float64())}) + result = round_trip(table) + assert result["col"] == [1.5, -2.5, None] + + def test_passthrough_int32(self): + table = pa.table({"col": pa.array([1, -1, None], type=pa.int32())}) + result = round_trip(table) + assert result["col"] == [1, -1, None] + + def test_passthrough_date32(self): + table = pa.table({"col": pa.array([19738, None], type=pa.date32())}) + result = round_trip(table) + assert result["col"] == [19738 * 86_400_000, None] + + def test_passthrough_timestamp_millisecond(self): + table = pa.table({"col": pa.array([1000, None], type=pa.timestamp("ms"))}) + result = round_trip(table) + assert result["col"] == [1000, None] + + +class TestFallback: + def test_fallback_fixed_size_binary(self): + table = pa.table( + {"col": pa.array([b"\x01\x02", b"\x03\x04", None], type=pa.binary(2))} + ) + result = round_trip(table) + col = result["col"] + assert isinstance(col[0], str) + assert isinstance(col[1], str) + assert col[0] != col[1] + assert col[2] is None + + +class TestEmpty: + def test_empty_batch(self): + # PyArrow's new_stream with an empty table writes no record batches, + # so we use RecordBatchStreamWriter directly to produce a valid empty batch. + schema = pa.schema([("col", pa.int8())]) + batch = pa.record_batch({"col": pa.array([], type=pa.int8())}) + buf = io.BytesIO() + writer = ipc.RecordBatchStreamWriter(buf, schema) + writer.write_batch(batch) + writer.close() + ds = VirtualDataSlice() + ds.from_arrow_ipc(buf.getvalue()) + result = json.loads(ds.render_to_columns_json()) + assert result["col"] == [] diff --git a/rust/perspective-python/perspective/tests/virtual_servers/test_duckdb.py b/rust/perspective-python/perspective/tests/virtual_servers/test_duckdb.py new file mode 100644 index 0000000..af85c88 --- /dev/null +++ b/rust/perspective-python/perspective/tests/virtual_servers/test_duckdb.py @@ -0,0 +1,908 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 os +import tempfile +import urllib.request + +import pytest +import duckdb + +from perspective import Client +from perspective.virtual_servers.duckdb import DuckDBVirtualServer + +_SUPERSTORE_LOCAL = os.path.join( + os.path.dirname(__file__), + "..", + "..", + "..", + "node_modules", + "superstore-arrow", + "superstore.parquet", +) + +_SUPERSTORE_URL = ( + "https://cdn.jsdelivr.net/npm/superstore-arrow@3.2.0/superstore.parquet" +) + + +def _get_superstore_parquet(): + if os.path.exists(_SUPERSTORE_LOCAL): + return _SUPERSTORE_LOCAL + path = os.path.join(tempfile.gettempdir(), "superstore.parquet") + if not os.path.exists(path): + urllib.request.urlretrieve(_SUPERSTORE_URL, path) + return path + + +SUPERSTORE_PARQUET = _get_superstore_parquet() + + +@pytest.fixture(scope="module") +def client(): + db = duckdb.connect() + db.execute("SET default_null_order=NULLS_FIRST_ON_ASC_LAST_ON_DESC") + db.execute( + f"CREATE TABLE superstore AS SELECT * FROM read_parquet('{SUPERSTORE_PARQUET}')" + ) + + server = DuckDBVirtualServer(db) + + def handle_request(msg): + session.handle_request(msg) + + def handle_response(msg): + c.handle_response(msg) + + session = server.new_session(handle_response) + c = Client(handle_request) + return c + + +class TestDuckDBClient: + def test_get_hosted_table_names(self, client): + tables = client.get_hosted_table_names() + assert tables == ["memory.superstore"] + + +class TestDuckDBTable: + def test_schema(self, client): + table = client.open_table("memory.superstore") + schema = table.schema() + assert schema == { + "Product Name": "string", + "Ship Date": "date", + "City": "string", + "Row ID": "integer", + "Customer Name": "string", + "Quantity": "integer", + "Discount": "float", + "Sub-Category": "string", + "Segment": "string", + "Category": "string", + "Order Date": "date", + "Order ID": "string", + "Sales": "float", + "State": "string", + "Postal Code": "float", + "Country": "string", + "Customer ID": "string", + "Ship Mode": "string", + "Region": "string", + "Profit": "float", + "Product ID": "string", + } + + def test_columns(self, client): + table = client.open_table("memory.superstore") + columns = table.columns() + assert 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", + ] + + def test_size(self, client): + table = client.open_table("memory.superstore") + size = table.size() + assert size == 9994 + + +class TestDuckDBView: + def test_num_rows(self, client): + table = client.open_table("memory.superstore") + view = table.view(columns=["Sales", "Profit"]) + num_rows = view.num_rows() + assert num_rows == 9994 + view.delete() + + def test_num_columns(self, client): + table = client.open_table("memory.superstore") + view = table.view(columns=["Sales", "Profit", "State"]) + num_columns = view.num_columns() + assert num_columns == 3 + view.delete() + + def test_schema(self, client): + table = client.open_table("memory.superstore") + view = table.view(columns=["Sales", "Profit", "State"]) + schema = view.schema() + assert schema == { + "Sales": "float", + "Profit": "float", + "State": "string", + } + view.delete() + + def test_to_json(self, client): + table = client.open_table("memory.superstore") + view = table.view(columns=["Sales", "Quantity"]) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Sales": 261.96, "Quantity": 2}, + {"Sales": 731.94, "Quantity": 3}, + {"Sales": 14.62, "Quantity": 2}, + {"Sales": 957.5775, "Quantity": 5}, + {"Sales": 22.368, "Quantity": 2}, + ] + view.delete() + + def test_to_columns(self, client): + table = client.open_table("memory.superstore") + view = table.view(columns=["Sales", "Quantity"]) + columns = view.to_columns(start_row=0, end_row=5) + assert columns == { + "Sales": [261.96, 731.94, 14.62, 957.5775, 22.368], + "Quantity": [2, 3, 2, 5, 2], + } + view.delete() + + def test_column_paths(self, client): + table = client.open_table("memory.superstore") + view = table.view(columns=["Sales", "Profit", "State"]) + paths = view.column_paths() + assert paths == ["Sales", "Profit", "State"] + view.delete() + + +class TestDuckDBGroupBy: + def test_single_group_by(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Sales"], + group_by=["Region"], + aggregates={"Sales": "sum"}, + ) + num_rows = view.num_rows() + assert num_rows == 5 + json = view.to_json() + assert json == [ + {"__ROW_PATH__": [], "Sales": 2297200.860299955}, + {"__ROW_PATH__": ["Central"], "Sales": 501239.8908000005}, + {"__ROW_PATH__": ["East"], "Sales": 678781.2399999979}, + {"__ROW_PATH__": ["South"], "Sales": 391721.9050000003}, + {"__ROW_PATH__": ["West"], "Sales": 725457.8245000006}, + ] + view.delete() + + def test_multi_level_group_by(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Sales"], + group_by=["Region", "Category"], + aggregates={"Sales": "sum"}, + ) + json = view.to_json() + assert json == [ + {"__ROW_PATH__": [], "Sales": 2297200.860299955}, + {"__ROW_PATH__": ["Central"], "Sales": 501239.8908000005}, + {"__ROW_PATH__": ["Central", "Furniture"], "Sales": 163797.16380000004}, + { + "__ROW_PATH__": ["Central", "Office Supplies"], + "Sales": 167026.41500000027, + }, + {"__ROW_PATH__": ["Central", "Technology"], "Sales": 170416.3119999999}, + {"__ROW_PATH__": ["East"], "Sales": 678781.2399999979}, + {"__ROW_PATH__": ["East", "Furniture"], "Sales": 208291.20400000009}, + {"__ROW_PATH__": ["East", "Office Supplies"], "Sales": 205516.0549999999}, + {"__ROW_PATH__": ["East", "Technology"], "Sales": 264973.9810000003}, + {"__ROW_PATH__": ["South"], "Sales": 391721.9050000003}, + {"__ROW_PATH__": ["South", "Furniture"], "Sales": 117298.6840000001}, + {"__ROW_PATH__": ["South", "Office Supplies"], "Sales": 125651.31299999992}, + {"__ROW_PATH__": ["South", "Technology"], "Sales": 148771.9079999999}, + {"__ROW_PATH__": ["West"], "Sales": 725457.8245000006}, + {"__ROW_PATH__": ["West", "Furniture"], "Sales": 252612.7435000003}, + {"__ROW_PATH__": ["West", "Office Supplies"], "Sales": 220853.24900000007}, + {"__ROW_PATH__": ["West", "Technology"], "Sales": 251991.83199999997}, + ] + view.delete() + + def test_group_by_with_count_aggregate(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Sales"], + group_by=["Region"], + aggregates={"Sales": "count"}, + ) + json = view.to_json() + assert json == [ + {"__ROW_PATH__": [], "Sales": 9994}, + {"__ROW_PATH__": ["Central"], "Sales": 2323}, + {"__ROW_PATH__": ["East"], "Sales": 2848}, + {"__ROW_PATH__": ["South"], "Sales": 1620}, + {"__ROW_PATH__": ["West"], "Sales": 3203}, + ] + view.delete() + + def test_group_by_with_avg_aggregate(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Sales"], + group_by=["Category"], + aggregates={"Sales": "avg"}, + ) + json = view.to_json() + assert json == [ + {"__ROW_PATH__": [], "Sales": 229.8580008304938}, + {"__ROW_PATH__": ["Furniture"], "Sales": 349.83488698727007}, + {"__ROW_PATH__": ["Office Supplies"], "Sales": 119.32410089611732}, + {"__ROW_PATH__": ["Technology"], "Sales": 452.70927612344155}, + ] + view.delete() + + def test_group_by_with_min_aggregate(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Quantity"], + group_by=["Region"], + aggregates={"Quantity": "min"}, + ) + json = view.to_json() + assert json == [ + {"__ROW_PATH__": [], "Quantity": 1}, + {"__ROW_PATH__": ["Central"], "Quantity": 1}, + {"__ROW_PATH__": ["East"], "Quantity": 1}, + {"__ROW_PATH__": ["South"], "Quantity": 1}, + {"__ROW_PATH__": ["West"], "Quantity": 1}, + ] + view.delete() + + def test_group_by_with_max_aggregate(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Quantity"], + group_by=["Region"], + aggregates={"Quantity": "max"}, + ) + json = view.to_json() + assert json == [ + {"__ROW_PATH__": [], "Quantity": 14}, + {"__ROW_PATH__": ["Central"], "Quantity": 14}, + {"__ROW_PATH__": ["East"], "Quantity": 14}, + {"__ROW_PATH__": ["South"], "Quantity": 14}, + {"__ROW_PATH__": ["West"], "Quantity": 14}, + ] + view.delete() + + +class TestDuckDBSplitBy: + def test_single_split_by(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Sales"], + split_by=["Region"], + group_by=["Category"], + aggregates={"Sales": "sum"}, + ) + + column_paths = view.column_paths() + assert column_paths == [ + "Central_Sales", + "East_Sales", + "South_Sales", + "West_Sales", + ] + + json = view.to_json() + assert json == [ + { + "__ROW_PATH__": [], + "Central|Sales": 501239.8908000005, + "East|Sales": 678781.2399999979, + "South|Sales": 391721.9050000003, + "West|Sales": 725457.8245000006, + }, + { + "__ROW_PATH__": ["Furniture"], + "Central|Sales": 163797.16380000004, + "East|Sales": 208291.20400000009, + "South|Sales": 117298.6840000001, + "West|Sales": 252612.7435000003, + }, + { + "__ROW_PATH__": ["Office Supplies"], + "Central|Sales": 167026.41500000027, + "East|Sales": 205516.0549999999, + "South|Sales": 125651.31299999992, + "West|Sales": 220853.24900000007, + }, + { + "__ROW_PATH__": ["Technology"], + "Central|Sales": 170416.3119999999, + "East|Sales": 264973.9810000003, + "South|Sales": 148771.9079999999, + "West|Sales": 251991.83199999997, + }, + ] + view.delete() + + def test_split_by_without_group_by(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Sales"], + split_by=["Category"], + ) + paths = view.column_paths() + assert any("Furniture" in c for c in paths) + assert any("Office Supplies" in c for c in paths) + assert any("Technology" in c for c in paths) + view.delete() + + +class TestDuckDBFilter: + def test_filter_with_equals(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Sales", "Region"], + filter=[["Region", "==", "West"]], + ) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Sales": 14.62, "Region": "West"}, + {"Sales": 48.86, "Region": "West"}, + {"Sales": 7.28, "Region": "West"}, + {"Sales": 907.152, "Region": "West"}, + {"Sales": 18.504, "Region": "West"}, + ] + view.delete() + + def test_filter_with_not_equals(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Sales", "Region"], + filter=[["Region", "!=", "West"]], + ) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Sales": 261.96, "Region": "South"}, + {"Sales": 731.94, "Region": "South"}, + {"Sales": 957.5775, "Region": "South"}, + {"Sales": 22.368, "Region": "South"}, + {"Sales": 15.552, "Region": "South"}, + ] + view.delete() + + def test_filter_with_greater_than(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Sales", "Quantity"], + filter=[["Quantity", ">", 5]], + ) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Sales": 48.86, "Quantity": 7}, + {"Sales": 907.152, "Quantity": 6}, + {"Sales": 1706.184, "Quantity": 9}, + {"Sales": 665.88, "Quantity": 6}, + {"Sales": 19.46, "Quantity": 7}, + ] + view.delete() + + def test_filter_with_less_than(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Sales", "Quantity"], + filter=[["Quantity", "<", 3]], + ) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Sales": 261.96, "Quantity": 2}, + {"Sales": 14.62, "Quantity": 2}, + {"Sales": 22.368, "Quantity": 2}, + {"Sales": 55.5, "Quantity": 2}, + {"Sales": 8.56, "Quantity": 2}, + ] + view.delete() + + def test_filter_with_greater_than_or_equal(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Sales", "Quantity"], + filter=[["Quantity", ">=", 10]], + ) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Sales": 40.096, "Quantity": 14}, + {"Sales": 43.12, "Quantity": 14}, + {"Sales": 384.45, "Quantity": 11}, + {"Sales": 3347.37, "Quantity": 13}, + {"Sales": 100.24, "Quantity": 10}, + ] + view.delete() + + def test_filter_with_less_than_or_equal(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Sales", "Quantity"], + filter=[["Quantity", "<=", 2]], + ) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Sales": 261.96, "Quantity": 2}, + {"Sales": 14.62, "Quantity": 2}, + {"Sales": 22.368, "Quantity": 2}, + {"Sales": 55.5, "Quantity": 2}, + {"Sales": 8.56, "Quantity": 2}, + ] + view.delete() + + def test_filter_with_like(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Sales", "State"], + filter=[["State", "LIKE", "Cal%"]], + ) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Sales": 14.62, "State": "California"}, + {"Sales": 48.86, "State": "California"}, + {"Sales": 7.28, "State": "California"}, + {"Sales": 907.152, "State": "California"}, + {"Sales": 18.504, "State": "California"}, + ] + view.delete() + + def test_multiple_filters(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Sales", "Region", "Quantity"], + filter=[ + ["Region", "==", "West"], + ["Quantity", ">", 3], + ], + ) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Sales": 48.86, "Region": "West", "Quantity": 7}, + {"Sales": 7.28, "Region": "West", "Quantity": 4}, + {"Sales": 907.152, "Region": "West", "Quantity": 6}, + {"Sales": 114.9, "Region": "West", "Quantity": 5}, + {"Sales": 1706.184, "Region": "West", "Quantity": 9}, + ] + view.delete() + + def test_filter_with_group_by(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Sales"], + group_by=["Category"], + filter=[["Region", "==", "West"]], + aggregates={"Sales": "sum"}, + ) + num_rows = view.num_rows() + assert num_rows == 4 + json = view.to_json() + assert json == [ + {"__ROW_PATH__": [], "Sales": 725457.8245000006}, + {"__ROW_PATH__": ["Furniture"], "Sales": 252612.7435000003}, + {"__ROW_PATH__": ["Office Supplies"], "Sales": 220853.24900000007}, + {"__ROW_PATH__": ["Technology"], "Sales": 251991.83199999997}, + ] + view.delete() + + +class TestDuckDBSort: + def test_sort_ascending(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Sales", "Quantity"], + sort=[["Sales", "asc"]], + ) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Sales": 0.444, "Quantity": 1}, + {"Sales": 0.556, "Quantity": 1}, + {"Sales": 0.836, "Quantity": 1}, + {"Sales": 0.852, "Quantity": 1}, + {"Sales": 0.876, "Quantity": 1}, + ] + view.delete() + + def test_sort_descending(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Sales", "Quantity"], + sort=[["Sales", "desc"]], + ) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Sales": 22638.48, "Quantity": 6}, + {"Sales": 17499.95, "Quantity": 5}, + {"Sales": 13999.96, "Quantity": 4}, + {"Sales": 11199.968, "Quantity": 4}, + {"Sales": 10499.97, "Quantity": 3}, + ] + view.delete() + + def test_sort_with_group_by(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Sales"], + group_by=["Region"], + sort=[["Sales", "desc"]], + aggregates={"Sales": "sum"}, + ) + json = view.to_json() + assert json == [ + {"__ROW_PATH__": [], "Sales": 2297200.860299955}, + {"__ROW_PATH__": ["West"], "Sales": 725457.8245000006}, + {"__ROW_PATH__": ["East"], "Sales": 678781.2399999979}, + {"__ROW_PATH__": ["Central"], "Sales": 501239.8908000005}, + {"__ROW_PATH__": ["South"], "Sales": 391721.9050000003}, + ] + view.delete() + + def test_multi_column_sort(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Region", "Sales", "Quantity"], + sort=[ + ["Region", "asc"], + ["Sales", "desc"], + ], + ) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Region": "Central", "Sales": 17499.95, "Quantity": 5}, + {"Region": "Central", "Sales": 9892.74, "Quantity": 13}, + {"Region": "Central", "Sales": 9449.95, "Quantity": 5}, + {"Region": "Central", "Sales": 8159.952, "Quantity": 8}, + {"Region": "Central", "Sales": 5443.96, "Quantity": 4}, + ] + view.delete() + + +class TestDuckDBExpressions: + def test_simple_expression(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Sales", "doublesales"], + expressions={"doublesales": '"Sales" * 2'}, + ) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Sales": 261.96, "doublesales": 523.92}, + {"Sales": 731.94, "doublesales": 1463.88}, + {"Sales": 14.62, "doublesales": 29.24}, + {"Sales": 957.5775, "doublesales": 1915.155}, + {"Sales": 22.368, "doublesales": 44.736}, + ] + view.delete() + + def test_expression_with_multiple_columns(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Sales", "Profit", "margin"], + expressions={"margin": '"Profit" / "Sales"'}, + ) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Sales": 261.96, "Profit": 41.9136, "margin": 0.16000000000000003}, + {"Sales": 731.94, "Profit": 219.582, "margin": 0.3}, + {"Sales": 14.62, "Profit": 6.8714, "margin": 0.47000000000000003}, + {"Sales": 957.5775, "Profit": -383.031, "margin": -0.4}, + {"Sales": 22.368, "Profit": 2.5164, "margin": 0.1125}, + ] + view.delete() + + def test_expression_with_group_by(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["total"], + group_by=["Region"], + expressions={"total": '"Sales" + "Profit"'}, + aggregates={"total": "sum"}, + ) + json = view.to_json() + assert json == [ + {"__ROW_PATH__": [], "total": 2583597.882000014}, + {"__ROW_PATH__": ["Central"], "total": 540946.2532999996}, + {"__ROW_PATH__": ["East"], "total": 770304.0199999991}, + {"__ROW_PATH__": ["South"], "total": 438471.33530000027}, + {"__ROW_PATH__": ["West"], "total": 833876.2733999988}, + ] + view.delete() + + +class TestDuckDBViewport: + def test_start_row_and_end_row(self, client): + table = client.open_table("memory.superstore") + view = table.view(columns=["Sales", "Profit"]) + json = view.to_json(start_row=10, end_row=15) + assert json == [ + {"Sales": 1706.184, "Profit": 85.3092}, + {"Sales": 911.424, "Profit": 68.3568}, + {"Sales": 15.552, "Profit": 5.4432}, + {"Sales": 407.976, "Profit": 132.5922}, + {"Sales": 68.81, "Profit": -123.858}, + ] + view.delete() + + def test_start_col_and_end_col(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Sales", "Profit", "Quantity", "Discount"], + ) + json = view.to_json(start_row=0, end_row=5, start_col=1, end_col=3) + assert json == [ + {"Profit": 41.9136, "Quantity": 2}, + {"Profit": 219.582, "Quantity": 3}, + {"Profit": 6.8714, "Quantity": 2}, + {"Profit": -383.031, "Quantity": 5}, + {"Profit": 2.5164, "Quantity": 2}, + ] + view.delete() + + +class TestDuckDBDataTypes: + def test_integer_columns(self, client): + table = client.open_table("memory.superstore") + view = table.view(columns=["Quantity"]) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Quantity": 2}, + {"Quantity": 3}, + {"Quantity": 2}, + {"Quantity": 5}, + {"Quantity": 2}, + ] + view.delete() + + def test_float_columns(self, client): + table = client.open_table("memory.superstore") + view = table.view(columns=["Sales", "Profit"]) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Sales": 261.96, "Profit": 41.9136}, + {"Sales": 731.94, "Profit": 219.582}, + {"Sales": 14.62, "Profit": 6.8714}, + {"Sales": 957.5775, "Profit": -383.031}, + {"Sales": 22.368, "Profit": 2.5164}, + ] + view.delete() + + def test_string_columns(self, client): + table = client.open_table("memory.superstore") + view = table.view(columns=["Region", "State", "City"]) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Region": "South", "State": "Kentucky", "City": "Henderson"}, + {"Region": "South", "State": "Kentucky", "City": "Henderson"}, + {"Region": "West", "State": "California", "City": "Los Angeles"}, + {"Region": "South", "State": "Florida", "City": "Fort Lauderdale"}, + {"Region": "South", "State": "Florida", "City": "Fort Lauderdale"}, + ] + view.delete() + + def test_date_columns(self, client): + table = client.open_table("memory.superstore") + view = table.view(columns=["Order Date"]) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Order Date": 1478563200000}, + {"Order Date": 1478563200000}, + {"Order Date": 1465689600000}, + {"Order Date": 1444521600000}, + {"Order Date": 1444521600000}, + ] + view.delete() + + +class TestDuckDBCombinedOperations: + def test_group_by_filter_sort(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Sales"], + group_by=["Category"], + filter=[["Region", "==", "West"]], + sort=[["Sales", "desc"]], + aggregates={"Sales": "sum"}, + ) + json = view.to_json() + assert json == [ + {"__ROW_PATH__": [], "Sales": 725457.8245000006}, + {"__ROW_PATH__": ["Furniture"], "Sales": 252612.7435000003}, + {"__ROW_PATH__": ["Technology"], "Sales": 251991.83199999997}, + {"__ROW_PATH__": ["Office Supplies"], "Sales": 220853.24900000007}, + ] + view.delete() + + def test_split_by_group_by_filter(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Sales"], + group_by=["Category"], + split_by=["Region"], + filter=[["Quantity", ">", 3]], + aggregates={"Sales": "sum"}, + ) + + paths = view.column_paths() + assert paths == [ + "Central_Sales", + "East_Sales", + "South_Sales", + "West_Sales", + ] + + num_rows = view.num_rows() + assert num_rows == 4 + + json = view.to_json() + assert json == [ + { + "__ROW_PATH__": [], + "Central|Sales": 332883.0567999998, + "East|Sales": 455143.735, + "South|Sales": 274208.7699999999, + "West|Sales": 470561.28350000136, + }, + { + "__ROW_PATH__": ["Furniture"], + "Central|Sales": 111457.73279999988, + "East|Sales": 140376.95899999997, + "South|Sales": 80859.618, + "West|Sales": 165219.5734999998, + }, + { + "__ROW_PATH__": ["Office Supplies"], + "Central|Sales": 103937.78599999992, + "East|Sales": 135823.893, + "South|Sales": 84393.3579999999, + "West|Sales": 140206.93099999975, + }, + { + "__ROW_PATH__": ["Technology"], + "Central|Sales": 117487.53800000002, + "East|Sales": 178942.883, + "South|Sales": 108955.79400000005, + "West|Sales": 165134.77900000007, + }, + ] + view.delete() + + def test_split_by_only(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Sales"], + split_by=["Region"], + filter=[["Quantity", ">", 3]], + ) + + paths = view.column_paths() + assert paths == [ + "Central_Sales", + "East_Sales", + "South_Sales", + "West_Sales", + ] + + num_rows = view.num_rows() + assert num_rows == 4284 + + json = view.to_json(start_row=0, end_row=1) + assert json == [ + { + "Central|Sales": None, + "East|Sales": None, + "South|Sales": 957.5775, + "West|Sales": None, + }, + ] + view.delete() + + def test_expressions_group_by_sort(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["profitmargin"], + group_by=["Region"], + expressions={"profitmargin": '"Profit" / "Sales" * 100'}, + sort=[["profitmargin", "desc"]], + aggregates={"profitmargin": "avg"}, + ) + json = view.to_json() + assert json == [ + {"__ROW_PATH__": [], "profitmargin": 12.031392972104467}, + {"__ROW_PATH__": ["West"], "profitmargin": 21.948661793784012}, + {"__ROW_PATH__": ["East"], "profitmargin": 16.722695960406636}, + {"__ROW_PATH__": ["South"], "profitmargin": 16.35190329218107}, + {"__ROW_PATH__": ["Central"], "profitmargin": -10.407293926323575}, + ] + view.delete() + + +class TestDuckDBMinMax: + def test_min_max_integer(self, client): + table = client.open_table("memory.superstore") + view = table.view(columns=["Quantity"]) + min_val, max_val = view.get_min_max("Quantity") + assert min_val == 1 + assert max_val == 14 + view.delete() + + def test_min_max_float(self, client): + table = client.open_table("memory.superstore") + view = table.view(columns=["Sales"]) + min_val, max_val = view.get_min_max("Sales") + assert min_val == 0.444 + assert max_val == 22638.48 + view.delete() + + def test_min_max_string(self, client): + table = client.open_table("memory.superstore") + view = table.view(columns=["Category"]) + min_val, max_val = view.get_min_max("Category") + assert min_val == "Furniture" + assert max_val == "Technology" + view.delete() + + def test_min_max_with_group_by(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Sales"], + group_by=["Region"], + aggregates={"Sales": "sum"}, + ) + min_val, max_val = view.get_min_max("Sales") + assert min_val > 0 + assert max_val > 0 + assert max_val >= min_val + view.delete() + + def test_min_max_with_filter(self, client): + table = client.open_table("memory.superstore") + view = table.view( + columns=["Quantity"], + filter=[["Quantity", ">", 10]], + ) + min_val, max_val = view.get_min_max("Quantity") + assert min_val >= 11 + assert max_val == 14 + view.delete() diff --git a/rust/perspective-python/perspective/tests/virtual_servers/test_polars.py b/rust/perspective-python/perspective/tests/virtual_servers/test_polars.py new file mode 100644 index 0000000..29b6363 --- /dev/null +++ b/rust/perspective-python/perspective/tests/virtual_servers/test_polars.py @@ -0,0 +1,1032 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 os +import tempfile +import pytest +import polars as pl + +from perspective import Client +from perspective.virtual_servers.polars import PolarsVirtualServer +import urllib + + +def approx_json(expected): + """Wrap float values in pytest.approx for approximate comparison.""" + return [ + {k: pytest.approx(v) if isinstance(v, float) else v for k, v in row.items()} + for row in expected + ] + + +_SUPERSTORE_LOCAL = os.path.join( + os.path.dirname(__file__), + "..", + "..", + "..", + "node_modules", + "superstore-arrow", + "superstore.parquet", +) + +_SUPERSTORE_URL = ( + "https://cdn.jsdelivr.net/npm/superstore-arrow@3.2.0/superstore.parquet" +) + + +def _get_superstore_parquet(): + if os.path.exists(_SUPERSTORE_LOCAL): + return _SUPERSTORE_LOCAL + path = os.path.join(tempfile.gettempdir(), "superstore.parquet") + if not os.path.exists(path): + urllib.request.urlretrieve(_SUPERSTORE_URL, path) + return path + + +SUPERSTORE_PARQUET = _get_superstore_parquet() + + +@pytest.fixture(scope="module") +def client(): + df = pl.read_parquet(SUPERSTORE_PARQUET) + server = PolarsVirtualServer({"superstore": df}) + + def handle_request(msg): + session.handle_request(msg) + + def handle_response(msg): + c.handle_response(msg) + + session = server.new_session(handle_response) + c = Client(handle_request) + return c + + +class TestPolarsClient: + def test_get_hosted_table_names(self, client): + tables = client.get_hosted_table_names() + assert tables == ["superstore"] + + +class TestPolarsTable: + def test_schema(self, client): + table = client.open_table("superstore") + schema = table.schema() + assert schema == { + "Product Name": "string", + "Ship Date": "date", + "City": "string", + "Row ID": "integer", + "Customer Name": "string", + "Quantity": "integer", + "Discount": "float", + "Sub-Category": "string", + "Segment": "string", + "Category": "string", + "Order Date": "date", + "Order ID": "string", + "Sales": "float", + "State": "string", + "Postal Code": "float", + "Country": "string", + "Customer ID": "string", + "Ship Mode": "string", + "Region": "string", + "Profit": "float", + "Product ID": "string", + } + + def test_columns(self, client): + table = client.open_table("superstore") + columns = table.columns() + assert 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", + ] + + def test_size(self, client): + table = client.open_table("superstore") + size = table.size() + assert size == 9994 + + +class TestPolarsView: + def test_num_rows(self, client): + table = client.open_table("superstore") + view = table.view(columns=["Sales", "Profit"]) + num_rows = view.num_rows() + assert num_rows == 9994 + view.delete() + + def test_num_columns(self, client): + table = client.open_table("superstore") + view = table.view(columns=["Sales", "Profit", "State"]) + num_columns = view.num_columns() + assert num_columns == 3 + view.delete() + + def test_schema(self, client): + table = client.open_table("superstore") + view = table.view(columns=["Sales", "Profit", "State"]) + schema = view.schema() + assert schema == { + "Sales": "float", + "Profit": "float", + "State": "string", + } + view.delete() + + def test_to_json(self, client): + table = client.open_table("superstore") + view = table.view(columns=["Sales", "Quantity"]) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Sales": 261.96, "Quantity": 2}, + {"Sales": 731.94, "Quantity": 3}, + {"Sales": 14.62, "Quantity": 2}, + {"Sales": 957.5775, "Quantity": 5}, + {"Sales": 22.368, "Quantity": 2}, + ] + view.delete() + + def test_to_columns(self, client): + table = client.open_table("superstore") + view = table.view(columns=["Sales", "Quantity"]) + columns = view.to_columns(start_row=0, end_row=5) + assert columns == { + "Sales": [261.96, 731.94, 14.62, 957.5775, 22.368], + "Quantity": [2, 3, 2, 5, 2], + } + view.delete() + + def test_column_paths(self, client): + table = client.open_table("superstore") + view = table.view(columns=["Sales", "Profit", "State"]) + paths = view.column_paths() + assert paths == ["Sales", "Profit", "State"] + view.delete() + + +class TestPolarsGroupBy: + def test_single_group_by(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales"], + group_by=["Region"], + aggregates={"Sales": "sum"}, + ) + num_rows = view.num_rows() + assert num_rows == 5 + json = view.to_json() + assert json == approx_json( + [ + {"__ROW_PATH__": [], "Sales": 2297200.860299955}, + {"__ROW_PATH__": ["Central"], "Sales": 501239.8908000005}, + {"__ROW_PATH__": ["East"], "Sales": 678781.2399999979}, + {"__ROW_PATH__": ["South"], "Sales": 391721.9050000003}, + {"__ROW_PATH__": ["West"], "Sales": 725457.8245000006}, + ] + ) + view.delete() + + def test_multi_level_group_by(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales"], + group_by=["Region", "Category"], + aggregates={"Sales": "sum"}, + ) + json = view.to_json() + assert json == approx_json( + [ + {"__ROW_PATH__": [], "Sales": 2297200.860299955}, + {"__ROW_PATH__": ["Central"], "Sales": 501239.8908000005}, + {"__ROW_PATH__": ["Central", "Furniture"], "Sales": 163797.16380000004}, + { + "__ROW_PATH__": ["Central", "Office Supplies"], + "Sales": 167026.41500000027, + }, + {"__ROW_PATH__": ["Central", "Technology"], "Sales": 170416.3119999999}, + {"__ROW_PATH__": ["East"], "Sales": 678781.2399999979}, + {"__ROW_PATH__": ["East", "Furniture"], "Sales": 208291.20400000009}, + { + "__ROW_PATH__": ["East", "Office Supplies"], + "Sales": 205516.0549999999, + }, + {"__ROW_PATH__": ["East", "Technology"], "Sales": 264973.9810000003}, + {"__ROW_PATH__": ["South"], "Sales": 391721.9050000003}, + {"__ROW_PATH__": ["South", "Furniture"], "Sales": 117298.6840000001}, + { + "__ROW_PATH__": ["South", "Office Supplies"], + "Sales": 125651.31299999992, + }, + {"__ROW_PATH__": ["South", "Technology"], "Sales": 148771.9079999999}, + {"__ROW_PATH__": ["West"], "Sales": 725457.8245000006}, + {"__ROW_PATH__": ["West", "Furniture"], "Sales": 252612.7435000003}, + { + "__ROW_PATH__": ["West", "Office Supplies"], + "Sales": 220853.24900000007, + }, + {"__ROW_PATH__": ["West", "Technology"], "Sales": 251991.83199999997}, + ] + ) + view.delete() + + def test_group_by_with_count_aggregate(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales"], + group_by=["Region"], + aggregates={"Sales": "count"}, + ) + json = view.to_json() + assert json == [ + {"__ROW_PATH__": [], "Sales": 9994}, + {"__ROW_PATH__": ["Central"], "Sales": 2323}, + {"__ROW_PATH__": ["East"], "Sales": 2848}, + {"__ROW_PATH__": ["South"], "Sales": 1620}, + {"__ROW_PATH__": ["West"], "Sales": 3203}, + ] + view.delete() + + def test_group_by_with_avg_aggregate(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales"], + group_by=["Category"], + aggregates={"Sales": "avg"}, + ) + json = view.to_json() + assert json == approx_json( + [ + {"__ROW_PATH__": [], "Sales": 229.8580008304938}, + {"__ROW_PATH__": ["Furniture"], "Sales": 349.83488698727007}, + {"__ROW_PATH__": ["Office Supplies"], "Sales": 119.32410089611732}, + {"__ROW_PATH__": ["Technology"], "Sales": 452.70927612344155}, + ] + ) + view.delete() + + def test_group_by_with_min_aggregate(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Quantity"], + group_by=["Region"], + aggregates={"Quantity": "min"}, + ) + json = view.to_json() + assert json == [ + {"__ROW_PATH__": [], "Quantity": 1}, + {"__ROW_PATH__": ["Central"], "Quantity": 1}, + {"__ROW_PATH__": ["East"], "Quantity": 1}, + {"__ROW_PATH__": ["South"], "Quantity": 1}, + {"__ROW_PATH__": ["West"], "Quantity": 1}, + ] + view.delete() + + def test_group_by_with_max_aggregate(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Quantity"], + group_by=["Region"], + aggregates={"Quantity": "max"}, + ) + json = view.to_json() + assert json == [ + {"__ROW_PATH__": [], "Quantity": 14}, + {"__ROW_PATH__": ["Central"], "Quantity": 14}, + {"__ROW_PATH__": ["East"], "Quantity": 14}, + {"__ROW_PATH__": ["South"], "Quantity": 14}, + {"__ROW_PATH__": ["West"], "Quantity": 14}, + ] + view.delete() + + +class TestPolarsFilter: + def test_filter_with_equals(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales", "Region"], + filter=[["Region", "==", "West"]], + ) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Sales": 14.62, "Region": "West"}, + {"Sales": 48.86, "Region": "West"}, + {"Sales": 7.28, "Region": "West"}, + {"Sales": 907.152, "Region": "West"}, + {"Sales": 18.504, "Region": "West"}, + ] + view.delete() + + def test_filter_with_not_equals(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales", "Region"], + filter=[["Region", "!=", "West"]], + ) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Sales": 261.96, "Region": "South"}, + {"Sales": 731.94, "Region": "South"}, + {"Sales": 957.5775, "Region": "South"}, + {"Sales": 22.368, "Region": "South"}, + {"Sales": 15.552, "Region": "South"}, + ] + view.delete() + + def test_filter_with_greater_than(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales", "Quantity"], + filter=[["Quantity", ">", 5]], + ) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Sales": 48.86, "Quantity": 7}, + {"Sales": 907.152, "Quantity": 6}, + {"Sales": 1706.184, "Quantity": 9}, + {"Sales": 665.88, "Quantity": 6}, + {"Sales": 19.46, "Quantity": 7}, + ] + view.delete() + + def test_filter_with_less_than(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales", "Quantity"], + filter=[["Quantity", "<", 3]], + ) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Sales": 261.96, "Quantity": 2}, + {"Sales": 14.62, "Quantity": 2}, + {"Sales": 22.368, "Quantity": 2}, + {"Sales": 55.5, "Quantity": 2}, + {"Sales": 8.56, "Quantity": 2}, + ] + view.delete() + + def test_filter_with_greater_than_or_equal(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales", "Quantity"], + filter=[["Quantity", ">=", 10]], + ) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Sales": 40.096, "Quantity": 14}, + {"Sales": 43.12, "Quantity": 14}, + {"Sales": 384.45, "Quantity": 11}, + {"Sales": 3347.37, "Quantity": 13}, + {"Sales": 100.24, "Quantity": 10}, + ] + view.delete() + + def test_filter_with_less_than_or_equal(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales", "Quantity"], + filter=[["Quantity", "<=", 2]], + ) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Sales": 261.96, "Quantity": 2}, + {"Sales": 14.62, "Quantity": 2}, + {"Sales": 22.368, "Quantity": 2}, + {"Sales": 55.5, "Quantity": 2}, + {"Sales": 8.56, "Quantity": 2}, + ] + view.delete() + + def test_multiple_filters(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales", "Region", "Quantity"], + filter=[ + ["Region", "==", "West"], + ["Quantity", ">", 3], + ], + ) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Sales": 48.86, "Region": "West", "Quantity": 7}, + {"Sales": 7.28, "Region": "West", "Quantity": 4}, + {"Sales": 907.152, "Region": "West", "Quantity": 6}, + {"Sales": 114.9, "Region": "West", "Quantity": 5}, + {"Sales": 1706.184, "Region": "West", "Quantity": 9}, + ] + view.delete() + + def test_filter_with_group_by(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales"], + group_by=["Category"], + filter=[["Region", "==", "West"]], + aggregates={"Sales": "sum"}, + ) + num_rows = view.num_rows() + assert num_rows == 4 + json = view.to_json() + assert json == approx_json( + [ + {"__ROW_PATH__": [], "Sales": 725457.8245000006}, + {"__ROW_PATH__": ["Furniture"], "Sales": 252612.7435000003}, + {"__ROW_PATH__": ["Office Supplies"], "Sales": 220853.24900000007}, + {"__ROW_PATH__": ["Technology"], "Sales": 251991.83199999997}, + ] + ) + view.delete() + + +class TestPolarsSplitBy: + def test_single_split_by(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales"], + split_by=["Region"], + group_by=["Category"], + aggregates={"Sales": "sum"}, + ) + + column_paths = view.column_paths() + assert column_paths == [ + "Central_Sales", + "East_Sales", + "South_Sales", + "West_Sales", + ] + + json = view.to_json() + assert json == approx_json( + [ + { + "__ROW_PATH__": [], + "Central|Sales": 501239.8908000005, + "East|Sales": 678781.2399999979, + "South|Sales": 391721.9050000003, + "West|Sales": 725457.8245000006, + }, + { + "__ROW_PATH__": ["Furniture"], + "Central|Sales": 163797.16380000004, + "East|Sales": 208291.20400000009, + "South|Sales": 117298.6840000001, + "West|Sales": 252612.7435000003, + }, + { + "__ROW_PATH__": ["Office Supplies"], + "Central|Sales": 167026.41500000027, + "East|Sales": 205516.0549999999, + "South|Sales": 125651.31299999992, + "West|Sales": 220853.24900000007, + }, + { + "__ROW_PATH__": ["Technology"], + "Central|Sales": 170416.3119999999, + "East|Sales": 264973.9810000003, + "South|Sales": 148771.9079999999, + "West|Sales": 251991.83199999997, + }, + ] + ) + view.delete() + + def test_split_by_without_group_by(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales"], + split_by=["Category"], + ) + paths = view.column_paths() + assert any("Furniture" in c for c in paths) + assert any("Office Supplies" in c for c in paths) + assert any("Technology" in c for c in paths) + view.delete() + + +class TestPolarsSort: + def test_sort_ascending(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales", "Quantity"], + sort=[["Sales", "asc"]], + ) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Sales": 0.444, "Quantity": 1}, + {"Sales": 0.556, "Quantity": 1}, + {"Sales": 0.836, "Quantity": 1}, + {"Sales": 0.852, "Quantity": 1}, + {"Sales": 0.876, "Quantity": 1}, + ] + view.delete() + + def test_sort_descending(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales", "Quantity"], + sort=[["Sales", "desc"]], + ) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Sales": 22638.48, "Quantity": 6}, + {"Sales": 17499.95, "Quantity": 5}, + {"Sales": 13999.96, "Quantity": 4}, + {"Sales": 11199.968, "Quantity": 4}, + {"Sales": 10499.97, "Quantity": 3}, + ] + view.delete() + + def test_sort_with_group_by(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales"], + group_by=["Region"], + sort=[["Sales", "desc"]], + aggregates={"Sales": "sum"}, + ) + json = view.to_json() + assert json == approx_json( + [ + {"__ROW_PATH__": [], "Sales": 2297200.860299955}, + {"__ROW_PATH__": ["West"], "Sales": 725457.8245000006}, + {"__ROW_PATH__": ["East"], "Sales": 678781.2399999979}, + {"__ROW_PATH__": ["Central"], "Sales": 501239.8908000005}, + {"__ROW_PATH__": ["South"], "Sales": 391721.9050000003}, + ] + ) + view.delete() + + def test_multi_column_sort(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Region", "Sales", "Quantity"], + sort=[ + ["Region", "asc"], + ["Sales", "desc"], + ], + ) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Region": "Central", "Sales": 17499.95, "Quantity": 5}, + {"Region": "Central", "Sales": 9892.74, "Quantity": 13}, + {"Region": "Central", "Sales": 9449.95, "Quantity": 5}, + {"Region": "Central", "Sales": 8159.952, "Quantity": 8}, + {"Region": "Central", "Sales": 5443.96, "Quantity": 4}, + ] + view.delete() + + +class TestPolarsViewport: + def test_start_row_and_end_row(self, client): + table = client.open_table("superstore") + view = table.view(columns=["Sales", "Profit"]) + json = view.to_json(start_row=10, end_row=15) + assert json == [ + {"Sales": 1706.184, "Profit": 85.3092}, + {"Sales": 911.424, "Profit": 68.3568}, + {"Sales": 15.552, "Profit": 5.4432}, + {"Sales": 407.976, "Profit": 132.5922}, + {"Sales": 68.81, "Profit": -123.858}, + ] + view.delete() + + def test_start_col_and_end_col(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales", "Profit", "Quantity", "Discount"], + ) + json = view.to_json(start_row=0, end_row=5, start_col=1, end_col=3) + assert json == [ + {"Profit": 41.9136, "Quantity": 2}, + {"Profit": 219.582, "Quantity": 3}, + {"Profit": 6.8714, "Quantity": 2}, + {"Profit": -383.031, "Quantity": 5}, + {"Profit": 2.5164, "Quantity": 2}, + ] + view.delete() + + +class TestPolarsDataTypes: + def test_integer_columns(self, client): + table = client.open_table("superstore") + view = table.view(columns=["Quantity"]) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Quantity": 2}, + {"Quantity": 3}, + {"Quantity": 2}, + {"Quantity": 5}, + {"Quantity": 2}, + ] + view.delete() + + def test_float_columns(self, client): + table = client.open_table("superstore") + view = table.view(columns=["Sales", "Profit"]) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Sales": 261.96, "Profit": 41.9136}, + {"Sales": 731.94, "Profit": 219.582}, + {"Sales": 14.62, "Profit": 6.8714}, + {"Sales": 957.5775, "Profit": -383.031}, + {"Sales": 22.368, "Profit": 2.5164}, + ] + view.delete() + + def test_string_columns(self, client): + table = client.open_table("superstore") + view = table.view(columns=["Region", "State", "City"]) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Region": "South", "State": "Kentucky", "City": "Henderson"}, + {"Region": "South", "State": "Kentucky", "City": "Henderson"}, + {"Region": "West", "State": "California", "City": "Los Angeles"}, + {"Region": "South", "State": "Florida", "City": "Fort Lauderdale"}, + {"Region": "South", "State": "Florida", "City": "Fort Lauderdale"}, + ] + view.delete() + + def test_date_columns(self, client): + table = client.open_table("superstore") + view = table.view(columns=["Order Date"]) + json = view.to_json(start_row=0, end_row=5) + assert json == [ + {"Order Date": 1478563200000}, + {"Order Date": 1478563200000}, + {"Order Date": 1465689600000}, + {"Order Date": 1444521600000}, + {"Order Date": 1444521600000}, + ] + view.delete() + + +class TestPolarsGroupRollupMode: + def test_flat_mode_with_group_by(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales"], + group_by=["Region"], + aggregates={"Sales": "sum"}, + group_rollup_mode="flat", + ) + num_rows = view.num_rows() + assert num_rows == 4 + json = view.to_json() + assert json == approx_json( + [ + {"__ROW_PATH__": ["Central"], "Sales": 501239.8908000005}, + {"__ROW_PATH__": ["East"], "Sales": 678781.2399999979}, + {"__ROW_PATH__": ["South"], "Sales": 391721.9050000003}, + {"__ROW_PATH__": ["West"], "Sales": 725457.8245000006}, + ] + ) + view.delete() + + def test_flat_mode_with_multi_level_group_by(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales"], + group_by=["Region", "Category"], + aggregates={"Sales": "sum"}, + group_rollup_mode="flat", + ) + num_rows = view.num_rows() + assert num_rows == 12 + json = view.to_json() + assert json == approx_json( + [ + {"__ROW_PATH__": ["Central", "Furniture"], "Sales": 163797.16380000004}, + { + "__ROW_PATH__": ["Central", "Office Supplies"], + "Sales": 167026.41500000027, + }, + {"__ROW_PATH__": ["Central", "Technology"], "Sales": 170416.3119999999}, + {"__ROW_PATH__": ["East", "Furniture"], "Sales": 208291.20400000009}, + { + "__ROW_PATH__": ["East", "Office Supplies"], + "Sales": 205516.0549999999, + }, + {"__ROW_PATH__": ["East", "Technology"], "Sales": 264973.9810000003}, + {"__ROW_PATH__": ["South", "Furniture"], "Sales": 117298.6840000001}, + { + "__ROW_PATH__": ["South", "Office Supplies"], + "Sales": 125651.31299999992, + }, + {"__ROW_PATH__": ["South", "Technology"], "Sales": 148771.9079999999}, + {"__ROW_PATH__": ["West", "Furniture"], "Sales": 252612.7435000003}, + { + "__ROW_PATH__": ["West", "Office Supplies"], + "Sales": 220853.24900000007, + }, + {"__ROW_PATH__": ["West", "Technology"], "Sales": 251991.83199999997}, + ] + ) + view.delete() + + def test_flat_mode_with_group_by_and_split_by(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales"], + group_by=["Category"], + split_by=["Region"], + aggregates={"Sales": "sum"}, + group_rollup_mode="flat", + ) + num_rows = view.num_rows() + assert num_rows == 3 + json = view.to_json() + assert json == approx_json( + [ + { + "__ROW_PATH__": ["Furniture"], + "Central|Sales": 163797.16380000004, + "East|Sales": 208291.20400000009, + "South|Sales": 117298.6840000001, + "West|Sales": 252612.7435000003, + }, + { + "__ROW_PATH__": ["Office Supplies"], + "Central|Sales": 167026.41500000027, + "East|Sales": 205516.0549999999, + "South|Sales": 125651.31299999992, + "West|Sales": 220853.24900000007, + }, + { + "__ROW_PATH__": ["Technology"], + "Central|Sales": 170416.3119999999, + "East|Sales": 264973.9810000003, + "South|Sales": 148771.9079999999, + "West|Sales": 251991.83199999997, + }, + ] + ) + view.delete() + + def test_flat_mode_with_group_by_and_split_by_and_sort(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales"], + group_by=["Category"], + split_by=["Region"], + sort=[["Sales", "desc"]], + aggregates={"Sales": "sum"}, + group_rollup_mode="flat", + ) + num_rows = view.num_rows() + assert num_rows == 3 + json = view.to_json() + assert json == approx_json( + [ + { + "__ROW_PATH__": ["Technology"], + "Central|Sales": 170416.3119999999, + "East|Sales": 264973.9810000003, + "South|Sales": 148771.9079999999, + "West|Sales": 251991.83199999997, + }, + { + "__ROW_PATH__": ["Furniture"], + "Central|Sales": 163797.16380000004, + "East|Sales": 208291.20400000009, + "South|Sales": 117298.6840000001, + "West|Sales": 252612.7435000003, + }, + { + "__ROW_PATH__": ["Office Supplies"], + "Central|Sales": 167026.41500000027, + "East|Sales": 205516.0549999999, + "South|Sales": 125651.31299999992, + "West|Sales": 220853.24900000007, + }, + ] + ) + view.delete() + + def test_flat_mode_with_group_by_and_sort(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales"], + group_by=["Region"], + sort=[["Sales", "desc"]], + aggregates={"Sales": "sum"}, + group_rollup_mode="flat", + ) + json = view.to_json() + assert json == approx_json( + [ + {"__ROW_PATH__": ["West"], "Sales": 725457.8245000006}, + {"__ROW_PATH__": ["East"], "Sales": 678781.2399999979}, + {"__ROW_PATH__": ["Central"], "Sales": 501239.8908000005}, + {"__ROW_PATH__": ["South"], "Sales": 391721.9050000003}, + ] + ) + view.delete() + + def test_total_mode(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales"], + aggregates={"Sales": "sum"}, + group_rollup_mode="total", + ) + num_rows = view.num_rows() + assert num_rows == 1 + json = view.to_json() + assert json == approx_json([{"Sales": 2297200.860299955}]) + view.delete() + + def test_total_mode_with_multiple_columns(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales", "Quantity"], + aggregates={"Sales": "sum", "Quantity": "sum"}, + group_rollup_mode="total", + ) + json = view.to_json() + assert json == approx_json( + [{"Sales": 2297200.860299955, "Quantity": 37873}] + ) + view.delete() + + def test_total_mode_with_split_by(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales"], + split_by=["Region"], + aggregates={"Sales": "sum"}, + group_rollup_mode="total", + ) + num_rows = view.num_rows() + assert num_rows == 1 + json = view.to_json() + assert json == approx_json( + [ + { + "Central|Sales": 501239.8908000005, + "East|Sales": 678781.2399999979, + "South|Sales": 391721.9050000003, + "West|Sales": 725457.8245000006, + } + ] + ) + view.delete() + + +class TestPolarsCombinedOperations: + def test_group_by_filter_sort(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales"], + group_by=["Category"], + filter=[["Region", "==", "West"]], + sort=[["Sales", "desc"]], + aggregates={"Sales": "sum"}, + ) + json = view.to_json() + assert json == approx_json( + [ + {"__ROW_PATH__": [], "Sales": 725457.8245000006}, + {"__ROW_PATH__": ["Furniture"], "Sales": 252612.7435000003}, + {"__ROW_PATH__": ["Technology"], "Sales": 251991.83199999997}, + {"__ROW_PATH__": ["Office Supplies"], "Sales": 220853.24900000007}, + ] + ) + view.delete() + + def test_split_by_group_by_filter(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales"], + group_by=["Category"], + split_by=["Region"], + filter=[["Quantity", ">", 3]], + aggregates={"Sales": "sum"}, + ) + + paths = view.column_paths() + assert paths == [ + "Central_Sales", + "East_Sales", + "South_Sales", + "West_Sales", + ] + + num_rows = view.num_rows() + assert num_rows == 4 + + json = view.to_json() + assert json == approx_json( + [ + { + "__ROW_PATH__": [], + "Central|Sales": 332883.0567999998, + "East|Sales": 455143.735, + "South|Sales": 274208.7699999999, + "West|Sales": 470561.28350000136, + }, + { + "__ROW_PATH__": ["Furniture"], + "Central|Sales": 111457.73279999988, + "East|Sales": 140376.95899999997, + "South|Sales": 80859.618, + "West|Sales": 165219.5734999998, + }, + { + "__ROW_PATH__": ["Office Supplies"], + "Central|Sales": 103937.78599999992, + "East|Sales": 135823.893, + "South|Sales": 84393.3579999999, + "West|Sales": 140206.93099999975, + }, + { + "__ROW_PATH__": ["Technology"], + "Central|Sales": 117487.53800000002, + "East|Sales": 178942.883, + "South|Sales": 108955.79400000005, + "West|Sales": 165134.77900000007, + }, + ] + ) + view.delete() + + def test_split_by_only(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["Sales"], + split_by=["Region"], + filter=[["Quantity", ">", 3]], + ) + + paths = view.column_paths() + assert paths == [ + "Central_Sales", + "East_Sales", + "South_Sales", + "West_Sales", + ] + + num_rows = view.num_rows() + assert num_rows == 4284 + + json = view.to_json(start_row=0, end_row=1) + assert json == [ + { + "Central|Sales": None, + "East|Sales": None, + "South|Sales": 957.5775, + "West|Sales": None, + }, + ] + view.delete() + + def test_expressions_group_by_sort(self, client): + table = client.open_table("superstore") + view = table.view( + columns=["profitmargin"], + group_by=["Region"], + expressions={"profitmargin": '"Profit" / "Sales" * 100'}, + sort=[["profitmargin", "desc"]], + aggregates={"profitmargin": "avg"}, + ) + json = view.to_json() + assert json == approx_json( + [ + {"__ROW_PATH__": [], "profitmargin": 12.031392972104467}, + {"__ROW_PATH__": ["West"], "profitmargin": 21.948661793784012}, + {"__ROW_PATH__": ["East"], "profitmargin": 16.722695960406636}, + {"__ROW_PATH__": ["South"], "profitmargin": 16.35190329218107}, + {"__ROW_PATH__": ["Central"], "profitmargin": -10.407293926323575}, + ] + ) + view.delete() diff --git a/rust/perspective-python/perspective/tests/widget/__init__.py b/rust/perspective-python/perspective/tests/widget/__init__.py new file mode 100644 index 0000000..284e708 --- /dev/null +++ b/rust/perspective-python/perspective/tests/widget/__init__.py @@ -0,0 +1,11 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ diff --git a/rust/perspective-python/perspective/tests/widget/test_widget.py b/rust/perspective-python/perspective/tests/widget/test_widget.py new file mode 100644 index 0000000..818bac2 --- /dev/null +++ b/rust/perspective-python/perspective/tests/widget/test_widget.py @@ -0,0 +1,278 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 functools import partial +from types import MethodType +import numpy as np +from perspective.widget import PerspectiveWidget +from pytest import raises +import pytest +import perspective as psp + +client = psp.Server().new_local_client() +Table = client.table + +pytest.skip(allow_module_level=True) + + +def mock_post(self, msg, msg_id=None, assert_msg=None): + """Mock the widget's `post()` method so we can introspect the contents.""" + assert msg == assert_msg + + +class TestWidget: + def test_widget(self): + data = {"a": np.arange(0, 50)} + widget = PerspectiveWidget(data, plugin="X Bar") + assert widget.plugin == "X Bar" + load_msg = widget._make_load_message() + assert load_msg.to_columns() == { + "id": -2, + "type": "table", + "data": {"table_name": widget.table_name, "options": {}}, + } + + def test_widget_indexed(self): + data = {"a": np.arange(0, 50)} + widget = PerspectiveWidget(data, plugin="X Bar", index="a") + assert widget.plugin == "X Bar" + load_msg = widget._make_load_message() + assert load_msg.to_columns() == { + "id": -2, + "type": "table", + "data": {"table_name": widget.table_name, "options": {"index": "a"}}, + } + + def test_widget_no_data(self): + widget = PerspectiveWidget(None, plugin="X Bar", group_by=["a"]) + assert widget.plugin == "X Bar" + assert widget.group_by == ["a"] + + def test_widget_schema(self): + schema = { + "a": "integer", + "b": "float", + "c": "boolean", + "d": "date", + "e": "datetime", + "f": "string", + } + widget = PerspectiveWidget(schema) + assert widget.table.schema() == schema + + def test_widget_schema_with_index(self): + widget = PerspectiveWidget({"a": "integer"}, index="a") + assert widget.table.get_index() == "a" + + def test_widget_schema_with_limit(self): + widget = PerspectiveWidget({"a": "integer"}, limit=5) + assert widget.table.get_limit() == 5 + + def test_widget_no_data_with_index(self): + # should fail + with raises(TypeError): + PerspectiveWidget(None, index="a") + + def test_widget_no_data_with_limit(self): + # should fail + with raises(TypeError): + PerspectiveWidget(None, limit=5) + + def test_widget_eventual_data(self): + table = Table({"a": np.arange(0, 50)}) + widget = PerspectiveWidget(None, plugin="X Bar") + assert widget.plugin == "X Bar" + + with raises(TypeError): + widget._make_load_message() + + widget.load(table) + + load_msg = widget._make_load_message() + assert load_msg.to_columns() == { + "id": -2, + "type": "table", + "data": {"table_name": widget.table_name, "options": {}}, + } + + def test_widget_eventual_data_server(self): + widget = PerspectiveWidget(None, plugin="X Bar", server=True) + assert widget.plugin == "X Bar" + widget.load({"a": np.arange(0, 50)}, index="a") + load_msg = widget._make_load_message() + assert load_msg.to_columns() == { + "id": -2, + "type": "table", + "data": { + "table_name": widget.table_name, + }, + } + + def test_widget_eventual_data_indexed(self): + widget = PerspectiveWidget(None, plugin="X Bar") + assert widget.plugin == "X Bar" + widget.load({"a": np.arange(0, 50)}, index="a") + load_msg = widget._make_load_message() + assert load_msg.to_columns() == { + "id": -2, + "type": "table", + "data": {"table_name": widget.table_name, "options": {"index": "a"}}, + } + + def test_widget_eventual_table_indexed(self): + table = Table({"a": np.arange(0, 50)}, index="a") + widget = PerspectiveWidget(None, plugin="X Bar") + assert widget.plugin == "X Bar" + widget.load(table) + load_msg = widget._make_load_message() + assert load_msg.to_columns() == { + "id": -2, + "type": "table", + "data": {"table_name": widget.table_name, "options": {"index": "a"}}, + } + + def test_widget_load_table(self): + table = Table({"a": np.arange(0, 50)}) + widget = PerspectiveWidget(table, plugin="X Bar") + assert widget.plugin == "X Bar" + load_msg = widget._make_load_message() + assert load_msg.to_columns() == { + "id": -2, + "type": "table", + "data": {"table_name": widget.table_name, "options": {}}, + } + + def test_widget_load_table_indexed(self): + table = Table({"a": np.arange(0, 50)}, index="a") + widget = PerspectiveWidget(table, plugin="X Bar") + assert widget.plugin == "X Bar" + load_msg = widget._make_load_message() + assert load_msg.to_columns() == { + "id": -2, + "type": "table", + "data": {"table_name": widget.table_name, "options": {"index": "a"}}, + } + + def test_widget_load_table_ignore_limit(self): + table = Table({"a": np.arange(0, 50)}) + widget = PerspectiveWidget(table, limit=1) + assert widget.table.size() == 50 + + def test_widget_pass_index(self): + data = {"a": [1, 2, 3, 1]} + widget = PerspectiveWidget(data, index="a") + assert widget.table.size() == 3 + + def test_widget_pass_limit(self): + data = {"a": np.arange(0, 50)} + widget = PerspectiveWidget(data, limit=1) + assert widget.table.size() == 1 + + def test_widget_pass_options_invalid(self): + data = {"a": np.arange(0, 50)} + with raises(TypeError): + PerspectiveWidget(data, index="index", limit=1) + + # server mode + def test_widget_load_table_server(self): + table = Table({"a": np.arange(0, 50)}) + widget = PerspectiveWidget(table, server=True) + load_msg = widget._make_load_message() + assert load_msg.to_columns() == { + "id": -2, + "type": "table", + "data": {"table_name": widget.table_name}, + } + + def test_widget_no_data_with_server(self): + # should fail + widget = PerspectiveWidget(None, server=True) + with raises(TypeError): + widget._make_load_message() + + def test_widget_eventual_data_with_server(self): + # should fail + widget = PerspectiveWidget(None, server=True) + + with raises(TypeError): + widget._make_load_message() + + # then succeed + widget.load(Table({"a": np.arange(0, 50)})) + load_msg = widget._make_load_message() + assert load_msg.to_columns() == { + "id": -2, + "type": "table", + "data": {"table_name": widget.table_name}, + } + + # clear + + def test_widget_clear(self): + data = {"a": np.arange(0, 50)} + widget = PerspectiveWidget(data) + widget.clear() + assert widget.table.size() == 0 + + def test_widget_clear_server(self): + data = {"a": np.arange(0, 50)} + widget = PerspectiveWidget(data, server=True) + widget.clear() + assert widget.table.size() == 0 + + # replace + + def test_widget_replace(self): + data = {"a": np.arange(0, 50)} + widget = PerspectiveWidget(data) + widget.replace({"a": [1]}) + assert widget.table.size() == 1 + + def test_widget_replace_server(self): + data = {"a": np.arange(0, 50)} + widget = PerspectiveWidget(data, server=True) + widget.replace({"a": [1]}) + assert widget.table.size() == 1 + + # delete + + def test_widget_delete(self): + data = {"a": np.arange(0, 50)} + widget = PerspectiveWidget(data) + mocked_post = partial(mock_post, assert_msg={"cmd": "delete"}) + widget.post = MethodType(mocked_post, widget) + widget.delete() + assert widget.table is None + + def test_widget_delete_with_view(self): + data = {"a": np.arange(0, 50)} + widget = PerspectiveWidget(data) + + # create a view on the manager + table_name, table = list(widget.manager._tables.items())[0] + make_view_message = { + "id": 1, + "table_name": table_name, + "view_name": "view1", + "cmd": "view", + "config": {"group_by": ["a"]}, + } + widget.manager._process(make_view_message, lambda x: True) + + assert len(widget.manager._views) == 1 + + mocked_post = partial(mock_post, assert_msg={"cmd": "delete"}) + + widget.post = MethodType(mocked_post, widget) + widget.delete() + + assert widget.table is None diff --git a/rust/perspective-python/perspective/tests/widget/test_widget_pandas.py b/rust/perspective-python/perspective/tests/widget/test_widget_pandas.py new file mode 100644 index 0000000..086e7bb --- /dev/null +++ b/rust/perspective-python/perspective/tests/widget/test_widget_pandas.py @@ -0,0 +1,453 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 pandas as pd +import numpy as np +import pytest +from perspective.widget import PerspectiveWidget +import perspective as psp + +client = psp.Server().new_local_client() +Table = client.table + + +pytest.skip(allow_module_level=True) + + +class TestWidgetPandas: + def test_widget_load_table_df(self, superstore): + table = Table(superstore) + widget = PerspectiveWidget(table) + assert widget.table.schema() == { + "index": "integer", + "Country": "string", + "Region": "string", + "Category": "string", + "City": "string", + "Customer ID": "string", + "Discount": "float", + "Order Date": "date", + "Order ID": "string", + "Postal Code": "string", + "Product ID": "string", + "Profit": "float", + "Quantity": "integer", + "Row ID": "integer", + "Sales": "integer", + "Segment": "string", + "Ship Date": "date", + "Ship Mode": "string", + "State": "string", + "Sub-Category": "string", + } + + assert sorted(widget.columns) == sorted( + [ + "index", + "Category", + "City", + "Country", + "Customer ID", + "Discount", + "Order Date", + "Order ID", + "Postal Code", + "Product ID", + "Profit", + "Quantity", + "Region", + "Row ID", + "Sales", + "Segment", + "Ship Date", + "Ship Mode", + "State", + "Sub-Category", + ] + ) + view = widget.table.view() + assert view.num_rows() == len(superstore) + assert view.num_columns() == len(superstore.columns) + 1 # index + + def test_widget_load_data_df(self, superstore): + widget = PerspectiveWidget(superstore) + assert sorted(widget.columns) == sorted( + [ + "index", + "Category", + "City", + "Country", + "Customer ID", + "Discount", + "Order Date", + "Order ID", + "Postal Code", + "Product ID", + "Profit", + "Quantity", + "Region", + "Row ID", + "Sales", + "Segment", + "Ship Date", + "Ship Mode", + "State", + "Sub-Category", + ] + ) + view = widget.table.view() + assert view.num_rows() == len(superstore) + assert view.num_columns() == 20 + + def test_widget_load_series(self, superstore): + series = pd.Series(superstore["Profit"].values, name="profit") + widget = PerspectiveWidget(series) + assert widget.table.schema() == {"index": "integer", "profit": "float"} + + assert sorted(widget.columns) == sorted(["index", "profit"]) + view = widget.table.view() + assert view.num_rows() == len(superstore) + assert view.num_columns() == 2 + + def test_widget_load_pivot_table(self, superstore): + pivot_table = pd.pivot_table( + superstore, + values="Discount", + index=["Country", "Region"], + columns=["Category", "Segment"], + ) + widget = PerspectiveWidget(pivot_table) + assert widget.group_by == ["Country", "Region"] + assert widget.split_by == ["Category", "Segment"] + assert widget.columns == ["value"] + # table should host flattened data + view = widget.table.view() + assert view.num_rows() == 60 + assert view.num_columns() == 6 + + def test_widget_load_pivot_table_with_user_pivots(self, superstore): + pivot_table = pd.pivot_table( + superstore, + values="Discount", + index=["Country", "Region"], + columns="Category", + ) + widget = PerspectiveWidget(pivot_table, group_by=["Category", "Segment"]) + assert widget.group_by == ["Category", "Segment"] + assert widget.split_by == [] + assert widget.columns == [ + "index", + "Country", + "Region", + "Financials", + "Industrials", + "Technology", + ] + # table should host flattened data + view = widget.table.view() + assert view.num_rows() == 5 + assert view.num_columns() == 6 + + def test_widget_load_group_by(self, superstore): + df_pivoted = superstore.set_index(["Country", "Region"]) + widget = PerspectiveWidget(df_pivoted) + assert widget.group_by == ["Country", "Region"] + assert widget.split_by == [] + assert sorted(widget.columns) == sorted( + [ + "index", + "Category", + "Country", + "City", + "Customer ID", + "Discount", + "Order Date", + "Order ID", + "Postal Code", + "Product ID", + "Profit", + "Quantity", + "Region", + "Row ID", + "Sales", + "Segment", + "Ship Date", + "Ship Mode", + "State", + "Sub-Category", + ] + ) + assert widget.table.size() == 100 + view = widget.table.view() + assert view.num_rows() == len(superstore) + assert view.num_columns() == len(superstore.columns) + 1 # index + + def test_widget_load_group_by_with_user_pivots(self, superstore): + df_pivoted = superstore.set_index(["Country", "Region"]) + widget = PerspectiveWidget(df_pivoted, group_by=["Category", "Segment"]) + assert widget.group_by == ["Category", "Segment"] + assert widget.split_by == [] + assert sorted(widget.columns) == sorted( + [ + "index", + "Category", + "Country", + "City", + "Customer ID", + "Discount", + "Order Date", + "Order ID", + "Postal Code", + "Product ID", + "Profit", + "Quantity", + "Region", + "Row ID", + "Sales", + "Segment", + "Ship Date", + "Ship Mode", + "State", + "Sub-Category", + ] + ) + assert widget.table.size() == 100 + view = widget.table.view() + assert view.num_rows() == len(superstore) + assert view.num_columns() == len(superstore.columns) + 1 # index + + def test_widget_load_split_by(self, superstore): + arrays = [ + np.array( + [ + "bar", + "bar", + "bar", + "bar", + "baz", + "baz", + "baz", + "baz", + "foo", + "foo", + "foo", + "foo", + "qux", + "qux", + "qux", + "qux", + ] + ), + np.array( + [ + "one", + "one", + "two", + "two", + "one", + "one", + "two", + "two", + "one", + "one", + "two", + "two", + "one", + "one", + "two", + "two", + ] + ), + np.array( + [ + "X", + "Y", + "X", + "Y", + "X", + "Y", + "X", + "Y", + "X", + "Y", + "X", + "Y", + "X", + "Y", + "X", + "Y", + ] + ), + ] + tuples = list(zip(*arrays)) + index = pd.MultiIndex.from_tuples(tuples, names=["first", "second", "third"]) + df_both = pd.DataFrame( + np.random.randn(3, 16), index=["A", "B", "C"], columns=index + ) + widget = PerspectiveWidget(df_both) + assert widget.columns == ["value"] + assert widget.split_by == ["first", "second", "third"] + assert widget.group_by == ["index"] + + def test_widget_load_split_by_preserve_user_settings(self, superstore): + arrays = [ + np.array( + [ + "bar", + "bar", + "bar", + "bar", + "baz", + "baz", + "baz", + "baz", + "foo", + "foo", + "foo", + "foo", + "qux", + "qux", + "qux", + "qux", + ] + ), + np.array( + [ + "one", + "one", + "two", + "two", + "one", + "one", + "two", + "two", + "one", + "one", + "two", + "two", + "one", + "one", + "two", + "two", + ] + ), + np.array( + [ + "X", + "Y", + "X", + "Y", + "X", + "Y", + "X", + "Y", + "X", + "Y", + "X", + "Y", + "X", + "Y", + "X", + "Y", + ] + ), + ] + tuples = list(zip(*arrays)) + index = pd.MultiIndex.from_tuples(tuples, names=["first", "second", "third"]) + df_both = pd.DataFrame( + np.random.randn(3, 16), index=["A", "B", "C"], columns=index + ) + widget = PerspectiveWidget(df_both, columns=["first", "third"]) + assert widget.columns == ["first", "third"] + assert widget.split_by == ["first", "second", "third"] + assert widget.group_by == ["index"] + + def test_pivottable_values_index(self, superstore): + arrays = { + "A": [ + "bar", + "bar", + "bar", + "bar", + "baz", + "baz", + "baz", + "baz", + "foo", + "foo", + "foo", + "foo", + "qux", + "qux", + "qux", + "qux", + ], + "B": [ + "one", + "one", + "two", + "two", + "one", + "one", + "two", + "two", + "one", + "one", + "two", + "two", + "one", + "one", + "two", + "two", + ], + "C": [ + "X", + "Y", + "X", + "Y", + "X", + "Y", + "X", + "Y", + "X", + "Y", + "X", + "Y", + "X", + "Y", + "X", + "Y", + ], + "D": np.arange(16), + } + + df = pd.DataFrame(arrays) + df_pivot = df.pivot_table( + values=["D"], index=["A"], columns=["B", "C"], aggfunc={"D": "count"} + ) + widget = PerspectiveWidget(df_pivot) + assert widget.columns == ["value"] + assert widget.split_by == ["B", "C"] + assert widget.group_by == ["A"] + + def test_pivottable_multi_values(self, superstore): + pt = pd.pivot_table( + superstore, + values=["Discount", "Sales"], + index=["Country", "Region"], + aggfunc={"Discount": "count", "Sales": "sum"}, + columns=["State", "Quantity"], + ) + widget = PerspectiveWidget(pt) + assert widget.columns == ["Discount", "Sales"] + assert widget.split_by == ["State", "Quantity"] + assert widget.group_by == ["Country", "Region"] 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 0000000..550789b --- /dev/null +++ b/rust/perspective-python/perspective/virtual_servers/__init__.py @@ -0,0 +1,142 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 VirtualServerHandler: + """ + 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_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_min_max(self, view_name, column_name, config): + """ + [OPTIONAL] Get the min and max values of a column in a view. + Returns a tuple of (min, max) as native Python values. + """ + + 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/perspective/virtual_servers/clickhouse.py b/rust/perspective-python/perspective/virtual_servers/clickhouse.py new file mode 100644 index 0000000..67d00a1 --- /dev/null +++ b/rust/perspective-python/perspective/virtual_servers/clickhouse.py @@ -0,0 +1,240 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 + +from datetime import datetime +import logging + +from perspective.virtual_servers import VirtualServerHandler + +logger = logging.getLogger(__name__) + +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", +] + +STRING_AGGS = [ + "count", + "any_value", + "arbitrary", + "first", + "countif", + "last", + "string_agg", +] + +FILTER_OPS = [ + "==", + "!=", + "LIKE", + "IS DISTINCT FROM", + "IS NOT DISTINCT FROM", + ">=", + "<=", + ">", + "<", +] + + +class ClickhouseVirtualSession: + def __init__(self, callback, db): + self.session = perspective.VirtualServer(ClickhouseVirtualServerHandler(db)) + self.callback = callback + + def handle_request(self, msg): + self.callback(self.session.handle_request(msg)) + + +class ClickhouseVirtualServer: + def __init__(self, db): + self.db = db + + def new_session(self, callback): + return ClickhouseVirtualSession(callback, self.db) + + +class ClickhouseVirtualServerHandler(VirtualServerHandler): + """ + An implementation of a `perspective.VirtualServerHandler` for ClickHouse. + """ + + def __init__(self, db): + self.db = db + self.sql_builder = perspective.GenericSQLVirtualServerModel( + {"create_entity": "VIEW", "grouping_fn": "GROUPING"} + ) + + def get_features(self): + return { + "group_by": True, + "split_by": False, + "sort": True, + "expressions": True, + "group_rollup_mode": ["rollup", "flat", "total"], + "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): + query = "SHOW TABLES" + results = run_query(self.db, query) + return [result[0] for result in results] + + def table_schema(self, table_name, config=None): + query = self.sql_builder.table_schema(table_name) + results = run_query(self.db, query) + schema = {} + for result in results: + col_name = result[0] + if not col_name.startswith("__"): + schema[col_name] = clickhouse_type_to_psp(result[1]) + + return schema + + def view_column_size(self, view_name, config): + query = f"SELECT COUNT() FROM system.columns WHERE table = '{view_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 = self.sql_builder.table_size(table_name) + results = run_query(self.db, query) + return results[0][0] + + def table_make_view(self, table_name, view_name, config): + query = self.sql_builder.table_make_view(table_name, view_name, config) + run_query(self.db, query, execute=True) + + def table_validate_expression(self, view_name, expression): + query = self.sql_builder.table_validate_expression(view_name, expression) + results = run_query(self.db, query) + return clickhouse_type_to_psp(results[0][1]) + + def view_delete(self, view_name): + query = self.sql_builder.view_delete(view_name) + run_query(self.db, query, execute=True) + + def view_get_min_max(self, view_name, column_name, config): + query = self.sql_builder.view_get_min_max(view_name, column_name, config) + results = run_query(self.db, query) + row = results[0] + return (row[0], row[1]) + + def view_get_data(self, view_name, config, schema, viewport, data): + group_by = config["group_by"] + query = self.sql_builder.view_get_data(view_name, config, viewport, schema) + results, columns, dtypes = run_query(self.db, query, columns=True) + for cidx, col in enumerate(columns): + dtype = clickhouse_type_to_psp(str(dtypes[cidx])) + for ridx, row in enumerate(results): + grouping_id = row[0] if len(group_by) > 0 else None + + value = row[cidx] + if dtype == "string" and not isinstance(value, str): + value = str(value) + + data.set_col(dtype, col, ridx, value, grouping_id) + + +################################################################################ +# +# ClickHouse Utils + + +def clickhouse_type_to_psp(name): + """Convert a ClickHouse `dtype` to a Perspective `ColumnType`.""" + if name.startswith("Nullable(") and name.endswith(")"): + name = name[9:-1] + + if name.startswith("Array"): + return "string" + + if name in ("Int64", "UInt64", "Float64"): + return "float" + + if name == "String": + return "string" + + if name == "DateTime": + return "datetime" + + if name == "Date": + return "date" + + 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.command(query) + else: + req = db.query(query) + result = req.result_rows + except Exception as e: + logger.error(e) + logger.error(f"{query}") + raise e + else: + logger.debug(f"{datetime.now() - start} {query}") + if columns: + return ( + result, + req.column_names, + [(x.name if hasattr(x, "name") else str(x)) for x in req.column_types], + ) + else: + return result 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 0000000..a2e4f79 --- /dev/null +++ b/rust/perspective-python/perspective/virtual_servers/duckdb.py @@ -0,0 +1,236 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 io +import duckdb +import perspective +import pyarrow.ipc as ipc + +from datetime import datetime +import logging + +from perspective.virtual_servers import VirtualServerHandler + +logger = logging.getLogger(__name__) + +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(DuckDBVirtualServerHandler(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 DuckDBVirtualServerHandler(VirtualServerHandler): + """ + An implementation of a `perspective.VirtualServerHandler` for DuckDB. + """ + + def __init__(self, db): + self.db = db + self.sql_builder = perspective.GenericSQLVirtualServerModel({}) + + def get_features(self): + return { + "group_by": True, + "split_by": True, + "sort": True, + "expressions": True, + "group_rollup_mode": ["rollup", "flat", "total"], + "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): + query = self.sql_builder.get_hosted_tables() + results = run_query(self.db, query) + return [f"{result[0]}.{result[2]}" for result in results] + + def table_schema(self, table_name, config=None): + query = self.sql_builder.table_schema(table_name) + results = run_query(self.db, query) + schema = {} + for result in results: + col_name = result[0] + if not col_name.startswith("__"): + schema[col_name] = duckdb_type_to_psp(result[1]) + + return schema + + def view_column_size(self, table_name, config): + query = self.sql_builder.view_column_size(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 = self.sql_builder.table_size(table_name) + results = run_query(self.db, query) + return results[0][0] + + def table_make_view(self, table_name, view_name, config): + query = self.sql_builder.table_make_view(table_name, view_name, config) + run_query(self.db, query, execute=True) + + def table_validate_expression(self, view_name, expression): + query = self.sql_builder.table_validate_expression(view_name, expression) + results = run_query(self.db, query) + return duckdb_type_to_psp(results[0][1]) + + def view_delete(self, view_name): + query = self.sql_builder.view_delete(view_name) + run_query(self.db, query, execute=True) + + def view_get_min_max(self, view_name, column_name, config): + query = self.sql_builder.view_get_min_max(view_name, column_name, config) + results = run_query(self.db, query) + row = results[0] + return (row[0], row[1]) + + def view_get_data(self, view_name, config, schema, viewport, data): + query = self.sql_builder.view_get_data(view_name, config, viewport, schema) + result = self.db.sql(query) + arrow_table = result.fetch_arrow_table() + buf = io.BytesIO() + with ipc.new_stream(buf, arrow_table.schema) as writer: + writer.write_table(arrow_table) + data.from_arrow_ipc(buf.getvalue()) + + +################################################################################ +# +# DuckDB Utils + + +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 diff --git a/rust/perspective-python/perspective/virtual_servers/polars.py b/rust/perspective-python/perspective/virtual_servers/polars.py new file mode 100644 index 0000000..6d60ed9 --- /dev/null +++ b/rust/perspective-python/perspective/virtual_servers/polars.py @@ -0,0 +1,710 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 logging +import polars as pl +import perspective + +from datetime import datetime +import re + +from perspective.virtual_servers import VirtualServerHandler + +logger = logging.getLogger(__name__) + +NUMBER_AGGS = [ + "sum", + "count", + "any_value", + "avg", + "mean", + "max", + "min", + "first", + "last", +] + +STRING_AGGS = [ + "count", + "any_value", + "first", + "last", +] + +FILTER_OPS = [ + "==", + "!=", + ">=", + "<=", + ">", + "<", +] + +AGG_MAP = { + "sum": lambda e: e.sum(), + "count": lambda e: e.count(), + "avg": lambda e: e.mean(), + "mean": lambda e: e.mean(), + "min": lambda e: e.min(), + "max": lambda e: e.max(), + "first": lambda e: e.first(), + "last": lambda e: e.last(), + "any_value": lambda e: e.first(), + "arbitrary": lambda e: e.first(), +} + + +class PolarsVirtualSession: + def __init__(self, callback, tables): + self.session = perspective.VirtualServer(PolarsVirtualServerHandler(tables)) + self.callback = callback + + def handle_request(self, msg): + self.callback(self.session.handle_request(msg)) + + +class PolarsVirtualServer: + def __init__(self, tables): + self.tables = tables + + def new_session(self, callback): + return PolarsVirtualSession(callback, self.tables) + + +class PolarsVirtualServerHandler(VirtualServerHandler): + """ + An implementation of a `perspective.VirtualServerHandler` for Polars. + """ + + def __init__(self, tables): + self.tables = tables + self.views = {} + self.view_schemas = {} + + def get_features(self): + return { + "group_by": True, + "split_by": True, + "sort": True, + "expressions": True, + "group_rollup_mode": ["rollup", "flat", "total"], + "filter_ops": { + "integer": FILTER_OPS, + "float": FILTER_OPS, + "string": FILTER_OPS, + "boolean": ["==", "!="], + "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): + return list(self.tables.keys()) + + def table_schema(self, table_name, config=None): + df = self.tables[table_name] + schema = {} + for col_name, dtype in df.schema.items(): + if not col_name.startswith("__"): + schema[col_name] = polars_type_to_psp(dtype) + return schema + + def table_size(self, table_name): + return self.tables[table_name].height + + def view_schema(self, view_name, config): + if view_name in self.view_schemas: + return self.view_schemas[view_name] + return self.table_schema(view_name) + + def view_size(self, view_name): + if view_name in self.views: + return self.views[view_name].height + return self.table_size(view_name) + + def table_validate_expression(self, table_name, expression): + df = self.tables.get(table_name) + if df is None: + return None + expr = parse_expression(expression) + result = df.select(expr.alias("__expr__")) + return polars_type_to_psp(result["__expr__"].dtype) + + def table_make_view(self, table_name, view_name, config): + start = datetime.now() + df = self.tables[table_name] + group_by = config.get("group_by", []) + columns = [c for c in config.get("columns", []) if c is not None] + aggregates = config.get("aggregates", {}) + sort = config.get("sort", []) + filters = config.get("filter", []) + split_by = config.get("split_by", []) + expressions = config.get("expressions", {}) + group_rollup_mode = config.get("group_rollup_mode", "rollup") + + if expressions: + for expr_name, expr_str in expressions.items(): + expr = parse_expression(expr_str) + df = df.with_columns(expr.alias(expr_name)) + + df = apply_filters(df, filters) + + col_alias = lambda c: c.replace("_", "-") + is_flat = group_rollup_mode == "flat" + is_total = group_rollup_mode == "total" + + if is_total: + if split_by: + result = build_split_by_total( + df, split_by, columns, aggregates, col_alias + ) + else: + result = build_total(df, columns, aggregates, col_alias) + elif split_by and group_by: + if is_flat: + result = build_split_by_grouped_flat( + df, group_by, split_by, columns, aggregates, col_alias + ) + result = apply_sort_split_by_flat( + result, sort, columns, group_by, split_by + ) + else: + result = build_split_by_grouped( + df, group_by, split_by, columns, aggregates, col_alias + ) + result = apply_sort_grouped(result, sort, group_by, col_alias) + elif split_by: + result = build_split_by_flat(df, split_by, columns, col_alias) + result = apply_sort_flat(result, sort, col_alias) + elif group_by: + if is_flat: + result = build_flat_group_by( + df, group_by, columns, aggregates, col_alias + ) + result = apply_sort_flat(result, sort, col_alias) + else: + result = build_rollup(df, group_by, columns, aggregates, col_alias) + result = apply_sort_grouped(result, sort, group_by, col_alias) + else: + select_exprs = [pl.col(c).alias(col_alias(c)) for c in columns] + result = df.select(select_exprs) + result = apply_sort_flat(result, sort, col_alias) + + self.views[view_name] = result + self.view_schemas[view_name] = compute_view_schema(result) + logger.debug( + f"{datetime.now() - start} table_make_view {table_name} -> {view_name}" + ) + + def view_delete(self, view_name): + self.views.pop(view_name, None) + self.view_schemas.pop(view_name, None) + + def view_get_min_max(self, view_name, column_name, config): + df = self.views[view_name] + col = df[column_name] + min_val = col.min() + max_val = col.max() + return (min_val, max_val) + + def view_get_data(self, view_name, config, schema, viewport, data): + df = self.views.get(view_name) + if df is None: + return + + group_by = config.get("group_by", []) + split_by = config.get("split_by", []) + group_rollup_mode = config.get("group_rollup_mode", "rollup") + is_split_by = len(split_by) > 0 + is_flat = group_rollup_mode == "flat" + + start_row = viewport.get("start_row", 0) or 0 + end_row = viewport.get("end_row") or df.height + start_col = viewport.get("start_col", 0) or 0 + end_col = viewport.get("end_col") + + length = min(end_row, df.height) - start_row + if length <= 0: + return + df_slice = df.slice(start_row, length) + + data_columns = [c for c in schema.keys() if not c.startswith("__")] + if end_col is not None: + data_columns = data_columns[start_col:end_col] + else: + data_columns = data_columns[start_col:] + + has_group_by = len(group_by) > 0 + has_grouping_id = has_group_by and not is_flat + + all_cols = [] + if has_grouping_id: + all_cols.append("__GROUPING_ID__") + for idx in range(len(group_by)): + all_cols.append(f"__ROW_PATH_{idx}__") + all_cols.extend(data_columns) + + grouping_ids = None + if has_grouping_id: + grouping_ids = df_slice["__GROUPING_ID__"].to_list() + + for cidx, col in enumerate(all_cols): + if cidx == 0 and has_grouping_id: + continue + + series = df_slice[col] + dtype = polars_type_to_psp(series.dtype) + values = series.to_list() + + push_col = col + if is_split_by and not col.startswith("__"): + push_col = col.replace("_", "|") + + for ridx, value in enumerate(values): + if grouping_ids: + grouping_id = grouping_ids[ridx] + elif has_group_by: + grouping_id = 0 + else: + grouping_id = None + + if value is not None and isinstance(value, float) and value != value: + value = None + + data.set_col(dtype, push_col, ridx, value, grouping_id) + + +################################################################################ +# +# Polars Utils + + +def polars_type_to_psp(dtype): + """Convert a Polars `dtype` to a Perspective `ColumnType`.""" + if dtype in (pl.Utf8, pl.String): + return "string" + if dtype == pl.Categorical: + return "string" + if dtype in (pl.Int8, pl.Int16, pl.Int32, pl.UInt8, pl.UInt16): + return "integer" + if dtype in (pl.Int64, pl.UInt64, pl.UInt32, pl.Float32, pl.Float64): + return "float" + if dtype == pl.Date: + return "date" + if dtype == pl.Boolean: + return "boolean" + if isinstance(dtype, pl.Datetime) or dtype == pl.Datetime: + return "datetime" + + msg = f"Unknown Polars type '{dtype}'" + raise ValueError(msg) + + +def apply_filters(df, filters): + """Apply a list of filter configs to a DataFrame.""" + if not filters: + return df + + mask = pl.lit(True) + for filt in filters: + col_name = filt[0] + op = filt[1] + value = filt[2] if len(filt) > 2 else None + + if value is None: + continue + + col_expr = pl.col(col_name) + if op == "==": + mask = mask & (col_expr == value) + elif op == "!=": + mask = mask & (col_expr != value) + elif op == ">": + mask = mask & (col_expr > value) + elif op == "<": + mask = mask & (col_expr < value) + elif op == ">=": + mask = mask & (col_expr >= value) + elif op == "<=": + mask = mask & (col_expr <= value) + + return df.filter(mask) + + +def get_polars_agg_expr(col, agg_name, filter_expr=None): + """Convert an aggregate name to a Polars expression.""" + if isinstance(agg_name, list): + agg_name = agg_name[0] + if isinstance(agg_name, dict): + agg_name = "first" + expr = pl.col(col) + if filter_expr is not None: + expr = expr.filter(filter_expr) + if agg_name in AGG_MAP: + return AGG_MAP[agg_name](expr) + + msg = f"Unknown aggregate '{agg_name}'" + raise ValueError(msg) + + +def default_aggregate(col_name, df): + """Return the default aggregate for a column based on its type.""" + dtype = df[col_name].dtype + psp_type = polars_type_to_psp(dtype) + if psp_type in ("integer", "float"): + return "sum" + return "count" + + +def build_rollup(df, group_by, columns, aggregates, col_alias): + """Emulate GROUP BY ROLLUP using multiple group_by operations.""" + n = len(group_by) + frames = [] + data_columns = [c for c in columns if c not in group_by] + + for level in range(n + 1): + num_groups = n - level + active_groups = group_by[:num_groups] + + agg_exprs = [] + for col in data_columns: + agg_name = aggregates.get(col, default_aggregate(col, df)) + agg_exprs.append(get_polars_agg_expr(col, agg_name).alias(col_alias(col))) + + if active_groups: + grouped = df.group_by(active_groups, maintain_order=True).agg(agg_exprs) + else: + grouped = df.select(agg_exprs) + + for idx in range(n): + if idx < num_groups: + grouped = grouped.with_columns( + pl.col(group_by[idx]).alias(f"__ROW_PATH_{idx}__") + ) + else: + src_dtype = df[group_by[idx]].dtype + grouped = grouped.with_columns( + pl.lit(None).cast(src_dtype).alias(f"__ROW_PATH_{idx}__") + ) + + grouping_id = sum(1 << i for i in range(num_groups, n)) + grouped = grouped.with_columns( + pl.lit(grouping_id).cast(pl.Int64).alias("__GROUPING_ID__") + ) + + for gb_col in active_groups: + if gb_col in grouped.columns: + grouped = grouped.drop(gb_col) + + frames.append(grouped) + + result = pl.concat(frames, how="diagonal") + path_cols = [f"__ROW_PATH_{i}__" for i in range(n)] + data_col_aliases = [col_alias(c) for c in data_columns] + final_order = ["__GROUPING_ID__"] + path_cols + data_col_aliases + result = result.select([c for c in final_order if c in result.columns]) + return result + + +def build_flat_group_by(df, group_by, columns, aggregates, col_alias): + """Build a simple GROUP BY (no rollup) - only leaf-level rows.""" + n = len(group_by) + data_columns = [c for c in columns if c not in group_by] + + agg_exprs = [] + for col in data_columns: + agg_name = aggregates.get(col, default_aggregate(col, df)) + agg_exprs.append(get_polars_agg_expr(col, agg_name).alias(col_alias(col))) + + grouped = df.group_by(group_by, maintain_order=True).agg(agg_exprs) + + for idx in range(n): + grouped = grouped.with_columns( + pl.col(group_by[idx]).alias(f"__ROW_PATH_{idx}__") + ) + + for gb_col in group_by: + if gb_col in grouped.columns: + grouped = grouped.drop(gb_col) + + path_cols = [f"__ROW_PATH_{i}__" for i in range(n)] + data_col_aliases = [col_alias(c) for c in data_columns] + final_order = path_cols + data_col_aliases + result = grouped.select([c for c in final_order if c in grouped.columns]) + return result.sort(path_cols) + + +def build_total(df, columns, aggregates, col_alias): + """Build a single total row aggregating the entire dataset.""" + agg_exprs = [] + for col in columns: + agg_name = aggregates.get(col, default_aggregate(col, df)) + agg_exprs.append(get_polars_agg_expr(col, agg_name).alias(col_alias(col))) + return df.select(agg_exprs) + + +def build_split_by_total(df, split_by, columns, aggregates, col_alias): + """Build a single total row with split_by (pivot) columns.""" + split_col = split_by[0] + data_columns = [c for c in columns if c not in split_by] + split_values = sorted(df[split_col].unique().to_list()) + + agg_exprs = [] + for sv in split_values: + filter_expr = pl.col(split_col) == sv + for dc in data_columns: + agg_name = aggregates.get(dc, default_aggregate(dc, df)) + col_name = f"{sv}_{col_alias(dc)}" + agg_exprs.append( + get_polars_agg_expr(dc, agg_name, filter_expr=filter_expr).alias( + col_name + ) + ) + + return df.select(agg_exprs) + + +def build_split_by_grouped_flat(df, group_by, split_by, columns, aggregates, col_alias): + """Build a flat grouped view with split_by (pivot) columns - no rollup rows.""" + n = len(group_by) + split_col = split_by[0] + data_columns = [c for c in columns if c not in group_by and c not in split_by] + split_values = sorted(df[split_col].unique().to_list()) + + agg_exprs = [] + for sv in split_values: + filter_expr = pl.col(split_col) == sv + for dc in data_columns: + agg_name = aggregates.get(dc, default_aggregate(dc, df)) + col_name = f"{sv}_{col_alias(dc)}" + agg_exprs.append( + get_polars_agg_expr(dc, agg_name, filter_expr=filter_expr).alias( + col_name + ) + ) + + for dc in data_columns: + agg_name = aggregates.get(dc, default_aggregate(dc, df)) + agg_exprs.append(get_polars_agg_expr(dc, agg_name).alias(f"__SORT_{dc}__")) + + grouped = df.group_by(group_by, maintain_order=True).agg(agg_exprs) + + for idx in range(n): + grouped = grouped.with_columns( + pl.col(group_by[idx]).alias(f"__ROW_PATH_{idx}__") + ) + + for gb_col in group_by: + if gb_col in grouped.columns: + grouped = grouped.drop(gb_col) + + path_cols = [f"__ROW_PATH_{i}__" for i in range(n)] + data_col_names = [] + for sv in split_values: + for dc in data_columns: + data_col_names.append(f"{sv}_{col_alias(dc)}") + sort_col_names = [f"__SORT_{dc}__" for dc in data_columns] + final_order = path_cols + data_col_names + sort_col_names + result = grouped.select([c for c in final_order if c in grouped.columns]) + return result.sort(path_cols) + + +def apply_sort_grouped(df, sort_config, group_by, col_alias): + """Apply sort to a ROLLUP result DataFrame.""" + n = len(group_by) + + sort_cols = [] + sort_desc = [] + for entry in sort_config: + col = entry[0] + direction = entry[1] + if direction != "none": + aliased = col_alias(col) + if aliased in df.columns: + sort_cols.append(aliased) + sort_desc.append(direction in ("desc", "col desc")) + + if not sort_cols: + # Default: tree order by row path, nulls first + path_cols = [f"__ROW_PATH_{i}__" for i in range(n)] + return df.sort(path_cols, descending=[False] * n, nulls_last=False) + + # With explicit sort: grand total first, then rest sorted + is_total = pl.lit(True) + for i in range(n): + is_total = is_total & pl.col(f"__ROW_PATH_{i}__").is_null() + + total_row = df.filter(is_total) + rest = df.filter(~is_total) + rest = rest.sort(sort_cols, descending=sort_desc) + return pl.concat([total_row, rest]) + + +def apply_sort_split_by_flat(df, sort_config, columns, group_by, split_by): + """Apply sort to a flat split_by grouped DataFrame using __SORT__ columns.""" + data_columns = [c for c in columns if c not in group_by and c not in split_by] + sort_cols = [] + sort_desc = [] + for entry in sort_config: + col = entry[0] + direction = entry[1] + if direction != "none": + sort_name = f"__SORT_{col}__" + if sort_name in df.columns: + sort_cols.append(sort_name) + sort_desc.append(direction in ("desc", "col desc")) + + if sort_cols: + df = df.sort(sort_cols, descending=sort_desc) + + drop_cols = [ + f"__SORT_{dc}__" for dc in data_columns if f"__SORT_{dc}__" in df.columns + ] + if drop_cols: + df = df.drop(drop_cols) + return df + + +def apply_sort_flat(df, sort_config, col_alias): + """Apply sort to a flat (non-grouped) DataFrame.""" + if not sort_config: + return df + + sort_cols = [] + sort_descending = [] + for sort_entry in sort_config: + col = sort_entry[0] + direction = sort_entry[1] + if direction != "none": + aliased = col_alias(col) + if aliased in df.columns: + sort_cols.append(aliased) + sort_descending.append(direction in ("desc", "col desc")) + + if sort_cols: + return df.sort(sort_cols, descending=sort_descending) + return df + + +def compute_view_schema(df): + """Compute the Perspective schema for a view DataFrame.""" + schema = {} + for col_name, dtype in df.schema.items(): + if col_name.startswith("__"): + continue + schema[col_name] = polars_type_to_psp(dtype) + return schema + + +def build_split_by_grouped(df, group_by, split_by, columns, aggregates, col_alias): + """Build a grouped rollup with split_by (pivot) columns.""" + n = len(group_by) + split_col = split_by[0] + data_columns = [c for c in columns if c not in group_by and c not in split_by] + split_values = sorted(df[split_col].unique().to_list()) + + frames = [] + for level in range(n + 1): + num_groups = n - level + active_groups = group_by[:num_groups] + + agg_exprs = [] + for sv in split_values: + filter_expr = pl.col(split_col) == sv + for dc in data_columns: + agg_name = aggregates.get(dc, default_aggregate(dc, df)) + col_name = f"{sv}_{col_alias(dc)}" + agg_exprs.append( + get_polars_agg_expr(dc, agg_name, filter_expr=filter_expr).alias( + col_name + ) + ) + + if active_groups: + grouped = df.group_by(active_groups, maintain_order=True).agg(agg_exprs) + else: + grouped = df.select(agg_exprs) + + for idx in range(n): + if idx < num_groups: + grouped = grouped.with_columns( + pl.col(group_by[idx]).alias(f"__ROW_PATH_{idx}__") + ) + else: + src_dtype = df[group_by[idx]].dtype + grouped = grouped.with_columns( + pl.lit(None).cast(src_dtype).alias(f"__ROW_PATH_{idx}__") + ) + + grouping_id = sum(1 << i for i in range(num_groups, n)) + grouped = grouped.with_columns( + pl.lit(grouping_id).cast(pl.Int64).alias("__GROUPING_ID__") + ) + + for gb_col in active_groups: + if gb_col in grouped.columns: + grouped = grouped.drop(gb_col) + + frames.append(grouped) + + result = pl.concat(frames, how="diagonal") + path_cols = [f"__ROW_PATH_{i}__" for i in range(n)] + data_col_names = [] + for sv in split_values: + for dc in data_columns: + data_col_names.append(f"{sv}_{col_alias(dc)}") + final_order = ["__GROUPING_ID__"] + path_cols + data_col_names + result = result.select([c for c in final_order if c in result.columns]) + return result + + +def build_split_by_flat(df, split_by, columns, col_alias): + """Build a flat (non-grouped) split_by view.""" + split_col = split_by[0] + data_columns = [c for c in columns if c not in split_by] + split_values = sorted(df[split_col].unique().to_list()) + + exprs = [] + for sv in split_values: + for dc in data_columns: + col_name = f"{sv}_{col_alias(dc)}" + exprs.append( + pl.when(pl.col(split_col) == sv) + .then(pl.col(dc)) + .otherwise(None) + .alias(col_name) + ) + + return df.select(exprs) + + +def parse_expression(expr_str): + """Parse a Perspective expression string into a Polars expression.""" + pattern = r'"([^"]*)"' + parts = [] + last_end = 0 + for match in re.finditer(pattern, expr_str): + parts.append(expr_str[last_end : match.start()]) + col_name = match.group(1) + parts.append(f'pl.col("{col_name}")') + last_end = match.end() + parts.append(expr_str[last_end:]) + polars_expr_str = "".join(parts) + return eval(polars_expr_str, {"pl": pl, "__builtins__": {}}) diff --git a/rust/perspective-python/perspective/widget/__init__.py b/rust/perspective-python/perspective/widget/__init__.py new file mode 100644 index 0000000..adf2bca --- /dev/null +++ b/rust/perspective-python/perspective/widget/__init__.py @@ -0,0 +1,349 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 base64 +import logging +import os +import re +import importlib.metadata +import inspect + +from string import Template +from ipywidgets import DOMWidget +from traitlets import Unicode, observe +from .viewer import PerspectiveViewer + +__version__ = re.sub(".dev[0-9]+", "", importlib.metadata.version("perspective-python")) + +__all__ = ["PerspectiveWidget"] + +__doc__ = """ +`PerspectiveWidget` is a JupyterLab widget that implements the same API as +``, allowing for fast, intuitive +transformations/visualizations of various data formats within JupyterLab. + +`PerspectiveWidget` is compatible with Jupyterlab 3 and Jupyter Notebook 6 via a +[prebuilt extension](https://jupyterlab.readthedocs.io/en/stable/extension/extension_dev.html#prebuilt-extensions). +To use it, simply install `perspective-python` and the extensions should be +available. + +`perspective-python`'s JupyterLab extension also provides convenient builtin +viewers for `csv`, `json`, or `arrow` files. Simply right-click on a file with +this extension and choose the appropriate `Perpective` option from the context +menu. + +## `PerspectiveWidget` + +Building on top of the API provided by `perspective.Table`, the +`PerspectiveWidget` is a JupyterLab plugin that offers the entire functionality +of Perspective within the Jupyter environment. It supports the same API +semantics of ``, along with the additional data types +supported by `perspective.Table`. `PerspectiveWidget` takes keyword arguments +for the managed `View`: + +```python +from perspective.widget import PerspectiveWidget +w = perspective.PerspectiveWidget( + data, + plugin="X Bar", + aggregates={"datetime": "any"}, + sort=[["date", "desc"]] +) +``` + +### Creating a widget + +A widget is created through the `PerspectiveWidget` constructor, which takes as +its first, required parameter a `perspective.Table`, a dataset, a schema, or +`None`, which serves as a special value that tells the Widget to defer loading +any data until later. In maintaining consistency with the Javascript API, +Widgets cannot be created with empty dictionaries or lists—`None` should be used +if the intention is to await data for loading later on. A widget can be +constructed from a dataset: + +```python +from perspective.widget import PerspectiveWidget +PerspectiveWidget(data, group_by=["date"]) +``` + +.. or a schema: + +```python +PerspectiveWidget({"a": int, "b": str}) +``` + +.. or an instance of a `perspective.Table`: + +```python +table = perspective.table(data) +PerspectiveWidget(table) +``` +""" + + +class PerspectiveWidget(DOMWidget, PerspectiveViewer): + """`PerspectiveWidget` allows for Perspective to be used as a Jupyter + widget. + + Using `perspective.Table`, you can create a widget that extends the full + functionality of `perspective-viewer`. Changes on the viewer can be + programatically set on the `PerspectiveWidget` instance. + + # Examples + + >>> from perspective.widget import PerspectiveWidget + >>> data = { + ... "a": [1, 2, 3], + ... "b": [ + ... "2019/07/11 7:30PM", + ... "2019/07/11 8:30PM", + ... "2019/07/11 9:30PM" + ... ] + ... } + >>> widget = PerspectiveWidget( + ... data, + ... group_by=["a"], + ... sort=[["b", "desc"]], + ... filter=[["a", ">", 1]] + ... ) + >>> widget.sort + [["b", "desc"]] + >>> widget.sort.append(["a", "asc"]) + >>> widget.sort + [["b", "desc"], ["a", "asc"]] + >>> widget.table.update({"a": [4, 5]}) # Browser UI updates + """ + + # Required by ipywidgets for proper registration of the backend + _model_name = Unicode("PerspectiveModel").tag(sync=True) + _model_module = Unicode("@perspective-dev/jupyterlab").tag(sync=True) + _model_module_version = Unicode("~{}".format(__version__)).tag(sync=True) + _view_name = Unicode("PerspectiveView").tag(sync=True) + _view_module = Unicode("@perspective-dev/jupyterlab").tag(sync=True) + _view_module_version = Unicode("~{}".format(__version__)).tag(sync=True) + + def __init__( + self, + data, + index=None, + limit=None, + binding_mode="server", + **kwargs, + ): + """Initialize an instance of `PerspectiveWidget` + with the given table/data and viewer configuration. + + If an `AsyncTable` is passed in, then certain widget methods like + `update()` and `delete()` return coroutines which must be awaited. + + # Arguments + + - `data` (`Table`|`AsyncTable`|`dict`|`list`|`pandas.DataFrame`|`bytes`|`str`): a + `perspective.Table` instance, a `perspective.AsyncTable` instance, or + a dataset to be loaded in the widget. + + # Keyword Arguments + + - `index` (`str`): A column name to be used as the primary key. + Ignored if `server` is True. + - `binding_mode` (`str`): "client-server" or "server" + - `limit` (`int`): A upper limit on the number of rows in the Table. + Cannot be set at the same time as `index`, ignored if `server` + is True. + - `kwargs` (`dict`): configuration options for the `PerspectiveViewer`, + and `Table` constructor if `data` is a dataset. + + # Examples + + >>> widget = PerspectiveWidget( + ... {"a": [1, 2, 3]}, + ... aggregates={"a": "avg"}, + ... group_by=["a"], + ... sort=[["b", "desc"]], + ... filter=[["a", ">", 1]], + ... expressions=["\"a\" + 100"]) + """ + + self.binding_mode = binding_mode + + # Pass table load options to the front-end, unless in server mode + self._options = {} + + if index is not None and limit is not None: + raise TypeError("Index and Limit cannot be set at the same time!") + + # Parse the dataset we pass in - if it's Pandas, preserve pivots + # if isinstance(data, pandas.DataFrame) or isinstance(data, pandas.Series): + # data, config = deconstruct_pandas(data) + + # if config.get("group_by", None) and "group_by" not in kwargs: + # kwargs.update({"group_by": config["group_by"]}) + + # if config.get("split_by", None) and "split_by" not in kwargs: + # kwargs.update({"split_by": config["split_by"]}) + + # if config.get("columns", None) and "columns" not in kwargs: + # kwargs.update({"columns": config["columns"]}) + + # Initialize the viewer + super(PerspectiveWidget, self).__init__(**kwargs) + + # Handle messages from the the front end + self.on_msg(self.handle_message) + self._sessions = {} + + # If an empty dataset is provided, don't call `load()` and wait + # for the user to call `load()`. + if data is None: + if index is not None or limit is not None: + raise TypeError( + "Cannot initialize PerspectiveWidget `index` or `limit` without a Table, data, or schema!" + ) + else: + if index is not None: + self._options.update({"index": index}) + + if limit is not None: + self._options.update({"limit": limit}) + + loading = self.load(data, **self._options) + if inspect.isawaitable(loading): + import asyncio + + asyncio.create_task(loading) + + def load(self, data, **options): + """Load the widget with data.""" + # Viewer will ignore **options if `data` is a Table or View. + return super(PerspectiveWidget, self).load(data, **options) + + def update(self, data): + """Update the widget with new data.""" + return super(PerspectiveWidget, self).update(data) + + def clear(self): + """Clears the widget's underlying `Table`.""" + return super(PerspectiveWidget, self).clear() + + def replace(self, data): + """Replaces the widget's `Table` with new data conforming to the same + schema. Does not clear user-set state. If in client mode, serializes + the data and sends it to the browser. + """ + return super(PerspectiveWidget, self).replace(data) + + def delete(self, delete_table=True): + """Delete the Widget's data and clears its internal state. + + # Arguments + + - `delete_table` (`bool`): whether the underlying `Table` will be + deleted. Defaults to True. + """ + ret = super(PerspectiveWidget, self).delete(delete_table) + + # Close the underlying comm and remove widget from the front-end + self.close() + return ret + + @observe("value") + def handle_message(self, widget, content, buffers): + """Given a message from `PerspectiveJupyterClient.send`, process the + message and return the result to `self.post`. + + # Arguments + + - `widget`: a reference to the `Widget` instance that received the + message. + - `content` (dict): - the message from the front-end. Automatically + de-serialized by ipywidgets. + - `buffers`: optional arraybuffers from the front-end, if any. + """ + if content["type"] == "connect": + client_id = content["client_id"] + logging.debug("view {} connected", client_id) + + def send_response(msg): + self.send({"type": "binary_msg", "client_id": client_id}, [msg]) + + self._sessions[client_id] = self.new_proxy_session(send_response) + elif content["type"] == "binary_msg": + [binary_msg] = buffers + client_id = content["client_id"] + session = self._sessions[client_id] + if session is not None: + import asyncio + + asyncio.create_task(session.handle_request_async(binary_msg)) + else: + logging.error("No session for client_id {}".format(client_id)) + elif content["type"] == "hangup": + # XXX(tom): client won't reliably send this so shouldn't rely on it + # to clean up; does jupyter notify us when the client on the + # websocket, i.e. the view, disconnects? + client_id = content["client_id"] + logging.debug("view {} hangup", client_id) + session = self._sessions.pop(client_id, None) + if session: + session.close() + + def _repr_mimebundle_(self, **kwargs): + super_bundle = super(DOMWidget, self)._repr_mimebundle_(**kwargs) + if not _jupyter_html_export_enabled(): + return super_bundle + + # Serialize viewer attrs + view data to be rendered in the template + viewer_attrs = self.save() + data = self.table.view().to_arrow() + b64_data = base64.encodebytes(data) + template_path = os.path.join( + os.path.dirname(__file__), "../templates/exported_widget.html.template" + ) + with open(template_path, "r") as template_data: + template = Template(template_data.read()) + + def psp_cdn(module, path=None): + if path is None: + path = f"cdn/{module}.js" + + # perspective developer affordance: works with your local `pnpm run start blocks` + # return f"http://localhost:8080/node_modules/@perspective-dev/{module}/dist/{path}" + return f"https://cdn.jsdelivr.net/npm/@perspective-dev/{module}@{__version__}/dist/{path}" + + return super(DOMWidget, self)._repr_mimebundle_(**kwargs) | { + "text/html": template.substitute( + psp_cdn_perspective=psp_cdn("perspective"), + psp_cdn_perspective_viewer=psp_cdn("perspective-viewer"), + psp_cdn_perspective_viewer_datagrid=psp_cdn( + "perspective-viewer-datagrid" + ), + psp_cdn_perspective_viewer_charts=psp_cdn("perspective-viewer-charts"), + psp_cdn_perspective_viewer_themes=psp_cdn( + "perspective-viewer-themes", "css/themes.css" + ), + viewer_id=self.model_id, + viewer_attrs=viewer_attrs, + b64_data=b64_data.decode("utf-8"), + ) + } + + +def _jupyter_html_export_enabled(): + return os.environ.get("PSP_JUPYTER_HTML_EXPORT", None) == "1" + + +def set_jupyter_html_export(val): + """Enables HTML export for Jupyter widgets, when set to True. + HTML export can also be enabled by setting the environment variable + `PSP_JUPYTER_HTML_EXPORT` to the string `1`. + """ + os.environ["PSP_JUPYTER_HTML_EXPORT"] = "1" if val else "0" diff --git a/rust/perspective-python/perspective/widget/viewer/__init__.py b/rust/perspective-python/perspective/widget/viewer/__init__.py new file mode 100644 index 0000000..802508d --- /dev/null +++ b/rust/perspective-python/perspective/widget/viewer/__init__.py @@ -0,0 +1,15 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 .viewer import PerspectiveViewer + +__all__ = ["PerspectiveViewer"] diff --git a/rust/perspective-python/perspective/widget/viewer/validate.py b/rust/perspective-python/perspective/widget/viewer/validate.py new file mode 100644 index 0000000..97fa3b5 --- /dev/null +++ b/rust/perspective-python/perspective/widget/viewer/validate.py @@ -0,0 +1,22 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + + +def validate_version(version): + # basic semver of form \d+\.\d+\.\d+(\+.+)? + spl = version.split(".", 2) + return ( + len(spl) == 3 + and spl[0].isdigit() + and spl[1].isdigit() + and (spl[2].split("+")[0]).isdigit() + ) diff --git a/rust/perspective-python/perspective/widget/viewer/viewer.py b/rust/perspective-python/perspective/widget/viewer/viewer.py new file mode 100644 index 0000000..0c48f69 --- /dev/null +++ b/rust/perspective-python/perspective/widget/viewer/viewer.py @@ -0,0 +1,343 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 random import random +from .viewer_traitlets import PerspectiveTraitlets +import perspective + +# from .. import Server + +# ─ │ ┌ ┬ ┐ +# ┄ ┆ ├ ┼ ┤ ╲ ╱ +# ┈ ┊ └ ┴ ┘ +# ━ ┃ ┏ ┳ ┓ ┏ ┯ ┓ ┏ ┳ ┓ ┏ ┯ ┓ +# ┅ ┇ ┣ ╋ ┫ ┣ ┿ ┫ ┠ ╂ ┨ ┠ ┼ ┨ +# ┉ ┋ ┗ ┻ ┛ ┗ ┷ ┛ ┗ ┻ ┛ ┗ ┷ ┛ + +# global_server = PySyncServer() + + +class PerspectiveViewer(PerspectiveTraitlets, object): + """PerspectiveViewer wraps the `perspective.Table` API and exposes an API + around creating views, loading data, and updating data. + """ + + # Viewer attributes that should be saved in `save()` and restored using + # `restore()`. Symmetric to `PERSISTENT_ATTRIBUTES` in `perspective-viewer`. + PERSISTENT_ATTRIBUTES = ( + "group_by", + "split_by", + "filter", + "sort", + "aggregates", + "columns", + "expressions", + "plugin", + "plugin_config", + "theme", + "settings", + "title", + "version", + ) + + def __init__( + self, + plugin="Datagrid", + columns=None, + group_by=None, + split_by=None, + aggregates=None, + sort=None, + filter=None, + expressions=None, + plugin_config=None, + settings=True, + theme=None, + title=None, + # ignored, here for restore compatibility + version=None, + ): + """Initialize an instance of `PerspectiveViewer` with the given viewer + configuration. Do not pass a `Table` or data into the constructor - + use the :func:`load()` method to provide the viewer with data. + + Keyword Arguments: + columns (:obj:`list` of :obj:`str`): A list of column names to be + visible to the user. + group_by (:obj:`list` of :obj:`str`): A list of column names to + use as group by. + split_by (:obj:`list` of :obj:`str`): A list of column names + to use as split by. + aggregates (:obj:`dict` of :obj:`str` to :obj:`str`): A dictionary + of column names to aggregate types, which specify aggregates + for individual columns. + sort (:obj:`list` of :obj:`list` of :obj:`str`): A list of lists, + each list containing a column name and a sort direction + (``asc``, ``desc``, ``asc abs``, ``desc abs``, ``col asc``, + ``col desc``, ``col asc abs``, ``col desc abs``). + filter (:obj:`list` of :obj:`list` of :obj:`str`): A list of lists, + each list containing a column name, a filter comparator, and a + value to filter by. + expressions (:obj:`list` of :obj:`str`): A list of string + expressions which are applied to the view. + plugin (:obj:`str`/:obj:`perspective.Plugin`): Which plugin to + select by default. + plugin_config (:obj:`dict`): A configuration for the plugin, i.e. + the datagrid plugin or a chart plugin. + settings(:obj:`bool`): Whether the perspective query settings + panel should be open. + theme (:obj:`str`): The color theme to use. + version (:obj:`str`): The version this configuration is restored from. + This should only be used when restoring a configuration, + and should not be set manually. + + Examples: + >>> viewer = PerspectiveViewer( + ... aggregates={"a": "avg"}, + ... group_by=["a"], + ... sort=[["b", "desc"]], + ... filter=[["a", ">", 1]], + ... expressions=["\"a\" + 100"] + ... ) + """ + + # The Table under management by this viewer and its + # attached PerspectiveManager + self._table = None + self._client = None + + # Viewer configuration + self.plugin = plugin # validate_plugin(plugin) + self.columns = columns or [] # validate_columns(columns) or [] + self.group_by = group_by or [] # validate_group_by(group_by) or [] + self.split_by = split_by or [] # validate_split_by(split_by) or [] + self.aggregates = aggregates or {} # validate_aggregates(aggregates) or {} + self.sort = sort or [] # validate_sort(sort) or [] + self.filter = filter or [] # validate_filter(filter) or [] + self.expressions = expressions or {} # validate_expressions(expressions) or {} + self.plugin_config = ( + plugin_config or {} + ) # validate_plugin_config(plugin_config) or {} + self.settings = settings + self.theme = theme + self.title = title + + def new_proxy_session(self, cb): + return perspective.ProxySession(self._client, cb) + + @property + def client(self): + """Returns the ``perspective.Client`` or ``perspective.AsyncClient`` under management by the viewer.""" + return self._client + + @property + def table(self): + """Returns the ``perspective.Table`` or ``perspective.AsyncTable`` under management by the viewer.""" + return self._table + + def is_async(self): + """Returns whether this widget has an async interface or synchronous""" + return isinstance(self._table, perspective.perspective.AsyncTable) + + def load(self, data, **options): + """Given a ``perspective.Table``, a ``perspective.AsyncTable``, + or data that can be handled by ``perspective.Table``, pass it to the + viewer. Like `__init__`, load accepts a `perspective.Table`, a dataset, + or a schema. + + ``load()`` resets the state of the viewer: if a ``perspective.Table`` + has already been loaded, ``**options`` is ignored as the options + already set on the ``Table`` take precedence. + + If data is passed in, a ``perspective.Table`` is automatically created + by this method, and the options passed to ``**config`` are extended to + the new Table. If the widget already has a dataset, and the new data + has different columns to the old one, then the widget state (pivots, + sort, etc.) is cleared to prevent applying settings on columns that + don't exist. + + When a ``perspective.AsyncTable`` is loaded, the widget's interface + becomes async. Methods which operate on the underlying Perspective + view, inclusive of the ``load()`` call itself, return coroutine values + which must be awaited. + + Loading a ``perspective.Table`` or plain data will make the interface + synchronous again. + + Args: + data (:obj:`Table`|:obj:`AsyncTable`|:obj:`dict`|:obj:`list`|:obj:`pandas.DataFrame`|:obj:`bytes`|:obj:`str`): a + `perspective.Table` instance, a `perspective.AsyncTable` + instance, or a dataset to be loaded in the viewer. + + Keyword Arguments: + name (:obj:`str`): An optional name to reference the table by so it can + be accessed from the front-end. If not provided, a name will + be generated. + index (:obj:`str`): A column name to be used as the primary key. + Ignored if a ``Table`` or ``AsyncTable`` is supplied. + limit (:obj:`int`): A upper limit on the number of rows in the Table. + Cannot be set at the same time as `index`. Ignored if a + ``Table`` or ``AsyncTable`` is supplied. + + Returns: + coro (:obj:`coroutine`): when `AsyncTable` is passed, the `load()` call must be awaited + """ + name = options.pop("name", str(random())) + + # Reset the viewer when `load()` is called multiple times. + if self.table is not None: + self.reset() + + if isinstance(data, perspective.perspective.AsyncTable): + self._table = data + async def load_table(): + self._client = await self.table.get_client() + self.table_name = self.table.get_name() + # If the user does not set columns to show, synchronize viewer state + # with dataset. + if len(self.columns) == 0: + self.columns = await self.table.columns() + + return load_table() + elif isinstance(data, perspective.perspective.Table): + self._table = data + self._client = data.get_client() + elif isinstance(data, perspective.perspective.View) or isinstance(data, perspective.perspective.AsyncView): + raise TypeError( + "Views cannot be loaded directly, load a table or raw data instead" + ) + else: + self._table = perspective.table(data, name=name, **options) + self._client = perspective.GLOBAL_CLIENT + + # If the user does not set columns to show, synchronize viewer state + # with dataset. + if len(self.columns) == 0: + self.columns = self.table.columns() + + self.table_name = self.table.get_name() + + def update(self, data): + """Update the table under management by the viewer with new data. + This function follows the semantics of `Table.update()`, and will be + affected by whether an index is set on the underlying table. + + When this widget has loaded an ``AsyncTable``, returns a coroutine + which must be awaited. + + Args: + data (:obj:`dict`|:obj:`list`|:obj:`pandas.DataFrame`): the + update data for the table. + + Returns: + coro (:obj:`coroutine`): when async, must be awaited + """ + return self.table.update(data) + + def clear(self): + """Clears the rows of this viewer's ``Table``.""" + if self.table is not None: + return self.table.clear() + + def replace(self, data): + """Replaces the rows of this viewer's `Table` with new data. + + Args: + data (:obj:`dict`|:obj:`list`|:obj:`pandas.DataFrame`): new data + to set into the table - must conform to the table's schema. + + Returns: + coro (:obj:`coroutine`): when async, must be awaited + """ + if self.table is not None: + return self.table.replace(data) + + def save(self): + """Get the viewer's attributes as a dictionary, symmetric with `restore` + so that a viewer's configuration can be reproduced.""" + return { + attr: getattr(self, attr) + for attr in PerspectiveViewer.PERSISTENT_ATTRIBUTES + } + + def restore(self, **kwargs): + """Restore a given set of attributes, passed as kwargs + (e.g. dictionary). Symmetric with `save` so that a given viewer's + configuration can be reproduced.""" + for k, v in kwargs.items(): + if k in PerspectiveViewer.PERSISTENT_ATTRIBUTES: + setattr(self, k, v) + + def to_kwargs(self): + """Get the viewer's attributes as a list of kwargs, which can be passed to + the viewer constructor""" + attrs = self.save() + defaults = { + "columns": [], + "group_by": [], + "split_by": [], + "aggregates": {}, + "sort": [], + "filter": [], + "expressions": {}, + "plugin_config": {}, + "version": "2.10.0", + } + kwargs = {} + for key, default in defaults.items(): + if attrs.get(key) != default: + kwargs[key] = attrs[key] + return ", ".join( + ["{}={}".format(attr, repr(val)) for attr, val in kwargs.items()] + ) + + def reset(self): + """Resets the viewer's attributes and state, but does not delete or + modify the underlying `Table`. + + Example: + widget = PerspectiveWidget(data, group_by=["date"], plugin=Plugin.XBAR) + widget.reset() + widget.plugin # + """ + self.group_by = [] + self.split_by = [] + self.filter = [] + self.sort = [] + self.expressions = {} + self.aggregates = {} + self.columns = [] + self.plugin = "Datagrid" + self.plugin_config = {} + + def delete(self, delete_table=True): + """Delete the Viewer's data and clears its internal state. If + ``delete_table`` is True, the underlying `perspective.Table` and the + internal `View` object will be deleted. + + Args: + delete_table (:obj:`bool`) : whether the underlying `Table` will be + deleted. Defaults to True. + + Returns: + coro (:obj:`coroutine`): when async and `delete_table` is `True`, + must be awaited + """ + ret = None + if delete_table: + # Delete table + ret = self.table.delete() + self.table_name = None + self._table = None + + self.reset() + return ret diff --git a/rust/perspective-python/perspective/widget/viewer/viewer_traitlets.py b/rust/perspective-python/perspective/widget/viewer/viewer_traitlets.py new file mode 100644 index 0000000..535e363 --- /dev/null +++ b/rust/perspective-python/perspective/widget/viewer/viewer_traitlets.py @@ -0,0 +1,101 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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 traitlets import HasTraits, Unicode, List, Bool, Dict, validate, Enum + +import importlib.metadata + +__version__ = importlib.metadata.version("perspective-python") + +from .validate import ( + validate_version, +) + + +class PerspectiveTraitlets(HasTraits): + """Define the traitlet interface with `PerspectiveJupyterWidget` on the + front end. Attributes which are set here are synchronized between the + front-end and back-end. + + Examples: + >>> widget = perspective.PerspectiveWidget( + ... data, group_by=["a", "b", "c"]) + PerspectiveWidget(group_by=["a", "b", "c"]) + >>> widget.split_by=["b"] + >>> widget + PerspectiveWidget(group_by=["a", "b", "c"], split_by=["b"]) + """ + + # `perspective-viewer` options + plugin = Unicode("Datagrid").tag(sync=True) + columns = List(default_value=[]).tag(sync=True) + group_by = List(trait=Unicode(), default_value=[]).tag(sync=True, o=True) + split_by = List(trait=Unicode(), default_value=[]).tag(sync=True) + aggregates = Dict(default_value={}).tag(sync=True) + sort = List(default_value=[]).tag(sync=True) + filter = List(default_value=[]).tag(sync=True) + expressions = Dict(default_value=[]).tag(sync=True) + plugin_config = Dict(default_value={}).tag(sync=True) + settings = Bool(True).tag(sync=True) + theme = Unicode("Pro Light", allow_none=True).tag(sync=True) + + # used to tell the frontend which table to connect to + table_name = Unicode(None, allow_none=True).tag(sync=True) + + server = Bool(False).tag(sync=True) + binding_mode = Enum(("server", "client-server")).tag(default="server", sync=True) + title = Unicode(None, allow_none=True).tag(sync=True) + version = Unicode(__version__).tag(sync=True) + + # @validate("plugin") + # def _validate_plugin(self, proposal): + # return validate_plugin(proposal.value) + + # @validate("columns") + # def _validate_columns(self, proposal): + # return validate_columns(proposal.value) + + # @validate("group_by") + # def _validate_group_by(self, proposal): + # return validate_group_by(proposal.value) + + # @validate("split_by") + # def _validate_split_by(self, proposal): + # return validate_split_by(proposal.value) + + # @validate("aggregates") + # def _validate_aggregates(self, proposal): + # return validate_aggregates(proposal.value) + + # @validate("sort") + # def _validate_sort(self, proposal): + # return validate_sort(proposal.value) + + # @validate("filter") + # def _validate_filter(self, proposal): + # return validate_filter(proposal.value) + + # @validate("expressions") + # def _validate_expressions(self, proposal): + # return validate_expressions(proposal.value) + + # @validate("plugin_config") + # def _validate_plugin_config(self, proposal): + # return validate_plugin_config(proposal.value) + + # @validate("title") + # def _validate_title(self, proposal): + # return validate_title(proposal.value) + + @validate("version") + def _validate_version(self, proposal): + return validate_version(proposal.value) \ No newline at end of file diff --git a/rust/perspective-python/pyodide-tests/README.md b/rust/perspective-python/pyodide-tests/README.md new file mode 100644 index 0000000..6163a9d --- /dev/null +++ b/rust/perspective-python/pyodide-tests/README.md @@ -0,0 +1,43 @@ +# pyodide-tests + +Smoke and integration tests for the perspective-python Pyodide wheel. The tests +are specced in `pytest` and executed with `playwright`, using the +`pytest-pyodide` package. + +These tests require that a Pyodide wheel has been built to rust/target/wheels + +## test setup + +Create a virtual environment. Install perspective-python requirements and +special pyodide-only requirements: + +``` +pip install -r rust/perspective-python/requirements.txt +pip install -r rust/perspective-python/requirements-pyodide.txt +``` + +## running tests + +Run setup, select `perspective-pyodide` target: + +``` +pnpm -w run setup +``` + +Build the pyodide wheel: + +``` +PSP_BUILD_WHEEL=1 pnpm -w run build +``` + +Then run tests: + +``` +pnpm -w test +``` + +If you are prompted to install playwright browsers, run this in your venv: + +``` +python -m playwright install +``` diff --git a/rust/perspective-python/pyodide-tests/tests/__init__.py b/rust/perspective-python/pyodide-tests/tests/__init__.py new file mode 100644 index 0000000..284e708 --- /dev/null +++ b/rust/perspective-python/pyodide-tests/tests/__init__.py @@ -0,0 +1,11 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ diff --git a/rust/perspective-python/pyodide-tests/tests/conftest.py b/rust/perspective-python/pyodide-tests/tests/conftest.py new file mode 100644 index 0000000..e2e9a66 --- /dev/null +++ b/rust/perspective-python/pyodide-tests/tests/conftest.py @@ -0,0 +1,23 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + + +# This is where to change pytest_pyodide's config if necessary. +# See: https://github.com/pyodide/pyodide/blob/4b1c22ba27f62fba8ae2e13fbaf0e23a32b580ad/conftest.py#L44-L48 + + +def pytest_addoption(parser): + parser.addoption( + "--perspective-emscripten-wheel", + action="store", + help="path to emscripten wheel", + ) diff --git a/rust/perspective-python/pyodide-tests/tests/test_smoke.py b/rust/perspective-python/pyodide-tests/tests/test_smoke.py new file mode 100644 index 0000000..ff18bb7 --- /dev/null +++ b/rust/perspective-python/pyodide-tests/tests/test_smoke.py @@ -0,0 +1,221 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +# As of Feb 2025, the regular Python test suite doesn't work in pytest-pyodide +# --run-in-pyodide, for several reasons. +# One: https://github.com/pyodide/pytest-pyodide/issues/81 +# + +# These pytest-pyodide tests are still quirky. Be wary of trying to split into +# several files: relative imports will break, things seem to work best contained in +# one python module. + +from pytest_pyodide import run_in_pyodide, spawn_web_server +import pytest + + +# __________ +# < FIXTURES > +# ---------- +# \ ^__^ +# \ (oo)\_______ +# (__)\ )\/\ +# ||----w | +# || || + + +@pytest.fixture(scope="session") +def psp_wheel_path(pytestconfig): + wheel = pytestconfig.getoption("--perspective-emscripten-wheel") + if wheel is None: + raise RuntimeError( + "pytest option --perspective-emscripten-wheel= is required but wasn't specified" + ) + return wheel + + +# Based on micropip's test server fixture: +# https://github.com/pyodide/micropip/blob/eb8c4497d742e515d24d532db2b9cc014328265b/tests/conftest.py#L64-L87 +# Run web server serving the directory containing the perspective wheel, +# yielding the wheel's URL for the fixture value +# FIXME: Try using https://github.com/pyodide/pytest-pyodide/tree/main?tab=readme-ov-file#copying-files-to-pyodide +@pytest.fixture() +def psp_wheel_url(psp_wheel_path): + from pathlib import Path + + wheel = Path(psp_wheel_path) + wheels_dir = wheel.parent + with spawn_web_server(wheels_dir) as server: + host, port, _ = server + wheel_url = f"http://{host}:{port}/{wheel.name}" + yield wheel_url + + +@pytest.fixture(autouse=True) +@run_in_pyodide(packages=["micropip"]) +async def psp_installed(selenium, psp_wheel_url): + """Installs perspective wheel from rust/target/wheels dir using micropip""" + # Autoused, so every test has perspective installed without them explicitly listing it as a fixture + import micropip + + await micropip.install(psp_wheel_url) + + +@pytest.fixture +@run_in_pyodide +def server(selenium): + from perspective import AsyncServer + from pytest_pyodide.decorator import PyodideHandle + + s = AsyncServer() + return PyodideHandle(s) + + +@pytest.fixture +@run_in_pyodide +def client(selenium, psp_installed, server): + from perspective import AsyncClient + from pytest_pyodide.decorator import PyodideHandle + + async def send_request(msg): + await sess.handle_request(msg) + + async def send_response(msg): + await client.handle_response(msg) + + sess = server.new_session(send_response) + client = AsyncClient(send_request) + return PyodideHandle(client) + + +# _________ +# < PYTESTS > +# --------- +# \ ^__^ +# \ (oo)\_______ +# (__)\ )\/\ +# ||----w | +# || || + + +@run_in_pyodide +async def test_parsing_bad_csv_raises_exception(selenium): + import pytest + import perspective + + server = perspective.Server() + client = server.new_local_client() + with pytest.raises(perspective.PerspectiveError) as exc_info: + client.table("a,b,c\n1,2") + assert exc_info.match("CSV parse error") + + +@run_in_pyodide +async def test_parsing_good_csv(selenium): + import perspective + + server = perspective.Server() + client = server.new_local_client() + abc123 = client.table("a,b,c\n1,2,3\n") + assert abc123.columns() == ["a", "b", "c"] + + +# Test that perspective can load pyarrow Table values +@run_in_pyodide +async def test_perspective_and_pyarrow_together_at_last(selenium): + import micropip + + await micropip.install("pyarrow") + + import perspective + import pyarrow as pa + + n_legs = pa.array([2, 2, 4, 4, 5, 100]) + animals = pa.array( + ["Flamingo", "Parrot", "Dog", "Horse", "Brittle stars", "Centipede"] + ) + names = ["n_legs", "animals"] + table = pa.Table.from_arrays([n_legs, animals], names=names) + + server = perspective.Server() + client = server.new_local_client() + + animals = client.table(table, name="animals") + assert animals.columns() == ["n_legs", "animals"] + + +# Test that perspective can load pandas DataFrame values +@run_in_pyodide +async def test_pandas_dataframes(selenium): + import micropip + + await micropip.install(["pyarrow", "pandas"]) + + import perspective + import pandas as pd + + server = perspective.Server() + client = server.new_local_client() + df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]}) + ab = client.table(df, name="ab") + assert sorted(ab.columns()) == ["a", "b", "index"] + + +@run_in_pyodide +async def test_async_client_kitchen_sink(selenium, client): + """run various things on the async client""" + + from unittest.mock import Mock + from perspective import PerspectiveError + import pytest + + table = await client.table({"a": [0]}, name="my-cool-data", limit=100) + view = await table.view() + # synchronous methods + assert table.get_index() is None + assert table.get_name() == "my-cool-data" + limit = table.get_limit() + assert limit == 100 + + # view update callbacks + view_updated = Mock() + await view.on_update(view_updated) + for i in range(1, limit): + await table.update([{"a": i}]) + await table.size() # force update to process + assert (await table.size()) == limit + assert view_updated.call_count == limit - 1 + rex = await view.to_records() + assert rex == [{"a": i} for i in range(limit)] + + # view/table delete callbacks + view_deleted = Mock() + table_deleted = Mock() + await table.on_delete(table_deleted) + await view.on_delete(view_deleted) + with pytest.raises(PerspectiveError) as excinfo: + await table.delete() + assert excinfo.match(r"Cannot delete table with views") + await view.delete() + view_deleted.assert_called_once() + await table.delete() + table_deleted.assert_called_once() + + # Cleaning up the client fixture's PyodideHandle causes pytest to + # spew some nasty errors on exit. Seems like playwright's shutdown and + # deletion of the PyodideHandle are racing, and PyodideHandle's finalizer + # throws trying to execute code in the playwright browser which has already + # stopped its event loop. + # TODO: File bug on pytest-pyodide + # + # Exception ignored in: + # playwright._impl._errors.Error: Event loop is closed! Is Playwright already stopped? diff --git a/rust/perspective-python/pyproject.toml b/rust/perspective-python/pyproject.toml new file mode 100644 index 0000000..1f9a515 --- /dev/null +++ b/rust/perspective-python/pyproject.toml @@ -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). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +[build-system] +requires = ["maturin>=1.13.3,<2.0"] +build-backend = "maturin" + +[project] +name = "perspective-python" +requires-python = ">=3.9" +dynamic = ["version"] +dependencies = [] +license = "Apache-2.0" +license-files = ["LICENSE*"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Framework :: Jupyter :: JupyterLab", + "Framework :: Jupyter :: JupyterLab :: 3", + "Framework :: Jupyter :: JupyterLab :: Extensions", + # "Framework :: Jupyter :: JupyterLab :: Extensions :: Mime Renderers", + "Framework :: Jupyter :: JupyterLab :: Extensions :: Prebuilt", +] + +[project.optional-dependencies] +jupyter = ["ipywidgets>=7.5.1,<9"] +tornado = ["tornado>=5,<7"] +aiohttp = ["aiohttp>=2,<4"] +starlette = ["starlette<1"] + +[tool.maturin] +module-name = "perspective" +data = "perspective_python-4.5.2.data" +features = ["pyo3/extension-module"] +include = [ + { path = "perspective/*libpsp.so", format = "wheel" }, + { path = "perspective/*libpsp.dll", format = "wheel" }, +] + +[tool.pytest.ini_options] +python_classes = ["Test", "Describe"] +python_functions = ["test_", "it_", "and_", "but_", "they_"] +python_files = ["test_*.py"] +testpaths = ["tests"] +asyncio_default_fixture_loop_scope = "function" + +log_cli = true +log_cli_level = "INFO" +log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)" +log_cli_date_format = "%Y-%m-%d %H:%M:%S" diff --git a/rust/perspective-python/requirements-pyodide.txt b/rust/perspective-python/requirements-pyodide.txt new file mode 100644 index 0000000..2a0a63a --- /dev/null +++ b/rust/perspective-python/requirements-pyodide.txt @@ -0,0 +1,5 @@ +# Requirements for testing pyodide +pytest==8.2.2 +pytest-playwright==0.5.2 +pytest-pyodide==0.58.6 +pytest-timeout==2.3.1 diff --git a/rust/perspective-python/requirements.txt b/rust/perspective-python/requirements.txt new file mode 100644 index 0000000..3e67c6c --- /dev/null +++ b/rust/perspective-python/requirements.txt @@ -0,0 +1,25 @@ +clickhouse_connect==0.11.0 +duckdb==1.4.4 +Faker==26.0.0 +ipywidgets==8.1.3 +jupyterlab_git==0.50.1 +jupyterlab==4.2.3 +maturin==1.14.1 +numpy==2.2.6 +packaging==24.1 +pandas==2.3.0 +pdoc==15.0.3 +polars==1.13.1 +psutil==6.0.0 +pyarrow==21 +pytest-asyncio==0.25.3 +pytest-sugar==1.0.0 +pytest-timeout==2.3.1 +pytest==8.2.2 +pytest>=7.4.3 +ruff>=0.5.3 +tornado==6.4.1 +traitlets==5.14.3 +websocket-client==1.8.0 +wheel==0.46.3 +widgetsnbextension==4.0.11 diff --git a/rust/perspective-python/src/client/client_async.rs b/rust/perspective-python/src/client/client_async.rs new file mode 100644 index 0000000..d6f5eba --- /dev/null +++ b/rust/perspective-python/src/client/client_async.rs @@ -0,0 +1,1015 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 std::pin::pin; +use std::str::FromStr; +use std::sync::Arc; + +use futures::FutureExt; +use perspective_client::{ + Client, ColumnWindow, DeleteOptions, OnUpdateData, OnUpdateMode, OnUpdateOptions, Table, + TableData, TableInitOptions, TableReadFormat, TableRef, UpdateData, UpdateOptions, View, + ViewWindow, assert_table_api, assert_view_api, asyncfn, +}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::{PyAny, PyBytes, PyDict, PyString}; +use pythonize::depythonize; + +use super::pandas::arrow_to_pandas; +use super::polars::arrow_to_polars; +use super::table_data::TableDataExt; +use super::update_data::UpdateDataExt; +use super::{pandas, polars, pyarrow}; +use crate::py_async::{self, AllowThreads}; +use crate::py_err::{PyPerspectiveError, ResultTClientErrorExt}; + +fn py_to_table_ref_async(val: &Bound<'_, PyAny>) -> PyResult { + if let Ok(t) = val.extract::() { + Ok(TableRef::from(t.table.as_ref())) + } else if let Ok(name) = val.extract::() { + Ok(TableRef::from(name)) + } else { + Err(pyo3::exceptions::PyTypeError::new_err( + "Expected a Table or string table name", + )) + } +} + +fn py_to_table_ref_from_owned(py: Python<'_>, val: &Py) -> PyResult { + let bound = val.bind(py); + py_to_table_ref_async(bound) +} + +/// An instance of a [`Client`] is a connection to a single +/// `perspective_server::Server`, whether locally in-memory or remote over some +/// transport like a WebSocket. +/// +/// `AsyncClient` and Perspective objects derived from it have _async_ APIs, +/// suitable for integration with a Python event loop like `asyncio`. +/// @private +#[pyclass(module = "perspective")] +#[derive(Clone)] +pub struct AsyncClient { + pub(crate) client: Client, + close_cb: Arc>>, +} + +impl AsyncClient { + pub fn new_from_client(client: Client) -> Self { + AsyncClient { + client, + close_cb: Arc::default(), + } + } +} + +#[pymethods] +impl AsyncClient { + #[new] + #[pyo3(signature=(handle_request, handle_close=None, name=None))] + pub fn new( + handle_request: Py, + handle_close: Option>, + name: Option, + ) -> PyResult { + let handle_request = Arc::new(handle_request); + let client = Client::new_with_callback( + name.as_deref(), + asyncfn!(handle_request, async move |msg| { + if let Some(fut) = Python::with_gil(move |py| -> PyResult<_> { + let ret = handle_request.call1(py, (PyBytes::new(py, &msg),))?; + if isawaitable(ret.bind(py)).unwrap_or(false) { + Ok(Some(py_async::py_into_future(ret.into_bound(py))?)) + } else { + Ok(None) + } + })? { + let result = fut.await; + Python::with_gil(|_| { + result + .map(|_| ()) + .map_err(perspective_server::ServerError::from) + })? + } + + Ok(()) + }), + ); + + Ok(AsyncClient { + client: client.into_pyerr()?, + close_cb: handle_close.into(), + }) + } + + /// Handle a message from the external message queue. + /// [`Client::handle_response`] is part of the low-level message-handling + /// API necessary to implement new transports for a [`Client`] + /// connection to a local-or-remote `perspective_server::Server`, and + /// doesn't generally need to be called directly by "users" of a + /// [`Client`] once connected. + pub async fn handle_response(&self, bytes: Py) -> PyResult { + self.client + .handle_response(Python::with_gil(|py| bytes.as_bytes(py))) + .await + .into_pyerr() + } + + /// Creates a new [`Table`] from either a _schema_ or _data_. + /// + /// The [`Client::table`] factory function can be initialized with either a + /// _schema_ (see [`Table::schema`]), or data in one of these formats: + /// + /// - Apache Arrow + /// - CSV + /// - JSON row-oriented + /// - JSON column-oriented + /// + /// When instantiated with _data_, the schema is inferred from this data. + /// While this is convenient, inferrence is sometimes imperfect e.g. + /// when the input is empty, null or ambiguous. For these cases, + /// [`Client::table`] can first be instantiated with a explicit schema. + /// + /// When instantiated with a _schema_, the resulting [`Table`] is empty but + /// with known column names and column types. When subsqeuently + /// populated with [`Table::update`], these columns will be _coerced_ to + /// the schema's type. This behavior can be useful when + /// [`Client::table`]'s column type inferences doesn't work. + /// + /// The resulting [`Table`] is _virtual_, and invoking its methods + /// dispatches events to the `perspective_server::Server` this + /// [`Client`] connects to, where the data is stored and all calculation + /// occurs. + /// + /// # Arguments + /// + /// - `arg` - Either _schema_ or initialization _data_. + /// - `options` - Optional configuration which provides one of: + /// - `limit` - The max number of rows the resulting [`Table`] can + /// store. + /// - `index` - The column name to use as an _index_ column. If this + /// `Table` is being instantiated by _data_, this column name must be + /// present in the data. + /// - `name` - The name of the table. This will be generated if it is + /// not provided. + /// - `format` - The explicit format of the input data, can be one of + /// `"json"`, `"columns"`, `"csv"` or `"arrow"`. This overrides + /// language-specific type dispatch behavior, which allows stringified + /// and byte array alternative inputs. + /// - `page_to_disk` - Back this [`Table`]'s canonical data with the + /// on-disk (memory-mapped) storage backend instead of memory. + /// + /// # Python Examples + /// + /// Load a CSV from a `str`: + /// + /// ```python + /// table = await client.table("x,y\n1,2\n3,4") + /// ``` + #[pyo3(signature=(input, limit=None, index=None, name=None, format=None, page_to_disk=None))] + pub async fn table( + &self, + input: Py, + limit: Option, + index: Option>, + name: Option>, + format: Option>, + page_to_disk: Option, + ) -> PyResult { + let client = self.client.clone(); + let py_client = Python::with_gil(|_| self.clone()); + let table = Python::with_gil(|py| { + let mut options = TableInitOptions { + name: name.map(|x| x.extract::(py)).transpose()?, + page_to_disk, + ..TableInitOptions::default() + }; + + let format = TableReadFormat::parse(format.map(|x| x.to_string())) + .map_err(PyPerspectiveError::new_err)?; + + match (limit, index) { + (None, None) => {}, + (None, Some(index)) => { + options.index = Some(index.extract::(py)?); + }, + (Some(limit), None) => options.limit = Some(limit), + (Some(_), Some(_)) => { + Err(PyValueError::new_err("Cannot set both `limit` and `index`"))? + }, + }; + + let input = input.into_bound(py); + let input_data = if pyarrow::is_arrow_table(py, &input)? { + pyarrow::to_arrow_bytes(py, &input)?.into_any() + } else if pandas::is_pandas_df(py, &input)? { + pandas::pandas_to_arrow_bytes(py, &input)?.into_any() + } else if polars::is_polars_df(py, &input)? || polars::is_polars_lf(py, &input)? { + polars::polars_to_arrow_bytes(py, &input)?.into_any() + } else { + input + }; + + let table_data = TableData::from_py(input_data, format)?; + let table = client.table(table_data, options); + Ok::<_, PyErr>(table) + })?; + + let table = table.await.into_pyerr()?; + Ok(AsyncTable { + table: Arc::new(table), + client: py_client, + }) + } + + /// Opens a [`Table`] that is hosted on the `perspective_server::Server` + /// that is connected to this [`Client`]. + /// + /// The `name` property of [`TableInitOptions`] is used to identify each + /// [`Table`]. [`Table`] `name`s can be looked up for each [`Client`] + /// via [`Client::get_hosted_table_names`]. + /// + /// # Python Examples + /// + /// ```python + /// table = await client.open_table("table_one"); + /// ``` + pub async fn open_table(&self, name: String) -> PyResult { + let client = self.client.clone(); + let py_client = self.clone(); + let table = client.open_table(name).await.into_pyerr()?; + Ok(AsyncTable { + table: Arc::new(table), + client: py_client, + }) + } + + /// Creates a new read-only [`Table`] by performing an INNER JOIN on two + /// source tables. The resulting table is reactive: when either source + /// table is updated, the join is automatically recomputed. + /// + /// # Python Examples + /// + /// ```python + /// joined = await client.join(orders_table, products_table, "Product ID", "left") + /// ``` + #[pyo3(signature = (left, right, on, join_type=None, name=None, right_on=None))] + pub async fn join( + &self, + left: Py, + right: Py, + on: String, + join_type: Option, + name: Option, + right_on: Option, + ) -> PyResult { + let (left_ref, right_ref) = Python::with_gil(|py| { + let left_ref = py_to_table_ref_from_owned(py, &left)?; + let right_ref = py_to_table_ref_from_owned(py, &right)?; + Ok::<_, PyErr>((left_ref, right_ref)) + })?; + let jt = super::client_sync::parse_join_type(join_type.as_deref())?; + let options = perspective_client::JoinOptions { + join_type: Some(jt), + name, + right_on, + }; + let py_client = self.clone(); + let table = self + .client + .join(left_ref, right_ref, &on, options) + .await + .into_pyerr()?; + Ok(AsyncTable { + table: Arc::new(table), + client: py_client, + }) + } + + /// Retrieves the names of all tables that this client has access to. + /// + /// `name` is a string identifier unique to the [`Table`] (per [`Client`]), + /// which can be used in conjunction with [`Client::open_table`] to get + /// a [`Table`] instance without the use of [`Client::table`] + /// constructor directly (e.g., one created by another [`Client`]). + /// + /// # Python Examples + /// + /// ```python + /// tables = await client.get_hosted_table_names(); + /// ``` + pub async fn get_hosted_table_names(&self) -> PyResult> { + self.client.get_hosted_table_names().await.into_pyerr() + } + + /// Register a callback which is invoked whenever [`Client::table`] (on this + /// [`Client`]) or [`Table::delete`] (on a [`Table`] belinging to this + /// [`Client`]) are called. + pub async fn on_hosted_tables_update(&self, callback_py: Py) -> PyResult { + let callback = Box::new(move || { + let callback = Python::with_gil(|py| Py::clone_ref(&callback_py, py)); + async move { + let aggregate_errors: PyResult<()> = { + let callback = Python::with_gil(|py| Py::clone_ref(&callback, py)); + Python::with_gil(|py| { + callback.call0(py)?; + Ok(()) + }) + }; + + // TODO These are unrecoverable errors - we should mark them as such + if let Err(err) = aggregate_errors { + tracing::warn!("Error in on_hosted_tables_update callback: {:?}", err); + } + } + .boxed() + }); + + let callback_id = self + .client + .on_hosted_tables_update(callback) + .await + .into_pyerr()?; + Ok(callback_id) + } + + /// Remove a callback previously registered via + /// [`Client::on_hosted_tables_update`]. + pub async fn remove_hosted_tables_update(&self, id: u32) -> PyResult<()> { + self.client + .remove_hosted_tables_update(id) + .await + .into_pyerr() + } + + /// Provides the [`SystemInfo`] struct, implementation-specific metadata + /// about the [`perspective_server::Server`] runtime such as Memory and + /// CPU usage. + pub async fn system_info(&self) -> PyResult> { + let sysinfo = self.client.system_info().await.into_pyerr()?; + Python::with_gil(|py| Ok(pythonize::pythonize(py, &sysinfo)?.unbind())) + } + + /// Terminates this [`Client`], cleaning up any [`crate::View`] handles the + /// [`Client`] has open as well as its callbacks. + pub fn terminate(&self, py: Python<'_>) -> PyResult<()> { + if let Some(cb) = &*self.close_cb { + cb.call0(py)?; + } + + Ok(()) + } +} + +/// [`AsyncTable`] is Perspective's columnar data frame, analogous to a +/// Pandas/Polars `DataFrame` or Apache Arrow, supporting append & in-place +/// updates, removal by index, and update notifications. +/// +/// A [`AsyncTable`] contains columns, each of which have a unique name, are +/// strongly and consistently typed, and contains rows of data conforming to the +/// column's type. Each column in a [`AsyncTable`] must have the same number of +/// rows, though not every row must contain data; null-values are used to +/// indicate missing values in the dataset. The schema of a [`AsyncTable`] is +/// _immutable after creation_, which means the column names and data types +/// cannot be changed after the [`AsyncTable`] has been created. Columns cannot +/// be added or deleted after creation either, but a [`AsyncView`] can be used +/// to select an arbitrary set of columns from the [`AsyncTable`]. +#[pyclass] +#[derive(Clone)] +pub struct AsyncTable { + pub(super) table: Arc
    , + pub(super) client: AsyncClient, +} + +assert_table_api!(AsyncTable); + +#[pymethods] +impl AsyncTable { + pub fn get_index(&self) -> Option { + self.table.get_index() + } + + /// Get a copy of the [`Client`] this [`Table`] came from. + pub async fn get_client(&self) -> AsyncClient { + AsyncClient { + client: self.table.get_client(), + close_cb: self.client.close_cb.clone(), + } + } + + /// Returns the user-specified row limit for this table. + pub fn get_limit(&self) -> Option { + self.table.get_limit() + } + + /// Returns the user-specified name for this table, or the auto-generated + /// name if a name was not specified when the table was created. + pub fn get_name(&self) -> String { + self.table.get_name().into() + } + + /// Returns the number of rows in a [`Table`]. + pub async fn size(&self) -> PyResult { + self.table.size().await.into_pyerr() + } + + /// Returns the column names of this [`Table`] in "natural" order (the + /// ordering implied by the input format). + /// + /// # Python Examples + /// + /// ```python + /// columns = table.columns() + /// ``` + pub async fn columns(&self) -> PyResult> { + self.table.columns().await.into_pyerr() + } + + /// Removes all the rows in the [`Table`], but preserves everything else + /// including the schema, index, and any callbacks or registered + /// [`View`] instances. + /// + /// Calling [`Table::clear`], like [`Table::update`] and [`Table::remove`], + /// will trigger an update event to any registered listeners via + /// [`View::on_update`]. + pub async fn clear(&self) -> PyResult<()> { + self.table.clear().await.into_pyerr() + } + + /// Delete this [`Table`] and cleans up associated resources. + /// + /// [`Table`]s do not stop consuming resources or processing updates when + /// they are garbage collected in their host language - you must call + /// this method to reclaim these. + /// + /// # Arguments + /// + /// - `options` An options dictionary. + /// - `lazy` Whether to delete this [`Table`] _lazily_. When false (the + /// default), the delete will occur immediately, assuming it has no + /// [`View`] instances registered to it (which must be deleted first, + /// otherwise this method will throw an error). When true, the + /// [`Table`] will only be marked for deltion once its [`View`] + /// dependency count reaches 0. + /// + /// # Python Examples + /// + /// ```python + /// table = await client.table("x,y\n1,2\n3,4") + /// + /// # ... + /// + /// table.delete(lazy=True) + /// ``` + #[pyo3(signature=(lazy=false))] + pub async fn delete(&self, lazy: bool) -> PyResult<()> { + self.table.delete(DeleteOptions { lazy }).await.into_pyerr() + } + + /// Create a unique channel ID on this [`Table`], which allows + /// `View::on_update` callback calls to be associated with the + /// `Table::update` which caused them. + pub async fn make_port(&self) -> PyResult { + self.table.make_port().await.into_pyerr() + } + + /// Register a callback which is called exactly once, when this [`Table`] is + /// deleted with the [`Table::delete`] method. + /// + /// [`Table::on_delete`] resolves when the subscription message is sent, not + /// when the _delete_ event occurs. + pub async fn on_delete(&self, callback_py: Py) -> PyResult { + let callback = { + let callback_py = Python::with_gil(|py| Py::clone_ref(&callback_py, py)); + Box::new(move || { + Python::with_gil(|py| callback_py.call0(py)) + .expect("`on_delete()` callback failed"); + }) + }; + + let callback_id = self.table.on_delete(callback).await.into_pyerr()?; + Ok(callback_id) + } + + /// Removes a listener with a given ID, as returned by a previous call to + /// [`Table::on_delete`]. + pub async fn remove_delete(&self, callback_id: u32) -> PyResult<()> { + self.table.remove_delete(callback_id).await.into_pyerr() + } + + /// Removes rows from this [`Table`] with the `index` column values + /// supplied. + /// + /// # Arguments + /// + /// - `indices` - A list of `index` column values for rows that should be + /// removed. + /// + /// # Python Examples + /// + /// ```python + /// tbl = await client.table({"a": [1, 2, 3]}, index="a") + /// await tbl.remove([2, 3]) + /// ``` + #[pyo3(signature=(input, format=None))] + pub async fn remove(&self, input: Py, format: Option) -> PyResult<()> { + let table = &self.table; + let format = TableReadFormat::parse(format).map_err(PyPerspectiveError::new_err)?; + let table_data = Python::with_gil(|py| UpdateData::from_py(input.into_bound(py), format))?; + table.remove(table_data).await.into_pyerr() + } + + /// Replace all rows in this [`Table`] with the input data, coerced to this + /// [`Table`]'s existing [`Schema`], notifying any derived [`View`] and + /// [`View::on_update`] callbacks. + /// + /// Calling [`Table::replace`] is an easy way to replace _all_ the data in a + /// [`Table`] without losing any derived [`View`] instances or + /// [`View::on_update`] callbacks. [`Table::replace`] does _not_ infer + /// data types like [`Client::table`] does, rather it _coerces_ input + /// data to the `Schema` like [`Table::update`]. If you need a [`Table`] + /// with a different `Schema`, you must create a new one. + /// + /// # Python Examples + /// + /// ```python + /// await table.replace("x,y\n1,2") + /// ``` + #[pyo3(signature=(input, format=None))] + pub async fn replace(&self, input: Py, format: Option) -> PyResult<()> { + let table = &self.table; + let format = TableReadFormat::parse(format).map_err(PyPerspectiveError::new_err)?; + let table_data = Python::with_gil(|py| UpdateData::from_py(input.into_bound(py), format))?; + table.replace(table_data).await.into_pyerr() + } + + /// Updates the rows of this table and any derived [`View`] instances. + /// + /// Calling [`Table::update`] will trigger the [`View::on_update`] callbacks + /// register to derived [`View`], and the call itself will not resolve until + /// _all_ derived [`View`]'s are notified. + /// + /// When updating a [`Table`] with an `index`, [`Table::update`] supports + /// partial updates, by omitting columns from the update data. + /// + /// # Arguments + /// + /// - `input` - The input data for this [`Table`]. The schema of a [`Table`] + /// is immutable after creation, so this method cannot be called with a + /// schema. + /// - `options` - Options for this update step - see [`UpdateOptions`]. + /// + /// # Python Examples + /// + /// ```python + /// await table.update("x,y\n1,2") + /// ``` + #[pyo3(signature=(input, port_id=None, format=None))] + pub async fn update( + &self, + input: Py, + port_id: Option, + format: Option, + ) -> PyResult<()> { + let input_data: Py = Python::with_gil(|py| { + let input = input.into_bound(py); + let data = if pyarrow::is_arrow_table(py, &input)? { + pyarrow::to_arrow_bytes(py, &input)?.into_any() + } else if pandas::is_pandas_df(py, &input)? { + pandas::pandas_to_arrow_bytes(py, &input)?.into_any() + } else if polars::is_polars_df(py, &input)? || polars::is_polars_lf(py, &input)? { + polars::polars_to_arrow_bytes(py, &input)?.into_any() + } else { + input + }; + Ok(data.unbind()) as PyResult> + })?; + + let table = &self.table; + let format = TableReadFormat::parse(format).map_err(PyPerspectiveError::new_err)?; + let table_data = + Python::with_gil(|py| UpdateData::from_py(input_data.into_bound(py), format))?; + let options = UpdateOptions { port_id, format }; + AllowThreads(pin!(table.update(table_data, options))) + .await + .into_pyerr()?; + Ok(()) + } + + /// Validates the given expressions. + pub async fn validate_expressions(&self, expressions: Py) -> PyResult> { + let expressions = Python::with_gil(|py| depythonize(expressions.bind(py)))?; + let records = self + .table + .validate_expressions(expressions) + .await + .into_pyerr()?; + + Python::with_gil(|py| Ok(pythonize::pythonize(py, &records)?.unbind())) + } + + /// Returns a table's [`Schema`], a mapping of column names to column types. + /// + /// The mapping of a [`Table`]'s column names to data types is referred to + /// as a [`Schema`]. Each column has a unique name and a data type, one + /// of: + /// + /// - `"boolean"` - A boolean type + /// - `"date"` - A timesonze-agnostic date type (month/day/year) + /// - `"datetime"` - A millisecond-precision datetime type in the UTC + /// timezone + /// - `"float"` - A 64 bit float + /// - `"integer"` - A signed 32 bit integer (the integer type supported by + /// JavaScript) + /// - `"string"` - A `String` data type (encoded internally as a + /// _dictionary_) + /// + /// Note that all [`Table`] columns are _nullable_, regardless of the data + /// type. + pub async fn schema(&self) -> PyResult> { + let schema = self.table.schema().await.into_pyerr()?; + Ok(schema + .into_iter() + .map(|(x, y)| (x, format!("{y}"))) + .collect()) + } + + /// Create a new [`View`] from this table with a specified + /// [`ViewConfigUpdate`]. + /// + /// See [`View`] struct. + /// + /// # Python Examples + /// + /// ```python + /// view = await table.view( + /// columns=["Sales"], + /// aggregates={"Sales": "sum"}, + /// group_by=["Region", "Country"], + /// filter=[["Category", "in", ["Furniture", "Technology"]]] + /// ) + /// ``` + #[pyo3(signature = (**kwargs))] + pub async fn view(&self, kwargs: Option>) -> PyResult { + let config = kwargs + .map(|config| Python::with_gil(|py| depythonize(config.bind(py)))) + .transpose()?; + + let view = self.table.view(config).await.into_pyerr()?; + Ok(AsyncView { + view: Arc::new(view), + _client: self.client.clone(), + }) + } +} + +/// The [`View`] struct is Perspective's query and serialization interface. It +/// represents a query on the `Table`'s dataset and is always created from an +/// existing `Table` instance via the [`Table::view`] method. +/// +/// [`View`]s are immutable with respect to the arguments provided to the +/// [`Table::view`] method; to change these parameters, you must create a new +/// [`View`] on the same [`Table`]. However, each [`View`] is _live_ with +/// respect to the [`Table`]'s data, and will (within a conflation window) +/// update with the latest state as its parent [`Table`] updates, including +/// incrementally recalculating all aggregates, pivots, filters, etc. [`View`] +/// query parameters are composable, in that each parameter works independently +/// _and_ in conjunction with each other, and there is no limit to the number of +/// pivots, filters, etc. which can be applied. +#[pyclass] +#[derive(Clone)] +pub struct AsyncView { + pub(crate) view: Arc, + _client: AsyncClient, +} + +assert_view_api!(AsyncView); + +#[pymethods] +impl AsyncView { + /// Returns an array of strings containing the column paths of the [`View`] + /// without any of the source columns. + /// + /// A column path shows the columns that a given cell belongs to after + /// pivots are applied. + pub async fn column_paths(&self, window: Option>) -> PyResult> { + let window: ColumnWindow = Python::with_gil(|py| window.map(|x| depythonize(x.bind(py)))) + .transpose()? + .unwrap_or_default(); + + self.view.column_paths(window).await.into_pyerr() + } + + /// Delete this [`View`] and clean up all resources associated with it. + /// [`View`] objects do not stop consuming resources or processing + /// updates when they are garbage collected - you must call this method + /// to reclaim these. + pub async fn delete(&self) -> PyResult<()> { + self.view.delete().await.into_pyerr() + } + + /// Returns this [`View`]'s _dimensions_, row and column count, as well as + /// those of the [`crate::Table`] from which it was derived. + /// + /// - `num_table_rows` - The number of rows in the underlying + /// [`crate::Table`]. + /// - `num_table_columns` - The number of columns in the underlying + /// [`crate::Table`] (including the `index` column if this + /// [`crate::Table`] was constructed with one). + /// - `num_view_rows` - The number of rows in this [`View`]. If this + /// [`View`] has a `group_by` clause, `num_view_rows` will also include + /// aggregated rows. + /// - `num_view_columns` - The number of columns in this [`View`]. If this + /// [`View`] has a `split_by` clause, `num_view_columns` will include all + /// _column paths_, e.g. the number of `columns` clause times the number + /// of `split_by` groups. + pub async fn dimensions(&self) -> PyResult> { + let dim = self.view.dimensions().await.into_pyerr()?; + Python::with_gil(|py| Ok(pythonize::pythonize(py, &dim)?.unbind())) + } + + pub async fn expand(&self, index: u32) -> PyResult { + self.view.expand(index).await.into_pyerr() + } + + pub async fn collapse(&self, index: u32) -> PyResult { + self.view.collapse(index).await.into_pyerr() + } + + /// The expression schema of this [`View`], which contains only the + /// expressions created on this [`View`]. See [`View::schema`] for + /// details. + pub async fn expression_schema(&self) -> PyResult> { + Ok(self + .view + .expression_schema() + .await + .into_pyerr()? + .into_iter() + .map(|(k, v)| (k, format!("{v}"))) + .collect()) + } + + /// A copy of the config object passed to the [`Table::view`] method which + /// created this [`View`]. + pub async fn get_config(&self) -> PyResult> { + let config = self.view.get_config().await.into_pyerr()?; + Python::with_gil(|py| Ok(pythonize::pythonize(py, &config)?.unbind())) + } + + /// Calculates the [min, max] of the leaf nodes of a column `column_name`. + /// + /// # Returns + /// + /// A tuple of [min, max], whose types are column and aggregate dependent. + pub async fn get_min_max(&self, name: String) -> PyResult<(PyObject, PyObject)> { + let (min, max) = self.view.get_min_max(name).await.into_pyerr()?; + Python::with_gil(|py| { + Ok(( + super::client_sync::scalar_to_py(py, &min), + super::client_sync::scalar_to_py(py, &max), + )) + }) + } + + /// The number of aggregated rows in this [`View`]. This is affected by the + /// "group_by" configuration parameter supplied to this view's contructor. + /// + /// # Returns + /// + /// The number of aggregated rows. + pub async fn num_rows(&self) -> PyResult { + self.view.num_rows().await.into_pyerr() + } + + /// The number of aggregated columns in this [`View`]. This is affected by + /// the "split_by" configuration parameter supplied to this view's + /// contructor. + /// + /// # Returns + /// + /// The number of aggregated columns. + pub async fn num_columns(&self) -> PyResult { + let dim = self.view.dimensions().await.into_pyerr()?; + Ok(dim.num_view_columns) + } + + /// The schema of this [`View`]. + /// + /// The [`View`] schema differs from the `schema` returned by + /// [`Table::schema`]; it may have different column names due to + /// `expressions` or `columns` configs, or it maye have _different + /// column types_ due to the application og `group_by` and `aggregates` + /// config. You can think of [`Table::schema`] as the _input_ schema and + /// [`View::schema`] as the _output_ schema of a Perspective pipeline. + pub async fn schema(&self) -> PyResult> { + Ok(self + .view + .schema() + .await + .into_pyerr()? + .into_iter() + .map(|(k, v)| (k, format!("{v}"))) + .collect()) + } + + /// Register a callback with this [`View`]. Whenever the [`View`] is + /// deleted, this callback will be invoked. + pub async fn on_delete(&self, callback_py: Py) -> PyResult { + let callback = { + let callback_py = Arc::new(callback_py); + Box::new(move || { + Python::with_gil(|py| callback_py.call0(py)) + .expect("`on_delete()` callback failed"); + }) + }; + + let callback_id = self.view.on_delete(callback).await.into_pyerr()?; + Ok(callback_id) + } + + /// Unregister a previously registered [`View::on_delete`] callback. + pub async fn remove_delete(&self, callback_id: u32) -> PyResult<()> { + self.view.remove_delete(callback_id).await.into_pyerr() + } + + /// Register a callback with this [`View`]. Whenever the view's underlying + /// table emits an update, this callback will be invoked with an object + /// containing `port_id`, indicating which port the update fired on, and + /// optionally `delta`, which is the new data that was updated for each + /// cell or each row. + /// + /// # Arguments + /// + /// - `on_update` - A callback function invoked on update, which receives an + /// object with two keys: `port_id`, indicating which port the update was + /// triggered on, and `delta`, whose value is dependent on the mode + /// parameter. + /// - `options` - If this is provided as `OnUpdateOptions { mode: + /// Some(OnUpdateMode::Row) }`, then `delta` is an Arrow of the updated + /// rows. Otherwise `delta` will be [`Option::None`]. + #[pyo3(signature=(callback, mode=None))] + pub async fn on_update(&self, callback: Py, mode: Option) -> PyResult { + let callback = move |x: OnUpdateData| { + let callback = Python::with_gil(|py| Py::clone_ref(&callback, py)); + async move { + let aggregate_errors: PyResult<()> = { + let callback = Python::with_gil(|py| Py::clone_ref(&callback, py)); + Python::with_gil(|py| { + match &x.delta { + None => callback.call1(py, (x.port_id,))?, + Some(delta) => { + callback.call1(py, (x.port_id, PyBytes::new(py, delta)))? + }, + }; + + Ok(()) + }) + }; + + if let Err(err) = aggregate_errors { + tracing::warn!("Error in on_update callback: {:?}", err); + } + } + .boxed() + }; + + let mode = mode + .map(|x| OnUpdateMode::from_str(x.as_str())) + .transpose() + .into_pyerr()?; + + self.view + .on_update(Box::new(callback), OnUpdateOptions { mode }) + .await + .into_pyerr() + } + + /// Unregister a previously registered update callback with this [`View`]. + /// + /// # Arguments + /// + /// - `id` - A callback `id` as returned by a recipricol call to + /// [`View::on_update`]. + pub async fn remove_update(&self, callback_id: u32) -> PyResult<()> { + self.view.remove_update(callback_id).await.into_pyerr() + } + + #[pyo3(signature=(**window))] + pub async fn to_dataframe(&self, window: Option>) -> PyResult> { + let window: ViewWindow = Python::with_gil(|py| window.map(|x| depythonize(x.bind(py)))) + .transpose()? + .unwrap_or_default(); + let arrow = self.view.to_arrow(window).await.into_pyerr()?; + Python::with_gil(|py| arrow_to_pandas(py, &arrow)) + } + + #[pyo3(signature=(**window))] + pub async fn to_polars(&self, window: Option>) -> PyResult> { + let window: ViewWindow = Python::with_gil(|py| window.map(|x| depythonize(x.bind(py)))) + .transpose()? + .unwrap_or_default(); + let arrow = self.view.to_arrow(window).await.into_pyerr()?; + Python::with_gil(|py| arrow_to_polars(py, &arrow)) + } + + /// Serializes a [`View`] to the Apache Arrow data format. + #[pyo3(signature=(**window))] + pub async fn to_arrow(&self, window: Option>) -> PyResult> { + let window: ViewWindow = Python::with_gil(|py| window.map(|x| depythonize(x.bind(py)))) + .transpose()? + .unwrap_or_default(); + let arrow = self.view.to_arrow(window).await.into_pyerr()?; + Ok(Python::with_gil(|py| PyBytes::new(py, &arrow).into())) + } + + /// Serializes this [`View`] to CSV data in a standard format. + #[pyo3(signature=(**window))] + pub async fn to_csv(&self, window: Option>) -> PyResult { + let window: ViewWindow = Python::with_gil(|py| window.map(|x| depythonize(x.bind(py)))) + .transpose()? + .unwrap_or_default(); + + self.view.to_csv(window).await.into_pyerr() + } + + /// Serializes this [`View`] to a string of JSON data. Useful if you want to + /// save additional round trip serialize/deserialize cycles. + #[pyo3(signature=(**window))] + pub async fn to_columns_string(&self, window: Option>) -> PyResult { + let window: ViewWindow = Python::with_gil(|py| window.map(|x| depythonize(x.bind(py)))) + .transpose()? + .unwrap_or_default(); + + self.view.to_columns_string(window).await.into_pyerr() + } + + /// Serializes this [`View`] to Python objects in a column-oriented + /// format. + #[pyo3(signature = (**window))] + pub async fn to_columns(&self, window: Option>) -> PyResult> { + let json = self.to_columns_string(window).await?; + Python::with_gil(|py| { + let json_module = PyModule::import(py, "json")?; + let records = json_module.call_method1("loads", (json,))?; + Ok(records.unbind()) + }) + } + + /// Render this `View` as a JSON string. + #[pyo3(signature=(window=None))] + pub async fn to_json_string(&self, window: Option>) -> PyResult { + let window: ViewWindow = Python::with_gil(|py| window.map(|x| depythonize(x.bind(py)))) + .transpose()? + .unwrap_or_default(); + + self.view.to_json_string(window).await.into_pyerr() + } + + /// Renders this [`View`] as an [NDJSON](https://github.com/ndjson/ndjson-spec) + /// formatted `String`. + #[pyo3(signature=(window=None))] + pub async fn to_ndjson(&self, window: Option>) -> PyResult { + let window: ViewWindow = Python::with_gil(|py| window.map(|x| depythonize(x.bind(py)))) + .transpose()? + .unwrap_or_default(); + + self.view.to_ndjson(window).await.into_pyerr() + } + + /// Alias for [`View::to_json`]. + #[pyo3(signature = (**window))] + pub async fn to_records(&self, window: Option>) -> PyResult> { + let json = self.to_json_string(window).await?; + Python::with_gil(|py| { + let json_module = PyModule::import(py, "json")?; + let records = json_module.call_method1("loads", (json,))?; + Ok(records.unbind()) + }) + } + + /// Serializes this [`View`] to Python objects in a row-oriented + /// format. + #[pyo3(signature = (**window))] + pub async fn to_json(&self, window: Option>) -> PyResult> { + self.to_records(window).await + } +} + +fn isawaitable(object: &Bound<'_, PyAny>) -> PyResult { + let py = object.py(); + py.import("inspect")? + .call_method1("isawaitable", (object,))? + .extract() +} diff --git a/rust/perspective-python/src/client/client_sync.rs b/rust/perspective-python/src/client/client_sync.rs new file mode 100644 index 0000000..e125e3f --- /dev/null +++ b/rust/perspective-python/src/client/client_sync.rs @@ -0,0 +1,819 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 std::future::Future; +use std::sync::Arc; + +use perspective_client::config::Scalar; +use perspective_client::{JoinType, TableRef, assert_table_api, assert_view_api}; +#[cfg(doc)] +use perspective_client::{TableInitOptions, UpdateOptions, config::ViewConfigUpdate}; +use pyo3::exceptions::PyTypeError; +use pyo3::marker::Ungil; +use pyo3::prelude::*; +use pyo3::types::*; + +use super::client_async::*; +use crate::py_err::ResultTClientErrorExt; +use crate::server::Server; + +pub(crate) fn py_to_table_ref(val: &Bound<'_, PyAny>) -> PyResult { + if let Ok(t) = val.downcast::
    () { + let table_ref = t.borrow(); + Ok(TableRef::from(&*table_ref.0.table)) + } else if let Ok(name) = val.extract::() { + Ok(TableRef::from(name)) + } else { + Err(PyTypeError::new_err( + "Expected a Table or string table name", + )) + } +} + +pub(crate) fn parse_join_type(join_type: Option<&str>) -> PyResult { + match join_type { + Some("left") => Ok(JoinType::Left), + Some("outer") => Ok(JoinType::Outer), + None | Some("inner") => Ok(JoinType::Inner), + Some(other) => Err(pyo3::exceptions::PyValueError::new_err(format!( + "Unknown join type: \"{}\"", + other + ))), + } +} + +pub(crate) fn scalar_to_py(py: Python<'_>, scalar: &Scalar) -> PyObject { + match scalar { + Scalar::Float(x) => x.into_pyobject(py).unwrap().into_any().unbind(), + Scalar::String(x) => x.into_pyobject(py).unwrap().into_any().unbind(), + Scalar::Bool(x) => x.into_pyobject(py).unwrap().to_owned().into_any().unbind(), + Scalar::Null => py.None(), + } +} + +pub(crate) trait PyFutureExt: Future { + fn py_block_on(self, py: Python<'_>) -> Self::Output + where + Self: Sized + Send, + Self::Output: Ungil, + { + use pollster::FutureExt; + py.allow_threads(move || self.block_on()) + } +} + +impl PyFutureExt for F {} + +/// An instance of a [`Client`] is a connection to a single [`Server`], whether +/// locally in-memory or remote over some transport like a WebSocket. +/// +/// [`Client`] and Perspective objects derived from it have _synchronous_ APIs, +/// suitable for use in a repl or script context where this is the _only_ +/// [`Client`] connected to its [`Server`]. If you want to +/// integrate with a Web framework or otherwise connect multiple clients, +/// use [`AsyncClient`]. +#[pyclass(subclass, module = "perspective")] +pub struct Client(pub(crate) AsyncClient); + +#[pymethods] +impl Client { + #[new] + #[pyo3(signature = (handle_request, close_cb=None, name=None))] + pub fn new( + handle_request: Py, + close_cb: Option>, + name: Option, + ) -> PyResult { + let client = AsyncClient::new(handle_request, close_cb, name)?; + Ok(Client(client)) + } + + /// Create a new [`Client`] instance bound to a specific in-process + /// [`Server`] (e.g. generally _not_ the global [`Server`]). + #[staticmethod] + pub fn from_server(py: Python<'_>, server: Py) -> PyResult { + server.borrow(py).new_local_client() + } + + /// Handle a message from the external message queue. + /// [`Client::handle_response`] is part of the low-level message-handling + /// API necessary to implement new transports for a [`Client`] + /// connection to a local-or-remote [`Server`], and + /// doesn't generally need to be called directly by "users" of a + /// [`Client`] once connected. + pub fn handle_response(&self, py: Python<'_>, response: Py) -> PyResult { + self.0.handle_response(response).py_block_on(py) + } + + /// Creates a new [`Table`] from either a _schema_ or _data_. + /// + /// The [`Client::table`] factory function can be initialized with either a + /// _schema_ (see [`Table::schema`]), or data in one of these formats: + /// + /// - Apache Arrow + /// - CSV + /// - JSON row-oriented + /// - JSON column-oriented + /// - NDJSON + /// + /// When instantiated with _data_, the schema is inferred from this data. + /// While this is convenient, inferrence is sometimes imperfect e.g. + /// when the input is empty, null or ambiguous. For these cases, + /// [`Client::table`] can first be instantiated with a explicit schema. + /// + /// When instantiated with a _schema_, the resulting [`Table`] is empty but + /// with known column names and column types. When subsqeuently + /// populated with [`Table::update`], these columns will be _coerced_ to + /// the schema's type. This behavior can be useful when + /// [`Client::table`]'s column type inferences doesn't work. + /// + /// The resulting [`Table`] is _virtual_, and invoking its methods + /// dispatches events to the `perspective_server::Server` this + /// [`Client`] connects to, where the data is stored and all calculation + /// occurs. + /// + /// # Arguments + /// + /// - `arg` - Either _schema_ or initialization _data_. + /// - `options` - Optional configuration which provides one of: + /// - `limit` - The max number of rows the resulting [`Table`] can + /// store. + /// - `index` - The column name to use as an _index_ column. If this + /// `Table` is being instantiated by _data_, this column name must be + /// present in the data. + /// - `name` - The name of the table. This will be generated if it is + /// not provided. + /// - `format` - The explicit format of the input data, can be one of + /// `"json"`, `"columns"`, `"csv"` or `"arrow"`. This overrides + /// language-specific type dispatch behavior, which allows stringified + /// and byte array alternative inputs. + /// + /// # Python Examples + /// + /// Load a CSV from a `str`: + /// + /// ```python + /// table = client.table("x,y\n1,2\n3,4") + /// ``` + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = (input, limit=None, index=None, name=None, format=None, page_to_disk=None))] + pub fn table( + &self, + py: Python<'_>, + input: Py, + limit: Option, + index: Option>, + name: Option>, + format: Option>, + page_to_disk: Option, + ) -> PyResult
    { + Ok(Table( + self.0 + .table(input, limit, index, name, format, page_to_disk) + .py_block_on(py)?, + )) + } + + /// Opens a [`Table`] that is hosted on the `perspective_server::Server` + /// that is connected to this [`Client`]. + /// + /// The `name` property of [`TableInitOptions`] is used to identify each + /// [`Table`]. [`Table`] `name`s can be looked up for each [`Client`] + /// via [`Client::get_hosted_table_names`]. + /// + /// # Python Examples + /// + /// ```python + /// table = client.open_table("table_one"); + /// ``` + pub fn open_table(&self, py: Python<'_>, name: String) -> PyResult
    { + let client = self.0.clone(); + let table = client.open_table(name).py_block_on(py)?; + Ok(Table(table)) + } + + /// Creates a new read-only [`Table`] by performing a JOIN on two + /// source tables. The resulting table is reactive: when either source + /// table is updated, the join is automatically recomputed. + /// + /// # Python Examples + /// + /// ```python + /// joined = client.join(orders_table, products_table, "Product ID", "left") + /// ``` + #[pyo3(signature = (left, right, on, join_type=None, name=None, right_on=None))] + #[allow(clippy::too_many_arguments, reason = "This is a Python API")] + pub fn join( + &self, + py: Python<'_>, + left: &Bound<'_, PyAny>, + right: &Bound<'_, PyAny>, + on: String, + join_type: Option, + name: Option, + right_on: Option, + ) -> PyResult
    { + let left_ref = py_to_table_ref(left)?; + let right_ref = py_to_table_ref(right)?; + let jt = parse_join_type(join_type.as_deref())?; + let options = perspective_client::JoinOptions { + join_type: Some(jt), + name, + right_on, + }; + let table = self + .0 + .client + .join(left_ref, right_ref, &on, options) + .py_block_on(py) + .into_pyerr()?; + Ok(Table(AsyncTable { + table: Arc::new(table), + client: self.0.clone(), + })) + } + + /// Retrieves the names of all tables that this client has access to. + /// + /// `name` is a string identifier unique to the [`Table`] (per [`Client`]), + /// which can be used in conjunction with [`Client::open_table`] to get + /// a [`Table`] instance without the use of [`Client::table`] + /// constructor directly (e.g., one created by another [`Client`]). + /// + /// # Python Examples + /// + /// ```python + /// tables = client.get_hosted_table_names(); + /// ``` + pub fn get_hosted_table_names(&self, py: Python<'_>) -> PyResult> { + self.0.get_hosted_table_names().py_block_on(py) + } + + /// Register a callback which is invoked whenever [`Client::table`] (on this + /// [`Client`]) or [`Table::delete`] (on a [`Table`] belinging to this + /// [`Client`]) are called. + pub fn on_hosted_tables_update(&self, py: Python<'_>, callback: Py) -> PyResult { + self.0.on_hosted_tables_update(callback).py_block_on(py) + } + + /// Remove a callback previously registered via + /// [`Client::on_hosted_tables_update`]. + pub fn remove_hosted_tables_update(&self, py: Python<'_>, callback_id: u32) -> PyResult<()> { + self.0 + .remove_hosted_tables_update(callback_id) + .py_block_on(py) + } + + /// Provides the [`SystemInfo`] struct, implementation-specific metadata + /// about the [`perspective_server::Server`] runtime such as Memory and + /// CPU usage. + pub fn system_info(&self, py: Python<'_>) -> PyResult> { + self.0.system_info().py_block_on(py) + } + + /// Terminates this [`Client`], cleaning up any [`View`] handles the + /// [`Client`] has open as well as its callbacks. + pub fn terminate(&self, py: Python<'_>) -> PyResult<()> { + self.0.terminate(py) + } +} + +/// [`Table`] is Perspective's columnar data frame, analogous to a Pandas/Polars +/// `DataFrame` or Apache Arrow, supporting append & in-place updates, removal +/// by index, and update notifications. +/// +/// A [`Table`] contains columns, each of which have a unique name, are strongly +/// and consistently typed, and contains rows of data conforming to the column's +/// type. Each column in a [`Table`] must have the same number of rows, though +/// not every row must contain data; null-values are used to indicate missing +/// values in the dataset. The schema of a [`Table`] is _immutable after +/// creation_, which means the column names and data types cannot be changed +/// after the [`Table`] has been created. Columns cannot be added or deleted +/// after creation either, but a [`View`] can be used to select an arbitrary set +/// of columns from the [`Table`]. +#[pyclass(subclass, name = "Table", module = "perspective")] +pub struct Table(AsyncTable); + +assert_table_api!(Table); + +#[pymethods] +impl Table { + #[new] + fn new() -> PyResult { + Err(PyTypeError::new_err( + "Do not call Table's constructor directly, construct from a Client instance.", + )) + } + + /// Returns the name of the index column for the table. + /// + /// # Python Examples + /// + /// ```python + /// table = perspective.table("x,y\n1,2\n3,4", index="x"); + /// index = client.get_index() + /// ``` + pub fn get_index(&self) -> Option { + self.0.get_index() + } + + /// Get a copy of the [`Client`] this [`Table`] came from. + pub fn get_client(&self, py: Python<'_>) -> Client { + Client(self.0.get_client().py_block_on(py)) + } + + /// Returns the user-specified row limit for this table. + pub fn get_limit(&self) -> Option { + self.0.get_limit() + } + + /// Returns the user-specified name for this table, or the auto-generated + /// name if a name was not specified when the table was created. + pub fn get_name(&self) -> String { + self.0.get_name() + } + + /// Removes all the rows in the [`Table`], but preserves everything else + /// including the schema, index, and any callbacks or registered + /// [`View`] instances. + /// + /// Calling [`Table::clear`], like [`Table::update`] and [`Table::remove`], + /// will trigger an update event to any registered listeners via + /// [`View::on_update`]. + pub fn clear(&self, py: Python<'_>) -> PyResult<()> { + self.0.clear().py_block_on(py) + } + + /// Returns the column names of this [`Table`] in "natural" order (the + /// ordering implied by the input format). + /// + /// # Python Examples + /// + /// ```python + /// columns = table.columns() + /// ``` + pub fn columns(&self, py: Python<'_>) -> PyResult> { + self.0.columns().py_block_on(py) + } + + /// Delete this [`Table`] and cleans up associated resources. + /// + /// [`Table`]s do not stop consuming resources or processing updates when + /// they are garbage collected in their host language - you must call + /// this method to reclaim these. + /// + /// # Arguments + /// + /// - `options` An options dictionary. + /// - `lazy` Whether to delete this [`Table`] _lazily_. When false (the + /// default), the delete will occur immediately, assuming it has no + /// [`View`] instances registered to it (which must be deleted first, + /// otherwise this method will throw an error). When true, the + /// [`Table`] will only be marked for deltion once its [`View`] + /// dependency count reaches 0. + /// + /// # Python Examples + /// + /// ```python + /// table = client.table("x,y\n1,2\n3,4") + /// + /// # ... + /// + /// table.delete(lazy=True) + /// ``` + #[pyo3(signature=(lazy=false))] + pub fn delete(&self, py: Python<'_>, lazy: bool) -> PyResult<()> { + self.0.delete(lazy).py_block_on(py) + } + + /// Create a unique channel ID on this [`Table`], which allows + /// `View::on_update` callback calls to be associated with the + /// `Table::update` which caused them. + pub fn make_port(&self, py: Python<'_>) -> PyResult { + let table = self.0.clone(); + table.make_port().py_block_on(py) + } + + /// Register a callback which is called exactly once, when this [`Table`] is + /// deleted with the [`Table::delete`] method. + /// + /// [`Table::on_delete`] resolves when the subscription message is sent, not + /// when the _delete_ event occurs. + pub fn on_delete(&self, py: Python<'_>, callback: Py) -> PyResult { + let table = self.0.clone(); + table.on_delete(callback).py_block_on(py) + } + + #[pyo3(signature = (input, format=None))] + pub fn remove(&self, py: Python<'_>, input: Py, format: Option) -> PyResult<()> { + let table = self.0.clone(); + table.remove(input, format).py_block_on(py) + } + + /// Removes a listener with a given ID, as returned by a previous call to + /// [`Table::on_delete`]. + pub fn remove_delete(&self, py: Python<'_>, callback_id: u32) -> PyResult<()> { + let table = self.0.clone(); + table.remove_delete(callback_id).py_block_on(py) + } + + /// Returns a table's [`Schema`], a mapping of column names to column types. + /// + /// The mapping of a [`Table`]'s column names to data types is referred to + /// as a [`Schema`]. Each column has a unique name and a data type, one + /// of: + /// + /// - `"boolean"` - A boolean type + /// - `"date"` - A timesonze-agnostic date type (month/day/year) + /// - `"datetime"` - A millisecond-precision datetime type in the UTC + /// timezone + /// - `"float"` - A 64 bit float + /// - `"integer"` - A signed 32 bit integer (the integer type supported by + /// JavaScript) + /// - `"string"` - A `String` data type (encoded internally as a + /// _dictionary_) + /// + /// Note that all [`Table`] columns are _nullable_, regardless of the data + /// type. + pub fn schema(&self, py: Python<'_>) -> PyResult> { + let table = self.0.clone(); + table.schema().py_block_on(py) + } + + /// Validates the given expressions. + pub fn validate_expressions( + &self, + py: Python<'_>, + expression: Py, + ) -> PyResult> { + let table = self.0.clone(); + table.validate_expressions(expression).py_block_on(py) + } + + /// Create a new [`View`] from this table with a specified + /// [`ViewConfigUpdate`]. + /// + /// See [`View`] struct. + /// + /// # Examples + /// + /// ```python + /// view view = table.view( + /// columns=["Sales"], + /// aggregates={"Sales": "sum"}, + /// group_by=["Region", "State"], + /// ) + /// ``` + #[pyo3(signature = (**config))] + pub fn view(&self, py: Python<'_>, config: Option>) -> PyResult { + Ok(View(self.0.view(config).py_block_on(py)?)) + } + + /// Returns the number of rows in a [`Table`]. + pub fn size(&self, py: Python<'_>) -> PyResult { + self.0.size().py_block_on(py) + } + + /// Removes all the rows in the [`Table`], but preserves everything else + /// including the schema, index, and any callbacks or registered + /// [`View`] instances. + /// + /// Calling [`Table::clear`], like [`Table::update`] and [`Table::remove`], + /// will trigger an update event to any registered listeners via + /// [`View::on_update`]. + #[pyo3(signature = (input, format=None))] + pub fn replace( + &self, + py: Python<'_>, + input: Py, + format: Option, + ) -> PyResult<()> { + self.0.replace(input, format).py_block_on(py) + } + + /// Updates the rows of this table and any derived [`View`] instances. + /// + /// Calling [`Table::update`] will trigger the [`View::on_update`] callbacks + /// register to derived [`View`], and the call itself will not resolve until + /// _all_ derived [`View`]'s are notified. + /// + /// When updating a [`Table`] with an `index`, [`Table::update`] supports + /// partial updates, by omitting columns from the update data. + /// + /// # Arguments + /// + /// - `input` - The input data for this [`Table`]. The schema of a [`Table`] + /// is immutable after creation, so this method cannot be called with a + /// schema. + /// - `options` - Options for this update step - see + /// [`perspective_client::UpdateOptions`]. + /// ``` + #[pyo3(signature = (input, port_id=None, format=None))] + pub fn update( + &self, + py: Python<'_>, + input: Py, + port_id: Option, + format: Option, + ) -> PyResult<()> { + self.0.update(input, port_id, format).py_block_on(py) + } +} + +/// The [`View`] struct is Perspective's query and serialization interface. It +/// represents a query on the `Table`'s dataset and is always created from an +/// existing `Table` instance via the [`Table::view`] method. +/// +/// [`View`]s are immutable with respect to the arguments provided to the +/// [`Table::view`] method; to change these parameters, you must create a new +/// [`View`] on the same [`Table`]. However, each [`View`] is _live_ with +/// respect to the [`Table`]'s data, and will (within a conflation window) +/// update with the latest state as its parent [`Table`] updates, including +/// incrementally recalculating all aggregates, pivots, filters, etc. [`View`] +/// query parameters are composable, in that each parameter works independently +/// _and_ in conjunction with each other, and there is no limit to the number of +/// pivots, filters, etc. which can be applied. +/// +/// To construct a [`View`], call the [`Table::view`] factory method. A +/// [`Table`] can have as many [`View`]s associated with it as you need - +/// Perspective conserves memory by relying on a single [`Table`] to power +/// multiple [`View`]s concurrently. +#[pyclass(subclass, name = "View", module = "perspective")] +pub struct View(pub(crate) AsyncView); + +assert_view_api!(View); + +#[pymethods] +impl View { + #[new] + fn new() -> PyResult { + Err(PyTypeError::new_err( + "Do not call View's constructor directly, construct from a Table instance.", + )) + } + + /// Returns an array of strings containing the column paths of the [`View`] + /// without any of the source columns. + /// + /// A column path shows the columns that a given cell belongs to after + /// pivots are applied. + #[pyo3(signature = (**window))] + pub fn column_paths( + &self, + py: Python<'_>, + window: Option>, + ) -> PyResult> { + self.0.column_paths(window).py_block_on(py) + } + + /// Renders this [`View`] as a column-oriented JSON string. Useful if you + /// want to save additional round trip serialize/deserialize cycles. + #[pyo3(signature = (**window))] + pub fn to_columns_string( + &self, + py: Python<'_>, + window: Option>, + ) -> PyResult { + self.0.to_columns_string(window).py_block_on(py) + } + + /// Renders this `View` as a row-oriented JSON string. + #[pyo3(signature = (**window))] + pub fn to_json_string(&self, py: Python<'_>, window: Option>) -> PyResult { + self.0.to_json_string(window).py_block_on(py) + } + + /// Renders this [`View`] as an [NDJSON](https://github.com/ndjson/ndjson-spec) + /// formatted `String`. + #[pyo3(signature = (**window))] + pub fn to_ndjson(&self, py: Python<'_>, window: Option>) -> PyResult { + self.0.to_ndjson(window).py_block_on(py) + } + + /// Renders this [`View`] as a row-oriented Python `list`. + #[pyo3(signature = (**window))] + pub fn to_records<'a>( + &self, + py: Python<'a>, + window: Option>, + ) -> PyResult> { + let json = self.0.to_json_string(window).py_block_on(py)?; + let json_module = PyModule::import(py, "json")?; + json_module.call_method1("loads", (json,)) + } + + /// Renders this [`View`] as a row-oriented Python `list`. + #[pyo3(signature = (**window))] + pub fn to_json<'a>( + &self, + py: Python<'a>, + window: Option>, + ) -> PyResult> { + self.to_records(py, window) + } + + /// Renders this [`View`] as a column-oriented Python `dict`. + #[pyo3(signature = (**window))] + pub fn to_columns<'a>( + &self, + py: Python<'a>, + window: Option>, + ) -> PyResult> { + let json = self.0.to_columns_string(window).py_block_on(py)?; + let json_module = PyModule::import(py, "json")?; + json_module.call_method1("loads", (json,)) + } + + /// Renders this [`View`] as a CSV `String` in a standard format. + #[pyo3(signature = (**window))] + pub fn to_csv(&self, py: Python<'_>, window: Option>) -> PyResult { + self.0.to_csv(window).py_block_on(py) + } + + /// Renders this [`View`] as a `pandas.DataFrame`. + #[pyo3(signature = (**window))] + // #[deprecated(since="3.2.0", note="Please use `View::to_pandas`")] + pub fn to_dataframe(&self, py: Python<'_>, window: Option>) -> PyResult> { + self.0.to_dataframe(window).py_block_on(py) + } + + /// Renders this [`View`] as a `pandas.DataFrame`. + #[pyo3(signature = (**window))] + pub fn to_pandas(&self, py: Python<'_>, window: Option>) -> PyResult> { + self.0.to_dataframe(window).py_block_on(py) + } + + /// Renders this [`View`] as a `polars.DataFrame`. + #[pyo3(signature = (**window))] + pub fn to_polars(&self, py: Python<'_>, window: Option>) -> PyResult> { + self.0.to_polars(window).py_block_on(py) + } + + /// Renders this [`View`] as the Apache Arrow data format. + /// + /// # Arguments + /// + /// - `window` - a [`ViewWindow`] + #[pyo3(signature = (**window))] + pub fn to_arrow(&self, py: Python<'_>, window: Option>) -> PyResult> { + self.0.to_arrow(window).py_block_on(py) + } + + /// Delete this [`View`] and clean up all resources associated with it. + /// [`View`] objects do not stop consuming resources or processing + /// updates when they are garbage collected - you must call this method + /// to reclaim these. + pub fn delete(&self, py: Python<'_>) -> PyResult<()> { + self.0.delete().py_block_on(py) + } + + pub fn expand(&self, py: Python<'_>, index: u32) -> PyResult { + self.0.expand(index).py_block_on(py) + } + + pub fn collapse(&self, py: Python<'_>, index: u32) -> PyResult { + self.0.collapse(index).py_block_on(py) + } + + /// Returns this [`View`]'s _dimensions_, row and column count, as well as + /// those of the [`crate::Table`] from which it was derived. + /// + /// - `num_table_rows` - The number of rows in the underlying + /// [`crate::Table`]. + /// - `num_table_columns` - The number of columns in the underlying + /// [`crate::Table`] (including the `index` column if this + /// [`crate::Table`] was constructed with one). + /// - `num_view_rows` - The number of rows in this [`View`]. If this + /// [`View`] has a `group_by` clause, `num_view_rows` will also include + /// aggregated rows. + /// - `num_view_columns` - The number of columns in this [`View`]. If this + /// [`View`] has a `split_by` clause, `num_view_columns` will include all + /// _column paths_, e.g. the number of `columns` clause times the number + /// of `split_by` groups. + pub fn dimensions(&self, py: Python<'_>) -> PyResult> { + self.0.dimensions().py_block_on(py) + } + + /// The expression schema of this [`View`], which contains only the + /// expressions created on this [`View`]. See [`View::schema`] for + /// details. + pub fn expression_schema(&self, py: Python<'_>) -> PyResult> { + self.0.expression_schema().py_block_on(py) + } + + /// A copy of the [`ViewConfig`] object passed to the [`Table::view`] method + /// which created this [`View`]. + pub fn get_config(&self, py: Python<'_>) -> PyResult> { + self.0.get_config().py_block_on(py) + } + + /// Calculates the [min, max] of the leaf nodes of a column `column_name`. + /// + /// # Returns + /// + /// A tuple of [min, max], whose types are column and aggregate dependent. + pub fn get_min_max( + &self, + py: Python<'_>, + column_name: String, + ) -> PyResult<(PyObject, PyObject)> { + self.0.get_min_max(column_name).py_block_on(py) + } + + /// The number of aggregated rows in this [`View`]. This is affected by the + /// "group_by" configuration parameter supplied to this view's contructor. + /// + /// # Returns + /// + /// The number of aggregated rows. + pub fn num_rows(&self, py: Python<'_>) -> PyResult { + self.0.num_rows().py_block_on(py) + } + + /// The number of aggregated columns in this [`View`]. This is affected by + /// the "split_by" configuration parameter supplied to this view's + /// contructor. + /// + /// # Returns + /// + /// The number of aggregated columns. + pub fn num_columns(&self, py: Python<'_>) -> PyResult { + self.0.num_columns().py_block_on(py) + } + + /// The schema of this [`View`]. + /// + /// The [`View`] schema differs from the `schema` returned by + /// [`Table::schema`]; it may have different column names due to + /// `expressions` or `columns` configs, or it maye have _different + /// column types_ due to the application og `group_by` and `aggregates` + /// config. You can think of [`Table::schema`] as the _input_ schema and + /// [`View::schema`] as the _output_ schema of a Perspective pipeline. + pub fn schema(&self, py: Python<'_>) -> PyResult> { + self.0.schema().py_block_on(py) + } + + /// Register a callback with this [`View`]. Whenever the [`View`] is + /// deleted, this callback will be invoked. + pub fn on_delete(&self, py: Python<'_>, callback: Py) -> PyResult { + self.0.on_delete(callback).py_block_on(py) + } + + /// Unregister a previously registered [`View::on_delete`] callback. + pub fn remove_delete(&self, py: Python<'_>, callback_id: u32) -> PyResult<()> { + self.0.remove_delete(callback_id).py_block_on(py) + } + + /// Register a callback with this [`View`]. Whenever the view's underlying + /// table emits an update, this callback will be invoked with an object + /// containing `port_id`, indicating which port the update fired on, and + /// optionally `delta`, which is the new data that was updated for each + /// cell or each row. + /// + /// # Arguments + /// + /// - `on_update` - A callback function invoked on update, which receives an + /// object with two keys: `port_id`, indicating which port the update was + /// triggered on, and `delta`, whose value is dependent on the mode + /// parameter. + /// - `options` - If this is provided as `OnUpdateOptions { mode: + /// Some(OnUpdateMode::Row) }`, then `delta` is an Arrow of the updated + /// rows. Otherwise `delta` will be [`Option::None`]. + #[pyo3(signature = (callback, mode=None))] + pub fn on_update( + &self, + py: Python<'_>, + callback: Py, + mode: Option, + ) -> PyResult { + self.0.on_update(callback, mode).py_block_on(py) + } + + /// Unregister a previously registered update callback with this [`View`]. + /// + /// # Arguments + /// + /// - `id` - A callback `id` as returned by a recipricol call to + /// [`View::on_update`]. + /// + /// # Examples + /// + /// ```rust + /// let callback = |_| async { print!("Updated!") }; + /// let cid = view.on_update(callback, OnUpdateOptions::default()).await?; + /// view.remove_update(cid).await?; + /// ``` + pub fn remove_update(&self, py: Python<'_>, callback_id: u32) -> PyResult<()> { + self.0.remove_update(callback_id).py_block_on(py) + } +} diff --git a/rust/perspective-python/src/client/mod.rs b/rust/perspective-python/src/client/mod.rs new file mode 100644 index 0000000..b98cc99 --- /dev/null +++ b/rust/perspective-python/src/client/mod.rs @@ -0,0 +1,20 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +pub mod client_async; +pub mod client_sync; +mod pandas; +mod polars; +pub mod proxy_session; +mod pyarrow; +pub mod table_data; +pub mod update_data; diff --git a/rust/perspective-python/src/client/pandas.rs b/rust/perspective-python/src/client/pandas.rs new file mode 100644 index 0000000..379dbaa --- /dev/null +++ b/rust/perspective-python/src/client/pandas.rs @@ -0,0 +1,110 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 pyo3::exceptions::{PyImportError, PyValueError}; +use pyo3::prelude::*; +use pyo3::types::{PyAny, PyBytes, PyDict, PyList}; + +use super::pyarrow; + +fn get_pandas_df_cls(py: Python<'_>) -> PyResult>> { + let sys = PyModule::import(py, "sys")?; + if sys.getattr("modules")?.contains("pandas")? { + let pandas = PyModule::import(py, "pandas")?; + Ok(Some(pandas.getattr("DataFrame")?.into_pyobject(py)?)) + } else { + Ok(None) + } +} + +pub fn is_pandas_df(py: Python, df: &Bound<'_, PyAny>) -> PyResult { + if let Some(df_class) = get_pandas_df_cls(py)? { + df.is_instance(&df_class) + } else { + Ok(false) + } +} + +// ipc_bytes = self.to_arrow() +// table = pa.ipc.open_stream(ipc_bytes).read_all() +// x = pd.DataFrame(table.to_pandas()) +// print("AAA", x) +// return x + +pub fn arrow_to_pandas(py: Python<'_>, arrow: &[u8]) -> PyResult> { + let pyarrow = PyModule::import(py, "pyarrow")?; + let bytes = PyBytes::new(py, arrow); + Ok(pyarrow + .getattr("ipc")? + .getattr("open_stream")? + .call1((bytes,))? + .getattr("read_all")? + .call0()? + .getattr("to_pandas")? + .call0()? + .unbind()) +} + +pub fn pandas_to_arrow_bytes<'py>( + py: Python<'py>, + df: &Bound<'py, PyAny>, +) -> PyResult> { + let pyarrow = match PyModule::import(py, "pyarrow") { + Ok(pyarrow) => pyarrow, + Err(_) => { + return Err(PyImportError::new_err( + "Perspective requires pyarrow to convert pandas DataFrames. Please install \ + pyarrow.", + )); + }, + }; + + let df_class = get_pandas_df_cls(py)? + .ok_or_else(|| PyValueError::new_err("Failed to import pandas.DataFrame"))?; + + if !df.is_instance(&df_class)? { + return Err(PyValueError::new_err("Input is not a pandas.DataFrame")); + } + + let kwargs = PyDict::new(py); + kwargs.set_item("preserve_index", true)?; + + let table = pyarrow + .getattr("Table")? + .call_method("from_pandas", (df,), Some(&kwargs))?; + + // rename from __index_level_0__ to index + let old_names: Vec = table.getattr("column_names")?.extract()?; + let mut new_names: Vec = old_names + .into_iter() + .map(|e| { + if e == "__index_level_0__" { + "index".to_string() + } else { + e + } + }) + .collect(); + + let names = PyList::new(py, new_names.clone())?; + let table = table.call_method1("rename_columns", (names,))?; + + // move the index column to be the first column. + if new_names[new_names.len() - 1] == "index" { + new_names.rotate_right(1); + let order = PyList::new(py, new_names)?; + let table = table.call_method1("select", (order,))?; + pyarrow::to_arrow_bytes(py, &table) + } else { + pyarrow::to_arrow_bytes(py, &table) + } +} diff --git a/rust/perspective-python/src/client/polars.rs b/rust/perspective-python/src/client/polars.rs new file mode 100644 index 0000000..7fa4d33 --- /dev/null +++ b/rust/perspective-python/src/client/polars.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 pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::{PyAny, PyBytes, PyList}; + +use super::pyarrow; + +fn get_polars_df_cls(py: Python<'_>) -> PyResult>> { + let sys = PyModule::import(py, "sys")?; + if sys.getattr("modules")?.contains("polars")? { + let polars = PyModule::import(py, "polars")?; + Ok(Some(polars.getattr("DataFrame")?.into_pyobject(py)?)) + } else { + Ok(None) + } +} + +fn get_polars_lf_cls(py: Python<'_>) -> PyResult>> { + let sys = PyModule::import(py, "sys")?; + if sys.getattr("modules")?.contains("polars")? { + let polars = PyModule::import(py, "polars")?; + Ok(Some(polars.getattr("LazyFrame")?.into_pyobject(py)?)) + } else { + Ok(None) + } +} + +pub fn is_polars_df(py: Python, df: &Bound<'_, PyAny>) -> PyResult { + if let Some(df_class) = get_polars_df_cls(py)? { + df.is_instance(&df_class) + } else { + Ok(false) + } +} + +pub fn is_polars_lf(py: Python, df: &Bound<'_, PyAny>) -> PyResult { + if let Some(df_class) = get_polars_lf_cls(py)? { + df.is_instance(&df_class) + } else { + Ok(false) + } +} + +pub fn arrow_to_polars(py: Python<'_>, arrow: &[u8]) -> PyResult> { + let polars = PyModule::import(py, "polars")?; + let bytes = PyBytes::new(py, arrow); + Ok(polars.getattr("read_ipc_stream")?.call1((bytes,))?.unbind()) +} + +pub fn polars_to_arrow_bytes<'py>( + py: Python<'py>, + df: &Bound<'py, PyAny>, +) -> PyResult> { + let df_class = get_polars_df_cls(py)? + .ok_or_else(|| PyValueError::new_err("Failed to import polars.DataFrame"))?; + + let lf_class = get_polars_lf_cls(py)? + .ok_or_else(|| PyValueError::new_err("Failed to import polars.LazyFrame"))?; + + if !df.is_instance(&df_class)? && !df.is_instance(&lf_class)? { + return Err(PyValueError::new_err( + "Input is not a polars.DataFrame or polars.LazyFrame", + )); + } + + let is_lazyframe = df.is_instance(&lf_class)?; + let table = if is_lazyframe { + df.call_method0("collect")?.call_method0("to_arrow")? + } else { + df.call_method0("to_arrow")? + }; + + // rename from __index_level_0__ to index + let old_names: Vec = table.getattr("column_names")?.extract()?; + let mut new_names: Vec = old_names + .into_iter() + .map(|e| { + if e == "__index_level_0__" { + "index".to_string() + } else { + e + } + }) + .collect(); + + let names = PyList::new(py, new_names.clone())?; + let table = table.call_method1("rename_columns", (names,))?; + + // move the index column to be the first column. + if new_names[new_names.len() - 1] == "index" { + new_names.rotate_right(1); + let order = PyList::new(py, new_names)?; + let table = table.call_method1("select", (order,))?; + pyarrow::to_arrow_bytes(py, &table) + } else { + pyarrow::to_arrow_bytes(py, &table) + } +} diff --git a/rust/perspective-python/src/client/proxy_session.rs b/rust/perspective-python/src/client/proxy_session.rs new file mode 100644 index 0000000..48462fb --- /dev/null +++ b/rust/perspective-python/src/client/proxy_session.rs @@ -0,0 +1,74 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 perspective_client::Session; +use pyo3::exceptions::PyTypeError; +use pyo3::prelude::*; +use pyo3::types::*; + +use super::client_async::AsyncClient; +use super::client_sync::{Client as SyncClient, PyFutureExt}; +use crate::py_err::ResultTClientErrorExt; + +#[pyclass(module = "perspective")] +#[derive(Clone)] +pub struct ProxySession(perspective_client::ProxySession); + +#[pymethods] +impl ProxySession { + /// Construct a proxy session from an AsyncClient or Client object and a + /// `handle_request` callback. The callback should ultimately invoke + /// `handle_request` on another client, passing along the argument + /// passed to it. + #[new] + fn new(py: Python<'_>, client: Py, handle_request: Py) -> PyResult { + let client = if let Ok(py_client) = client.downcast_bound::(py) { + py_client.borrow().client.clone() + } else if let Ok(py_client) = client.downcast_bound::(py) { + py_client.borrow().0.client.clone() + } else { + return Err(PyTypeError::new_err( + "ProxySession::new() not passed a Perspective client", + )); + }; + + let callback = { + move |msg: &[u8]| { + let msg = msg.to_vec(); + Python::with_gil(|py| { + let bytes = PyBytes::new(py, &msg); + handle_request.call1(py, (bytes,))?; + Ok(()) + }) + } + }; + + Ok(ProxySession(perspective_client::ProxySession::new( + client, callback, + ))) + } + + pub fn handle_request(&self, py: Python<'_>, data: Vec) -> PyResult<()> { + self.0.handle_request(&data).py_block_on(py).into_pyerr()?; + Ok(()) + } + + pub async fn handle_request_async(&self, data: Vec) -> PyResult<()> { + self.0.handle_request(&data).await.into_pyerr()?; + Ok(()) + } + + pub fn close(&self, py: Python<'_>) -> PyResult<()> { + self.0.clone().close().py_block_on(py); + Ok(()) + } +} diff --git a/rust/perspective-python/src/client/pyarrow.rs b/rust/perspective-python/src/client/pyarrow.rs new file mode 100644 index 0000000..91dbe33 --- /dev/null +++ b/rust/perspective-python/src/client/pyarrow.rs @@ -0,0 +1,64 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::{PyAny, PyBytes}; + +fn get_pyarrow_table_cls(py: Python<'_>) -> PyResult>> { + let sys = PyModule::import(py, "sys")?; + if sys.getattr("modules")?.contains("pyarrow")? { + let pandas = PyModule::import(py, "pyarrow")?; + Ok(Some(pandas.getattr("Table")?.unbind())) + } else { + Ok(None) + } +} + +pub fn is_arrow_table(py: Python, table: &Bound<'_, PyAny>) -> PyResult { + if let Some(table_class) = get_pyarrow_table_cls(py)? { + table.is_instance(table_class.bind(py)) + } else { + Ok(false) + } +} + +pub fn to_arrow_bytes<'py>( + py: Python<'py>, + table: &Bound<'py, PyAny>, +) -> PyResult> { + let pyarrow = PyModule::import(py, "pyarrow")?; + let table_class = get_pyarrow_table_cls(py)? + .ok_or_else(|| PyValueError::new_err("Failed to import pyarrow.Table"))?; + + if !table.is_instance(table_class.bind(py))? { + return Err(PyValueError::new_err("Input is not a pyarrow.Table")); + } + + let sink = pyarrow.call_method0("BufferOutputStream")?; + + { + let writer = pyarrow.call_method1( + "RecordBatchFileWriter", + (sink.clone(), table.getattr("schema")?), + )?; + + writer.call_method1("write_table", (table,))?; + writer.call_method0("close")?; + } + + // Get the value from the sink and convert it to Python bytes + let value = sink.call_method0("getvalue")?; + let obj = value.call_method0("to_pybytes")?; + let pybytes = obj.downcast_into::()?; + Ok(pybytes) +} diff --git a/rust/perspective-python/src/client/table_data.rs b/rust/perspective-python/src/client/table_data.rs new file mode 100644 index 0000000..3281352 --- /dev/null +++ b/rust/perspective-python/src/client/table_data.rs @@ -0,0 +1,92 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 perspective_client::config::ColumnType; +use perspective_client::{TableData, TableReadFormat, UpdateData}; +use pyo3::exceptions::{PyTypeError, PyValueError}; +use pyo3::prelude::*; +use pyo3::types::{PyAny, PyAnyMethods, PyDict, PyList, PyString, PyType}; + +use super::update_data::UpdateDataExt; +use crate::py_err::ResultTClientErrorExt; + +fn psp_type_from_py_type(_py: Python<'_>, val: Bound<'_, PyAny>) -> PyResult { + if let Ok(pystr) = val.downcast::() { + ColumnType::try_from(pystr.to_string_lossy().as_ref()).into_pyerr() + } else if let Ok(val) = val.downcast::() { + let (module, typename) = (val.module()?, val.name()?); + match ( + module.to_string_lossy().as_ref(), + typename.to_string_lossy().as_ref(), + ) { + ("builtins", "int") => Ok(ColumnType::Integer), + ("builtins", "float") => Ok(ColumnType::Float), + ("builtins", "str") => Ok(ColumnType::String), + ("builtins", "bool") => Ok(ColumnType::Boolean), + ("datetime", "date") => Ok(ColumnType::Date), + ("datetime", "datetime") => Ok(ColumnType::Datetime), + (modname, typename) => Err(PyTypeError::new_err(format!("{modname}.{typename}"))), + } + } else { + Err(PyTypeError::new_err(format!( + "Unknown schema type {:?}", + val.get_type().name()? + ))) + } +} + +fn from_dict(py: Python<'_>, pydict: &Bound<'_, PyDict>) -> Result { + let first_key = pydict.keys().get_item(0)?; + let first_item = pydict + .get_item(first_key)? + .ok_or_else(|| PyValueError::new_err("Schema has no columns"))?; + + if first_item.downcast::().is_ok() { + let json_module = PyModule::import(py, "json")?; + let string = json_module.call_method("dumps", (pydict,), None)?; + Ok(UpdateData::JsonColumns(string.extract::()?).into()) + } else { + let mut schema = vec![]; + for (key, val) in pydict.into_iter() { + schema.push((key.extract::()?, psp_type_from_py_type(py, val)?)); + } + + Ok(TableData::Schema(schema)) + } +} + +#[extend::ext] +pub impl TableData { + fn from_py( + input: Bound<'_, PyAny>, + format: Option, + ) -> Result { + if let Ok(view) = input.downcast::() { + Ok(TableData::View((*view.borrow().view).clone())) + } else if let Ok(view) = input.downcast::() { + Ok(TableData::View((*view.borrow().0.view).clone())) + } else if let Some(update) = UpdateData::from_py_partial(&input, format)? { + Ok(TableData::Update(update)) + } else if let Ok(pylist) = input.downcast::() { + let json_module = PyModule::import(input.py(), "json")?; + let string = json_module.call_method("dumps", (pylist,), None)?; + Ok(UpdateData::JsonRows(string.extract::()?).into()) + } else if let Ok(pydict) = input.downcast::() { + from_dict(input.py(), pydict) + } else { + Err(PyTypeError::new_err(format!( + "Unknown input type {:?}", + input.get_type().name()? + ))) + } + } +} diff --git a/rust/perspective-python/src/client/update_data.rs b/rust/perspective-python/src/client/update_data.rs new file mode 100644 index 0000000..e1c69ab --- /dev/null +++ b/rust/perspective-python/src/client/update_data.rs @@ -0,0 +1,113 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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::any::Any; + +use perspective_client::{TableReadFormat, UpdateData}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::{PyAny, PyBytes, PyDict, PyList, PyString}; + +fn from_arrow( + pybytes: &Bound<'_, PyBytes>, + format: Option, +) -> Result, PyErr> { + // TODO need to explicitly qualify this b/c bug in + // rust-analyzer - should be: just `pybytes.as_bytes()`. + let vec = pyo3::prelude::PyBytesMethods::as_bytes(pybytes).to_vec(); + + match format { + Some(TableReadFormat::Csv) => Ok(Some(UpdateData::Csv(String::from_utf8(vec)?))), + Some(TableReadFormat::JsonString) => { + Ok(Some(UpdateData::JsonRows(String::from_utf8(vec)?))) + }, + Some(TableReadFormat::Ndjson) => Ok(Some(UpdateData::Ndjson(String::from_utf8(vec)?))), + Some(TableReadFormat::ColumnsString) => { + Ok(Some(UpdateData::JsonColumns(String::from_utf8(vec)?))) + }, + None | Some(TableReadFormat::Arrow) => Ok(Some(UpdateData::Arrow(vec.into()))), + } +} + +fn from_string( + pystring: &Bound<'_, PyString>, + format: Option, +) -> Result, PyErr> { + let string = pystring.extract::()?; + match format { + None | Some(TableReadFormat::Csv) => Ok(Some(UpdateData::Csv(string))), + Some(TableReadFormat::JsonString) => Ok(Some(UpdateData::JsonRows(string))), + Some(TableReadFormat::Ndjson) => Ok(Some(UpdateData::Ndjson(string))), + Some(TableReadFormat::ColumnsString) => Ok(Some(UpdateData::JsonColumns(string))), + Some(TableReadFormat::Arrow) => Ok(Some(UpdateData::Arrow(string.into_bytes().into()))), + } +} + +fn from_list(py: Python<'_>, pylist: &Bound<'_, PyList>) -> Result, PyErr> { + let json_module = PyModule::import(py, "json")?; + let string = json_module.call_method("dumps", (pylist,), None)?; + Ok(Some(UpdateData::JsonRows(string.extract::()?))) +} + +fn from_dict(py: Python<'_>, pydict: &Bound<'_, PyDict>) -> Result, PyErr> { + if pydict.keys().is_empty() { + return Err(PyValueError::new_err("Cannot infer type of empty dict")); + } + + let first_key = pydict.keys().get_item(0)?; + let first_item = pydict + .get_item(first_key)? + .ok_or_else(|| PyValueError::new_err("Bad Input"))?; + + if first_item.downcast::().is_ok() { + let json_module = PyModule::import(py, "json")?; + let string = json_module.call_method("dumps", (pydict,), None)?; + Ok(Some(UpdateData::JsonColumns(string.extract::()?))) + } else { + Ok(None) + } +} + +#[extend::ext] +pub impl UpdateData { + fn from_py_partial( + input: &Bound<'_, PyAny>, + format: Option, + ) -> Result, PyErr> { + let py = input.py(); + if let Ok(pybytes) = input.downcast::() { + from_arrow(pybytes, format) + } else if let Ok(pystring) = input.downcast::() { + from_string(pystring, format) + } else if let Ok(pylist) = input.downcast::() { + from_list(py, pylist) + } else if let Ok(pydict) = input.downcast::() { + from_dict(py, pydict) + } else { + Ok(None) + } + } + + fn from_py( + input: Bound<'_, PyAny>, + format: Option, + ) -> Result { + if let Some(x) = Self::from_py_partial(&input, format)? { + Ok(x) + } else { + Err(PyValueError::new_err(format!( + "Unknown input type {:?}", + input.unbind().type_id().clone() + ))) + } + } +} diff --git a/rust/perspective-python/src/lib.rs b/rust/perspective-python/src/lib.rs new file mode 100644 index 0000000..6485a7f --- /dev/null +++ b/rust/perspective-python/src/lib.rs @@ -0,0 +1,81 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#![doc = include_str!("../docs/lib.md")] +#![warn(unstable_features)] + +mod client; +pub(crate) mod py_async; +mod py_err; +mod server; + +pub use client::client_sync::{Client, Table, View}; +pub use client::proxy_session::ProxySession; +pub use perspective_client::ViewWindow; +pub use perspective_client::config::ViewConfigUpdate; +use py_err::PyPerspectiveError; +use pyo3::prelude::*; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::{EnvFilter, fmt}; + +/// Create a tracing filter which mimics the default behavior of reading from +/// env, customized to exclude timestamp. +/// [`tracing` filter docs](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/layer/index.html#per-layer-filtering) +fn init_tracing() { + let fmt_layer = fmt::layer().without_time().with_target(true); + let filter_layer = EnvFilter::try_from_default_env() + .or_else(|_| EnvFilter::try_new("info")) + .unwrap(); + + tracing_subscriber::registry() + .with(filter_layer) + .with(fmt_layer) + .init(); +} + +/// Returns the number of threads the internal threadpool will use. +#[pyfunction] +fn num_cpus() -> i32 { + perspective_server::num_cpus() +} + +/// Set the number of threads the internal threadpool will use. Can also be set +/// with `NUM_OMP_THREADS` environment variable. +#[pyfunction] +fn set_num_cpus(num_cpus: i32) { + perspective_server::set_num_cpus(num_cpus) +} + +/// Perspective Python main module. +#[pymodule] +fn perspective(py: Python, m: &Bound) -> PyResult<()> { + init_tracing(); + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + 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)?)?; + Ok(()) +} diff --git a/rust/perspective-python/src/py_async.rs b/rust/perspective-python/src/py_async.rs new file mode 100644 index 0000000..58672b9 --- /dev/null +++ b/rust/perspective-python/src/py_async.rs @@ -0,0 +1,108 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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, pin}; +use std::task::{Context, Poll}; + +use pyo3::Python; +#[cfg(not(target_os = "emscripten"))] +pub use pyo3_async_runtimes::tokio::into_future as py_into_future; + +#[cfg(target_os = "emscripten")] +pub use self::trivial::into_future as py_into_future; + +// https://pyo3.rs/main/async-await#release-the-gil-across-await +pub struct AllowThreads(pub F); + +impl Future for AllowThreads +where + F: Future + Unpin + Send, + F::Output: Send, +{ + type Output = F::Output; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let waker = cx.waker(); + Python::with_gil(|py| { + py.allow_threads(|| pin!(&mut self.0).poll(&mut Context::from_waker(waker))) + }) + } +} + +/// This do-nothing, panic all the time runtime is sufficient in emscripten +/// for the primitive test suite in test_smoke.py to pass +mod trivial { + use std::future::Future; + + use pyo3::prelude::*; + use pyo3_async_runtimes::generic::{ContextExt, JoinError, Runtime}; + + struct TrivialJoinError {} + impl JoinError for TrivialJoinError { + fn is_panic(&self) -> bool { + unimplemented!("TrivialJoinError::is_panic") + } + + fn into_panic(self) -> Box { + unimplemented!("TrivialJoinError::into_panic") + } + } + struct TrivialJoinHandle {} + impl Future for TrivialJoinHandle { + type Output = Result<(), TrivialJoinError>; + + fn poll( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll { + unimplemented!("TrivialJoinHandle::poll") + } + } + + struct TrivialRuntime {} + + impl Runtime for TrivialRuntime { + type JoinError = TrivialJoinError; + type JoinHandle = TrivialJoinHandle; + + fn spawn(_fut: F) -> Self::JoinHandle + where + F: std::future::Future + Send + 'static, + { + unimplemented!("TrivialRuntime::spawn") + } + } + + impl ContextExt for TrivialRuntime { + fn get_task_locals() -> Option { + None + } + + fn scope( + _locals: pyo3_async_runtimes::TaskLocals, + _fut: F, + ) -> std::pin::Pin + Send>> + where + F: std::future::Future + Send + 'static, + { + unimplemented!("TrivialRuntime::scope") + } + } + + #[allow(unused)] + pub fn into_future( + awaitable: Bound, + ) -> PyResult> + Send + use<>> { + pyo3_async_runtimes::generic::into_future::(awaitable) + } +} diff --git a/rust/perspective-python/src/py_err.rs b/rust/perspective-python/src/py_err.rs new file mode 100644 index 0000000..a14730e --- /dev/null +++ b/rust/perspective-python/src/py_err.rs @@ -0,0 +1,31 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 perspective_client::ClientError; +use pyo3::create_exception; +use pyo3::prelude::*; + +#[extend::ext] +pub impl Result { + fn into_pyerr(self) -> PyResult { + match self { + Ok(x) => Ok(x), + Err(x) => Err(PyPerspectiveError::new_err(format!("{x}"))), + } + } +} + +create_exception!( + perspective, + PyPerspectiveError, + pyo3::exceptions::PyException +); diff --git a/rust/perspective-python/src/server/generic_sql_model.rs b/rust/perspective-python/src/server/generic_sql_model.rs new file mode 100644 index 0000000..8af84ee --- /dev/null +++ b/rust/perspective-python/src/server/generic_sql_model.rs @@ -0,0 +1,184 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 indexmap::IndexMap; +use perspective_client::config::ViewConfig; +use perspective_client::proto::{ColumnType, ViewPort}; +use perspective_client::virtual_server::GenericSQLVirtualServerModel; +use pyo3::exceptions::PyValueError; +use pyo3::types::{PyAnyMethods, PyDict, PyDictMethods}; +use pyo3::{Py, PyAny, PyResult, Python, pyclass, pymethods}; + +#[pyclass(name = "GenericSQLVirtualServerModel")] +pub struct PyGenericSQLVirtualServerModel { + inner: GenericSQLVirtualServerModel, +} + +#[pymethods] +impl PyGenericSQLVirtualServerModel { + #[new] + pub fn new(py: Python<'_>, config: Option>) -> Self { + Self { + inner: GenericSQLVirtualServerModel::new( + config + .map(|x| pythonize::depythonize(x.bind(py)).unwrap()) + .unwrap_or_default(), + ), + } + } + + pub fn get_hosted_tables(&self) -> PyResult { + self.inner + .get_hosted_tables() + .map_err(|e| PyValueError::new_err(e.to_string())) + } + + pub fn table_schema(&self, table_id: &str) -> PyResult { + self.inner + .table_schema(table_id) + .map_err(|e| PyValueError::new_err(e.to_string())) + } + + pub fn table_size(&self, table_id: &str) -> PyResult { + self.inner + .table_size(table_id) + .map_err(|e| PyValueError::new_err(e.to_string())) + } + + pub fn view_column_size(&self, view_id: &str) -> PyResult { + self.inner + .view_column_size(view_id) + .map_err(|e| PyValueError::new_err(e.to_string())) + } + + pub fn table_validate_expression(&self, table_id: &str, expression: &str) -> PyResult { + self.inner + .table_validate_expression(table_id, expression) + .map_err(|e| PyValueError::new_err(e.to_string())) + } + + pub fn view_delete(&self, view_id: &str) -> PyResult { + self.inner + .view_delete(view_id) + .map_err(|e| PyValueError::new_err(e.to_string())) + } + + pub fn table_make_view( + &self, + table_id: &str, + view_id: &str, + config: Py, + ) -> PyResult { + let config: ViewConfig = Python::with_gil(|py| { + pythonize::depythonize(config.bind(py)) + .map_err(|e| PyValueError::new_err(e.to_string())) + })?; + + self.inner + .table_make_view(table_id, view_id, &config) + .map_err(|e| PyValueError::new_err(e.to_string())) + } + + pub fn view_get_data( + &self, + view_id: &str, + config: Py, + viewport: Py, + schema: Py, + ) -> PyResult { + Python::with_gil(|py| { + let config: ViewConfig = pythonize::depythonize(config.bind(py)) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + + let viewport: PyViewPort = pythonize::depythonize(viewport.bind(py)) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + + let schema = + self.parse_schema(schema.downcast_bound::(py).map_err(|_| { + PyValueError::new_err("Schema must be a dict mapping column names to types") + })?)?; + + self.inner + .view_get_data(view_id, &config, &viewport.into(), &schema) + .map_err(|e| PyValueError::new_err(e.to_string())) + }) + } + + pub fn view_schema(&self, view_id: &str) -> PyResult { + self.inner + .view_schema(view_id) + .map_err(|e| PyValueError::new_err(e.to_string())) + } + + pub fn view_size(&self, view_id: &str) -> PyResult { + self.inner + .view_size(view_id) + .map_err(|e| PyValueError::new_err(e.to_string())) + } + + pub fn view_get_min_max( + &self, + view_id: &str, + column_name: &str, + config: Py, + ) -> PyResult { + let config: ViewConfig = Python::with_gil(|py| { + pythonize::depythonize(config.bind(py)) + .map_err(|e| PyValueError::new_err(e.to_string())) + })?; + + self.inner + .view_get_min_max(view_id, column_name, &config) + .map_err(|e| PyValueError::new_err(e.to_string())) + } +} + +impl PyGenericSQLVirtualServerModel { + fn parse_schema( + &self, + schema: &pyo3::Bound<'_, PyDict>, + ) -> PyResult> { + let mut result = IndexMap::new(); + for (key, value) in schema.iter() { + let key: String = key.extract()?; + let value: String = value.extract()?; + let column_type = ColumnType::from_str(&value) + .map_err(|_| PyValueError::new_err(format!("Unknown column type: {}", value)))?; + + result.insert(key, column_type); + } + + Ok(result) + } +} + +#[derive(serde::Deserialize)] +struct PyViewPort { + start_row: Option, + start_col: Option, + end_row: Option, + end_col: Option, +} + +impl From for ViewPort { + fn from(value: PyViewPort) -> Self { + ViewPort { + start_row: value.start_row, + start_col: value.start_col, + end_row: value.end_row, + end_col: value.end_col, + emit_legacy_row_path_names: None, + } + } +} diff --git a/rust/perspective-python/src/server/mod.rs b/rust/perspective-python/src/server/mod.rs new file mode 100644 index 0000000..033b2d5 --- /dev/null +++ b/rust/perspective-python/src/server/mod.rs @@ -0,0 +1,23 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +pub(crate) mod generic_sql_model; +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::*; +pub use session_async::PyAsyncSession; +pub use session_sync::PySession; diff --git a/rust/perspective-python/src/server/server_async.rs b/rust/perspective-python/src/server/server_async.rs new file mode 100644 index 0000000..b990fba --- /dev/null +++ b/rust/perspective-python/src/server/server_async.rs @@ -0,0 +1,95 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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::pin::pin; +use std::sync::Arc; + +use futures::future::BoxFuture; +// use async_lock::RwLock; +use perspective_server::{Server, ServerResult}; +use pollster::FutureExt; +use pyo3::IntoPyObjectExt; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::PyAny; + +use super::PyAsyncSession; +use super::session_async::PyConnection; +use crate::py_async::AllowThreads; + +/// @private +#[pyclass(subclass, module = "perspective")] +#[derive(Clone)] +pub struct AsyncServer { + pub server: Server, +} + +#[pymethods] +impl AsyncServer { + #[new] + #[pyo3(signature = (on_poll_request=None))] + pub fn new(on_poll_request: Option>) -> Self { + Self { + server: Server::new(on_poll_request.map(|f| { + let f = Arc::new(f); + Arc::new(move |server: &Server| { + let f = f.clone(); + let server = server.clone(); + Box::pin(async move { + Python::with_gil(|py| { + f.call1(py, (AsyncServer { server }.into_py_any(py).unwrap(),)) + })?; + Ok(()) + }) as BoxFuture<'static, ServerResult<()>> + }) + as Arc BoxFuture<'static, ServerResult<()>> + Send + Sync> + })), + } + } + + pub fn new_session(&self, _py: Python, response_cb: Py) -> PyAsyncSession { + let session = self + .server + .new_session(PyConnection(response_cb.into())) + .block_on(); + + let session = Arc::new(async_lock::RwLock::new(Some(session))); + PyAsyncSession { session } + } + + // #[pyo3(signature = (loop_callback=None))] + // pub fn new_local_client( + // &self, + // py: Python<'_>, + // loop_callback: Option>, + // ) -> PyResult { + // let client = + // crate::client::client_sync::Client(AsyncClient::new_from_client( + // self.server + // .new_local_client() + // .take() + // .map_err(PyValueError::new_err)?, + // )); + + // Ok(client) + // } + + pub async fn poll(&self) -> PyResult<()> { + AllowThreads(pin!(async move { + self.server + .poll() + .await + .map_err(|e| PyValueError::new_err(format!("{e}"))) + })) + .await + } +} diff --git a/rust/perspective-python/src/server/server_sync.rs b/rust/perspective-python/src/server/server_sync.rs new file mode 100644 index 0000000..311a201 --- /dev/null +++ b/rust/perspective-python/src/server/server_sync.rs @@ -0,0 +1,126 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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::sync::Arc; + +use futures::future::BoxFuture; +#[cfg(doc)] +use perspective_client::{Client, Table}; +use perspective_server::ServerResult; +use pollster::FutureExt; +use pyo3::IntoPyObjectExt; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::PyAny; + +use super::session_sync::{PyConnectionSync, PySession}; +use crate::client::client_async::AsyncClient; + +/// An instance of a Perspective server. Each [`Server`] instance is separate, +/// and does not share [`Table`] (or other) data with other [`Server`]s. +/// +/// # Arguments +/// +/// - `on_poll_request` A callback function which the `Server` will invoke when +/// there are updates that need to be flushed, after which you must +/// _eventually_ call [`Server::poll`] (or else no updates will be processed). +/// This optimization allows batching updates, depending on context. +#[pyclass(subclass, module = "perspective")] +#[derive(Clone)] +pub struct Server { + pub server: perspective_server::Server, +} + +#[pymethods] +impl Server { + #[new] + #[pyo3(signature = (on_poll_request=None))] + pub fn new(on_poll_request: Option>) -> Self { + Self { + server: perspective_server::Server::new(on_poll_request.map(|f| { + let f = Arc::new(f); + Arc::new(move |server: &perspective_server::Server| { + let f = f.clone(); + let server = server.clone(); + Box::pin(async move { + Python::with_gil(|py| { + f.call1(py, (Server { server }.into_py_any(py).unwrap(),)) + })?; + Ok(()) + }) as BoxFuture<'static, ServerResult<()>> + }) + as Arc< + dyn Fn(&perspective_server::Server) -> BoxFuture<'static, ServerResult<()>> + + Send + + Sync, + > + })), + } + } + + /// Create a new [`Client`] instance bound to this [`Server`] directly. + pub fn new_local_client(&self) -> PyResult { + let client = crate::client::client_sync::Client(AsyncClient::new_from_client( + self.server + .new_local_client() + .take() + .map_err(PyValueError::new_err)?, + )); + + Ok(client) + } + + /// Create a [`Session`] for this [`Server`], suitable for exactly one + /// [`Client`] (not necessarily in this process). A [`Session`] represents + /// the server-side state of a single client-to-server connection. + /// + /// # Arguments + /// + /// - `session_handler` - An implementor of [`SessionHandler`] which will be + /// invoked by the [`Server`] when a response message needs to be sent to + /// the [`Client`]. The response itself should be passed to + /// [`Client::handle_response`] eventually, though it may-or-may-not be in + /// the same process. + pub fn new_session(&self, _py: Python, response_cb: Py) -> PySession { + let session = self + .server + .new_session(PyConnectionSync(response_cb.into())) + .block_on(); + + let session = Arc::new(std::sync::RwLock::new(Some(session))); + PySession { session } + } + + /// Flush pending updates to this [`Server`], including notifications to + /// [`View::on_update`] callbacks. + /// + /// [`Server::poll`] only needs to be called if you've implemented + /// a custom Perspective [`Server`] and provided the `on_poll_request` + /// constructor keyword argument. + /// + /// Calling [`Session::poll`] may result in the `send_response` parameter + /// which was used to construct this (or other) [`Session`] to fire. + /// Whenever a [`Session::handle_request`] method is invoked for a + /// `perspective_server::Server`, at least one [`Session::poll`] should be + /// scheduled to clear other clients message queues. + /// + /// `poll()` _must_ be called after [`Table::update`] or [`Table::remove`] + /// and `on_poll_request` is notified, or the changes will not be applied. + pub fn poll(&self, py: Python<'_>) -> PyResult<()> { + py.allow_threads(|| { + self.server + .poll() + .block_on() + .map_err(|e| PyValueError::new_err(format!("{e}"))) + }) + } +} diff --git a/rust/perspective-python/src/server/session_async.rs b/rust/perspective-python/src/server/session_async.rs new file mode 100644 index 0000000..52f1748 --- /dev/null +++ b/rust/perspective-python/src/server/session_async.rs @@ -0,0 +1,98 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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::pin::pin; +use std::sync::Arc; + +// use async_lock::RwLock; +use perspective_client::Session; +use perspective_server::{LocalSession, SessionHandler}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::{PyAny, PyBytes}; + +use crate::py_async::{self, AllowThreads}; + +#[pyclass(module = "perspective")] +#[derive(Clone)] +pub struct PyAsyncSession { + pub session: Arc>>, +} + +#[derive(Clone)] +pub struct PyConnection(pub Arc>); + +impl SessionHandler for PyConnection { + async fn send_response<'a>( + &'a mut self, + msg: &'a [u8], + ) -> Result<(), perspective_server::ServerError> { + let result = Python::with_gil(move |py| { + self.0 + .call1(py, (PyBytes::new(py, msg),)) + .and_then(|x| py_async::py_into_future(x.into_bound(py))) + })? + .await; + + // test + Python::with_gil(|_py| { + result + .map(|_| ()) + .map_err(perspective_server::ServerError::from) + })?; + + Ok(()) + } +} + +impl PyAsyncSession { + async fn with_session(&self, f_ctx: F) -> PyResult + where + F: for<'a> Fn(&'a LocalSession) -> std::pin::Pin + 'a + Send>>, + { + let lock = self.session.read().await; + let val = lock.as_ref().ok_or_else(|| { + PyValueError::new_err(format!("Session {:?} already deleted", self.session)) + })?; + + Ok(f_ctx(val).await) + } +} + +#[allow(non_local_definitions)] +#[pymethods] +impl PyAsyncSession { + pub async fn handle_request(&self, data: Vec) -> PyResult<()> { + AllowThreads(pin!(self.with_session(move |session| { + let data = data.clone(); + Box::pin(async move { + session + .handle_request(&data) + .await + .map_err(|e| PyValueError::new_err(format!("{e}"))) + }) + }))) + .await? + } + + pub async fn close(&self) -> PyResult<()> { + AllowThreads(pin!(async move { self.session.write().await.take() })) + .await + .ok_or_else(|| { + PyValueError::new_err(format!("Session {:?} already deleted", self.session)) + })? + .close() + .await; + + Ok(()) + } +} diff --git a/rust/perspective-python/src/server/session_sync.rs b/rust/perspective-python/src/server/session_sync.rs new file mode 100644 index 0000000..ca98bdd --- /dev/null +++ b/rust/perspective-python/src/server/session_sync.rs @@ -0,0 +1,101 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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::sync::Arc; + +// use async_lock::RwLock; +use perspective_client::Session; +use perspective_server::{LocalSession, SessionHandler}; +use pollster::FutureExt; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::{PyAny, PyBytes}; + +#[pyclass(module = "perspective")] +#[derive(Clone)] +pub struct PySession { + pub(crate) session: Arc>>, +} + +#[derive(Clone)] +pub struct PyConnectionSync(pub Arc>); + +impl SessionHandler for PyConnectionSync { + async fn send_response<'a>( + &'a mut self, + msg: &'a [u8], + ) -> Result<(), perspective_server::ServerError> { + Python::with_gil(move |py| { + self.0 + .call1(py, (PyBytes::new(py, msg),)) + .map(|_| ()) + .map_err(perspective_server::ServerError::from) + })?; + Ok(()) + } +} + +impl PySession { + fn with_session(&self, f_ctx: F) -> PyResult + where + F: Fn(&LocalSession) -> G, + { + let lock = self + .session + .read() + .map_err(|_| PyValueError::new_err("Internal lock error"))?; + + let val = lock.as_ref().ok_or_else(|| { + PyValueError::new_err(format!("Session {:?} already deleted", self.session)) + })?; + + Ok(f_ctx(val)) + } +} + +#[allow(non_local_definitions)] +#[pymethods] +impl PySession { + pub fn handle_request(&self, py: Python<'_>, data: Vec) -> PyResult<()> { + py.allow_threads(|| { + self.with_session(|session| { + session + .handle_request(&data) + .block_on() + .map_err(|e| PyValueError::new_err(format!("{e}"))) + }) + })? + } + + pub fn close(&self, py: Python<'_>) -> PyResult<()> { + let z = py.allow_threads(|| { + let lock = self + .session + .write() + .map_err(|_| PyValueError::new_err("Internal lock error")); + + if let Ok(mut lock) = lock { + lock.take() + } else { + None + } + }); + + z.ok_or_else(|| { + PyValueError::new_err(format!("Session {:?} already deleted", self.session)) + })? + .close() + .block_on(); + + Ok(()) + } +} 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 0000000..710f4dc --- /dev/null +++ b/rust/perspective-python/src/server/virtual_server_sync.rs @@ -0,0 +1,639 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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, RowPathStyle, 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; + +fn py_to_scalar(val: pyo3::Bound<'_, PyAny>) -> PyResult { + if val.is_none() { + Ok(perspective_client::config::Scalar::Null) + } else if let Ok(b) = val.extract::() { + Ok(perspective_client::config::Scalar::Bool(b)) + } else if let Ok(f) = val.extract::() { + Ok(perspective_client::config::Scalar::Float(f)) + } else if let Ok(s) = val.extract::() { + Ok(perspective_client::config::Scalar::String(s)) + } else { + Ok(perspective_client::config::Scalar::Null) + } +} + +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_min_max( + &self, + view_id: &str, + column_name: &str, + config: &perspective_client::config::ViewConfig, + ) -> VirtualServerFuture< + '_, + Result< + ( + perspective_client::config::Scalar, + perspective_client::config::Scalar, + ), + Self::Error, + >, + > { + let handler = Python::with_gil(|py| self.0.clone_ref(py)); + let view_id = view_id.to_string(); + let column_name = column_name.to_string(); + let config = config.clone(); + Box::pin(async move { + Python::with_gil(|py| { + let has_method = handler + .getattr(py, pyo3::intern!(py, "view_get_min_max")) + .is_ok(); + + if !has_method { + return Err(PyValueError::new_err("view_get_min_max not implemented")); + } + + let config_py = pythonize::pythonize(py, &config)?; + let result = handler.call_method1( + py, + pyo3::intern!(py, "view_get_min_max"), + (&view_id, &column_name, config_py), + )?; + + let tuple = result.downcast_bound::(py)?; + let min = py_to_scalar(tuple.get_item(0)?)?; + let max = py_to_scalar(tuple.get_item(1)?)?; + Ok((min, max)) + }) + }) + } + + fn view_get_data( + &self, + view_id: &str, + config: &perspective_client::config::ViewConfig, + schema: &IndexMap, + 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 schema = schema.clone(); + let window: PyViewPort = viewport.clone().into(); + Box::pin(async move { + Python::with_gil(|py| { + let data = + PyVirtualDataSlice(Arc::new(Mutex::new(VirtualDataSlice::new(config.clone())))); + let _ = handler.call_method1( + py, + pyo3::intern!(py, "view_get_data"), + ( + &view_id, + pythonize::pythonize(py, &config)?, + pythonize::pythonize(py, &schema)?, + 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)] +#[pyclass(name = "VirtualDataSlice")] +pub struct PyVirtualDataSlice(Arc>); + +#[pymethods] +impl PyVirtualDataSlice { + #[new] + pub fn py_new() -> Self { + use perspective_client::config::{GroupRollupMode, ViewConfig}; + let config = ViewConfig { + group_rollup_mode: GroupRollupMode::Total, + ..Default::default() + }; + PyVirtualDataSlice(Arc::new(Mutex::new(VirtualDataSlice::new(config)))) + } + + #[allow(clippy::wrong_self_convention)] + pub fn from_arrow_ipc(&self, ipc: &[u8]) -> PyResult<()> { + self.0 + .lock() + .unwrap() + .from_arrow_ipc(ipc) + .map_err(|e| PyValueError::new_err(e.to_string())) + } + + pub fn render_to_columns_json(&self) -> PyResult { + self.0 + .lock() + .unwrap() + .render_to_columns_json(RowPathStyle::Sidecar) + .map_err(|e| PyValueError::new_err(e.to_string())) + } + + #[pyo3(signature=(dtype, name, index, val, grouping_id = None))] + pub fn set_col( + &self, + dtype: &str, + name: &str, + index: u32, + val: Py, + grouping_id: Option, + ) -> PyResult<()> { + match dtype { + "string" => self.set_string_col(name, index, val, grouping_id), + "integer" => self.set_integer_col(name, index, val, grouping_id), + "float" => self.set_float_col(name, index, val, grouping_id), + "date" => self.set_datetime_col(name, index, val, grouping_id), + "datetime" => self.set_datetime_col(name, index, val, grouping_id), + "boolean" => self.set_boolean_col(name, index, val, grouping_id), + _ => Err(PyValueError::new_err("Unknown type")), + } + } + + #[pyo3(signature=(name, index, val, grouping_id = None))] + pub fn set_string_col( + &self, + name: &str, + index: u32, + val: Py, + grouping_id: Option, + ) -> PyResult<()> { + Python::with_gil(|py| { + if val.is_none(py) { + self.0 + .lock() + .unwrap() + .set_col(name, grouping_id, index as usize, None as Option) + .unwrap(); + } else if let Ok(val) = val.downcast_bound::(py) { + self.0 + .lock() + .unwrap() + .set_col( + name, + grouping_id, + index as usize, + val.extract::().ok(), + ) + .unwrap(); + } else { + tracing::error!("Unhandled") + }; + + Ok(()) + }) + } + + #[pyo3(signature=(name, index, val, grouping_id = None))] + pub fn set_integer_col( + &self, + name: &str, + index: u32, + val: Py, + grouping_id: Option, + ) -> PyResult<()> { + Python::with_gil(|py| { + if val.is_none(py) { + self.0 + .lock() + .unwrap() + .set_col(name, grouping_id, index as usize, None as Option) + .unwrap(); + } else if let Ok(val) = val.extract::(py) { + self.0 + .lock() + .unwrap() + .set_col(name, grouping_id, index as usize, Some(val)) + .unwrap(); + } else { + tracing::error!("Unhandled") + }; + + Ok(()) + }) + } + + #[pyo3(signature=(name, index, val, grouping_id = None))] + pub fn set_float_col( + &self, + name: &str, + index: u32, + val: Py, + grouping_id: Option, + ) -> PyResult<()> { + Python::with_gil(|py| { + if val.is_none(py) { + self.0 + .lock() + .unwrap() + .set_col(name, grouping_id, index as usize, None as Option) + .unwrap(); + } else if let Ok(val) = val.extract::(py) { + self.0 + .lock() + .unwrap() + .set_col(name, grouping_id, index as usize, Some(val)) + .unwrap(); + } else { + tracing::error!("Unhandled") + }; + + Ok(()) + }) + } + + #[pyo3(signature=(name, index, val, grouping_id = None))] + pub fn set_boolean_col( + &self, + name: &str, + index: u32, + val: Py, + grouping_id: Option, + ) -> PyResult<()> { + Python::with_gil(|py| { + if val.is_none(py) { + self.0 + .lock() + .unwrap() + .set_col(name, grouping_id, index as usize, None as Option) + .unwrap(); + } else if let Ok(val) = val.extract::(py) { + self.0 + .lock() + .unwrap() + .set_col(name, grouping_id, index as usize, Some(val)) + .unwrap(); + } else { + tracing::error!("Unhandled") + }; + + Ok(()) + }) + } + + #[pyo3(signature=(name, index, val, grouping_id = None))] + pub fn set_datetime_col( + &self, + name: &str, + index: u32, + val: Py, + grouping_id: Option, + ) -> PyResult<()> { + Python::with_gil(|py| { + if val.is_none(py) { + self.0 + .lock() + .unwrap() + .set_col(name, grouping_id, 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, grouping_id, index as usize, Some(timestamp)) + .unwrap(); + } else if let Ok(val) = val.extract::(py) { + self.0 + .lock() + .unwrap() + .set_col(name, grouping_id, 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-python/test.mjs b/rust/perspective-python/test.mjs new file mode 100644 index 0000000..d4172fa --- /dev/null +++ b/rust/perspective-python/test.mjs @@ -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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import { getPyodideDistDir } from "@perspective-dev/scripts/pyodide.mjs"; +import { getEmscriptenWheelPath } from "@perspective-dev/scripts/workspace.mjs"; + +// avoid executing this script directly, instead run `pnpm run test` from the workspace root +// to pass extra arguments to pytest, run (with perspective-python focused): +// +// pnpm run test -k "my_cool_test" +const execOpts = { stdio: "inherit" }; +if (process.env.PSP_PYODIDE) { + const pyodideDistDir = getPyodideDistDir(); + if (!fs.existsSync(pyodideDistDir)) { + console.error( + `Error: Pyodide distribution not found at ${pyodideDistDir}\n\nRun: pnpm -w run install_pyodide\n\n`, + ); + process.exit(1); + } + const emscriptenWheel = getEmscriptenWheelPath(); + if (!fs.existsSync(emscriptenWheel)) { + console.error( + `Error: Emscripten wheel not found at ${emscriptenWheel}\n\nRun: pnpm run build\n\n`, + ); + process.exit(1); + } + execFileSync( + "pytest", + [ + "pyodide-tests/", + "--runner=playwright", + "--runtime=chrome", + `--dist-dir=${pyodideDistDir}`, + `--perspective-emscripten-wheel=${emscriptenWheel}`, + "--verbose", + "--timeout=300", + // with --timeout_method=signal, tests hang. seems like the + // pytest-pyodide webserver fixture does not shut down + "--timeout_method=thread", + ...process.argv.slice(2), + ], + execOpts, + ); +} else { + execFileSync( + "pytest", + [ + "perspective/tests", + "-W error", + "--timeout=300", + ...process.argv.slice(2), + ], + execOpts, + ); +} diff --git a/rust/perspective-server/Cargo.toml b/rust/perspective-server/Cargo.toml new file mode 100644 index 0000000..c13f6ab --- /dev/null +++ b/rust/perspective-server/Cargo.toml @@ -0,0 +1,71 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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" +version = "4.5.2" +authors = ["Andrew Stein "] +edition = "2024" +description = "A data visualization and analytics component, especially well-suited for large and/or streaming datasets." +repository = "https://github.com/perspective-dev/perspective" +license = "Apache-2.0" +homepage = "https://perspective-dev.github.io" +keywords = [] +build = "build.rs" +include = [ + "src/**/*", + "build.rs", + "include/**/*", + "cpp/**/*", + "docs/**/*", + "cmake/**/*", + "Cargo.toml", +] + +[features] +default = ["python"] +external-cpp = [] +wasm-exceptions = [] +python = [] +disable-cpp = [] + +[build-dependencies] +cmake = "0.1.58" +num_cpus = "^1.15.0" +shlex = "1.3.0" +protobuf-src = { version = "2.1.1" } + +[dependencies] +perspective-client = { version = "4.5.2" } + +# 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-server/build.mjs b/rust/perspective-server/build.mjs new file mode 100644 index 0000000..c7f093f --- /dev/null +++ b/rust/perspective-server/build.mjs @@ -0,0 +1,94 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 os from "node:os"; +import path from "node:path"; +import * as url from "node:url"; +import * as fs from "node:fs"; + +import "zx/globals"; + +const __dirname = url.fileURLToPath(new URL(".", import.meta.url)).slice(0, -1); + +const env = process.env.PSP_DEBUG ? "debug" : "release"; +const cwd = path.join(process.cwd(), "build", env); +const cmake_dir = path.join(__dirname, "cpp", "perspective"); +const { compress } = await import("pro_self_extracting_wasm"); + +delete process.env.NODE; + +async function run_emsdk(cmd) { + const emsdkdir = `${__dirname}/../../.emsdk`; + const { emscripten } = JSON.parse( + fs.readFileSync(`${__dirname}/../../package.json`), + ); + + if (!emscripten) { + throw new Error("Emscripten version not specified in package.json"); + } + + await $({ env: { FORCE_COLOR: "1", ...process.env } })`cd ${emsdkdir} \ + && source ./emsdk_env.sh >/dev/null 2>&1 \ + && emsdk activate ${emscripten} >/dev/null \ + && cd ${cwd} \ + && ${cmd}`.verbose(); +} + +let cmake_flags = []; +let make_flags = []; + +if (!!process.env.PSP_BUILD_VERBOSE) { + cmake_flags.push("-Wdev"); + cmake_flags.push("--debug-output"); + make_flags.push("VERBOSE=1"); +} else { + cmake_flags.push("-Wno-dev"); // suppress developer warnings +} + +try { + fs.mkdirSync(cwd, { recursive: true }); + process.env.CLICOLOR_FORCE = 1; + const out = await $`cargo metadata`; + const p = JSON.parse(out.stdout).packages.find( + (x) => x.name === "perspective-client", + ).manifest_path; + + const pp = path.parse(p); + await run_emsdk([ + `emcmake`, + `cmake`, + cmake_dir, + ...cmake_flags, + `-DCMAKE_BUILD_TYPE=${env}`, + `-DRAPIDJSON_BUILD_EXAMPLES=OFF`, + `-DPSP_PROTO_PATH=${pp.dir}`, + `-DCMAKE_COLOR_DIAGNOSTICS=ON`, + ]); + + await run_emsdk([ + `emmake`, + `make`, + `-j${process.env.PSP_NUM_CPUS || os.cpus().length}`, + ...make_flags, + ]); + + fs.cpSync(`build/${env}/web`, "dist/wasm", { recursive: true }); + if (!process.env.PSP_HEAP_INSTRUMENTS) { + compress( + `./dist/wasm/perspective-server.wasm`, + `./dist/wasm/perspective-server.wasm`, + ); + } +} catch (e) { + console.error(e); + process.exit(1); +} diff --git a/rust/perspective-server/build.rs b/rust/perspective-server/build.rs new file mode 100644 index 0000000..975bbaa --- /dev/null +++ b/rust/perspective-server/build.rs @@ -0,0 +1,184 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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::fs; +use std::path::{Path, PathBuf}; + +use cmake::Config; +use protobuf_src::protoc; +use shlex::Shlex; + +fn main() -> Result<(), std::io::Error> { + if std::env::var("DOCS_RS").is_ok() { + return Ok(()); + } + + if std::option_env!("PSP_DISABLE_CPP").is_none() + && std::env::var("CARGO_FEATURE_DISABLE_CPP").is_err() + && let Some(artifact_dir) = cmake_build()? + { + cmake_link_deps(&artifact_dir)?; + } + + Ok(()) +} + +fn cmake_build() -> Result, std::io::Error> { + let mut dst = Config::new("cpp/perspective"); + if let Some(cpp_build_dir) = std::option_env!("PSP_CPP_BUILD_DIR") { + std::fs::create_dir_all(cpp_build_dir)?; + dst.out_dir(cpp_build_dir); + } + + let profile = std::env::var("PROFILE").unwrap(); + dst.always_configure(true); + dst.define("CMAKE_BUILD_TYPE", profile.as_str()); + dst.define("ARROW_BUILD_EXAMPLES", "OFF"); + dst.define("RAPIDJSON_BUILD_EXAMPLES", "OFF"); + dst.define("ARROW_CXX_FLAGS_DEBUG", "-Wno-error"); + dst.define( + "PSP_PROTOC_PATH", + protoc() + .parent() + .expect("protoc() returned root path or empty string"), + ); + dst.define("CMAKE_COLOR_DIAGNOSTICS", "ON"); + dst.define( + "PSP_PROTO_PATH", + std::env::var("DEP_PERSPECTIVE_CLIENT_PROTO_PATH").unwrap(), + ); + + dst.env( + "DEP_PERSPECTIVE_CLIENT_PROTO_PATH", + std::env::var("DEP_PERSPECTIVE_CLIENT_PROTO_PATH").unwrap(), + ); + + if cfg!(target_os = "macos") { + // Set CMAKE_OSX_ARCHITECTURES et al. for Mac builds. Arrow does not forward on + // CMAKE_OSX_ARCHITECTURES but it does forward on a CMAKE_TOOLCHAIN_FILE. In + // Conda builds, the environment sets `CMAKE_ARGS` up with various + // toolchain arguments. This block may need to be patched out or + // adjusted for Conda. + let toolchain_file = match std::env::var("PSP_ARCH").as_deref() { + Ok("x86_64") => Some("./cmake/toolchains/darwin-x86_64.cmake"), + Ok("aarch64") => Some("./cmake/toolchains/darwin-arm64.cmake"), + Err(std::env::VarError::NotPresent) => None, + arch @ Ok(_) | arch @ Err(_) => { + panic!("Unknown PSP_ARCH value: {arch:?}") + }, + }; + + if let Some(path) = toolchain_file { + dst.define( + "CMAKE_TOOLCHAIN_FILE", + std::fs::canonicalize(path).expect("Failed to canonicalize toolchain file."), + ); + } + } + + if std::env::var("TARGET") + .unwrap_or_default() + .contains("wasm32") + { + dst.define("PSP_WASM_BUILD", "1"); + } else { + dst.define("PSP_WASM_BUILD", "0"); + } + + if std::env::var("CARGO_FEATURE_PYTHON").is_ok() { + dst.define("CMAKE_POSITION_INDEPENDENT_CODE", "ON"); + dst.define("PSP_PYTHON_BUILD", "1"); + } + + if std::env::var("CARGO_FEATURE_EXTERNAL_CPP").is_err() { + dst.env("PSP_DISABLE_CLANGD", "1"); + } + + // WASM Exceptions don't work with the prebuilt Pyodide distribution. + // It must be rebuilt with WASM exceptions enabled + if std::env::var("CARGO_FEATURE_WASM_EXCEPTIONS").is_ok() { + dst.define("PSP_WASM_EXCEPTIONS", "1"); + } else { + dst.define("PSP_WASM_EXCEPTIONS", "0"); + } + + // Propagates to nested `cmake --build` invocations (e.g. the per-dep + // fetch builds in FindInstallDependency.cmake), unlike `-j` via `build_arg` + // which only reaches the top-level native tool. + dst.env("CMAKE_BUILD_PARALLEL_LEVEL", num_cpus::get().to_string()); + + // Conda sets CMAKE_ARGS for e.g. cross-compiling toolchain in the environment - + // normally they are passed directly to a cmake invocation in the recipe, + // but our conda recipe doesn't directly invoke cmake + if let Ok(cmake_args) = std::env::var("CMAKE_ARGS") { + println!("cargo:warning=Setting CMAKE_ARGS from environment {cmake_args:?}"); + for arg in Shlex::new(&cmake_args) { + dst.configure_arg(arg); + } + } + + println!("cargo:warning=Building cmake {profile}"); + if std::env::var("PSP_BUILD_VERBOSE").unwrap_or_default() != "" { + // checks non-empty env var + dst.very_verbose(true); + } + + let artifact_dir = dst.build(); + Ok(Some(artifact_dir)) +} + +fn cmake_link_deps(cmake_build_dir: &Path) -> Result<(), std::io::Error> { + println!( + "cargo:rustc-link-search=native={}/build", + cmake_build_dir.display() + ); + + // println!("cargo:warning=MESSAGE {}/build", cmake_build_dir.display()); + println!("cargo:rustc-link-lib=static=psp"); + link_cmake_static_archives(cmake_build_dir)?; + println!("cargo:rerun-if-changed=cpp/perspective"); + Ok(()) +} + +/// Walk the cmake output path and emit link instructions for all archives. +/// TODO Can this be faster pls? +/// TODO update apparently not https://github.com/rust-lang/cmake-rs/issues/149 +fn link_cmake_static_archives(dir: &Path) -> Result<(), std::io::Error> { + if dir.is_dir() { + for entry in fs::read_dir(dir)? { + let path = entry?.path(); + if path.is_dir() { + link_cmake_static_archives(&path)?; + } else { + let ext = path.extension().as_ref().map(|x| x.to_string_lossy()); + let stem = path.file_stem().as_ref().map(|x| x.to_string_lossy()); + let is_archive = (cfg!(windows) + && ext.as_deref() == Some("lib") + && stem.as_deref() != Some("perspective")) + || (!cfg!(windows) && ext.as_deref() == Some("a")); + if is_archive { + let a = if cfg!(windows) { + stem.unwrap().to_string() + } else { + stem.expect("bad")[3..].to_string() + }; + + // println!("cargo:warning=static link {} {}", a, dir.display()); + println!("cargo:rustc-link-search=native={}", dir.display()); + println!("cargo:rustc-link-lib=static={a}"); + } + } + } + } + + Ok(()) +} diff --git a/rust/perspective-server/clean.mjs b/rust/perspective-server/clean.mjs new file mode 100644 index 0000000..49a5253 --- /dev/null +++ b/rust/perspective-server/clean.mjs @@ -0,0 +1,15 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 "node:fs"; + +fs.rmSync("dist", { recursive: true, force: true }); diff --git a/rust/perspective-server/cmake/Boost.txt.in b/rust/perspective-server/cmake/Boost.txt.in new file mode 100644 index 0000000..139c9c2 --- /dev/null +++ b/rust/perspective-server/cmake/Boost.txt.in @@ -0,0 +1,18 @@ +cmake_minimum_required(VERSION 3.7.2) + +project(Boost-download NONE) + +# Makes GIT_SUBMODULES "" in ExternalProject_Add skip initializing submodules +cmake_policy(SET CMP0097 NEW) + +include(ExternalProject) +ExternalProject_Add(Boost + URL "https://sourceforge.net/projects/boost/files/boost/1.82.0/boost_1_82_0.tar.gz" + SOURCE_DIR "${CMAKE_BINARY_DIR}/Boost-src" + BINARY_DIR "${CMAKE_BINARY_DIR}/Boost-build" + SOURCE_SUBDIR "" + CONFIGURE_COMMAND "" + BUILD_COMMAND "" + INSTALL_COMMAND "" + TEST_COMMAND "" +) diff --git a/rust/perspective-server/cmake/Pybind.txt.in b/rust/perspective-server/cmake/Pybind.txt.in new file mode 100644 index 0000000..512b1d9 --- /dev/null +++ b/rust/perspective-server/cmake/Pybind.txt.in @@ -0,0 +1,15 @@ +cmake_minimum_required(VERSION 3.7.2) + +project(pybind11-download NONE) + +include(ExternalProject) +ExternalProject_Add(pybind11 + GIT_REPOSITORY https://github.com/pybind/pybind11.git + GIT_TAG v2.10.4 + SOURCE_DIR "${CMAKE_BINARY_DIR}/pybind11-src" + BINARY_DIR "${CMAKE_BINARY_DIR}/pybind11-build" + CONFIGURE_COMMAND "" + BUILD_COMMAND "" + INSTALL_COMMAND "" + TEST_COMMAND "" +) diff --git a/rust/perspective-server/cmake/arrow.txt.in b/rust/perspective-server/cmake/arrow.txt.in new file mode 100644 index 0000000..4dd39e7 --- /dev/null +++ b/rust/perspective-server/cmake/arrow.txt.in @@ -0,0 +1,24 @@ +cmake_minimum_required(VERSION 3.7.2) + +project(arrow-download NONE) + +# Makes GIT_SUBMODULES "" in ExternalProject_Add skip initializing submodules +cmake_policy(SET CMP0097 NEW) + +include(ExternalProject) +ExternalProject_Add(apachearrow + GIT_REPOSITORY https://github.com/apache/arrow.git + GIT_TAG apache-arrow-18.1.0 + GIT_SUBMODULES "" + GIT_SHALLOW TRUE + SOURCE_DIR "${CMAKE_BINARY_DIR}/arrow-src" + BINARY_DIR "${CMAKE_BINARY_DIR}/arrow-build" + SOURCE_SUBDIR "cpp" + CONFIGURE_COMMAND "" + BUILD_COMMAND "" + INSTALL_COMMAND "" + TEST_COMMAND "" + # This patch is to work around https://github.com/apache/arrow/issues/44384 + # It can be removed when a version of Arrow is released with https://github.com/apache/arrow/pull/44385 + PATCH_COMMAND "${CMAKE_COMMAND}" -E chdir git apply "${CMAKE_SOURCE_DIR}/patches/fix_arrow_libtool.patch" && "${CMAKE_COMMAND}" -E chdir git apply "${CMAKE_SOURCE_DIR}/patches/arrow_strptime.patch" +) diff --git a/rust/perspective-server/cmake/date.txt.in b/rust/perspective-server/cmake/date.txt.in new file mode 100644 index 0000000..c0123ea --- /dev/null +++ b/rust/perspective-server/cmake/date.txt.in @@ -0,0 +1,15 @@ +cmake_minimum_required(VERSION 3.7.2) + +project(date-download NONE) + +include(ExternalProject) +ExternalProject_Add(date + GIT_REPOSITORY https://github.com/HowardHinnant/date.git + GIT_TAG v3.0.0 + SOURCE_DIR "${CMAKE_BINARY_DIR}/date-src" + BINARY_DIR "${CMAKE_BINARY_DIR}/date-build" + CONFIGURE_COMMAND "" + BUILD_COMMAND "" + INSTALL_COMMAND "" + TEST_COMMAND "" +) diff --git a/rust/perspective-server/cmake/exprtk.txt.in b/rust/perspective-server/cmake/exprtk.txt.in new file mode 100644 index 0000000..d1a3942 --- /dev/null +++ b/rust/perspective-server/cmake/exprtk.txt.in @@ -0,0 +1,15 @@ +cmake_minimum_required(VERSION 3.7.2) + +project(exprtk-download NONE) + +include(ExternalProject) +ExternalProject_Add(exprtk + GIT_REPOSITORY https://github.com/ArashPartow/exprtk.git + GIT_TAG 0.0.3 + SOURCE_DIR "${CMAKE_BINARY_DIR}/exprtk-src" + BINARY_DIR "${CMAKE_BINARY_DIR}/exprtk-build" + CONFIGURE_COMMAND "" + BUILD_COMMAND "" + INSTALL_COMMAND "" + TEST_COMMAND "" +) diff --git a/rust/perspective-server/cmake/hopscotch.txt.in b/rust/perspective-server/cmake/hopscotch.txt.in new file mode 100644 index 0000000..c009403 --- /dev/null +++ b/rust/perspective-server/cmake/hopscotch.txt.in @@ -0,0 +1,15 @@ +cmake_minimum_required(VERSION 3.7.2) + +project(hopscotch-download NONE) + +include(ExternalProject) +ExternalProject_Add(hopscotch + GIT_REPOSITORY https://github.com/Tessil/hopscotch-map.git + GIT_TAG v2.3.1 + SOURCE_DIR "${CMAKE_BINARY_DIR}/hopscotch-src" + BINARY_DIR "${CMAKE_BINARY_DIR}/hopscotch-build" + CONFIGURE_COMMAND "" + BUILD_COMMAND "" + INSTALL_COMMAND "" + TEST_COMMAND "" +) diff --git a/rust/perspective-server/cmake/modules/.clangd.in b/rust/perspective-server/cmake/modules/.clangd.in new file mode 100644 index 0000000..9e4732a --- /dev/null +++ b/rust/perspective-server/cmake/modules/.clangd.in @@ -0,0 +1,3 @@ +CompileFlags: + @CLANGD_ADD_FLAGS@ + CompilationDatabase: @CMAKE_BINARY_DIR@ \ No newline at end of file diff --git a/rust/perspective-server/cmake/modules/FindColor.cmake b/rust/perspective-server/cmake/modules/FindColor.cmake new file mode 100644 index 0000000..d868433 --- /dev/null +++ b/rust/perspective-server/cmake/modules/FindColor.cmake @@ -0,0 +1,13 @@ +cmake_minimum_required(VERSION 3.7.2) + +if(NOT WIN32) + string(ASCII 27 Esc) + set(ColorReset "${Esc}[m") + set(Red "${Esc}[1;31m") + set(Green "${Esc}[1;32m") + set(Yellow "${Esc}[1;33m") + set(Blue "${Esc}[1;34m") + set(Magenta "${Esc}[1;35m") + set(Cyan "${Esc}[1;36m") + set(White "${Esc}[1;37m") +endif() diff --git a/rust/perspective-server/cmake/modules/FindExprTk.cmake b/rust/perspective-server/cmake/modules/FindExprTk.cmake new file mode 100644 index 0000000..86f7e07 --- /dev/null +++ b/rust/perspective-server/cmake/modules/FindExprTk.cmake @@ -0,0 +1,76 @@ +cmake_minimum_required(VERSION 3.7.2) + +# Program: Visualization Toolkit +# Module: Copyright.txt + +# Copyright (c) 1993-2015 Ken Martin, Will Schroeder, Bill Lorensen +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: + +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. + +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# * Neither name of Ken Martin, Will Schroeder, or Bill Lorensen nor the names +# of any contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS ``AS IS'' +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +# - Try to find ExprTk lib +# +# This module supports requiring a minimum version, e.g. you can do +# find_package(ExprTk 2.71828) +# to require version 2.71828 or newer of ExprTk. +# +# Once done this will define +# +# ExprTk_FOUND - system has eigen lib with correct version +# ExprTk_INCLUDE_DIRS - the eigen include directory +# ExprTk_VERSION - eigen version +# +# And the following imported target: +# +# ExprTk::ExprTk + +find_path(ExprTk_INCLUDE_DIR + NAMES exprtk.hpp + DOC "Path to ExprTk header") +mark_as_advanced(ExprTk_INCLUDE_DIR) + +if (ExprTk_INCLUDE_DIR) + file(STRINGS "${ExprTk_INCLUDE_DIR}/exprtk.hpp" _exprtk_version_header + REGEX "static \\* version") + string(REGEX MATCH "static \\* version = \"([0-9.]+)\"" _exprtk_version_match "${_exprtk_version_header}") + set(ExprTk_VERSION "${CMAKE_MATCH_1}") + unset(_exprtk_version_header) + unset(_exprtk_version_match) +endif () + +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(ExprTk + REQUIRED_VARS ExprTk_INCLUDE_DIR + VERSION_VAR ExprTk_VERSION) + +if (ExprTk_FOUND) + set(ExprTk_INCLUDE_DIRS "${ExprTk_INCLUDE_DIR}") + if (NOT TARGET ExprTk::ExprTk) + add_library(ExprTk::ExprTk INTERFACE IMPORTED) + set_target_properties(ExprTk::ExprTk PROPERTIES + INTERFACE_INCLUDE_DIRECTORIES "${ExprTk_INCLUDE_DIR}") + endif () +endif () diff --git a/rust/perspective-server/cmake/modules/FindInstallDependency.cmake b/rust/perspective-server/cmake/modules/FindInstallDependency.cmake new file mode 100644 index 0000000..e492158 --- /dev/null +++ b/rust/perspective-server/cmake/modules/FindInstallDependency.cmake @@ -0,0 +1,100 @@ +cmake_minimum_required(VERSION 3.7.2) + +# Workaround for: https://gitlab.kitware.com/cmake/cmake/-/issues/22740 +set(D "$") + +# ################################################## +# Helper to grab dependencies from remote sources # +# ################################################## +function(psp_build_dep name cmake_file) + if(EXISTS ${CMAKE_BINARY_DIR}/${name}-build) + psp_build_message("${Cyan}Dependency found - not rebuilding - ${CMAKE_BINARY_DIR}/${name}-build${ColorReset}") + elseif(NAME STREQUAL "Boost") + psp_build_message("${Cyan}Not building boost${ColorReset}") + else() + configure_file(${cmake_file} ${CMAKE_BINARY_DIR}/${name}-download/CMakeLists.txt) + set(_cwd ${CMAKE_BINARY_DIR}/${name}-download) + + message(STATUS "Configuring ${name} in ${_cwd}") + + execute_process(COMMAND ${CMAKE_COMMAND} -G "${CMAKE_GENERATOR}" . + RESULT_VARIABLE result + OUTPUT_VARIABLE cmd_output + ERROR_VARIABLE cmd_error + WORKING_DIRECTORY ${_cwd}) + + if(result) + message(FATAL_ERROR "CMake step for ${name} failed:\nSTDOUT:${cmd_output}\nSTDERR: ${cmd_error}") + endif() + + message("${cmd_output}") + + execute_process(COMMAND ${CMAKE_COMMAND} --build . + RESULT_VARIABLE result + WORKING_DIRECTORY ${CMAKE_BINARY_DIR}/${name}-download) + + if(result) + message(FATAL_ERROR "Build step for ${name} failed: ${result}") + endif() + endif() + + if(${name} STREQUAL arrow) + set(ARROW_DEFINE_OPTIONS ON) + set(ARROW_BUILD_SHARED OFF) + set(ARROW_BUILD_STATIC ON) + set(ARROW_BUILD_INTEGRATION OFF) + set(ARROW_JEMALLOC OFF) + set(ARROW_CSV ON) + set(ARROW_LZ4 ON) + set(ARROW_WITH_ZSTD ON) + set(ARROW_WITH_LZ4 ON) + set(ARROW_NO_EXPORT ON) + set(ARROW_CXXFLAGS " -Wno-documentation ") + set(ARROW_DEPENDENCY_SOURCE "BUNDLED" CACHE STRING "override arrow's dependency source" FORCE) + # if (PSP_ENABLE_WASM) + # set(ARROW_CXX_FLAGS_RELEASE " -msimd128 -mbulk-memory -mrelaxed-simd -s MEMORY64=1 " CACHE STRING "override arrow's dependency source" FORCE) + # set(ARROW_C_FLAGS_RELEASE " -msimd128 -mbulk-memory -mrelaxed-simd -s MEMORY64=1 " CACHE STRING "override arrow's dependency source" FORCE ) + # endif() + if(PSP_WASM_BUILD) + set(ARROW_ENABLE_THREADING OFF) + else() + set(ARROW_ENABLE_THREADING ON) + endif() + + add_subdirectory(${CMAKE_BINARY_DIR}/${name}-src/cpp/ + ${CMAKE_BINARY_DIR}/${name}-build + EXCLUDE_FROM_ALL + ) + set(${name}_INCLUDE_DIRS + "${CMAKE_BINARY_DIR}/${name}-build/src" # needed for generated headers, e.g. + "${CMAKE_BINARY_DIR}/${name}-src/cpp/src" + PARENT_SCOPE) + elseif(${name} STREQUAL re2) + # Overwrite re2's CMakeLists with our custom CMakeLists. + configure_file(${PSP_CMAKE_MODULE_PATH}/${name}/CMakeLists.txt ${CMAKE_BINARY_DIR}/${name}-src/ COPYONLY) + + add_subdirectory(${CMAKE_BINARY_DIR}/${name}-src + ${CMAKE_BINARY_DIR}/${name}-build + EXCLUDE_FROM_ALL) + set(${name}_INCLUDE_DIRS ${CMAKE_BINARY_DIR}/${name}-src PARENT_SCOPE) + elseif(${name} STREQUAL protobuf) + add_subdirectory(${CMAKE_BINARY_DIR}/${name}-src + ${CMAKE_BINARY_DIR}/${name}-build + EXCLUDE_FROM_ALL) + set(${name}_INCLUDE_DIRS ${CMAKE_BINARY_DIR}/${name}-src/src PARENT_SCOPE) + elseif(${name} STREQUAL rapidjson) + set(RAPIDJSON_SSE42 ON CACHE BOOL "Enable RapidJSON SIMD") + set(RAPIDJSON_BUILD_TESTS OFF CACHE BOOL "Disable rapidjson tests") + add_subdirectory(${CMAKE_BINARY_DIR}/${name}-src + ${CMAKE_BINARY_DIR}/${name}-build + EXCLUDE_FROM_ALL) + set(${name}_INCLUDE_DIRS "${CMAKE_BINARY_DIR}/${name}-src/include" PARENT_SCOPE) + # Header-only dependencies without a build step - no add_subdirectory() + elseif(${name} MATCHES "^(Boost|exprtk)") + set(${name}_INCLUDE_DIRS "${CMAKE_BINARY_DIR}/${name}-src" PARENT_SCOPE) + elseif(${name} MATCHES "^(hopscotch|date|ordered-map)$") + set(${name}_INCLUDE_DIRS "${CMAKE_BINARY_DIR}/${name}-src/include" PARENT_SCOPE) + else() + message(FATAL_ERROR "Unknown dependency `${name}`") + endif() +endfunction() diff --git a/rust/perspective-server/cmake/modules/FindProtoc.cmake b/rust/perspective-server/cmake/modules/FindProtoc.cmake new file mode 100644 index 0000000..6f1be10 --- /dev/null +++ b/rust/perspective-server/cmake/modules/FindProtoc.cmake @@ -0,0 +1,126 @@ +set(DEFAULT_PROTOBUF_VERSION "33.2") + +option(PSP_PROTOC_PATH "Path to the protoc binary" "") + +# Function to download and extract protoc +function(download_protoc VERSION DESTINATION) + if(CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows") + set(PROTOC_ZIP "protoc-${VERSION}-win64.zip") + elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin") + set(PROTOC_ZIP "protoc-${VERSION}-osx-x86_64.zip") + elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux") + if(CMAKE_HOST_SYSTEM_PROCESSOR STREQUAL "aarch64") + set(PROTOC_ZIP "protoc-${VERSION}-linux-aarch_64.zip") + else() + set(PROTOC_ZIP "protoc-${VERSION}-linux-x86_64.zip") + endif() + else() + message(FATAL_ERROR "Unsupported host system: ${CMAKE_HOST_SYSTEM_NAME}") + endif() + + set(PROTOC_URL "https://github.com/protocolbuffers/protobuf/releases/download/v${VERSION}/${PROTOC_ZIP}") + set(DOWNLOAD_PATH "${CMAKE_BINARY_DIR}/downloaded/${PROTOC_ZIP}") + + file(DOWNLOAD ${PROTOC_URL} ${DOWNLOAD_PATH} + STATUS DOWNLOAD_STATUS + SHOW_PROGRESS) + + list(GET DOWNLOAD_STATUS 0 STATUS_CODE) + if(STATUS_CODE EQUAL 0) + message(STATUS "Downloaded protoc: ${DOWNLOAD_PATH}") + file(ARCHIVE_EXTRACT INPUT ${DOWNLOAD_PATH} DESTINATION ${DESTINATION}) + file(REMOVE ${DOWNLOAD_PATH}) + else() + message(FATAL_ERROR "Failed to download protoc: Status code ${STATUS_CODE}") + endif() +endfunction() + +find_program(Protobuf_EXECUTABLE NAMES protoc PATHS ${PSP_PROTOC_PATH} ENV PATH ${CMAKE_BINARY_DIR}/protoc-release/bin/) + +if(NOT Protobuf_EXECUTABLE) + message(STATUS "Protobuf_EXECUTABLE not found, searching for protoc") + download_protoc(${DEFAULT_PROTOBUF_VERSION} ${CMAKE_BINARY_DIR}/protoc-release) + set(PROTOC_EXECUTABLE ${CMAKE_BINARY_DIR}/protoc-release/bin/protoc) + # find_program(PROTOC_EXECUTABLE NAMES protoc) +else() + message(STATUS "Using Protobuf_EXECUTABLE from parent scope") + set(PROTOC_EXECUTABLE ${Protobuf_EXECUTABLE}) +endif() + +if(NOT PROTOC_EXECUTABLE) + message(FATAL_ERROR "protoc not found") + set(Protoc_FOUND FALSE) +else() + set(Protoc_FOUND TRUE) + message(STATUS "Found protoc: ${PROTOC_EXECUTABLE}") + + function(CHECK_PROTOC_VERSION) + execute_process( + COMMAND ${PROTOC_EXECUTABLE} --version + OUTPUT_VARIABLE PROTOC_VERSION_OUTPUT + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + + if(NOT PROTOC_VERSION_OUTPUT MATCHES "^libprotoc ([0-9]+)\\.([0-9]+)(:?\\.([0-9]+))?") + message(WARNING "Unable to determine protoc version") + return() + endif() + + set(PROTOC_VERSION_MAJOR ${CMAKE_MATCH_1}) + set(PROTOC_VERSION_MINOR ${CMAKE_MATCH_2}) + set(PROTOC_VERSION_PATCH ${CMAKE_MATCH_4}) + + if (NOT PROTOC_VERSION_PATCH) + set(FOUND_VERSION "${PROTOC_VERSION_MAJOR}.${PROTOC_VERSION_MINOR}") + else() + set(FOUND_VERSION "${PROTOC_VERSION_MAJOR}.${PROTOC_VERSION_MINOR}.${PROTOC_VERSION_PATCH}") + endif() + + # Force the external project to use the same version as our installed protoc CLI + set(LIBPROTOBUF_VERSION "v${FOUND_VERSION}" PARENT_SCOPE) + + set(MIN_PROTOC_VERSION_MAJOR 3) + + if(PROTOC_VERSION_MAJOR LESS MIN_PROTOC_VERSION_MAJOR) + message(FATAL_ERROR "protoc version ${FOUND_VERSION} is too old, at least ${MIN_PROTOC_VERSION_MAJOR}.x.x is required") + else() + message(STATUS "Found protoc version ${FOUND_VERSION}") + endif() + endfunction() + + # Check the version of protoc + CHECK_PROTOC_VERSION() +endif() + +function(protobuf_generate_cpp SRCS HDRS) + if(NOT ARGN) + message(SEND_ERROR "Error: protobuf_generate_cpp() called without any proto files") + return() + endif() + + set(_PROTOBUF_GENERATE_CPP_SRCS) + set(_PROTOBUF_GENERATE_CPP_HDRS) + + foreach(PROTO_FILE ${ARGN}) + get_filename_component(PROTO_NAME ${PROTO_FILE} NAME_WE) + get_filename_component(PROTO_PATH ${PROTO_FILE} PATH) + + set(GENERATED_SRC "${CMAKE_CURRENT_BINARY_DIR}/${PROTO_NAME}.pb.cc") + set(GENERATED_HDR "${CMAKE_CURRENT_BINARY_DIR}/${PROTO_NAME}.pb.h") + + add_custom_command( + OUTPUT ${GENERATED_SRC} ${GENERATED_HDR} + COMMAND ${PROTOC_EXECUTABLE} --cpp_out ${CMAKE_CURRENT_BINARY_DIR} --proto_path ${PROTO_PATH} ${PROTO_FILE} + DEPENDS ${PROTO_FILE} ${PROTOC_EXECUTABLE} + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + COMMENT "Generating ${GENERATED_SRC} and ${GENERATED_HDR} from ${PROTO_FILE}" + ) + + set_source_files_properties(${GENERATED_SRC} ${GENERATED_HDR} PROPERTIES GENERATED TRUE) + + list(APPEND _PROTOBUF_GENERATE_CPP_SRCS ${GENERATED_SRC}) + list(APPEND _PROTOBUF_GENERATE_CPP_HDRS ${GENERATED_HDR}) + endforeach() + set(${SRCS} ${_PROTOBUF_GENERATE_CPP_SRCS} PARENT_SCOPE) + set(${HDRS} ${_PROTOBUF_GENERATE_CPP_HDRS} PARENT_SCOPE) +endfunction() diff --git a/rust/perspective-server/cmake/modules/FindRe2.cmake b/rust/perspective-server/cmake/modules/FindRe2.cmake new file mode 100644 index 0000000..78763a4 --- /dev/null +++ b/rust/perspective-server/cmake/modules/FindRe2.cmake @@ -0,0 +1,70 @@ +cmake_minimum_required(VERSION 3.7.2) + +# Copyright 2017 gRPC authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +find_package(re2 QUIET CONFIG) +if(re2_FOUND) + message(STATUS "Found RE2 via CMake.") + return() +endif() + +# As per https://github.com/grpc/grpc/issues/25434, idempotence is necessary +# because CMake fails when another target with the same name already exists. +if(TARGET re2::re2) + message(STATUS "Found RE2 via pkg-config already?") + return() +endif() + +find_package(PkgConfig QUIET) +if (PKG_CONFIG_FOUND) + # TODO(junyer): Use the IMPORTED_TARGET option whenever CMake 3.6 (or newer) + # becomes the minimum required: that will take care of the add_library() and + # set_property() calls; then we can simply alias PkgConfig::RE2 as re2::re2. + # For now, we can only set INTERFACE_* properties that existed in CMake 3.5. + pkg_check_modules(RE2 QUIET re2) +endif() + +if(RE2_FOUND) + set(re2_FOUND true) + add_library(re2::re2 INTERFACE IMPORTED) + if(RE2_INCLUDE_DIRS) + set_property(TARGET re2::re2 PROPERTY + INTERFACE_INCLUDE_DIRECTORIES "${RE2_INCLUDE_DIRS}") + endif() + if(RE2_CFLAGS_OTHER) + # Filter out the -std flag, which is handled by CMAKE_CXX_STANDARD. + # TODO(junyer): Use the FILTER option whenever CMake 3.6 (or newer) + # becomes the minimum required: that will allow this to be concise. + foreach(flag IN LISTS RE2_CFLAGS_OTHER) + if("${flag}" MATCHES "^-std=") + list(REMOVE_ITEM RE2_CFLAGS_OTHER "${flag}") + endif() + endforeach() + set_property(TARGET re2::re2 PROPERTY + INTERFACE_COMPILE_OPTIONS "${RE2_CFLAGS_OTHER}") + endif() + + if(RE2_LDFLAGS) + set_property(TARGET re2::re2 PROPERTY + INTERFACE_LINK_LIBRARIES "${RE2_LDFLAGS}") + endif() + + message(STATUS "Found RE2 via pkg-config.") + return() +endif() + +if(re2_FIND_REQUIRED) + message(WARNING "Failed to find RE2.") +endif() diff --git a/rust/perspective-server/cmake/modules/LLVMToolchain.cmake b/rust/perspective-server/cmake/modules/LLVMToolchain.cmake new file mode 100644 index 0000000..e25ecfa --- /dev/null +++ b/rust/perspective-server/cmake/modules/LLVMToolchain.cmake @@ -0,0 +1,9 @@ +set(LLVM_ROOT $ENV{LLVM_ROOT}) + +set(CMAKE_C_COMPILER "${LLVM_ROOT}/bin/clang") +set(CMAKE_CXX_COMPILER "${LLVM_ROOT}/bin/clang++") +set(CMAKE_AR "${LLVM_ROOT}/bin/llvm-ar") +set(CMAKE_RANLIB "${LLVM_ROOT}/bin/llvm-ranlib") + +# include_directories(BEFORE SYSTEM "${LLVM_ROOT}/include") +# link_directories(BEFORE "${LLVM_ROOT}/lib") diff --git a/rust/perspective-server/cmake/modules/SetupClangd.cmake b/rust/perspective-server/cmake/modules/SetupClangd.cmake new file mode 100644 index 0000000..0fe9fbf --- /dev/null +++ b/rust/perspective-server/cmake/modules/SetupClangd.cmake @@ -0,0 +1,38 @@ +if(NOT WORKSPACE_ROOT) + message(STATUS "${Yellow}WORKSPACE_ROOT not set, assuming ${CMAKE_CURRENT_SOURCE_DIR}/../../../..${ColorReset}") + set(WORKSPACE_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../../..") +endif() + +if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + set(CLANGD_ADD_FLAGS "Add: + [ + -target, + wasm32-unknown-emscripten, + --sysroot=${WORKSPACE_ROOT}/.emsdk/upstream/emscripten/cache/sysroot, + ]") +else() + set(CLANGD_ADD_FLAGS "") +endif() + + +# Check for existence of .clangd file +if(EXISTS ${CMAKE_CURRENT_LIST_DIR}/.clangd.in) + if(${CMAKE_SOURCE_DIR} STREQUAL ${CMAKE_CURRENT_SOURCE_DIR}) + message(STATUS "${Cyan}Found ${CMAKE_CURRENT_LIST_DIR}/.clangd.in${ColorReset}") + # Configure the .clangd file from the template + configure_file( + ${CMAKE_CURRENT_LIST_DIR}/.clangd.in + ${CMAKE_BINARY_DIR}/.clangd + @ONLY + ) + # Symlink .clangd to the project root + execute_process( + COMMAND ln -sf ${CMAKE_BINARY_DIR}/.clangd ${WORKSPACE_ROOT}/.clangd + ) + message(STATUS "${Cyan}Created ${CMAKE_BINARY_DIR}/.clangd${ColorReset}") + else() + message("${Cyan} ${CMAKE_CURRENT_SOURCE_DIR} is not the root of the project, skipping IDE setup${ColorReset}") + endif() +else() + message("${Yellow}No .clangd.in found, skipping IDE setup${ColorReset}") +endif() diff --git a/rust/perspective-server/cmake/ordered-map.txt.in b/rust/perspective-server/cmake/ordered-map.txt.in new file mode 100644 index 0000000..04c918b --- /dev/null +++ b/rust/perspective-server/cmake/ordered-map.txt.in @@ -0,0 +1,15 @@ +cmake_minimum_required(VERSION 3.7.2) + +project(ordered-map-download NONE) + +include(ExternalProject) +ExternalProject_Add(ordered-map + GIT_REPOSITORY https://github.com/Tessil/ordered-map.git + GIT_TAG v1.0.0 + SOURCE_DIR "${CMAKE_BINARY_DIR}/ordered-map-src" + BINARY_DIR "${CMAKE_BINARY_DIR}/ordered-map-build" + CONFIGURE_COMMAND "" + BUILD_COMMAND "" + INSTALL_COMMAND "" + TEST_COMMAND "" +) diff --git a/rust/perspective-server/cmake/protobuf.txt.in b/rust/perspective-server/cmake/protobuf.txt.in new file mode 100644 index 0000000..3550430 --- /dev/null +++ b/rust/perspective-server/cmake/protobuf.txt.in @@ -0,0 +1,23 @@ +cmake_minimum_required(VERSION 3.7.2) + +project(protobuf-download NONE) + +if("" STREQUAL "${LIBPROTOBUF_VERSION}") + set(LIBPROTOBUF_VERSION v33.2) + message(FATAL_ERROR "LIBPROTOBUF_VERSION not set, using default: ${D}{LIBPROTOBUF_VERSION}") +else() + set(LIBPROTOBUF_VERSION ${LIBPROTOBUF_VERSION}) + message(STATUS "LIBPROTOBUF_VERSION set to: ${D}{LIBPROTOBUF_VERSION}") +endif() + +include(ExternalProject) +ExternalProject_Add(protobuf + GIT_REPOSITORY https://github.com/protocolbuffers/protobuf.git + GIT_TAG "${D}{LIBPROTOBUF_VERSION}" + SOURCE_DIR "${CMAKE_BINARY_DIR}/protobuf-src" + BINARY_DIR "${CMAKE_BINARY_DIR}/protobuf-build" + CONFIGURE_COMMAND "" + BUILD_COMMAND "" + INSTALL_COMMAND "" + TEST_COMMAND "" +) diff --git a/rust/perspective-server/cmake/rapidjson.txt.in b/rust/perspective-server/cmake/rapidjson.txt.in new file mode 100644 index 0000000..18ef8c7 --- /dev/null +++ b/rust/perspective-server/cmake/rapidjson.txt.in @@ -0,0 +1,15 @@ +cmake_minimum_required(VERSION 3.7.2) + +project(rapidjson-download NONE) + +include(ExternalProject) +ExternalProject_Add(rapidjson + GIT_REPOSITORY https://github.com/tencent/rapidjson.git + GIT_TAG d621dc9e9c77f81e5c8a35b8dcc16dcd63351321 + SOURCE_DIR "${CMAKE_BINARY_DIR}/rapidjson-src" + BINARY_DIR "${CMAKE_BINARY_DIR}/rapidjson-build" + CONFIGURE_COMMAND "" + BUILD_COMMAND "" + INSTALL_COMMAND "" + TEST_COMMAND "" +) diff --git a/rust/perspective-server/cmake/re2.txt.in b/rust/perspective-server/cmake/re2.txt.in new file mode 100644 index 0000000..65b4899 --- /dev/null +++ b/rust/perspective-server/cmake/re2.txt.in @@ -0,0 +1,20 @@ +cmake_minimum_required(VERSION 3.7.2) + +project(re2-download NONE) + +# ******************************************************************* +# NOTE: this depedency's CMakeLists.txt is overloaded, make sure to +# adjust if you update the tag +# ******************************************************************* + +include(ExternalProject) +ExternalProject_Add(re2 + GIT_REPOSITORY https://github.com/google/re2.git + GIT_TAG 2021-11-01 + SOURCE_DIR "${CMAKE_BINARY_DIR}/re2-src" + BINARY_DIR "${CMAKE_BINARY_DIR}/re2-build" + CONFIGURE_COMMAND "" + BUILD_COMMAND "" + INSTALL_COMMAND "" + TEST_COMMAND "" +) diff --git a/rust/perspective-server/cmake/re2/CMakeLists.txt b/rust/perspective-server/cmake/re2/CMakeLists.txt new file mode 100644 index 0000000..5cfa11f --- /dev/null +++ b/rust/perspective-server/cmake/re2/CMakeLists.txt @@ -0,0 +1,176 @@ +# Copyright 2015 The RE2 Authors. All Rights Reserved. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file. + +# Old enough to support Ubuntu Xenial. +cmake_minimum_required(VERSION 3.5.1) + +project(RE2 CXX) +include(CTest) +include(GNUInstallDirs) + +if(NOT CMAKE_CXX_STANDARD) + set(CMAKE_CXX_STANDARD 11) + set(CMAKE_CXX_STANDARD_REQUIRED ON) +endif() + +option(BUILD_SHARED_LIBS "build shared libraries" OFF) +option(USEPCRE "use PCRE in tests and benchmarks" OFF) + +# CMake seems to have no way to enable/disable testing per subproject, +# so we provide an option similar to BUILD_TESTING, but just for RE2. +option(RE2_BUILD_TESTING "enable testing for RE2" ON) + +# ABI version +# http://tldp.org/HOWTO/Program-Library-HOWTO/shared-libraries.html +set(SONAME 9) + +set(EXTRA_TARGET_LINK_LIBRARIES) + +if(CMAKE_CXX_COMPILER_ID MATCHES "MSVC") + if(MSVC_VERSION LESS 1900) + message(FATAL_ERROR "you need Visual Studio 2015 or later") + endif() + if(BUILD_SHARED_LIBS) + # See http://www.kitware.com/blog/home/post/939 for details. + set(CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS ON) + endif() + # CMake defaults to /W3, but some users like /W4 (or /Wall) and /WX, + # so we disable various warnings that aren't particularly helpful. + add_compile_options(/wd4100 /wd4201 /wd4456 /wd4457 /wd4702 /wd4815) + # Without a byte order mark (BOM), Visual Studio assumes that the source + # file is encoded using the current user code page, so we specify UTF-8. + add_compile_options(/utf-8) +endif() + +if(WIN32) + add_definitions(-DUNICODE -D_UNICODE -DSTRICT -DNOMINMAX) + add_definitions(-D_CRT_SECURE_NO_WARNINGS -D_SCL_SECURE_NO_WARNINGS) +# ************************************ +# TKP NOTE: THIS SECTION IS CUSTOM +# ************************************ +elseif(APPLE OR (${CMAKE_SYSTEM_NAME} MATCHES "Emscripten")) + add_definitions(-DTARGET_OS_OSX=1) + # assume built-in pthreads on MacOS + set(CMAKE_THREAD_LIBS_INIT "-lpthread") + set(CMAKE_HAVE_THREADS_LIBRARY 1) + set(CMAKE_USE_WIN32_THREADS_INIT 0) + set(CMAKE_USE_PTHREADS_INIT 1) + set(THREADS_PREFER_PTHREAD_FLAG ON) + find_package(Threads REQUIRED) +# ************************************ +elseif(UNIX) + set(THREADS_PREFER_PTHREAD_FLAG ON) + find_package(Threads REQUIRED) +endif() + +if(USEPCRE) + add_definitions(-DUSEPCRE) + list(APPEND EXTRA_TARGET_LINK_LIBRARIES pcre) +endif() + +set(RE2_SOURCES + re2/bitstate.cc + re2/compile.cc + re2/dfa.cc + re2/filtered_re2.cc + re2/mimics_pcre.cc + re2/nfa.cc + re2/onepass.cc + re2/parse.cc + re2/perl_groups.cc + re2/prefilter.cc + re2/prefilter_tree.cc + re2/prog.cc + re2/re2.cc + re2/regexp.cc + re2/set.cc + re2/simplify.cc + re2/stringpiece.cc + re2/tostring.cc + re2/unicode_casefold.cc + re2/unicode_groups.cc + util/rune.cc + util/strutil.cc + ) + +add_library(re2 ${RE2_SOURCES}) +target_include_directories(re2 PUBLIC $) +set_target_properties(re2 PROPERTIES SOVERSION ${SONAME} VERSION ${SONAME}.0.0) +add_library(re2::re2 ALIAS re2) + +if(UNIX) + target_link_libraries(re2 PUBLIC Threads::Threads) +endif() + +if(RE2_BUILD_TESTING) + set(TESTING_SOURCES + re2/testing/backtrack.cc + re2/testing/dump.cc + re2/testing/exhaustive_tester.cc + re2/testing/null_walker.cc + re2/testing/regexp_generator.cc + re2/testing/string_generator.cc + re2/testing/tester.cc + util/pcre.cc + ) + + add_library(testing STATIC ${TESTING_SOURCES}) + target_link_libraries(testing PUBLIC re2) + + set(TEST_TARGETS + charclass_test + compile_test + filtered_re2_test + mimics_pcre_test + parse_test + possible_match_test + re2_test + re2_arg_test + regexp_test + required_prefix_test + search_test + set_test + simplify_test + string_generator_test + + dfa_test + exhaustive1_test + exhaustive2_test + exhaustive3_test + exhaustive_test + random_test + ) + + set(BENCHMARK_TARGETS + regexp_benchmark + ) + + foreach(target ${TEST_TARGETS}) + add_executable(${target} re2/testing/${target}.cc util/test.cc) + target_link_libraries(${target} testing ${EXTRA_TARGET_LINK_LIBRARIES}) + add_test(NAME ${target} COMMAND ${target}) + endforeach() + + foreach(target ${BENCHMARK_TARGETS}) + add_executable(${target} re2/testing/${target}.cc util/benchmark.cc) + target_link_libraries(${target} testing ${EXTRA_TARGET_LINK_LIBRARIES}) + endforeach() +endif() + +set(RE2_HEADERS + re2/filtered_re2.h + re2/re2.h + re2/set.h + re2/stringpiece.h + ) + +install(FILES ${RE2_HEADERS} + DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/re2) +install(TARGETS re2 EXPORT re2Config + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} + INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) +install(EXPORT re2Config + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/re2 NAMESPACE re2::) \ No newline at end of file diff --git a/rust/perspective-server/cmake/toolchains/darwin-arm64.cmake b/rust/perspective-server/cmake/toolchains/darwin-arm64.cmake new file mode 100644 index 0000000..688ccb3 --- /dev/null +++ b/rust/perspective-server/cmake/toolchains/darwin-arm64.cmake @@ -0,0 +1,24 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +# Minimal toolchain file to ensure that Arrow's bundled dependency builds use +# the correct architecture for Mac builds. + +# (Arrow does not as of Oct 2024 pass on CMAKE_OSX_ARCHITECTURES to its +# dependencies if it is set, but does pass on a toolchain file) + +set(CMAKE_OSX_ARCHITECTURES "arm64" CACHE STRING "Target architecture for Mac builds") +# Arrow uses this to choose a `-march` flag +set(CMAKE_SYSTEM_PROCESSOR "arm64") +# Prevent cmake from overwriting CMAKE_SYSTEM_PROCESSOR +# https://github.com/apache/arrow/issues/44448#issuecomment-2418649378 +set(CMAKE_SYSTEM_NAME "Darwin") diff --git a/rust/perspective-server/cmake/toolchains/darwin-x86_64.cmake b/rust/perspective-server/cmake/toolchains/darwin-x86_64.cmake new file mode 100644 index 0000000..0d26dda --- /dev/null +++ b/rust/perspective-server/cmake/toolchains/darwin-x86_64.cmake @@ -0,0 +1,24 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ 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). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +# Minimal toolchain file to ensure that Arrow's bundled dependency builds use +# the correct architecture for Mac builds. + +# (Arrow does not as of Oct 2024 pass on CMAKE_OSX_ARCHITECTURES to its +# dependencies if it is set, but does pass on a toolchain file) + +set(CMAKE_OSX_ARCHITECTURES "x86_64" CACHE STRING "Target architecture for Mac builds") +# Arrow uses this to choose a `-march` flag +set(CMAKE_SYSTEM_PROCESSOR "x86_64") +# Prevent cmake from overwriting CMAKE_SYSTEM_PROCESSOR +# https://github.com/apache/arrow/issues/44448#issuecomment-2418649378 +set(CMAKE_SYSTEM_NAME "Darwin") diff --git a/rust/perspective-server/cpp/perspective/.clangd.in b/rust/perspective-server/cpp/perspective/.clangd.in new file mode 100644 index 0000000..9e4732a --- /dev/null +++ b/rust/perspective-server/cpp/perspective/.clangd.in @@ -0,0 +1,3 @@ +CompileFlags: + @CLANGD_ADD_FLAGS@ + CompilationDatabase: @CMAKE_BINARY_DIR@ \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/CMakeLists.txt b/rust/perspective-server/cpp/perspective/CMakeLists.txt new file mode 100644 index 0000000..029ea01 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/CMakeLists.txt @@ -0,0 +1,736 @@ +cmake_minimum_required(VERSION 3.18.2) +project(psp) +include(CheckCCompilerFlag) + +set(CMAKE_BUILD_TYPE "Release") +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +set(CMAKE_IGNORE_PATH "/opt/homebrew") + +# CMAKE POLICIES +# option() should use new cmake behavior wrt variable clobbering +cmake_policy(SET CMP0077 NEW) + +# BOOST_ROOT has been removed on Windows VMs in Azure: +# +# - https://github.com/actions/virtual-environments/issues/687 +# - https://github.com/actions/virtual-environments/issues/319 +# +# `BOOST_ROOT` must be set in the environment, and policy CMP0074 +# must be set to `NEW` to allow BOOST_ROOT to be defined by env var +cmake_policy(SET CMP0074 NEW) + +# Set CMP0094 to NEW - find the first version that matches constraints, +# instead of the latest version installed. +cmake_policy(SET CMP0094 NEW) + +if(NOT DEFINED PSP_CMAKE_MODULE_PATH) + set(PSP_CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/../../cmake") +endif() + +set(CMAKE_MODULE_PATH "${PSP_CMAKE_MODULE_PATH}/modules" ${CMAKE_MODULE_PATH}) +set(MSVC_RUNTIME_LIBRARY MultiThreaded) + +option(PSP_WASM64 "Enable WASM Memory64 support" OFF) +option(PSP_WASM_OPFS "Enable on-disk (OPFS) tables in the browser via the custom `psp_opfs_*` bridge in `perspective-server.poly.ts` (NO_FILESYSTEM + JSPI; no WasmFS, no SharedArrayBuffer). Backwards compatible: the module loads and runs in-memory tables on non-JSPI browsers; on-disk activates only in a JSPI-capable Worker." ON) + +if(${CMAKE_SYSTEM_NAME} MATCHES "Windows") + set(WIN32 ON) + set(MACOS OFF) + set(LINUX OFF) +elseif(${CMAKE_SYSTEM_NAME} MATCHES "Darwin") + set(WIN32 OFF) + set(MACOS ON) + set(LINUX OFF) +else() + set(WIN32 OFF) + set(MACOS OFF) + set(LINUX ON) +endif() + +if (WIN32) + add_compile_options("/EHsc") + set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") +endif() + + +if(WIN32) + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /EHsc /MP /MT /c /bigobj") +else() + # set(CMAKE_CXX_FLAGS " ${CMAKE_CXX_FLAGS}") +endif() + +# # Helper function +function(string_starts_with str search) + string(FIND "${str}" "${search}" out) + + if("${out}" EQUAL 0) + return(true) + endif() + return(false) +endfunction() + +set(BUILD_MESSAGE "") + +function(psp_build_message message) + set(BUILD_MESSAGE "${BUILD_MESSAGE}\n${message}") +endfunction() + +# ###################### +# BUILD CONFIGURATION # +# ###################### +find_package(Color) +find_package(InstallDependency) + +# OPTIONS +option(CMAKE_BUILD_TYPE "Release/Debug build" RELEASE) +option(PSP_WASM_BUILD "Build the WebAssembly Project" ON) +option(PSP_CPP_BUILD "Build the C++ Project" OFF) +option(PSP_PYTHON_BUILD "Build the Python Bindings" OFF) +option(PSP_CPP_BUILD_STRICT "Build the C++ with strict warnings" OFF) +option(PSP_SANITIZE "Build with sanitizers" OFF) +option(PSP_HEAP_INSTRUMENTS "Build with heap inspection tooling" OFF) + +if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + set(PSP_WASM_BUILD ON) + set(PSP_CPP_BUILD OFF) +else() + set(PSP_WASM_BUILD OFF) + set(PSP_CPP_BUILD ON) +endif() + +if(PSP_WASM_BUILD AND PSP_CPP_BUILD) + message(FATAL_ERROR "${Red}CPP and Emscripten builds must be done separately${ColorReset}") +endif() + +if(DEFINED ENV{PSP_DEBUG}) + set(CMAKE_BUILD_TYPE DEBUG) +else() + if(NOT DEFINED CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE RELEASE) + endif() +endif() + +if(DEFINED ENV{PSP_HEAP_INSTRUMENTS}) + set(PSP_HEAP_INSTRUMENTS ON) +else() + set(PSP_HEAP_INSTRUMENTS OFF) +endif() + +if(DEFINED ENV{PSP_MANYLINUX}) + set(MANYLINUX ON) +else() + set(MANYLINUX OFF) +endif() + +if(DEFINED ENV{PSP_USE_CCACHE}) + set(CMAKE_C_COMPILE_LAUNCHER ccache) + set(CMAKE_CXX_COMPILER_LAUNCHER ccache) +endif() + +if(NOT DEFINED PSP_CPP_BUILD) + set(PSP_CPP_BUILD ON) +endif() + +if(NOT DEFINED PSP_PYTHON_BUILD) + set(PSP_PYTHON_BUILD OFF) +elseif(PSP_PYTHON_BUILD) + if(NOT DEFINED PSP_PYTHON_VERSION) + set(PSP_PYTHON_VERSION 3.10) + endif() + if(PSP_WASM_BUILD) + set(PSP_PYODIDE 1) + else() + set(PSP_PYODIDE 0) + endif() +endif() + +if(NOT DEFINED PSP_CPP_BUILD_STRICT) + set(PSP_CPP_BUILD_STRICT OFF) +endif() + +if(PSP_WASM_BUILD) + set(BUILD_MESSAGE "${BUILD_MESSAGE}\n${Cyan}Building WASM binding${ColorReset}") +else() + set(BUILD_MESSAGE "${BUILD_MESSAGE}\n${Yellow}Skipping WASM binding${ColorReset}") +endif() + +if(NOT DEFINED PSP_CPP_SRC) + set(PSP_CPP_SRC "${CMAKE_CURRENT_SOURCE_DIR}") +endif() + +if(PSP_CPP_BUILD) + set(BUILD_MESSAGE "${BUILD_MESSAGE}\n${Cyan}Building C++ binding${ColorReset}") +else() + set(BUILD_MESSAGE "${BUILD_MESSAGE}\n${Yellow}Skipping C++ binding${ColorReset}") +endif() + +if(PSP_PYTHON_BUILD) + if(NOT DEFINED PSP_PYTHON_SRC) + set(PSP_PYTHON_SRC "${CMAKE_CURRENT_SOURCE_DIR}/../../python/perspective/perspective") + endif() + + # set(BUILD_MESSAGE "${BUILD_MESSAGE}\n${Cyan}Building Python ${Red}${PSP_PYTHON_VERSION}${Cyan} binding${ColorReset}") +else() + set(BUILD_MESSAGE "${BUILD_MESSAGE}\n${Yellow}Skipping Python binding${ColorReset}") +endif() + +if(PSP_CPP_BUILD AND NOT PSP_CPP_BUILD_STRICT) + set(BUILD_MESSAGE "${BUILD_MESSAGE}\n${Yellow}Building C++ without strict warnings${ColorReset}") +else() + set(BUILD_MESSAGE "${BUILD_MESSAGE}\n${Cyan}Building C++ with strict warnings${ColorReset}") +endif() + +string(TOLOWER "${CMAKE_BUILD_TYPE}" CMAKE_BUILD_TYPE_LOWER) + +if(CMAKE_BUILD_TYPE_LOWER STREQUAL debug) + set(BUILD_MESSAGE "${BUILD_MESSAGE}\n${Red}Building DEBUG${ColorReset}") + add_definitions(-DPSP_DEBUG) + add_definitions(-DPSP_STORAGE_VERIFY) + add_definitions(-D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_FAST) + add_definitions(-D_GLIBCXX_ASSERTIONS) +else() + set(BUILD_MESSAGE "${BUILD_MESSAGE}\n${Cyan}Building RELEASE${ColorReset}") +endif() + +if(PSP_PYTHON_BUILD AND MACOS) + # fix for threads on osx + # assume built-in pthreads on MacOS + set(CMAKE_THREAD_LIBS_INIT "-lpthread") + set(CMAKE_HAVE_THREADS_LIBRARY 1) + set(CMAKE_USE_WIN32_THREADS_INIT 0) + set(CMAKE_USE_PTHREADS_INIT 1) + set(THREADS_PREFER_PTHREAD_FLAG ON) + + # don't link against build python + # https://blog.tim-smith.us/2015/09/python-extension-modules-os-x/ + set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -undefined dynamic_lookup") +endif() + +if(EMSCRIPTEN) + set(CMAKE_HAVE_LIBC_PTHREAD TRUE CACHE BOOL "psp: satisfy FindThreads on WASM without enabling pthreads" FORCE) +endif() + +# ###################### +set(psp_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/src/include") + +if(NOT DEFINED PSP_WASM_EXCEPTIONS AND NOT PSP_PYTHON_BUILD) + set(PSP_WASM_EXCEPTIONS ON) +endif() + +if(PSP_PYODIDE) + set(PSP_WASM_EXCEPTIONS ON) +endif() + +set(DEBUG_LEVEL "0") +if(PSP_HEAP_INSTRUMENTS) + set(DEBUG_LEVEL "3") +endif() + +if(PSP_WASM_BUILD) + #################### + # EMSCRIPTEN BUILD # + #################### + set(CMAKE_EXECUTABLE_SUFFIX ".js") + list(APPEND CMAKE_PREFIX_PATH /usr/local) + + set(EXTENDED_FLAGS " \ + -Wall \ + -fcolor-diagnostics \ + ") + + if(CMAKE_BUILD_TYPE_LOWER STREQUAL debug) + # Pyodide DEBUG block + set(OPT_FLAGS " \ + -O0 \ + -g3 \ + -Wcast-align \ + -Wover-aligned \ + --emit-tsd=perspective-server.d.ts \ + ") + if (PSP_WASM_EXCEPTIONS) + set(OPT_FLAGS "${OPT_FLAGS} -fwasm-exceptions ") + endif() + if(PSP_SANITIZE) + set(OPT_FLAGS "${OPT_FLAGS} \ + -fsanitize=undefined \ + -fsanitize=address \ + ") + endif() + else() + set(OPT_FLAGS " \ + -O3 -g${DEBUG_LEVEL} \ + -mbulk-memory \ + -msimd128 \ + -mrelaxed-simd \ + -flto \ + --emit-tsd=perspective-server.d.ts \ + ") + if (PSP_WASM_EXCEPTIONS) + set(OPT_FLAGS "${OPT_FLAGS} -fwasm-exceptions ") + endif() + endif() +elseif(PSP_CPP_BUILD OR PSP_PYTHON_BUILD) + if(WIN32) + if(CMAKE_BUILD_TYPE_LOWER STREQUAL debug) + set(OPT_FLAGS " \ + /DEBUG \ + /Z7 \ + /Zi + ") + else() + set(OPT_FLAGS " \ + /NDEBUG \ + /O2 \ + ") + endif() + else() + if(CMAKE_BUILD_TYPE_LOWER STREQUAL debug) + if (PSP_PYODIDE) + set(OPT_FLAGS " \ + -O0 \ + -g3 \ + -gsource-map \ + --profiling \ + -Wcast-align \ + -Wover-aligned \ + ") + else () + set(OPT_FLAGS " \ + -O0 \ + -fexceptions \ + -g3 \ + ") + if (PSP_WASM_EXCEPTIONS) + set(OPT_FLAGS "${OPT_FLAGS} -fwasm-exceptions ") + endif() + endif () + else() + set(OPT_FLAGS " \ + -O3 \ + -fexceptions \ + -g1 \ + -fopenmp-simd \ + ") + if (PSP_WASM_EXCEPTIONS) + set(OPT_FLAGS "${OPT_FLAGS} -fwasm-exceptions ") + endif() + endif() + endif() + + set(ASYNC_MODE_FLAGS "") + + if(WIN32) + foreach(warning 4244 4251 4267 4275 4290 4786 4305 4996) + SET(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /wd${warning}") + endforeach(warning) + else() + endif() + + if(PSP_PYTHON_BUILD) + set(CMAKE_POSITION_INDEPENDENT_CODE ON) + endif() +endif() + + +if (PSP_WASM_EXCEPTIONS) + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} \ + -O3 \ + -g${DEBUG_LEVEL} \ + ") + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} \ + -fwasm-exceptions \ + -O3 \ + -g${DEBUG_LEVEL} \ + ") +else() + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} \ + -O3 \ + ") +endif() + +if (PSP_WASM64) + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} \ + -s MEMORY64=1 \ + ") + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} \ + -s MEMORY64=1 \ + ") +endif() + +set(RAPIDJSON_BUILD_TESTS OFF CACHE BOOL "Disable rapidjson tests") + +if(PSP_PYODIDE) + set(RELOCATABLE_FLAGS " -sRELOCATABLE=1 -sSIDE_MODULE=2 -fwasm-exceptions -sSUPPORT_LONGJMP=wasm \ ") + string(APPEND CMAKE_EXE_LINKER_FLAGS "${RELOCATABLE_FLAGS} -sWASM_BIGINT=1 ") + string(APPEND CMAKE_C_FLAGS "${RELOCATABLE_FLAGS}") + string(APPEND CMAKE_CXX_FLAGS "${RELOCATABLE_FLAGS}") + string(APPEND CMAKE_CXX_FLAGS " -fvisibility=hidden") + + # Emscripten exceptions + string(APPEND CMAKE_CXX_FLAGS " -fexceptions") +endif() + +# Build (read: download and extract) header-only dependencies from external sources +set(all_deps_INCLUDE_DIRS "") +psp_build_dep("date" "${PSP_CMAKE_MODULE_PATH}/date.txt.in") +psp_build_dep("hopscotch" "${PSP_CMAKE_MODULE_PATH}/hopscotch.txt.in") +psp_build_dep("ordered-map" "${PSP_CMAKE_MODULE_PATH}/ordered-map.txt.in") +psp_build_dep("rapidjson" "${PSP_CMAKE_MODULE_PATH}/rapidjson.txt.in") +list(APPEND all_deps_INCLUDE_DIRS + ${date_INCLUDE_DIRS} + ${hopscotch_INCLUDE_DIRS} + ${ordered-map_INCLUDE_DIRS} + ${rapidjson_INCLUDE_DIRS}) + +psp_build_dep("Boost" "${PSP_CMAKE_MODULE_PATH}/Boost.txt.in") +list(APPEND all_deps_INCLUDE_DIRS ${Boost_INCLUDE_DIRS}) + +# Build dependencies as static libraries with add_subdirectory() +# Note that values set above for CMAKE_C_FLAGS, CMAKE_CXX_FLAGS, etc. will +# apply to dependency cmake builds + +# Arrow builds its own dependencies +psp_build_message("${Cyan}Building Apache Arrow${ColorReset}") +psp_build_dep("arrow" "${PSP_CMAKE_MODULE_PATH}/arrow.txt.in") +list(APPEND all_deps_INCLUDE_DIRS + ${arrow_INCLUDE_DIRS}) + +# Build re2 as our regex library +# this is a workaround for some re2-specific weirdness +add_definitions(-DTARGET_OS_OSX=1) +psp_build_dep("re2" "${PSP_CMAKE_MODULE_PATH}/re2.txt.in") +list(APPEND all_deps_INCLUDE_DIRS + ${re2_INCLUDE_DIRS}) + +# Build exprtk for expression parsing +psp_build_dep("exprtk" "${PSP_CMAKE_MODULE_PATH}/exprtk.txt.in") +list(APPEND all_deps_INCLUDE_DIRS + ${exprtk_INCLUDE_DIRS}) + +# Protobuf setup +set(protobuf_BUILD_TESTS OFF FORCE) +add_subdirectory(${PSP_CMAKE_MODULE_PATH}/../cpp/protos "${CMAKE_BINARY_DIR}/protos-build") + +# #################### +set(CMAKE_C_FLAGS " \ + ${CMAKE_C_FLAGS} \ + ${CMAKE_C_FLAGS_RELEASE} \ + ${EXTENDED_FLAGS} \ + ${OPT_FLAGS} \ + ") + +set(CMAKE_CXX_FLAGS " \ + ${CMAKE_CXX_FLAGS} \ + ${CMAKE_CXX_FLAGS_RELEASE} \ + ${EXTENDED_FLAGS} \ + ${OPT_FLAGS} \ + ") + +if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -std=c++1y") +endif() + +set(SOURCE_FILES + ${PSP_CPP_SRC}/src/cpp/aggregate.cpp + ${PSP_CPP_SRC}/src/cpp/aggspec.cpp + ${PSP_CPP_SRC}/src/cpp/arg_sort.cpp + ${PSP_CPP_SRC}/src/cpp/arrow_loader.cpp + ${PSP_CPP_SRC}/src/cpp/arrow_writer.cpp + ${PSP_CPP_SRC}/src/cpp/base.cpp + ${PSP_CPP_SRC}/src/cpp/base_impl_linux.cpp + ${PSP_CPP_SRC}/src/cpp/base_impl_osx.cpp + ${PSP_CPP_SRC}/src/cpp/base_impl_wasm.cpp + ${PSP_CPP_SRC}/src/cpp/base_impl_win.cpp + # ${PSP_CPP_SRC}/src/cpp/binding.cpp + + # ${PSP_CPP_SRC}/src/cpp/build_filter.cpp + # ${PSP_CPP_SRC}/src/cpp/calc_agg_dtype.cpp + ${PSP_CPP_SRC}/src/cpp/column.cpp + ${PSP_CPP_SRC}/src/cpp/comparators.cpp + ${PSP_CPP_SRC}/src/cpp/compat.cpp + ${PSP_CPP_SRC}/src/cpp/compat_impl_linux.cpp + ${PSP_CPP_SRC}/src/cpp/compat_impl_osx.cpp + ${PSP_CPP_SRC}/src/cpp/compat_impl_wasm.cpp + ${PSP_CPP_SRC}/src/cpp/compat_impl_win.cpp + ${PSP_CPP_SRC}/src/cpp/computed_expression.cpp + ${PSP_CPP_SRC}/src/cpp/computed_function.cpp + ${PSP_CPP_SRC}/src/cpp/config.cpp + ${PSP_CPP_SRC}/src/cpp/context_base.cpp + ${PSP_CPP_SRC}/src/cpp/context_grouped_pkey.cpp + ${PSP_CPP_SRC}/src/cpp/context_handle.cpp + ${PSP_CPP_SRC}/src/cpp/context_one.cpp + ${PSP_CPP_SRC}/src/cpp/context_two.cpp + ${PSP_CPP_SRC}/src/cpp/context_zero.cpp + ${PSP_CPP_SRC}/src/cpp/context_unit.cpp + ${PSP_CPP_SRC}/src/cpp/data.cpp + ${PSP_CPP_SRC}/src/cpp/data_slice.cpp + ${PSP_CPP_SRC}/src/cpp/data_table.cpp + ${PSP_CPP_SRC}/src/cpp/date.cpp + ${PSP_CPP_SRC}/src/cpp/dense_nodes.cpp + ${PSP_CPP_SRC}/src/cpp/dense_tree_context.cpp + ${PSP_CPP_SRC}/src/cpp/dense_tree.cpp + ${PSP_CPP_SRC}/src/cpp/dependency.cpp + ${PSP_CPP_SRC}/src/cpp/expression_tables.cpp + ${PSP_CPP_SRC}/src/cpp/expression_vocab.cpp + ${PSP_CPP_SRC}/src/cpp/extract_aggregate.cpp + ${PSP_CPP_SRC}/src/cpp/filter.cpp + ${PSP_CPP_SRC}/src/cpp/flat_traversal.cpp + ${PSP_CPP_SRC}/src/cpp/get_data_extents.cpp + ${PSP_CPP_SRC}/src/cpp/gnode.cpp + ${PSP_CPP_SRC}/src/cpp/gnode_state.cpp + ${PSP_CPP_SRC}/src/cpp/mask.cpp + ${PSP_CPP_SRC}/src/cpp/multi_sort.cpp + ${PSP_CPP_SRC}/src/cpp/none.cpp + ${PSP_CPP_SRC}/src/cpp/path.cpp + ${PSP_CPP_SRC}/src/cpp/pivot.cpp + ${PSP_CPP_SRC}/src/cpp/pool.cpp + ${PSP_CPP_SRC}/src/cpp/port.cpp + ${PSP_CPP_SRC}/src/cpp/process_state.cpp + ${PSP_CPP_SRC}/src/cpp/pyutils.cpp + ${PSP_CPP_SRC}/src/cpp/raii.cpp + ${PSP_CPP_SRC}/src/cpp/raii_impl_linux.cpp + ${PSP_CPP_SRC}/src/cpp/raii_impl_osx.cpp + ${PSP_CPP_SRC}/src/cpp/raii_impl_win.cpp + ${PSP_CPP_SRC}/src/cpp/range.cpp + ${PSP_CPP_SRC}/src/cpp/regex.cpp + ${PSP_CPP_SRC}/src/cpp/rlookup.cpp + ${PSP_CPP_SRC}/src/cpp/scalar.cpp + ${PSP_CPP_SRC}/src/cpp/schema_column.cpp + ${PSP_CPP_SRC}/src/cpp/schema.cpp + ${PSP_CPP_SRC}/src/cpp/slice.cpp + ${PSP_CPP_SRC}/src/cpp/sort_specification.cpp + ${PSP_CPP_SRC}/src/cpp/sparse_tree.cpp + ${PSP_CPP_SRC}/src/cpp/sparse_tree_node.cpp + ${PSP_CPP_SRC}/src/cpp/residency.cpp + ${PSP_CPP_SRC}/src/cpp/step_delta.cpp + ${PSP_CPP_SRC}/src/cpp/storage.cpp + ${PSP_CPP_SRC}/src/cpp/storage_impl_linux.cpp + ${PSP_CPP_SRC}/src/cpp/storage_impl_osx.cpp + ${PSP_CPP_SRC}/src/cpp/storage_impl_wasm.cpp + ${PSP_CPP_SRC}/src/cpp/storage_impl_win.cpp + ${PSP_CPP_SRC}/src/cpp/sym_table.cpp + ${PSP_CPP_SRC}/src/cpp/table.cpp + ${PSP_CPP_SRC}/src/cpp/time.cpp + ${PSP_CPP_SRC}/src/cpp/traversal.cpp + ${PSP_CPP_SRC}/src/cpp/traversal_nodes.cpp + ${PSP_CPP_SRC}/src/cpp/tree_context_common.cpp + ${PSP_CPP_SRC}/src/cpp/utils.cpp + ${PSP_CPP_SRC}/src/cpp/update_task.cpp + ${PSP_CPP_SRC}/src/cpp/view.cpp + ${PSP_CPP_SRC}/src/cpp/view_config.cpp + ${PSP_CPP_SRC}/src/cpp/vocab.cpp + ${PSP_CPP_SRC}/src/cpp/arrow_csv.cpp + ${PSP_CPP_SRC}/src/cpp/join_engine.cpp + ${PSP_CPP_SRC}/src/cpp/server.cpp + ${PSP_CPP_SRC}/src/cpp/binding_api.cpp +) + +if(PSP_HEAP_INSTRUMENTS) + list(APPEND SOURCE_FILES ${PSP_CPP_SRC}/src/cpp/heap_instruments.cpp) + add_compile_definitions(HEAP_INSTRUMENTS=1) +endif() + +set(PYTHON_SOURCE_FILES ${SOURCE_FILES}) +set(WASM_SOURCE_FILES ${SOURCE_FILES}) + +message("${BUILD_MESSAGE}\n") + +if(WIN32) + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /EHsc /MP /MT /c /bigobj") +else() + # set(CMAKE_CXX_FLAGS " ${CMAKE_CXX_FLAGS}") +endif() + +set(PSP_EXPORTED_FUNCTIONS + _psp_poll + _psp_new_server + _psp_free + _psp_alloc + _psp_handle_request + _psp_new_session + _psp_close_session + _psp_delete_server + _psp_is_memory64 + _psp_num_cpus + _psp_set_num_cpus + _psp_residency_prepare + _psp_residency_victim_fname + _psp_residency_commit +) + +if(PSP_HEAP_INSTRUMENTS) + list(APPEND PSP_EXPORTED_FUNCTIONS + _psp_print_used_memory + _psp_dump_stack_traces + _psp_clear_stack_traces + ) +endif() + +string(JOIN "," PSP_EXPORTED_FUNCTIONS_JOINED ${PSP_EXPORTED_FUNCTIONS}) + +# Common flags for WASM/JS build and Pyodide +if(PSP_PYODIDE) + set(PSP_WASM_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} \ + --no-entry \ + -s EXPORTED_FUNCTIONS=${PSP_EXPORTED_FUNCTIONS_JOINED} \ + -s SIDE_MODULE=2 \ + ") +else() +# -s MEMORY_GROWTH_GEOMETRIC_STEP=1.0 \ +# -s MEMORY_GROWTH_GEOMETRIC_CAP=536870912 \ + + set(PSP_FILESYSTEM_FLAGS "-s NO_FILESYSTEM=1 ") + set(PSP_CLOSURE_FLAGS "--closure=1 ") + + # On-disk tables in the browser are backed by OPFS via emscripten's WasmFS. + if(PSP_WASM_OPFS) + add_compile_definitions(PSP_WASM_OPFS_PERSIST=1) + endif() + + set(PSP_WASM_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} \ + --no-entry \ + ${PSP_CLOSURE_FLAGS} \ + ${PSP_FILESYSTEM_FLAGS} \ + -s ALLOW_MEMORY_GROWTH=1 \ + -s MODULARIZE=1 \ + -s WASM_BIGINT=1 \ + -s INCOMING_MODULE_JS_API=locateFile,psp_heap_size,psp_stack_trace,HEAPU8,HEAPU32,instantiateWasm \ + -s TEXTDECODER=2 \ + -s STANDALONE_WASM=1 \ + -s DYNAMIC_EXECUTION=0 \ + -s POLYFILL=0 \ + -s EXPORT_NAME=\"load_perspective\" \ + -s ERROR_ON_UNDEFINED_SYMBOLS=0 \ + -s NODEJS_CATCH_EXIT=0 \ + -s NODEJS_CATCH_REJECTION=0 \ + -s USE_ES6_IMPORT_META=1 \ + -s EXPORT_ES6=1 \ + -s EXPORTED_RUNTIME_METHODS=HEAPU8 \ + -s EXPORTED_FUNCTIONS=${PSP_EXPORTED_FUNCTIONS_JOINED} \ + ") + + if(PSP_WASM64) + set(PSP_WASM_LINKER_FLAGS "${PSP_WASM_LINKER_FLAGS} \ + -s MAXIMUM_MEMORY=16gb \ + ") + else() + set(PSP_WASM_LINKER_FLAGS "${PSP_WASM_LINKER_FLAGS} \ + -s MAXIMUM_MEMORY=4gb \ + ") + endif() +endif() + +if (PSP_WASM_EXCEPTIONS) + set(PSP_WASM_LINKER_FLAGS "${PSP_WASM_LINKER_FLAGS} -s EXCEPTION_STACK_TRACES=1 ") +endif() + +if(PSP_SANITIZE) + set(PSP_SANITIZE_FLAGS + -sINITIAL_MEMORY=640mb + -sTOTAL_MEMORY=640mb + -sALLOW_MEMORY_GROWTH=1 + ) +else() + set(PSP_SANITIZE_FLAGS) +endif() + +if(PSP_WASM_BUILD AND NOT PSP_PYTHON_BUILD) + set(CMAKE_EXE_LINKER_FLAGS "${PSP_WASM_LINKER_FLAGS} --pre-js \"${PSP_CPP_SRC}/env.js\" ") + + add_library(psp ${WASM_SOURCE_FILES}) + target_include_directories(psp PRIVATE ${psp_INCLUDE_DIRS}) + target_include_directories(psp SYSTEM PRIVATE ${all_deps_INCLUDE_DIRS}) + target_compile_definitions(psp PRIVATE PSP_ENABLE_WASM=1) + set_target_properties(psp PROPERTIES COMPILE_FLAGS "") + target_link_libraries(psp PRIVATE arrow_static re2 protos) + + add_executable(perspective_esm src/cpp/binding_api.cpp) + message(STATUS "all_deps_INCLUDE_DIRS ${all_deps_INCLUDE_DIRS}") + target_include_directories(perspective_esm PRIVATE ${psp_INCLUDE_DIRS}) + target_include_directories(perspective_esm SYSTEM PRIVATE ${all_deps_INCLUDE_DIRS}) + target_link_libraries(perspective_esm psp protos) + target_compile_definitions(perspective_esm PRIVATE PSP_ENABLE_WASM=1) + target_link_options(perspective_esm PUBLIC -sENVIRONMENT=web ${PSP_SANITIZE_FLAGS}) + set_target_properties(perspective_esm PROPERTIES RUNTIME_OUTPUT_DIRECTORY "./web/") + set_target_properties(perspective_esm PROPERTIES OUTPUT_NAME "perspective-server") +elseif(PSP_CPP_BUILD OR PSP_PYTHON_BUILD) + if(NOT WIN32) + set(CMAKE_SHARED_LIBRARY_SUFFIX .so) + + # Look for the binary using @loader_path (relative to binary location) + set(CMAKE_MACOSX_RPATH TRUE) + set(CMAKE_SKIP_BUILD_RPATH FALSE) + set(CMAKE_BUILD_WITH_INSTALL_RPATH TRUE) + set(CMAKE_INSTALL_RPATH_USE_LINK_PATH TRUE) + set(CMAKE_INSTALL_NAME_DIR "@rpath/") + + # module_origin_path is the location of the binary + if(MACOS) + set(module_origin_path "@loader_path/") + else() + set(module_origin_path "\$ORIGIN") + endif() + else() + set(CMAKE_SHARED_LIBRARY_PREFIX lib) + endif() + + if(PSP_PYTHON_BUILD) + # ####################### + # Python extra targets # + # ####################### + if(PSP_WASM_BUILD) + # Pyodide + set(CMAKE_EXECUTABLE_SUFFIX ".wasm") + set(CMAKE_EXE_LINKER_FLAGS "${PSP_WASM_LINKER_FLAGS} --pre-js \"${PSP_CPP_SRC}/env.js\" ") + add_library(psp STATIC ${PYTHON_SOURCE_FILES}) + target_include_directories(psp PRIVATE ${psp_INCLUDE_DIRS}) + target_include_directories(psp SYSTEM PRIVATE ${all_deps_INCLUDE_DIRS}) + target_compile_definitions(psp PRIVATE PSP_ENABLE_PYTHON=1 PSP_ENABLE_WASM=1) + else() + # Cpython + add_library(psp STATIC ${PYTHON_SOURCE_FILES}) + target_include_directories(psp PRIVATE ${psp_INCLUDE_DIRS}) + target_include_directories(psp SYSTEM PRIVATE ${all_deps_INCLUDE_DIRS}) + target_compile_definitions(psp PRIVATE PSP_ENABLE_PYTHON=1 PSP_PARALLEL_FOR=1) + endif() + + if(MACOS OR NOT MANYLINUX) + set_property(TARGET psp PROPERTY INSTALL_RPATH ${CMAKE_INSTALL_RPATH} ${module_origin_path}) + + target_compile_options(psp PRIVATE -fvisibility=hidden) + elseif(MANYLINUX) + # intentionally blank + else() + target_compile_options(psp PRIVATE -fvisibility=hidden) + endif() + + # Linking against arrow_static also links against its bundled dependencies + target_link_libraries(psp PRIVATE arrow_static re2 protos) + else() + add_library(psp STATIC ${WASM_SOURCE_FILES}) + target_include_directories(psp PRIVATE ${psp_INCLUDE_DIRS}) + target_include_directories(psp SYSTEM PRIVATE ${all_deps_INCLUDE_DIRS}) + target_compile_options(psp PRIVATE -fvisibility=hidden) + target_link_libraries(psp PRIVATE arrow_static re2 protos) + endif() + + if(PSP_CPP_BUILD_STRICT AND NOT WIN32) + target_compile_options(psp PRIVATE -Wall -Werror) + target_compile_options(psp PRIVATE $<$:-fPIC -O0>) + if(PSP_PYTHON_BUILD) + target_compile_options(psppy PRIVATE $<$:-fPIC -O0>) + endif() + elseif(WIN32) + target_compile_definitions(psp PRIVATE PERSPECTIVE_EXPORTS=1) + target_compile_definitions(psp PRIVATE WIN32=1) + target_compile_definitions(psp PRIVATE _WIN32=1) + endif() +endif() + +# TODO this needs to be omitted when built by `cmake-rs`, because +#`cargo publish --dry-run` complains when this generate `.clangd` in the Rust +# root dir instead of teh `target` dir. +if(NOT DEFINED ENV{PSP_DISABLE_CLANGD}) + include(SetupClangd) +endif() diff --git a/rust/perspective-server/cpp/perspective/env.js b/rust/perspective-server/cpp/perspective/env.js new file mode 100644 index 0000000..3ec3f1c --- /dev/null +++ b/rust/perspective-server/cpp/perspective/env.js @@ -0,0 +1,15 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +// this line/hack allows us to bypass the emscripten environment +// so we can run the same JS-file on both web and node. +globalThis.window = {}; diff --git a/rust/perspective-server/cpp/perspective/patches/arrow_strptime.patch b/rust/perspective-server/cpp/perspective/patches/arrow_strptime.patch new file mode 100644 index 0000000..5949ebc --- /dev/null +++ b/rust/perspective-server/cpp/perspective/patches/arrow_strptime.patch @@ -0,0 +1,53 @@ +diff --git a/cpp/src/arrow/util/decimal.cc b/cpp/src/arrow/util/decimal.cc +index c8457eae8..aa2b41cc9 100644 +--- a/cpp/src/arrow/util/decimal.cc ++++ b/cpp/src/arrow/util/decimal.cc +@@ -111,7 +111,11 @@ struct DecimalRealConversion : public BaseDecimalRealConversion { + + // 2. Losslessly convert `real` to `mant * 2**k` + int binary_exp = 0; ++#ifdef __EMSCRIPTEN__ ++ const Real real_mant = std::frexpf(real, &binary_exp); ++#else + const Real real_mant = std::frexp(real, &binary_exp); ++#endif + // `real_mant` is within 0.5 and 1 and has M bits of precision. + // Multiply it by 2^M to get an exact integer. + const uint64_t mant = static_cast(std::ldexp(real_mant, kMantissaBits)); +diff --git a/cpp/src/arrow/util/value_parsing.h b/cpp/src/arrow/util/value_parsing.h +index 609906052..1e3dfae7c 100644 +--- a/cpp/src/arrow/util/value_parsing.h ++++ b/cpp/src/arrow/util/value_parsing.h +@@ -804,7 +804,7 @@ static inline bool ParseTimestampStrptime(const char* buf, size_t length, + std::string clean_copy(buf, length); + struct tm result; + memset(&result, 0, sizeof(struct tm)); +-#ifdef _WIN32 ++#if defined(_WIN32) || defined(__EMSCRIPTEN__) + char* ret = arrow_strptime(clean_copy.c_str(), format, &result); + #else + char* ret = strptime(clean_copy.c_str(), format, &result); +diff --git a/cpp/src/arrow/vendored/xxhash/xxhash.h b/cpp/src/arrow/vendored/xxhash/xxhash.h +index a18e8c762..235590b19 100644 +--- a/cpp/src/arrow/vendored/xxhash/xxhash.h ++++ b/cpp/src/arrow/vendored/xxhash/xxhash.h +@@ -169,6 +169,11 @@ + * xxHash prototypes and implementation + */ + ++ ++#ifdef __EMSCRIPTEN__ ++#include ++#endif ++ + #if defined (__cplusplus) + extern "C" { + #endif +@@ -3422,6 +3427,7 @@ XXH_PUBLIC_API XXH64_hash_t XXH64_hashFromCanonical(XXH_NOESCAPE const XXH64_can + # endif + #endif + ++ + #if defined(__GNUC__) || defined(__clang__) + # if defined(__ARM_FEATURE_SVE) + # include diff --git a/rust/perspective-server/cpp/perspective/patches/fix_arrow_libtool.patch b/rust/perspective-server/cpp/perspective/patches/fix_arrow_libtool.patch new file mode 100644 index 0000000..81a16bf --- /dev/null +++ b/rust/perspective-server/cpp/perspective/patches/fix_arrow_libtool.patch @@ -0,0 +1,59 @@ +From f2b12a311d1a14ab5d9f67428201e1389748a9c1 Mon Sep 17 00:00:00 2001 +From: Tom Jakubowski +Date: Fri, 11 Oct 2024 12:23:29 -0700 +Subject: [PATCH] GH-44384: [C++] Use CMAKE_LIBTOOL on macOS + +When a builder sets `CMAKE_LIBTOOL`, use that as the program to bundle +dependencies. This matches the behavior of the Windows build. + +Also make a nitpicky minor update to the error message when a non-Apple +libtool is detected. +--- + cpp/cmake_modules/BuildUtils.cmake | 30 +++++++++++++++++------------- + 1 file changed, 17 insertions(+), 13 deletions(-) + +diff --git a/cpp/cmake_modules/BuildUtils.cmake b/cpp/cmake_modules/BuildUtils.cmake +index 692efa78376f4..eb94563afcf77 100644 +--- a/cpp/cmake_modules/BuildUtils.cmake ++++ b/cpp/cmake_modules/BuildUtils.cmake +@@ -97,23 +97,27 @@ function(arrow_create_merged_static_lib output_target) + endforeach() + + if(APPLE) +- # The apple-distributed libtool is what we want for bundling, but there is +- # a GNU libtool that has a namecollision (and happens to be bundled with R, too). +- # We are not compatible with GNU libtool, so we need to avoid it. +- +- # check in the obvious places first to find Apple's libtool +- # HINTS is used before system paths and before PATHS, so we use that +- # even though hard coded paths should go in PATHS +- # TODO: use a VALIDATOR when we require cmake >= 3.25 +- find_program(LIBTOOL_MACOS libtool HINTS /usr/bin +- /Library/Developer/CommandLineTools/usr/bin) +- +- # confirm that the libtool we found is not GNU libtool ++ if(CMAKE_LIBTOOL) ++ set(LIBTOOL_MACOS ${CMAKE_LIBTOOL}) ++ else() ++ # The apple-distributed libtool is what we want for bundling, but there is ++ # a GNU libtool that has a namecollision (and happens to be bundled with R, too). ++ # We are not compatible with GNU libtool, so we need to avoid it. ++ ++ # check in the obvious places first to find Apple's libtool ++ # HINTS is used before system paths and before PATHS, so we use that ++ # even though hard coded paths should go in PATHS ++ # TODO: use a VALIDATOR when we require cmake >= 3.25 ++ find_program(LIBTOOL_MACOS libtool ++ HINTS /usr/bin /Library/Developer/CommandLineTools/usr/bin) ++ endif() ++ ++ # confirm that the libtool we found is Apple's libtool + execute_process(COMMAND ${LIBTOOL_MACOS} -V + OUTPUT_VARIABLE LIBTOOL_V_OUTPUT + OUTPUT_STRIP_TRAILING_WHITESPACE) + if(NOT "${LIBTOOL_V_OUTPUT}" MATCHES ".*cctools-([0-9.]+).*") +- message(FATAL_ERROR "libtool found appears to be the incompatible GNU libtool: ${LIBTOOL_MACOS}" ++ message(FATAL_ERROR "libtool found appears not to be Apple's libtool: ${LIBTOOL_MACOS}" + ) + endif() + diff --git a/rust/perspective-server/cpp/perspective/src/cpp/aggregate.cpp b/rust/perspective-server/cpp/perspective/src/cpp/aggregate.cpp new file mode 100644 index 0000000..3abb77a --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/aggregate.cpp @@ -0,0 +1,465 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include + +#include + +namespace perspective { + +t_aggregate::t_aggregate( + const t_dtree& tree, + t_aggtype aggtype, + std::vector> icolumns, + std::shared_ptr ocolumn +) : + m_tree(tree), + m_aggtype(aggtype), + m_icolumns(std::move(icolumns)), + m_ocolumn(std::move(ocolumn)) {} + +void +t_aggregate::init() { + switch (m_aggtype) { + case AGGTYPE_SUM: + case AGGTYPE_PCT_SUM_PARENT: + case AGGTYPE_PCT_SUM_GRAND_TOTAL: { + switch (m_icolumns[0]->get_dtype()) { + case DTYPE_INT64: { + build_aggregate>(); + } break; + case DTYPE_INT32: { + build_aggregate>(); + } break; + case DTYPE_INT16: { + build_aggregate>(); + } break; + case DTYPE_INT8: { + build_aggregate< + t_aggimpl_sum>( + ); + } break; + case DTYPE_UINT64: { + build_aggregate>(); + } break; + case DTYPE_UINT32: { + build_aggregate>(); + } break; + case DTYPE_UINT16: { + build_aggregate>(); + } break; + case DTYPE_UINT8: { + build_aggregate>(); + } break; + case DTYPE_FLOAT64: { + build_aggregate>(); + } break; + case DTYPE_FLOAT32: { + build_aggregate>(); + } break; + case DTYPE_BOOL: { + build_aggregate>(); + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected dtype"); + } + } + } break; + case AGGTYPE_MUL: { + switch (m_icolumns[0]->get_dtype()) { + case DTYPE_INT64: { + build_aggregate>(); + } break; + case DTYPE_INT32: { + build_aggregate>(); + } break; + case DTYPE_INT16: { + build_aggregate>(); + } break; + case DTYPE_INT8: { + build_aggregate< + t_aggimpl_mul>( + ); + } break; + case DTYPE_UINT64: { + build_aggregate>(); + } break; + case DTYPE_UINT32: { + build_aggregate>(); + } break; + case DTYPE_UINT16: { + build_aggregate>(); + } break; + case DTYPE_UINT8: { + build_aggregate>(); + } break; + case DTYPE_FLOAT64: { + build_aggregate>(); + } break; + case DTYPE_FLOAT32: { + build_aggregate>(); + } break; + case DTYPE_BOOL: { + build_aggregate>(); + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected dtype"); + } + } + } break; + case AGGTYPE_COUNT: { + switch (m_icolumns[0]->get_dtype()) { + case DTYPE_STR: { + build_aggregate< + t_aggimpl_count>( + ); + } break; + case DTYPE_TIME: + case DTYPE_INT64: { + build_aggregate>(); + } break; + case DTYPE_INT32: { + build_aggregate>(); + } break; + case DTYPE_INT16: { + build_aggregate>(); + } break; + case DTYPE_INT8: { + build_aggregate>(); + } break; + case DTYPE_UINT64: { + build_aggregate>(); + } break; + case DTYPE_DATE: + case DTYPE_UINT32: { + build_aggregate>(); + } break; + case DTYPE_UINT16: { + build_aggregate>(); + } break; + case DTYPE_UINT8: { + build_aggregate>(); + } break; + case DTYPE_FLOAT64: { + build_aggregate< + t_aggimpl_count>( + ); + } break; + case DTYPE_FLOAT32: { + build_aggregate< + t_aggimpl_count>(); + } break; + case DTYPE_BOOL: { + build_aggregate>(); + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected dtype"); + } + } + } break; + case AGGTYPE_MEAN: { + switch (m_icolumns[0]->get_dtype()) { + case DTYPE_INT64: { + build_aggregate, + double>>(); + } break; + case DTYPE_INT32: { + build_aggregate, + double>>(); + } break; + case DTYPE_INT16: { + build_aggregate, + double>>(); + } break; + case DTYPE_INT8: { + build_aggregate, + double>>(); + } break; + case DTYPE_UINT64: { + build_aggregate, + double>>(); + } break; + case DTYPE_UINT32: { + build_aggregate, + double>>(); + } break; + case DTYPE_UINT16: { + build_aggregate, + double>>(); + } break; + case DTYPE_UINT8: { + build_aggregate, + double>>(); + } break; + case DTYPE_FLOAT64: { + build_aggregate, + double>>(); + } break; + case DTYPE_FLOAT32: { + build_aggregate, + double>>(); + } break; + case DTYPE_BOOL: { + build_aggregate, + double>>(); + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected dtype"); + } + } + } break; + case AGGTYPE_HIGH_WATER_MARK: { + switch (m_icolumns[0]->get_dtype()) { + case DTYPE_TIME: + case DTYPE_INT64: { + build_aggregate>(); + } break; + case DTYPE_INT32: { + build_aggregate>(); + } break; + case DTYPE_INT16: { + build_aggregate>(); + } break; + case DTYPE_INT8: { + build_aggregate< + t_aggimpl_hwm>(); + } break; + case DTYPE_UINT64: { + build_aggregate>(); + } break; + case DTYPE_DATE: + case DTYPE_UINT32: { + build_aggregate>(); + } break; + case DTYPE_UINT16: { + build_aggregate>(); + } break; + case DTYPE_UINT8: { + build_aggregate>(); + } break; + case DTYPE_FLOAT64: { + build_aggregate>(); + } break; + case DTYPE_FLOAT32: { + build_aggregate>(); + } break; + case DTYPE_BOOL: { + build_aggregate>(); + } break; + case DTYPE_STR: { + build_aggregate< + t_aggimpl_hwm>(); + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected dtype"); + } + } + } break; + case AGGTYPE_LOW_WATER_MARK: { + switch (m_icolumns[0]->get_dtype()) { + case DTYPE_TIME: + case DTYPE_INT64: { + build_aggregate>(); + } break; + case DTYPE_INT32: { + build_aggregate>(); + } break; + case DTYPE_INT16: { + build_aggregate>(); + } break; + case DTYPE_INT8: { + build_aggregate< + t_aggimpl_lwm>(); + } break; + case DTYPE_UINT64: { + build_aggregate>(); + } break; + case DTYPE_DATE: + case DTYPE_UINT32: { + build_aggregate>(); + } break; + case DTYPE_UINT16: { + build_aggregate>(); + } break; + case DTYPE_UINT8: { + build_aggregate>(); + } break; + case DTYPE_FLOAT64: { + build_aggregate>(); + } break; + case DTYPE_FLOAT32: { + build_aggregate>(); + } break; + case DTYPE_BOOL: { + build_aggregate>(); + } break; + case DTYPE_STR: { + build_aggregate< + t_aggimpl_lwm>(); + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected dtype"); + } + } + } break; + default: { + // Other aggregates will be filled in later + } + } +} +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/aggspec.cpp b/rust/perspective-server/cpp/perspective/src/cpp/aggspec.cpp new file mode 100644 index 0000000..3668855 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/aggspec.cpp @@ -0,0 +1,468 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include +#include + +namespace perspective { + +t_col_name_type::t_col_name_type() : m_type(DTYPE_NONE) {} + +t_col_name_type::t_col_name_type(std::string name, t_dtype type) : + m_name(std::move(name)), + m_type(type) {} + +t_aggspec::t_aggspec() = default; + +t_aggspec::t_aggspec( + const std::string& name, + t_aggtype agg, + const std::vector& dependencies +) : + m_name(name), + m_disp_name(name), + m_agg(agg), + m_dependencies(dependencies) {} + +t_aggspec::t_aggspec( + const std::string& aggname, t_aggtype agg, const std::string& dep +) : + m_name(aggname), + m_disp_name(aggname), + m_agg(agg), + m_dependencies(std::vector{t_dep(dep, DEPTYPE_COLUMN)}) {} + +t_aggspec::t_aggspec(t_aggtype agg, const std::string& dep) : + m_agg(agg), + m_dependencies(std::vector{t_dep(dep, DEPTYPE_COLUMN)}) {} + +t_aggspec::t_aggspec( + std::string name, + std::string disp_name, + t_aggtype agg, + const std::vector& dependencies +) : + m_name(std::move(name)), + m_disp_name(std::move(disp_name)), + m_agg(agg), + m_dependencies(dependencies) {} + +t_aggspec::t_aggspec( + std::string name, + std::string disp_name, + t_aggtype agg, + const std::vector& dependencies, + t_sorttype sort_type +) : + m_name(std::move(name)), + m_disp_name(std::move(disp_name)), + m_agg(agg), + m_dependencies(dependencies), + m_sort_type(sort_type) {} + +t_aggspec::t_aggspec( + std::string aggname, + std::string disp_aggname, + t_aggtype agg, + t_uindex agg_one_idx, + t_uindex agg_two_idx, + double agg_one_weight, + double agg_two_weight +) : + m_name(std::move(aggname)), + m_disp_name(std::move(disp_aggname)), + m_agg(agg), + m_agg_one_idx(agg_one_idx), + m_agg_two_idx(agg_two_idx), + m_agg_one_weight(agg_one_weight), + m_agg_two_weight(agg_two_weight) {} + +t_aggspec::~t_aggspec() = default; + +std::string +t_aggspec::name() const { + return m_name; +} + +t_tscalar +t_aggspec::name_scalar() const { + t_tscalar s; + s.set(m_name.c_str()); + return s; +} + +std::string +t_aggspec::disp_name() const { + return m_disp_name; +} + +t_aggtype +t_aggspec::agg() const { + return m_agg; +} + +std::string +t_aggspec::agg_str() const { + switch (m_agg) { + case AGGTYPE_SUM: { + return "sum"; + } break; + case AGGTYPE_SUM_ABS: { + return "sum abs"; + } break; + case AGGTYPE_ABS_SUM: { + return "abs sum"; + } break; + case AGGTYPE_MUL: { + return "mul"; + } break; + case AGGTYPE_COUNT: { + return "count"; + } break; + case AGGTYPE_MEAN: { + return "mean"; + } break; + case AGGTYPE_WEIGHTED_MEAN: { + return "weighted mean"; + } break; + case AGGTYPE_UNIQUE: { + return "unique"; + } break; + case AGGTYPE_ANY: { + return "any"; + } break; + case AGGTYPE_Q1: { + return "q1"; + } break; + case AGGTYPE_Q3: { + return "q3"; + } break; + case AGGTYPE_MEDIAN: { + return "median"; + } break; + case AGGTYPE_JOIN: { + return "join"; + } break; + case AGGTYPE_SCALED_DIV: { + return "scaled div"; + } break; + case AGGTYPE_SCALED_ADD: { + return "scaled add"; + } break; + case AGGTYPE_SCALED_MUL: { + return "scaled mul"; + } break; + case AGGTYPE_DOMINANT: { + return "dominant"; + } break; + case AGGTYPE_FIRST: { + return "first"; + } break; + case AGGTYPE_LAST_BY_INDEX: { + return "last by index"; + } break; + case AGGTYPE_LAST_MINUS_FIRST: { + return "last minus first"; + } break; + case AGGTYPE_PY_AGG: { + return "py agg"; + } break; + case AGGTYPE_AND: { + return "and"; + } break; + case AGGTYPE_OR: { + return "or"; + } break; + case AGGTYPE_LAST_VALUE: { + return "last value"; + } + case AGGTYPE_MAX_BY: { + return "max by"; + } + case AGGTYPE_MIN_BY: { + return "min by"; + } + case AGGTYPE_MAX: { + return "max"; + } + case AGGTYPE_MIN: { + return "min"; + } + case AGGTYPE_HIGH_WATER_MARK: { + return "high water mark"; + } + case AGGTYPE_LOW_WATER_MARK: { + return "low water mark"; + } + case AGGTYPE_HIGH_MINUS_LOW: { + return "high minuslow"; + } break; + case AGGTYPE_UDF_COMBINER: { + std::stringstream ss; + ss << "udf_combiner_" << disp_name(); + return ss.str(); + } + case AGGTYPE_UDF_REDUCER: { + + std::stringstream ss; + ss << "udf_reducer_" << disp_name(); + return ss.str(); + } + case AGGTYPE_SUM_NOT_NULL: { + return "sum not null"; + } + case AGGTYPE_MEAN_BY_COUNT: { + return "mean by count"; + } + case AGGTYPE_IDENTITY: { + return "identity"; + } + case AGGTYPE_DISTINCT_COUNT: { + return "distinct count"; + } + case AGGTYPE_DISTINCT_LEAF: { + return "distinct leaf"; + } + case AGGTYPE_PCT_SUM_PARENT: { + return "pct sum parent"; + } + case AGGTYPE_PCT_SUM_GRAND_TOTAL: { + return "pct sum grand total"; + } + case AGGTYPE_VARIANCE: { + return "variance"; + } + case AGGTYPE_STANDARD_DEVIATION: { + return "stddev"; + } + case AGGTYPE_GMV: { + return "gmv"; + } + default: { + PSP_COMPLAIN_AND_ABORT("Unknown agg type"); + return "unknown"; + } break; + } +} + +const std::vector& +t_aggspec::get_dependencies() const { + return m_dependencies; +} + +t_dtype +get_simple_accumulator_type(t_dtype coltype) { + switch (coltype) { + case DTYPE_BOOL: + case DTYPE_INT64: + case DTYPE_INT32: + case DTYPE_INT16: + case DTYPE_INT8: { + return DTYPE_INT64; + } break; + case DTYPE_UINT64: + case DTYPE_UINT32: + case DTYPE_UINT16: + case DTYPE_UINT8: { + return DTYPE_UINT64; + } + case DTYPE_FLOAT64: + case DTYPE_FLOAT32: { + return DTYPE_FLOAT64; + } + + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected coltype"); + } + } + return DTYPE_NONE; +} + +t_sorttype +t_aggspec::get_sort_type() const { + return m_sort_type; +} + +t_uindex +t_aggspec::get_agg_one_idx() const { + return m_agg_one_idx; +} + +t_uindex +t_aggspec::get_agg_two_idx() const { + return m_agg_two_idx; +} + +double +t_aggspec::get_agg_one_weight() const { + return m_agg_one_weight; +} + +double +t_aggspec::get_agg_two_weight() const { + return m_agg_two_weight; +} + +t_invmode +t_aggspec::get_inv_mode() const { + return m_invmode; +} + +std::vector +t_aggspec::get_input_depnames() const { + std::vector rval; + rval.reserve(m_dependencies.size()); + for (const auto& d : m_dependencies) { + rval.push_back(d.name()); + } + return rval; +} + +std::vector +t_aggspec::get_output_depnames() const { + std::vector rval; + rval.reserve(m_dependencies.size()); + for (const auto& d : m_dependencies) { + rval.push_back(d.name()); + } + return rval; +} + +std::vector +t_aggspec::get_output_specs(const t_schema& schema) const { + switch (agg()) { + case AGGTYPE_SUM: + case AGGTYPE_SUM_ABS: + case AGGTYPE_ABS_SUM: + case AGGTYPE_GMV: + case AGGTYPE_PCT_SUM_PARENT: + case AGGTYPE_PCT_SUM_GRAND_TOTAL: + case AGGTYPE_MUL: + case AGGTYPE_SUM_NOT_NULL: { + t_dtype coltype = schema.get_dtype(m_dependencies[0].name()); + return mk_col_name_type_vec( + name(), get_simple_accumulator_type(coltype) + ); + } + case AGGTYPE_ANY: + case AGGTYPE_UNIQUE: + case AGGTYPE_DOMINANT: + case AGGTYPE_Q1: + case AGGTYPE_Q3: + case AGGTYPE_MEDIAN: + case AGGTYPE_FIRST: + case AGGTYPE_LAST_BY_INDEX: + case AGGTYPE_LAST_MINUS_FIRST: + case AGGTYPE_OR: + case AGGTYPE_LAST_VALUE: + case AGGTYPE_MAX: + case AGGTYPE_MAX_BY: + case AGGTYPE_MIN_BY: + case AGGTYPE_MIN: + case AGGTYPE_HIGH_WATER_MARK: + case AGGTYPE_LOW_WATER_MARK: + case AGGTYPE_HIGH_MINUS_LOW: + case AGGTYPE_IDENTITY: + case AGGTYPE_DISTINCT_LEAF: { + t_dtype coltype = schema.get_dtype(m_dependencies[0].name()); + std::vector rval(1); + rval[0].m_name = name(); + rval[0].m_type = coltype; + return rval; + } + case AGGTYPE_COUNT: { + return mk_col_name_type_vec(name(), DTYPE_INT64); + } + case AGGTYPE_MEAN: + case AGGTYPE_MEAN_BY_COUNT: + case AGGTYPE_WEIGHTED_MEAN: { + return mk_col_name_type_vec(name(), DTYPE_F64PAIR); + } + case AGGTYPE_JOIN: { + return mk_col_name_type_vec(name(), DTYPE_STR); + } + case AGGTYPE_SCALED_DIV: + case AGGTYPE_SCALED_ADD: + case AGGTYPE_SCALED_MUL: + case AGGTYPE_VARIANCE: + case AGGTYPE_STANDARD_DEVIATION: { + return mk_col_name_type_vec(name(), DTYPE_FLOAT64); + } + case AGGTYPE_UDF_COMBINER: + case AGGTYPE_UDF_REDUCER: { + std::vector rval; + rval.reserve(m_odependencies.size()); + for (const auto& d : m_odependencies) { + t_col_name_type tp(d.name(), d.dtype()); + rval.push_back(tp); + } + return rval; + } + case AGGTYPE_AND: { + return mk_col_name_type_vec(name(), DTYPE_BOOL); + } + case AGGTYPE_DISTINCT_COUNT: { + return mk_col_name_type_vec(name(), DTYPE_UINT32); + } + default: { + PSP_COMPLAIN_AND_ABORT("Unknown agg type"); + } + } + + return {}; +} + +std::vector +t_aggspec::mk_col_name_type_vec(const std::string& name, t_dtype dtype) const { + std::vector rval(1); + rval[0].m_name = name; + rval[0].m_type = dtype; + return rval; +} + +bool +t_aggspec::is_combiner_agg() const { + return m_agg == AGGTYPE_UDF_COMBINER; +} + +bool +t_aggspec::is_reducer_agg() const { + return m_agg == AGGTYPE_UDF_REDUCER; +} + +bool +t_aggspec::is_non_delta() const { + switch (m_agg) { + case AGGTYPE_LAST_VALUE: + case AGGTYPE_LOW_WATER_MARK: + case AGGTYPE_HIGH_WATER_MARK: { + return true; + } + default: + return false; + } + return false; +} + +std::string +t_aggspec::get_first_depname() const { + if (m_dependencies.empty()) { + return ""; + } + + return m_dependencies[0].name(); +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/arg_sort.cpp b/rust/perspective-server/cpp/perspective/src/cpp/arg_sort.cpp new file mode 100644 index 0000000..2a13137 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/arg_sort.cpp @@ -0,0 +1,113 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include +#include + +namespace perspective { + +void +argsort(std::vector& output, const t_multisorter& sorter) { + if (output.empty()) { + return; + } + // Output should be the same size is v + for (t_index i = 0, loop_end = output.size(); i != loop_end; ++i) { + output[i] = i; + } + std::sort(output.begin(), output.end(), sorter); +} + +t_argsort_comparator::t_argsort_comparator( + const std::vector& v, const t_sorttype& sort_type +) : + m_v(v), + m_sort_type(sort_type) {} + +bool +t_argsort_comparator::operator()(t_index a, t_index b) const { + switch (m_sort_type) { + case SORTTYPE_ASCENDING: + return t_argsort_comparator_impl(m_v)(a, b); + case SORTTYPE_DESCENDING: + return t_argsort_comparator_impl(m_v)(a, b); + case SORTTYPE_ASCENDING_ABS: + return t_argsort_comparator_impl(m_v)(a, b); + case SORTTYPE_DESCENDING_ABS: + return t_argsort_comparator_impl(m_v)( + a, b + ); + case SORTTYPE_NONE: + return t_argsort_comparator_impl(m_v)(a, b); + } + + return a < b; +} + +namespace { + +void +init_output(std::vector& output) { + for (t_index i = 0, loop_end = output.size(); i != loop_end; ++i) { + output[i] = i; + } +} + +} // anonymous namespace + +void +simple_argsort( + std::vector& v, + std::vector& output, + const t_sorttype& sort_type +) { + init_output(output); + + switch (sort_type) { + case SORTTYPE_ASCENDING: { + std::sort( + output.begin(), + output.end(), + t_argsort_comparator_impl(v) + ); + } break; + case SORTTYPE_DESCENDING: { + std::sort( + output.begin(), + output.end(), + t_argsort_comparator_impl(v) + ); + } break; + case SORTTYPE_ASCENDING_ABS: { + std::sort( + output.begin(), + output.end(), + t_argsort_comparator_impl(v) + ); + } break; + case SORTTYPE_DESCENDING_ABS: { + std::sort( + output.begin(), + output.end(), + t_argsort_comparator_impl(v) + ); + } break; + case SORTTYPE_NONE: { + // output is already identity-initialized + } break; + } +} + +} // namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/arrow_csv.cpp b/rust/perspective-server/cpp/perspective/src/cpp/arrow_csv.cpp new file mode 100644 index 0000000..211811f --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/arrow_csv.cpp @@ -0,0 +1,639 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include +#include +#include + +template +static inline arrow::TimestampType::c_type +ConvertTimePoint(TimePoint tp, arrow::TimeUnit::type unit) { + auto duration = tp.time_since_epoch(); + switch (unit) { + case arrow::TimeUnit::SECOND: + return std::chrono::duration_cast(duration) + .count(); + case arrow::TimeUnit::MILLI: + return std::chrono::duration_cast( + duration + ) + .count(); + case arrow::TimeUnit::MICRO: + return std::chrono::duration_cast( + duration + ) + .count(); + case arrow::TimeUnit::NANO: + return std::chrono::duration_cast(duration + ) + .count(); + default: + // Compiler errors without default case even though all enum cases + // are handled + assert(0); + return 0; + } +} + +static inline bool +ParseYYYY_MM_DD(const char* s, arrow_vendored::date::year_month_day* out) { + uint16_t year = 0; + uint8_t month = 0; + uint8_t day = 0; + if (ARROW_PREDICT_FALSE(s[4] != '-') || ARROW_PREDICT_FALSE(s[7] != '-')) { + return false; + } + if (ARROW_PREDICT_FALSE(!arrow::internal::ParseUnsigned(s + 0, 4, &year))) { + return false; + } + if (ARROW_PREDICT_FALSE(!arrow::internal::ParseUnsigned(s + 5, 2, &month) + )) { + return false; + } + if (ARROW_PREDICT_FALSE(!arrow::internal::ParseUnsigned(s + 8, 2, &day))) { + return false; + } + *out = { + arrow_vendored::date::year{year}, + arrow_vendored::date::month{month}, + arrow_vendored::date::day{day} + }; + return out->ok(); +} + +static inline bool +ParseYYYY_DD_MM(const char* s, arrow_vendored::date::year_month_day* out) { + uint16_t year = 0; + uint8_t month = 0; + uint8_t day = 0; + if (ARROW_PREDICT_FALSE(s[2] != '/') || ARROW_PREDICT_FALSE(s[5] != '/')) { + return false; + } + if (ARROW_PREDICT_FALSE(!arrow::internal::ParseUnsigned(s + 6, 4, &year))) { + return false; + } + if (ARROW_PREDICT_FALSE(!arrow::internal::ParseUnsigned(s + 0, 2, &month) + )) { + return false; + } + if (ARROW_PREDICT_FALSE(!arrow::internal::ParseUnsigned(s + 3, 2, &day))) { + return false; + } + *out = { + arrow_vendored::date::year{year}, + arrow_vendored::date::month{month}, + arrow_vendored::date::day{day} + }; + return out->ok(); +} + +static inline bool +ParseYYYY_D_M(const char* s, arrow_vendored::date::year_month_day* out) { + uint16_t year = 0; + uint8_t month = 0; + uint8_t day = 0; + + if (ARROW_PREDICT_FALSE(s[1] != '/') || ARROW_PREDICT_FALSE(s[3] != '/')) { + return false; + } + if (ARROW_PREDICT_FALSE(!arrow::internal::ParseUnsigned(s + 4, 4, &year))) { + return false; + } + if (ARROW_PREDICT_FALSE(!arrow::internal::ParseUnsigned(s + 0, 1, &month) + )) { + return false; + } + if (ARROW_PREDICT_FALSE(!arrow::internal::ParseUnsigned(s + 2, 1, &day))) { + return false; + } + *out = { + arrow_vendored::date::year{year}, + arrow_vendored::date::month{month}, + arrow_vendored::date::day{day} + }; + return out->ok(); +} + +static inline std::string +extract_string(const char* ch, int start_idx, int number_of_chars) { + std::string out; + for (int i = start_idx; i < start_idx + number_of_chars; i++) { + out += ch[i]; + } + return out; +} + +static inline bool +ParseAM_PM(const char* s, std::chrono::seconds& seconds, int length) { + int hour = 0; + int twelve_hours = 12; + std::string am_pm; + std::string hour_string; + + if (length == 21) { + am_pm = extract_string(s, 19, 2); + hour_string = extract_string(s, 10, 2); + hour = atoi(hour_string.c_str()); + if (hour == 0) { + return false; + } + } else if (length == 23) { + am_pm = extract_string(s, 21, 2); + hour_string = extract_string(s, 12, 2); + hour = atoi(hour_string.c_str()); + if (hour == 0) { + return false; + } + } + + if ((am_pm == "PM" || am_pm == "pm") && (hour < twelve_hours)) { + std::chrono::hours hours_obj(twelve_hours); + seconds = std::chrono::duration_cast(hours_obj); + } else if ((am_pm == "AM" || am_pm == "am") && (hour == twelve_hours)) { + std::chrono::hours hours_obj(twelve_hours); + seconds = + std::chrono::duration_cast(hours_obj) * -1; + } + return true; +} + +namespace perspective::apachearrow { + +class UnixTimestampParser : public arrow::TimestampParser { +public: + bool + operator()( + const char* s, + size_t length, + arrow::TimeUnit::type out_unit, + int64_t* out, + bool* out_zone_offset_present = NULLPTR + ) const override { + size_t endptr; + std::string val(s, s + length); + int64_t value = std::stoll(static_cast(val), &endptr, 10); + if (endptr != length) { + return false; + } + (*out) = value; + return true; + } + + [[nodiscard]] + const char* + kind() const override { + return "unixtimestamp"; + } +}; + +static inline bool +ParseSSS(const char* s, std::chrono::milliseconds* out) { + uint16_t millis = 0; + if (ARROW_PREDICT_FALSE(s[0] != '.')) { + return false; + } + if (ARROW_PREDICT_FALSE(!arrow::internal::ParseUnsigned(s + 1, 3, &millis) + )) { + return false; + } + + if (ARROW_PREDICT_FALSE(millis >= 999)) { + return false; + } + *out = std::chrono::milliseconds(millis); + return true; +} + +static inline bool +ParseSSSSSS(const char* s, std::chrono::microseconds* out) { + uint32_t nanos = 0; + if (ARROW_PREDICT_FALSE(s[0] != '.')) { + return false; + } + if (ARROW_PREDICT_FALSE(!arrow::internal::ParseUnsigned(s + 1, 6, &nanos) + )) { + return false; + } + + if (ARROW_PREDICT_FALSE(nanos >= 999999)) { + return false; + } + *out = std::chrono::microseconds(nanos); + return true; +} + +static inline bool +ParseSSSSSSSSS(const char* s, std::chrono::nanoseconds* out) { + uint32_t nanos = 0; + if (ARROW_PREDICT_FALSE(s[0] != '.')) { + return false; + } + if (ARROW_PREDICT_FALSE(!arrow::internal::ParseUnsigned(s + 1, 9, &nanos) + )) { + return false; + } + + if (ARROW_PREDICT_FALSE(nanos >= 999999999)) { + return false; + } + *out = std::chrono::nanoseconds(nanos); + return true; +} + +static inline bool +ParseTZ(const char* s, std::chrono::minutes* out) { + uint8_t hours = 0; + uint8_t minutes = 0; + if ((ARROW_PREDICT_FALSE(s[0] != '+') && ARROW_PREDICT_FALSE(s[0] != '-')) + || ARROW_PREDICT_FALSE(s[3] != ':')) { + return false; + } + if (ARROW_PREDICT_FALSE(!arrow::internal::ParseUnsigned(s + 1, 2, &hours) + )) { + return false; + } + if (ARROW_PREDICT_FALSE(!arrow::internal::ParseUnsigned(s + 4, 2, &minutes) + )) { + return false; + } + + if (ARROW_PREDICT_FALSE(hours >= 12) + || ARROW_PREDICT_FALSE(minutes >= 59)) { + return false; + } + int32_t total = hours * 60 + minutes; + if (s[0] == '+') { + total = -total; + } + *out = std::chrono::minutes(total); + return true; +} + +class CustomISO8601Parser : public arrow::TimestampParser { +public: + bool + operator()( + const char* s, + size_t length, + arrow::TimeUnit::type unit, + int64_t* out, + bool* out_zone_offset_present = NULLPTR + ) const override { + // if we are trying to parse this with seconds, fail + // and it will try to parse this again but as + // nanoseconds :) then it wont truncate the fractional bits. + if (unit == arrow::TimeUnit::SECOND) { + return false; + } + + if (!arrow::internal::ParseTimestampISO8601(s, length, unit, out)) { + if (s[length - 1] == 'Z') { + --length; + } + if (length == 23) { + // "YYYY-MM-DD[ T]hh:mm:ss.sss" -- millis + + arrow_vendored::date::year_month_day ymd; + if (ARROW_PREDICT_FALSE(!ParseYYYY_MM_DD(s, &ymd))) { + return false; + } + std::chrono::seconds seconds; + if (ARROW_PREDICT_FALSE(!arrow::internal::detail::ParseHH_MM_SS( + s + 11, &seconds + ))) { + return false; + } + std::chrono::milliseconds millis; + if (ARROW_PREDICT_FALSE(!ParseSSS(s + 19, &millis))) { + return false; + } + + *out = ConvertTimePoint( + arrow_vendored::date::sys_days(ymd) + seconds + millis, unit + ); + return true; + } + + if (length == 25) { + // "2008-09-15[ T]15:53:00+05:00" -- seconds with TZ + arrow_vendored::date::year_month_day ymd; + if (ARROW_PREDICT_FALSE(!ParseYYYY_MM_DD(s, &ymd))) { + return false; + } + std::chrono::seconds seconds; + if (ARROW_PREDICT_FALSE(!arrow::internal::detail::ParseHH_MM_SS( + s + 11, &seconds + ))) { + return false; + } + std::chrono::minutes tz; + if (ARROW_PREDICT_FALSE(!ParseTZ(s + 19, &tz))) { + return false; + } + + *out = ConvertTimePoint( + arrow_vendored::date::sys_days(ymd) + tz + seconds, unit + ); + return true; + } + if (length == 26) { + // YYYY-MM-DD[ T]hh:mm:ss.ssssss -- micros + + arrow_vendored::date::year_month_day ymd; + if (ARROW_PREDICT_FALSE(!ParseYYYY_MM_DD(s, &ymd))) { + return false; + } + std::chrono::seconds seconds; + if (ARROW_PREDICT_FALSE(!arrow::internal::detail::ParseHH_MM_SS( + s + 11, &seconds + ))) { + return false; + } + std::chrono::microseconds micros; + if (ARROW_PREDICT_FALSE(!ParseSSSSSS(s + 19, µs))) { + return false; + } + // round the micros into millis as Perspective does not support + // nano precision. + auto millis = + std::chrono::duration_cast(micros + ); + *out = ConvertTimePoint( + arrow_vendored::date::sys_days(ymd) + seconds + millis, unit + ); + + return true; + } + + if (length == 29) { + // YYYY-MM-DD[ T]hh:mm:ss.sssssssss -- nanos + // arrow handles YYYY-MM-DD[ T]hh:mm:ss.sss[+-]HH:MM + arrow_vendored::date::year_month_day ymd; + if (ARROW_PREDICT_FALSE(!ParseYYYY_MM_DD(s, &ymd))) { + return false; + } + std::chrono::seconds seconds; + if (ARROW_PREDICT_FALSE(!arrow::internal::detail::ParseHH_MM_SS( + s + 11, &seconds + ))) { + return false; + } + // we can now be at sss[+-]HH:MM -- millis and TZ + // or sssssssss -- nanos + std::chrono::nanoseconds nanos; + if (ARROW_PREDICT_FALSE(!ParseSSSSSSSSS(s + 19, &nanos))) { + return false; + } + // Truncate the nanos into millis as Perspective does not + // support nano precision. + auto millis = + std::chrono::duration_cast(nanos + ); + + *out = ConvertTimePoint( + arrow_vendored::date::sys_days(ymd) + seconds + millis, unit + ); + + return true; + } + if (length == 32) { + // YYYY-MM-DD[ T]hh:mm:ss.ssssss[+-]HH:MM -- micros with TZ + + arrow_vendored::date::year_month_day ymd; + if (ARROW_PREDICT_FALSE(!ParseYYYY_MM_DD(s, &ymd))) { + return false; + } + std::chrono::seconds seconds; + if (ARROW_PREDICT_FALSE(!arrow::internal::detail::ParseHH_MM_SS( + s + 11, &seconds + ))) { + return false; + } + std::chrono::microseconds micros; + if (ARROW_PREDICT_FALSE(!ParseSSSSSS(s + 19, µs))) { + return false; + } + // round the micros into millis as Perspective does not support + // nano precision. + auto millis = + std::chrono::duration_cast(micros + ); + + std::chrono::minutes tz; + if (ARROW_PREDICT_FALSE(!ParseTZ(s + 26, &tz))) { + return false; + } + *out = ConvertTimePoint( + arrow_vendored::date::sys_days(ymd) + seconds + millis + tz, + unit + ); + + return true; + } + + if (length == 35) { + // YYYY-MM-DD[ T]hh:mm:ss.sssssssss[+-]HH:MM -- nanos with TZ + + arrow_vendored::date::year_month_day ymd; + if (ARROW_PREDICT_FALSE(!ParseYYYY_MM_DD(s, &ymd))) { + return false; + } + std::chrono::seconds seconds; + if (ARROW_PREDICT_FALSE(!arrow::internal::detail::ParseHH_MM_SS( + s + 11, &seconds + ))) { + return false; + } + std::chrono::nanoseconds nanos; + if (ARROW_PREDICT_FALSE(!ParseSSSSSSSSS(s + 19, &nanos))) { + return false; + } + // round the nanos into millis as Perspective does not support + // nano precision. + auto millis = + std::chrono::duration_cast(nanos + ); + + std::chrono::minutes tz; + if (ARROW_PREDICT_FALSE(!ParseTZ(s + 29, &tz))) { + return false; + } + + *out = ConvertTimePoint( + arrow_vendored::date::sys_days(ymd) + seconds + millis + tz, + unit + ); + + return true; + } + return false; + } + return true; + } + + [[nodiscard]] + const char* + kind() const override { + return "custom_ISO8601"; + } +}; + +class USTimestampParser : public arrow::TimestampParser { +public: + bool + operator()( + const char* s, + size_t length, + arrow::TimeUnit::type unit, + int64_t* out, + bool* out_zone_offset_present = NULLPTR + ) const override { + + if (!arrow::internal::ParseTimestampISO8601(s, length, unit, out)) { + if (length == 23) { + arrow_vendored::date::year_month_day ymd; + if (ARROW_PREDICT_FALSE(!ParseYYYY_DD_MM(s, &ymd))) { + return false; + } + std::chrono::seconds seconds; + if (ARROW_PREDICT_FALSE(!arrow::internal::detail::ParseHH_MM_SS( + s + 12, &seconds + ))) { + return false; + } + + std::chrono::seconds am_pm(0); + if (ARROW_PREDICT_FALSE(!ParseAM_PM(s, am_pm, 23))) { + return false; + } + + *out = ConvertTimePoint( + arrow_vendored::date::sys_days(ymd) + seconds + am_pm, unit + ); + return true; + } + if (length == 21) { + arrow_vendored::date::year_month_day ymd; + if (ARROW_PREDICT_FALSE(!ParseYYYY_D_M(s, &ymd))) { + return false; + } + std::chrono::seconds seconds; + if (ARROW_PREDICT_FALSE(!arrow::internal::detail::ParseHH_MM_SS( + s + 10, &seconds + ))) { + return false; + } + + std::chrono::seconds am_pm(0); + if (ARROW_PREDICT_FALSE(!ParseAM_PM(s, am_pm, 21))) { + return false; + } + + *out = ConvertTimePoint( + arrow_vendored::date::sys_days(ymd) + seconds + am_pm, unit + ); + return true; + } + return false; + } + return true; + } + + [[nodiscard]] + const char* + kind() const override { + return "USTimestampParser"; + } +}; + +std::vector> DATE_PARSERS{ + std::make_shared(), + std::make_shared(), + arrow::TimestampParser::MakeStrptime("%Y-%m-%d\\D%H:%M:%S.%f"), + arrow::TimestampParser::MakeStrptime("%m/%d/%Y, %I:%M:%S %p"), + // US locale string + arrow::TimestampParser::MakeStrptime("%m-%d-%Y"), + arrow::TimestampParser::MakeStrptime("%m/%d/%Y"), + arrow::TimestampParser::MakeStrptime("%d %m %Y"), + // TODO: time type column + arrow::TimestampParser::MakeStrptime("%H:%M:%S.%f") +}; + +std::vector> DATE_READERS{ + std::make_shared(), + std::make_shared(), + std::make_shared(), + arrow::TimestampParser::MakeStrptime("%Y-%m-%d\\D%H:%M:%S.%f"), + arrow::TimestampParser::MakeStrptime("%m/%d/%Y, %I:%M:%S %p"), + // US locale + arrow::TimestampParser::MakeStrptime("%m-%d-%Y"), + arrow::TimestampParser::MakeStrptime("%m/%d/%Y"), + arrow::TimestampParser::MakeStrptime("%d %m %Y"), + arrow::TimestampParser::MakeStrptime("%H:%M:%S.%f") +}; + +std::optional +parseAsArrowTimestamp(const std::string& input) { + for (const auto& candidate : DATE_PARSERS) { + int64_t datetime; + if (candidate->operator()( + input.c_str(), input.size(), arrow::TimeUnit::MILLI, &datetime + )) { + return datetime; + } + } + + return std::nullopt; +} + +std::shared_ptr<::arrow::Table> +csvToTable( + const std::string_view& csv, + bool is_update, + std::unordered_map>& schema +) { + const arrow::io::IOContext& io_context = arrow::io::default_io_context(); + auto input = std::make_shared(csv); + auto read_options = arrow::csv::ReadOptions::Defaults(); + auto parse_options = arrow::csv::ParseOptions::Defaults(); + auto convert_options = arrow::csv::ConvertOptions::Defaults(); +#ifdef PSP_PARALLEL_FOR + read_options.use_threads = true; +#else + read_options.use_threads = false; +#endif + parse_options.newlines_in_values = true; + + if (is_update) { + convert_options.column_types = std::move(schema); + convert_options.timestamp_parsers = DATE_READERS; + } else { + convert_options.timestamp_parsers = DATE_PARSERS; + } + + auto maybe_reader = arrow::csv::TableReader::Make( + io_context, input, read_options, parse_options, convert_options + ); + + std::shared_ptr reader = *maybe_reader; + + auto maybe_table = reader->Read(); + if (!maybe_table.ok()) { + PSP_COMPLAIN_AND_ABORT(maybe_table.status().ToString()); + } + return *maybe_table; +} + +} // namespace perspective::apachearrow diff --git a/rust/perspective-server/cpp/perspective/src/cpp/arrow_loader.cpp b/rust/perspective-server/cpp/perspective/src/cpp/arrow_loader.cpp new file mode 100644 index 0000000..ea5a1a9 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/arrow_loader.cpp @@ -0,0 +1,1001 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include "perspective/base.h" +#include "perspective/raw_types.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "perspective/exception.h" +#include +#include +#include + +namespace perspective::apachearrow { + +std::shared_ptr<::arrow::Table> +deduplicate_table(std::shared_ptr<::arrow::Table> input) { + auto columns = input->ColumnNames(); + std::set columns_seen; + bool is_changed; + for (auto& column : columns) { + std::stringstream ss; + ss << column; + while (columns_seen.find(ss.str()) != columns_seen.end()) { + ss << "*"; + is_changed = true; + } + + if (is_changed) { + column = ss.str(); + } + + columns_seen.insert(column); + } + + if (is_changed) { + input = *input->RenameColumns(columns); + } + + return input; +} + +void +load_stream( + const std::uint8_t* ptr, + const uint32_t length, + std::shared_ptr& table +) { + arrow::io::BufferReader buffer_reader(std::make_shared( + reinterpret_cast(ptr), length + )); + + auto status = arrow::ipc::RecordBatchStreamReader::Open(&buffer_reader); + if (!status.ok()) { + std::stringstream ss; + ss << "Failed to open RecordBatchStreamReader: " + << status.status().ToString() << "\n"; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } else { + auto batch_reader = *status; + auto status5 = batch_reader->ToTable(); + if (!status5.ok()) { + std::stringstream ss; + ss << "Failed to read stream record batch: " + << status5.status().ToString() << "\n"; + PSP_COMPLAIN_AND_ABORT(ss.str()); + }; + + table = deduplicate_table(*status5); + } +} + +void +load_file( + const std::uint8_t* ptr, + const uint32_t length, + std::shared_ptr& table +) { + arrow::io::BufferReader buffer_reader(std::make_shared( + reinterpret_cast(ptr), length + )); + + auto status = arrow::ipc::RecordBatchFileReader::Open(&buffer_reader); + if (!status.ok()) { + std::stringstream ss; + ss << "Failed to open RecordBatchFileReader: " + << status.status().ToString() << "\n"; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } else { + std::shared_ptr batch_reader = + *status; + + std::vector> batches; + auto num_batches = batch_reader->num_record_batches(); + batches.reserve(num_batches); + for (int i = 0; i < num_batches; ++i) { + auto status2 = batch_reader->ReadRecordBatch(i); + if (!status2.ok()) { + PSP_COMPLAIN_AND_ABORT( + "Failed to read file record batch: " + + status2.status().ToString() + ); + } + std::shared_ptr chunk = *status2; + batches.push_back(chunk); + } + + auto status3 = + arrow::Table::FromRecordBatches(batch_reader->schema(), batches); + + if (!status3.ok()) { + std::stringstream ss; + ss << "Failed to create Table from RecordBatches: " + << status3.status().ToString() << "\n"; + PSP_COMPLAIN_AND_ABORT(ss.str()); + }; + table = deduplicate_table(std::move(*status3)); + }; +} + +using namespace perspective; + +ArrowLoader::ArrowLoader() = default; +ArrowLoader::~ArrowLoader() = default; +ArrowLoader::ArrowLoader(ArrowLoader&&) noexcept = default; + +t_dtype +convert_type(const std::string& src) { + if (src == "dictionary" || src == "utf8" || src == "binary" + || src == "large_utf8") { + return DTYPE_STR; + } + if (src == "bool") { + return DTYPE_BOOL; + } + if (src == "int8") { + return DTYPE_INT8; + } + if (src == "uint8") { + return DTYPE_UINT8; + } + if (src == "int16") { + return DTYPE_INT16; + } + if (src == "uint16") { + return DTYPE_UINT16; + } + if (src == "int32") { + return DTYPE_INT32; + } + if (src == "uint32") { + return DTYPE_UINT32; + } + if (src == "uint64") { + return DTYPE_UINT64; + } + if (src == "int64") { + return DTYPE_INT64; + } + if (src == "decimal" || src == "decimal128") { + return DTYPE_FLOAT64; + } + if (src == "float") { + return DTYPE_FLOAT32; + } + if (src == "double") { + return DTYPE_FLOAT64; + } + if (src == "timestamp") { + return DTYPE_TIME; + } + if (src == "time32" || src == "time64" || src == "time32[s]" ) { + return DTYPE_UINT32; + } + if (src == "date32" || src == "date64") { + return DTYPE_DATE; + } + if (src == "null") { + return DTYPE_STR; + } + if (src == "list") { + return DTYPE_STR; + } + std::stringstream ss; + ss << "Could not load arrow column of type `" << src << "`\n"; + PSP_COMPLAIN_AND_ABORT(ss.str()); + return DTYPE_STR; +} + +void +ArrowLoader::initialize(const std::uint8_t* ptr, const uint32_t length) { + if (std::memcmp("ARROW1", (const void*)ptr, 6) == 0) { + load_file(ptr, length, m_table); + } else { + load_stream(ptr, length, m_table); + } + + const auto validation = m_table->ValidateFull(); + if (!validation.ok()) { + PSP_COMPLAIN_AND_ABORT(validation.ToString()); + } + + std::shared_ptr schema = m_table->schema(); + std::vector> fields = schema->fields(); + + for (const auto& field : fields) { + m_names.push_back(field->name()); + m_types.push_back(convert_type(field->type()->name())); + } +} + +void +ArrowLoader::init_csv( + const std::string_view& csv, + bool is_update, + std::unordered_map>& + psp_schema +) { + m_table = deduplicate_table(csvToTable(csv, is_update, psp_schema)); + std::shared_ptr schema = m_table->schema(); + std::vector> fields = schema->fields(); + for (const auto& field : fields) { + m_names.push_back(field->name()); + m_types.push_back(convert_type(field->type()->name())); + } +} + +void +ArrowLoader::fill_table( + t_data_table& tbl, + const t_schema& input_schema, + const std::string& index, + std::uint32_t offset, + bool is_update +) { + bool implicit_index = false; + std::shared_ptr schema = m_table->schema(); + std::vector> fields = schema->fields(); + + parallel_for(int(m_names.size()), [&](int cidx) { + auto name = m_names[cidx]; + t_dtype type = m_types[cidx]; + + if (input_schema.has_column(name)) { + // Skip columns that are defined in the arrow but not + // in the Table's input schema. + + auto raw_type = fields[cidx]->type()->name(); + + if (name == "__INDEX__") { + implicit_index = true; + auto input_schema_type = input_schema.get_dtype(name); + std::shared_ptr pkey_col_sptr = + tbl.add_column_sptr("psp_pkey", input_schema_type, true); + fill_column( + tbl, pkey_col_sptr, name, cidx, type, raw_type, is_update + ); + tbl.clone_column("psp_pkey", "psp_okey"); + // continue; + } else { + auto col = tbl.get_column(name); + fill_column(tbl, col, name, cidx, type, raw_type, is_update); + } + } + }); + + // Fill index column - recreated every time a `t_data_table` is created. + if (!implicit_index) { + if (index.empty()) { + // Use row number as index if not explicitly provided or + // provided with + // `__INDEX__` + auto* key_col = tbl.add_column("psp_pkey", DTYPE_INT32, true); + auto* okey_col = tbl.add_column("psp_okey", DTYPE_INT32, true); + + for (std::uint32_t ridx = 0; ridx < tbl.size(); ++ridx) { + key_col->set_nth(ridx, (ridx + offset)); + okey_col->set_nth(ridx, (ridx + offset)); + } + } else { + if (!input_schema.has_column(index)) { + std::stringstream ss; + ss << "Specified index `" << index + << "` is invalid as it does not appear in the Table.\n"; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + + tbl.clone_column(index, "psp_pkey"); + tbl.clone_column(index, "psp_okey"); + } + } +} + +template +void +iter_col_copy( + const std::shared_ptr& dest, + std::shared_ptr src, + const int64_t offset, + const int64_t len +) { + std::shared_ptr scol = std::static_pointer_cast(src); + const typename T::value_type* vals = scol->raw_values(); + for (uint32_t i = 0; i < len; i++) { + dest->set_nth(offset + i, static_cast(vals[i])); + } +} + +void +copy_string_list( + std::shared_ptr& list, + const std::shared_ptr& dest, + const int64_t num_rows +) { + const auto typed_data = + std::static_pointer_cast<::arrow::StringArray>(list->values()); + + // the raw bytes of the strings. + const uint8_t* raw_data = typed_data->value_data()->data(); + // col_offsets are the column level offsets, so each element is the boundary + // of a row. + const auto* col_offsets = list->raw_value_offsets(); + // these are the offset locations of the strings within the raw data. + // each element is the boundary of a string. + const auto* string_offsets = typed_data->raw_value_offsets(); + + for (uint32_t i = 0; i < num_rows; i++) { + rapidjson::StringBuffer s; + rapidjson::Writer writer(s); + writer.StartArray(); + auto row_array_length = col_offsets[i + 1] - col_offsets[i]; + for (uint32_t j = 0; j < row_array_length; j++) { + const auto elem_location = col_offsets[i] + j; + const auto start_loc = string_offsets[elem_location]; + const auto string_len = + string_offsets[elem_location + 1] - start_loc; + writer.String((const char*)raw_data + start_loc, string_len); + } + writer.EndArray(); + dest->set_nth(i, s.GetString()); + } +} + +template +void +copy_integer_list( + std::shared_ptr& list, + const std::shared_ptr& dest, + const int64_t num_rows +) { + const auto typed_data = std::static_pointer_cast(list->values()); + const auto* array_offsets = list->raw_value_offsets(); + const auto* raw_values = typed_data->raw_values(); + for (uint32_t i = 0; i < num_rows; i++) { + rapidjson::StringBuffer s; + rapidjson::Writer writer(s); + writer.StartArray(); + const auto row_array_length = array_offsets[i + 1] - array_offsets[i]; + for (uint32_t j = 0; j < row_array_length; j++) { + const auto elem_location = array_offsets[i] + j; + const auto elem = raw_values[elem_location]; + writer.Int64(elem); + } + writer.EndArray(); + dest->set_nth(i, s.GetString()); + } +} + +void +copy_bool_list( + std::shared_ptr& list, + const std::shared_ptr& dest, + const int64_t num_rows +) { + const auto typed_data = + std::static_pointer_cast(list->values()); + const auto* array_offsets = list->raw_value_offsets(); + for (uint32_t i = 0; i < num_rows; i++) { + rapidjson::StringBuffer s; + rapidjson::Writer writer(s); + writer.StartArray(); + const auto row_array_length = array_offsets[i + 1] - array_offsets[i]; + for (uint32_t j = 0; j < row_array_length; j++) { + const auto elem_location = array_offsets[i] + j; + const auto elem = typed_data->Value(elem_location); + writer.Bool(elem); + } + writer.EndArray(); + dest->set_nth(i, s.GetString()); + } +} + +template +void +copy_float_list( + std::shared_ptr& list, + const std::shared_ptr& dest, + const int64_t num_rows +) { + const auto typed_data = std::static_pointer_cast(list->values()); + const auto* array_offsets = list->raw_value_offsets(); + const auto* raw_values = typed_data->raw_values(); + for (uint32_t i = 0; i < num_rows; i++) { + rapidjson::StringBuffer s; + rapidjson::Writer writer(s); + writer.StartArray(); + const auto row_array_length = array_offsets[i + 1] - array_offsets[i]; + for (uint32_t j = 0; j < row_array_length; j++) { + const auto elem_location = array_offsets[i] + j; + const auto elem = raw_values[elem_location]; + writer.Double(elem); + } + writer.EndArray(); + dest->set_nth(i, s.GetString()); + } +} + +void +copy_array( + const std::shared_ptr& dest, + const std::shared_ptr& src, + const int64_t offset, + const int64_t len +) { + switch (src->type()->id()) { + case arrow::ListType::type_id: { + auto list = std::static_pointer_cast<::arrow::ListArray>(src); + + switch (list->value_type()->id()) { + case arrow::BooleanType::type_id: { + copy_bool_list(list, dest, len); + } break; + case arrow::HalfFloatType::type_id: { + copy_float_list(list, dest, len); + } break; + case arrow::FloatType::type_id: { + copy_float_list(list, dest, len); + } break; + case arrow::DoubleType::type_id: { + copy_float_list(list, dest, len); + } break; + case ::arrow::Int8Type::type_id: { + copy_integer_list(list, dest, len); + } break; + case ::arrow::Int16Type::type_id: { + copy_integer_list(list, dest, len); + } break; + case ::arrow::Int32Type::type_id: { + copy_integer_list(list, dest, len); + } break; + case ::arrow::UInt32Type::type_id: { + copy_integer_list(list, dest, len); + } break; + case ::arrow::StringType::type_id: { + copy_string_list(list, dest, len); + } break; + default: + auto val = list->value_type(); + std::stringstream ss; + ss << "Given Unsupported type '" << *val << "'.\n"; + auto cc = ss.str(); + PSP_COMPLAIN_AND_ABORT(cc); + } + } break; + case arrow::DictionaryType::type_id: { + auto dictionary_type = + static_cast(src->type().get()); + + auto value_type = dictionary_type->value_type(); + + // If there are duplicate values in the dictionary + // at different indices, i.e. [0 => a, 1 => b, 2 => + // a], tables with explicit indexes on a string + // column created from a dictionary array may have + // duplicate primary keys. + auto scol = std::static_pointer_cast(src); + if (value_type->id() == arrow::large_utf8()->id()) { + auto dict = std::static_pointer_cast( + scol->dictionary() + ); + + const uint8_t* values = dict->value_data()->data(); + const std::uint64_t dsize = dict->length(); + t_vocab* vocab = dest->_get_vocab(); + std::string elem; + // vocab len + null bytes + vocab->reserve(dict->value_data()->size() + dsize, dsize); + for (std::uint64_t i = 0; i < dsize; ++i) { + std::int64_t bidx = dict->value_offset(i); + std::size_t es = dict->value_length(i); + elem.assign( + reinterpret_cast(values) + bidx, es + ); + + vocab->get_interned(elem); + } + } else { + auto dict = std::static_pointer_cast( + scol->dictionary() + ); + + const uint8_t* values = dict->value_data()->data(); + const std::uint64_t dsize = dict->length(); + t_vocab* vocab = dest->_get_vocab(); + std::string elem; + vocab->reserve(dict->value_data()->size() + dsize, dsize); + for (std::uint64_t i = 0; i < dsize; ++i) { + std::int32_t bidx = dict->value_offset(i); + std::size_t es = dict->value_length(i); + elem.assign( + reinterpret_cast(values) + bidx, es + ); + + vocab->get_interned(elem); + } + } + + auto indices = scol->indices(); + switch (indices->type()->id()) { + case arrow::Int8Type::type_id: { + iter_col_copy<::arrow::Int8Array, t_uindex>( + dest, indices, offset, len + ); + } break; + case ::arrow::UInt8Type::type_id: { + iter_col_copy<::arrow::UInt8Array, t_uindex>( + dest, indices, offset, len + ); + } break; + case ::arrow::Int16Type::type_id: { + iter_col_copy<::arrow::Int16Array, t_uindex>( + dest, indices, offset, len + ); + } break; + case ::arrow::UInt16Type::type_id: { + iter_col_copy<::arrow::UInt16Array, t_uindex>( + dest, indices, offset, len + ); + } break; + case ::arrow::Int32Type::type_id: { + iter_col_copy<::arrow::Int32Array, t_uindex>( + dest, indices, offset, len + ); + } break; + case ::arrow::UInt32Type::type_id: { + iter_col_copy<::arrow::UInt32Array, t_uindex>( + dest, indices, offset, len + ); + } break; + case ::arrow::Int64Type::type_id: { + iter_col_copy<::arrow::Int64Array, t_uindex>( + dest, indices, offset, len + ); + } break; + case ::arrow::UInt64Type::type_id: { + iter_col_copy<::arrow::UInt64Array, t_uindex>( + dest, indices, offset, len + ); + } break; + default: { + std::stringstream ss; + ss << "Could not copy dictionary array " + "indices of type'" + << indices->type()->name() << "'\n"; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + } + } break; + case arrow::LargeStringType::type_id: { + std::shared_ptr scol = + std::static_pointer_cast(src); + const arrow::LargeStringArray::offset_type* offsets = + scol->raw_value_offsets(); + const uint8_t* values = scol->value_data()->data(); + + std::string elem; + + for (std::uint32_t i = 0; i < len; ++i) { + arrow::LargeStringArray::offset_type bidx = offsets[i]; + std::size_t es = offsets[i + 1] - bidx; + elem.assign(reinterpret_cast(values) + bidx, es); + dest->set_nth(offset + i, elem); + } + } break; + case arrow::BinaryType::type_id: + case arrow::StringType::type_id: { + std::shared_ptr scol = + std::static_pointer_cast(src); + const int32_t* offsets = scol->raw_value_offsets(); + const uint8_t* values = scol->value_data()->data(); + + std::string elem; + + for (std::uint32_t i = 0; i < len; ++i) { + std::int32_t bidx = offsets[i]; + std::size_t es = offsets[i + 1] - bidx; + elem.assign(reinterpret_cast(values) + bidx, es); + dest->set_nth(offset + i, elem); + } + } break; + case arrow::Int8Type::type_id: { + auto scol = std::static_pointer_cast(src); + std::memcpy( + dest->get_nth(offset), + (void*)scol->raw_values(), + len + ); + } break; + case arrow::UInt8Type::type_id: { + auto scol = std::static_pointer_cast(src); + std::memcpy( + dest->get_nth(offset), + (void*)scol->raw_values(), + len + ); + } break; + case arrow::Int16Type::type_id: { + auto scol = std::static_pointer_cast(src); + std::memcpy( + dest->get_nth(offset), + (void*)scol->raw_values(), + len * 2 + ); + } break; + case arrow::UInt16Type::type_id: { + auto scol = std::static_pointer_cast(src); + std::memcpy( + dest->get_nth(offset), + (void*)scol->raw_values(), + len * 2 + ); + } break; + case arrow::Int32Type::type_id: { + auto scol = std::static_pointer_cast(src); + std::memcpy( + dest->get_nth(offset), + (void*)scol->raw_values(), + len * 4 + ); + } break; + case arrow::UInt32Type::type_id: { + auto scol = std::static_pointer_cast(src); + std::memcpy( + dest->get_nth(offset), + (void*)scol->raw_values(), + len * 4 + ); + } break; + case arrow::Int64Type::type_id: { + auto scol = std::static_pointer_cast(src); + std::memcpy( + dest->get_nth(offset), + (void*)scol->raw_values(), + len * 8 + ); + } break; + case arrow::UInt64Type::type_id: { + auto scol = std::static_pointer_cast(src); + std::memcpy( + dest->get_nth(offset), + (void*)scol->raw_values(), + len * 8 + ); + } break; + case arrow::TimestampType::type_id: { + std::shared_ptr tunit = + std::static_pointer_cast(src->type()); + auto scol = std::static_pointer_cast(src); + switch (tunit->unit()) { + case arrow::TimeUnit::MILLI: { + std::memcpy( + dest->get_nth(offset), + (void*)scol->raw_values(), + len * 8 + ); + } break; + case arrow::TimeUnit::NANO: { + const int64_t* vals = scol->raw_values(); + for (uint32_t i = 0; i < len; i++) { + dest->set_nth(offset + i, vals[i] / 1000000); + } + } break; + case arrow::TimeUnit::MICRO: { + const int64_t* vals = scol->raw_values(); + for (uint32_t i = 0; i < len; i++) { + dest->set_nth(offset + i, vals[i] / 1000); + } + } break; + case arrow::TimeUnit::SECOND: { + const int64_t* vals = scol->raw_values(); + for (uint32_t i = 0; i < len; i++) { + dest->set_nth(offset + i, vals[i] * 1000); + } + } break; + } + } break; + case arrow::Date64Type::type_id: { + std::shared_ptr date_type = + std::static_pointer_cast(src->type()); + auto scol = std::static_pointer_cast(src); + const int64_t* vals = scol->raw_values(); + for (uint32_t i = 0; i < len; i++) { + std::chrono::milliseconds timestamp(vals[i]); + date::sys_days days(date::floor(timestamp)); + auto ymd = date::year_month_day{days}; + std::int32_t year = static_cast(ymd.year()); + std::uint32_t month = static_cast(ymd.month()); + std::uint32_t day = static_cast(ymd.day()); + // Decrement month by 1, as date::month is + // [1-12] but t_date::month() is [0-11] + dest->set_nth(offset + i, t_date(year, month - 1, day)); + } + } break; + case arrow::Date32Type::type_id: { + std::shared_ptr date_type = + std::static_pointer_cast(src->type()); + auto scol = std::static_pointer_cast(src); + const int32_t* vals = scol->raw_values(); + for (uint32_t i = 0; i < len; i++) { + date::days days{vals[i]}; + auto ymd = date::year_month_day{date::sys_days{days}}; + // years are signed, month/day are unsigned + std::int32_t year = static_cast(ymd.year()); + std::uint32_t month = static_cast(ymd.month()); + std::uint32_t day = static_cast(ymd.day()); + // Decrement month by 1, as date::month is + // [1-12] but t_date::month() is [0-11] + dest->set_nth(offset + i, t_date(year, month - 1, day)); + } + } break; + case arrow::FloatType::type_id: { + auto scol = std::static_pointer_cast(src); + std::memcpy( + dest->get_nth(offset), (void*)scol->raw_values(), len * 4 + ); + } break; + case arrow::DoubleType::type_id: { + auto scol = std::static_pointer_cast(src); + std::memcpy( + dest->get_nth(offset), + (void*)scol->raw_values(), + len * 8 + ); + } break; + case arrow::Decimal128Type::type_id: + case arrow::DecimalType::type_id: { + std::shared_ptr scol = + std::static_pointer_cast(src); + auto decimal_type = + std::static_pointer_cast(src->type()); + int32_t scale = decimal_type->scale(); + auto* vals = (arrow::Decimal128*)scol->raw_values(); + for (uint32_t i = 0; i < len; ++i) { + dest->set_nth(offset + i, vals[i].ToDouble(scale)); + } + } break; + case arrow::BooleanType::type_id: { + auto scol = std::static_pointer_cast(src); + const uint8_t* null_bitmap = scol->values()->data(); + for (uint32_t i = 0; i < len; ++i) { + std::uint8_t elem = null_bitmap[i / 8]; + bool v = (elem & (1 << (i % 8))) != 0; + dest->set_nth(offset + i, v); + } + } break; + case arrow::NullType::type_id: { + for (uint32_t i = 0; i < len; ++i) { + dest->set_valid(i, false); + } + } break; + case arrow::Time32Type::type_id: { + auto scol = std::static_pointer_cast(src); + std::memcpy( + dest->get_nth(offset), + (void*)scol->raw_values(), + len * 4 + ); + } break; + // case arrow::Type { + + // } break; + default: { + std::stringstream ss; + std::string arrow_type = src->type()->ToString(); + ss << "Could not load Arrow column of type `" << arrow_type + << "`.\n"; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + } +} + +// Defines the full matrix of type interactions between arrow arrays and +// schema-defined tables. +#define FILL_COLUMN_ITER(ARRAY_TYPE) \ + switch (column_dtype) { \ + case DTYPE_INT8: { \ + iter_col_copy(col, array, offset, len); \ + } break; \ + case DTYPE_UINT8: { \ + iter_col_copy(col, array, offset, len); \ + } break; \ + case DTYPE_INT16: { \ + iter_col_copy(col, array, offset, len); \ + } break; \ + case DTYPE_UINT16: { \ + iter_col_copy(col, array, offset, len); \ + } break; \ + case DTYPE_INT32: { \ + iter_col_copy(col, array, offset, len); \ + } break; \ + case DTYPE_UINT32: { \ + iter_col_copy(col, array, offset, len); \ + } break; \ + case DTYPE_INT64: { \ + iter_col_copy(col, array, offset, len); \ + } break; \ + case DTYPE_UINT64: { \ + iter_col_copy(col, array, offset, len); \ + } break; \ + case DTYPE_FLOAT32: { \ + iter_col_copy(col, array, offset, len); \ + } break; \ + case DTYPE_FLOAT64: { \ + iter_col_copy(col, array, offset, len); \ + } break; \ + default: { \ + std::stringstream ss; \ + ss << "Could not fill arrow column `" << name << "` iteratively" \ + << " due to mismatched types."; \ + PSP_COMPLAIN_AND_ABORT(ss.str()); \ + } \ + } + +void +ArrowLoader::fill_column( + t_data_table& tbl, + const std::shared_ptr& col, + const std::string& name, + std::int32_t cidx, + t_dtype type, + std::string& raw_type, + bool is_update +) { + int64_t offset = 0; + std::shared_ptr carray = + m_table->GetColumnByName(name); + + if (carray == nullptr) { + LOG_DEBUG( + "Could not find column `" << name << "` in arrow table." + << "\n" + ); + return; + } + for (auto i = 0; i < carray->num_chunks(); ++i) { + std::shared_ptr array = carray->chunk(i); + int64_t len = array->length(); + + // If the Arrow array schema is different from the data + // table schema, iteratively fill. + t_dtype column_dtype = col->get_dtype(); + + // `type`: arrow array dtype converted to `t_dtype` + // `column_dtype`: dtype of the `t_column` + if (type != column_dtype) { + LOG_DEBUG( + "Type " << type << " != " << column_dtype << " for column " + << name << " - filling iteratively" + ); + switch (type) { + case DTYPE_INT8: { + FILL_COLUMN_ITER(::arrow::Int8Array); + } break; + case DTYPE_UINT8: { + FILL_COLUMN_ITER(::arrow::UInt8Array); + } break; + case DTYPE_INT16: { + FILL_COLUMN_ITER(::arrow::Int16Array); + } break; + case DTYPE_UINT16: { + FILL_COLUMN_ITER(::arrow::UInt16Array); + } break; + case DTYPE_INT32: { + FILL_COLUMN_ITER(::arrow::Int32Array); + } break; + case DTYPE_UINT32: { + FILL_COLUMN_ITER(::arrow::UInt32Array); + } break; + case DTYPE_INT64: { + FILL_COLUMN_ITER(::arrow::Int64Array); + } break; + case DTYPE_UINT64: { + FILL_COLUMN_ITER(::arrow::UInt64Array); + } break; + case DTYPE_FLOAT32: { + FILL_COLUMN_ITER(::arrow::FloatArray); + } break; + case DTYPE_FLOAT64: { + FILL_COLUMN_ITER(::arrow::DoubleArray); + } break; + default: { + std::stringstream ss; + ss << "Could not fill column `" << name << "` with " + << "t_dtype: `" << get_dtype_descr(column_dtype) << "`, " + << "array type: `" << get_dtype_descr(type) << "`\n"; + PSP_COMPLAIN_AND_ABORT(ss.str()); + }; + } + } else { + copy_array(col, array, offset, len); + } + + // Fill validity bitmap. Operate only on the current chunk's + // range [offset, offset+len); a whole-column fill here would + // clobber validity bits set by other chunks in a multi-batch + // ChunkedArray. + std::int64_t null_count = array->null_count(); + + if (null_count == 0) { + col->set_valid_range(offset, len); + } else { + const uint8_t* null_bitmap = array->null_bitmap_data(); + + // If the arrow column is of null type, the null + // bitmap is a nullptr - so just mark this chunk's rows + // as invalid and move on. + if (null_bitmap == nullptr) { + for (uint32_t i = 0; i < len; ++i) { + if (is_update) { + col->unset(offset + i); + } else { + col->clear(offset + i); + } + } + } else { + // Read the null bitmap and set the correct rows + // as valid + for (uint32_t i = 0; i < len; ++i) { + std::uint8_t elem = null_bitmap[i / 8]; + bool v = (elem & (1 << (i % 8))) != 0; + if (!v) { + if (is_update) { + col->unset(offset + i); + } else { + col->clear(offset + i); + } + } else { + col->set_valid(offset + i, v); + } + } + } + } + + offset += len; + } +} + +// Getters + +std::uint32_t +ArrowLoader::row_count() const { + const std::int64_t n = m_table->num_rows(); + if (n < 0 + || static_cast(n) + > std::numeric_limits::max()) { + PSP_COMPLAIN_AND_ABORT( + "Arrow table row count exceeds maximum supported size" + ); + } + + return static_cast(n); +} + +std::vector +ArrowLoader::names() const { + return m_names; +} + +std::vector +ArrowLoader::types() const { + return m_types; +} + +} // namespace perspective::apachearrow \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/cpp/arrow_writer.cpp b/rust/perspective-server/cpp/perspective/src/cpp/arrow_writer.cpp new file mode 100644 index 0000000..536f355 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/arrow_writer.cpp @@ -0,0 +1,81 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include + +namespace perspective::apachearrow { +using namespace perspective; + +// TODO: unsure about efficacy of these functions when get exists +template <> +double +get_scalar(t_tscalar& t) { + return t.to_double(); +} +template <> +float +get_scalar(t_tscalar& t) { + return static_cast(t.to_double()); +} +template <> +std::uint8_t +get_scalar(t_tscalar& t) { + return static_cast(t.to_int64()); +} +template <> +std::int8_t +get_scalar(t_tscalar& t) { + return static_cast(t.to_int64()); +} +template <> +std::int16_t +get_scalar(t_tscalar& t) { + return static_cast(t.to_int64()); +} +template <> +std::uint16_t +get_scalar(t_tscalar& t) { + return static_cast(t.to_int64()); +} +template <> +std::int32_t +get_scalar(t_tscalar& t) { + return static_cast(t.to_int64()); +} +template <> +std::uint32_t +get_scalar(t_tscalar& t) { + return static_cast(t.to_int64()); +} +template <> +std::int64_t +get_scalar(t_tscalar& t) { + return static_cast(t.to_int64()); +} +template <> +std::uint64_t +get_scalar(t_tscalar& t) { + return static_cast(t.to_int64()); +} +template <> +bool +get_scalar(t_tscalar& t) { + return t.get(); +} + +// std::int32_t +// get_idx(std::int32_t cidx, std::int32_t ridx, std::int32_t stride, +// t_get_data_extents extents) { +// return (ridx - extents.m_srow) * stride + (cidx - extents.m_scol); +// } + +} // namespace perspective::apachearrow \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/cpp/base.cpp b/rust/perspective-server/cpp/perspective/src/cpp/base.cpp new file mode 100644 index 0000000..1cc8314 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/base.cpp @@ -0,0 +1,821 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include +#include + +namespace perspective { + +void +psp_abort(const std::string& message) { + throw PerspectiveException(message.c_str()); +} + +bool +is_numeric_type(t_dtype dtype) { + switch (dtype) { + case DTYPE_UINT8: + case DTYPE_UINT16: + case DTYPE_UINT32: + case DTYPE_UINT64: + case DTYPE_INT8: + case DTYPE_INT16: + case DTYPE_INT32: + case DTYPE_INT64: + case DTYPE_FLOAT32: + case DTYPE_FLOAT64: { + return true; + } break; + default: { + return false; + } + } +} + +bool +is_linear_order_type(t_dtype dtype) { + switch (dtype) { + case DTYPE_UINT8: + case DTYPE_UINT16: + case DTYPE_UINT32: + case DTYPE_UINT64: + case DTYPE_INT8: + case DTYPE_INT16: + case DTYPE_INT32: + case DTYPE_INT64: + case DTYPE_FLOAT32: + case DTYPE_FLOAT64: + case DTYPE_DATE: + case DTYPE_TIME: + case DTYPE_BOOL: { + return true; + } break; + default: { + return false; + } + } +} + +bool +is_floating_point(t_dtype dtype) { + return (dtype == DTYPE_FLOAT32 || dtype == DTYPE_FLOAT64); +} + +bool +is_deterministic_sized(t_dtype dtype) { + switch (dtype) { + case DTYPE_OBJECT: + case DTYPE_INT64: + case DTYPE_UINT64: + case DTYPE_INT32: + case DTYPE_UINT32: + case DTYPE_INT16: + case DTYPE_UINT16: + case DTYPE_BOOL: + case DTYPE_INT8: + case DTYPE_UINT8: + case DTYPE_FLOAT64: + case DTYPE_FLOAT32: + case DTYPE_STR: + case DTYPE_TIME: + case DTYPE_DATE: + case DTYPE_F64PAIR: { + return true; + } + default: { + return false; + } + } + + PSP_COMPLAIN_AND_ABORT("Reached unreachable"); + return false; +} + +t_uindex +get_dtype_size(t_dtype dtype) { + switch (dtype) { + case DTYPE_OBJECT: { + return sizeof(void*); + } + case DTYPE_INT64: + case DTYPE_UINT64: { + return sizeof(std::int64_t); + } + case DTYPE_INT32: + case DTYPE_UINT32: { + return sizeof(std::int32_t); + } + case DTYPE_INT16: + case DTYPE_UINT16: { + return 2; + } + case DTYPE_BOOL: + case DTYPE_INT8: + case DTYPE_UINT8: + case DTYPE_NONE: { + return 1; + } + case DTYPE_FLOAT64: { + return sizeof(double); + } + case DTYPE_FLOAT32: { + return sizeof(float); + } + case DTYPE_STR: { + return sizeof(t_uindex); + } + case DTYPE_TIME: { + return sizeof(std::int64_t); + } + case DTYPE_DATE: { + return sizeof(std::uint32_t); + } + case DTYPE_F64PAIR: { + return sizeof(std::pair); + } + default: { + PSP_COMPLAIN_AND_ABORT("Unknown dtype"); + } + } + + PSP_COMPLAIN_AND_ABORT("Reached unreachable"); + return sizeof(DTYPE_INT64); +} + +bool +is_vlen_dtype(t_dtype dtype) { + return dtype == DTYPE_STR || dtype == DTYPE_USER_VLEN; +} + +std::string +get_dtype_descr(t_dtype dtype) { + switch (dtype) { + case DTYPE_NONE: { + return "none"; + } break; + case DTYPE_INT64: { + return "int64"; + } break; + case DTYPE_INT32: { + return "int32"; + } break; + case DTYPE_INT16: { + return "int16"; + } break; + case DTYPE_INT8: { + return "int8"; + } break; + case DTYPE_UINT64: { + return "uint64"; + } break; + case DTYPE_UINT32: { + return "uint32"; + } break; + case DTYPE_UINT16: { + return "uint16"; + } break; + case DTYPE_UINT8: { + return "uint8"; + } break; + case DTYPE_BOOL: { + return "bool"; + } break; + case DTYPE_FLOAT64: { + return "float64"; + } break; + case DTYPE_FLOAT32: { + return "float32"; + } break; + case DTYPE_STR: { + return "str"; + } break; + case DTYPE_TIME: { + return "datetime"; + } break; + case DTYPE_DATE: { + return "date"; + } break; + case DTYPE_ENUM: { + return "e"; + } break; + case DTYPE_OID: { + return "oid"; + } break; + case DTYPE_USER_FIXED: { + return "ufix"; + } break; + case DTYPE_LAST: { + return "last"; + } break; + case DTYPE_USER_VLEN: { + return "uvlen"; + } break; + case DTYPE_F64PAIR: { + return "f64pair"; + } break; + case DTYPE_OBJECT: { + return "object"; + } + default: { + PSP_COMPLAIN_AND_ABORT("Encountered unknown dtype"); + } + } + return {"dummy"}; +} + +std::string +dtype_to_str(t_dtype dtype) { + std::stringstream ss; + switch (dtype) { + case DTYPE_FLOAT32: + case DTYPE_FLOAT64: { + ss << "float"; + } break; + case DTYPE_UINT8: + case DTYPE_UINT16: + case DTYPE_UINT32: + case DTYPE_UINT64: + case DTYPE_INT8: + case DTYPE_INT16: + case DTYPE_INT32: + case DTYPE_INT64: { + ss << "integer"; + } break; + case DTYPE_BOOL: { + ss << "boolean"; + } break; + case DTYPE_DATE: { + ss << "date"; + } break; + case DTYPE_TIME: { + ss << "datetime"; + } break; + case DTYPE_STR: { + ss << "string"; + } break; + case DTYPE_OBJECT: { + ss << "object"; + } break; + case DTYPE_NONE: { + ss << "none"; + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Cannot convert unknown dtype to string!"); + } + } + + return ss.str(); +} + +t_dtype +str_to_dtype(const std::string& typestring) { + // returns most commonly used types in the JS/python public APIs. + if (typestring == "integer") { + return DTYPE_INT32; + } + if (typestring == "float") { + return DTYPE_FLOAT64; + } + if (typestring == "boolean") { + return DTYPE_BOOL; + } + if (typestring == "date") { + return DTYPE_DATE; + } + if (typestring == "datetime") { + return DTYPE_TIME; + } + if (typestring == "string") { + return DTYPE_STR; + } + + PSP_COMPLAIN_AND_ABORT( + "Could not convert unknown type string `" + typestring + "` to dtype." + ); + return DTYPE_NONE; +} + +std::string +filter_op_to_str(t_filter_op op) { + switch (op) { + case FILTER_OP_LT: { + return "<"; + } break; + case FILTER_OP_LTEQ: { + return "<="; + } break; + case FILTER_OP_GT: { + return ">"; + } break; + case FILTER_OP_GTEQ: { + return ">="; + } break; + case FILTER_OP_EQ: { + return "=="; + } break; + case FILTER_OP_NE: { + return "!="; + } break; + case FILTER_OP_BEGINS_WITH: { + return "startswith"; + } break; + case FILTER_OP_ENDS_WITH: { + return "endswith"; + } break; + case FILTER_OP_CONTAINS: { + return "contains"; + } break; + case FILTER_OP_OR: { + return "or"; + } break; + case FILTER_OP_IN: { + return "in"; + } break; + case FILTER_OP_NOT_IN: { + return "not in"; + } break; + case FILTER_OP_AND: { + return "and"; + } break; + case FILTER_OP_IS_NULL: { + return "is null"; + } break; + case FILTER_OP_IS_NOT_NULL: { + return "is not null"; + } break; + } + PSP_COMPLAIN_AND_ABORT("Reached end of function"); + return ""; +} + +t_filter_op +str_to_filter_op(const std::string& str) { + if (str == "<") { + return t_filter_op::FILTER_OP_LT; + } + if (str == "<=") { + return t_filter_op::FILTER_OP_LTEQ; + } + if (str == ">") { + return t_filter_op::FILTER_OP_GT; + } + if (str == ">=") { + return t_filter_op::FILTER_OP_GTEQ; + } + if (str == "==") { + return t_filter_op::FILTER_OP_EQ; + } + if (str == "!=") { + return t_filter_op::FILTER_OP_NE; + } + if (str == "begins with" || str == "startswith") { + return t_filter_op::FILTER_OP_BEGINS_WITH; + } + if (str == "ends with" || str == "endswith") { + return t_filter_op::FILTER_OP_ENDS_WITH; + } + if (str == "in") { + return t_filter_op::FILTER_OP_IN; + } + if (str == "contains") { + return t_filter_op::FILTER_OP_CONTAINS; + } + if (str == "not in") { + return t_filter_op::FILTER_OP_NOT_IN; + } + if (str == "&" || str == "and") { + return t_filter_op::FILTER_OP_AND; + } + if (str == "|" || str == "or") { + return t_filter_op::FILTER_OP_OR; + } + if (str == "is null" || str == "is None") { + return t_filter_op::FILTER_OP_IS_NULL; + } + if (str == "is not null" || str == "is not None") { + return t_filter_op::FILTER_OP_IS_NOT_NULL; + } + + std::stringstream ss; + ss << "Unknown filter operator string: `" << str << "`" << std::endl; + PSP_COMPLAIN_AND_ABORT(ss.str()); + return t_filter_op::FILTER_OP_AND; +} + +t_sorttype +str_to_sorttype(const std::string& str) { + if (str == "none") { + return SORTTYPE_NONE; + } + if (str == "asc" || str == "col asc") { + return SORTTYPE_ASCENDING; + } + if (str == "desc" || str == "col desc") { + return SORTTYPE_DESCENDING; + } + if (str == "asc abs" || str == "col asc abs") { + return SORTTYPE_ASCENDING_ABS; + } + if (str == "desc abs" || str == "col desc abs") { + return SORTTYPE_DESCENDING_ABS; + } + + std::stringstream ss; + ss << "Unknown sort type string: `" << str << std::endl; + PSP_COMPLAIN_AND_ABORT(ss.str()); + return SORTTYPE_DESCENDING; +} + +std::string +sorttype_to_str(t_sorttype type) { + switch (type) { + case SORTTYPE_NONE: { + return "none"; + } break; + case SORTTYPE_ASCENDING: { + return "asc"; + } break; + case SORTTYPE_DESCENDING: { + return "desc"; + } break; + case SORTTYPE_ASCENDING_ABS: { + return "asc abs"; + } break; + case SORTTYPE_DESCENDING_ABS: { + return "desc abs"; + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unknown sort type"); + } + } +} + +t_aggtype +str_to_aggtype(const std::string& str) { + if (str == "distinct count" || str == "distinctcount" || str == "distinct" + || str == "distinct_count") { + return t_aggtype::AGGTYPE_DISTINCT_COUNT; + } + if (str == "sum") { + return t_aggtype::AGGTYPE_SUM; + } + if (str == "mul") { + return t_aggtype::AGGTYPE_MUL; + } + if (str == "avg" || str == "mean") { + return t_aggtype::AGGTYPE_MEAN; + } + if (str == "count") { + return t_aggtype::AGGTYPE_COUNT; + } + if (str == "weighted mean" || str == "weighted_mean") { + return t_aggtype::AGGTYPE_WEIGHTED_MEAN; + } + if (str == "unique") { + return t_aggtype::AGGTYPE_UNIQUE; + } + if (str == "any") { + return t_aggtype::AGGTYPE_ANY; + } + if (str == "q1") { + return t_aggtype::AGGTYPE_Q1; + } + if (str == "q3") { + return t_aggtype::AGGTYPE_Q3; + } + if (str == "median") { + return t_aggtype::AGGTYPE_MEDIAN; + } + if (str == "join") { + return t_aggtype::AGGTYPE_JOIN; + } + if (str == "div") { + return t_aggtype::AGGTYPE_SCALED_DIV; + } + if (str == "add") { + return t_aggtype::AGGTYPE_SCALED_ADD; + } + if (str == "dominant") { + return t_aggtype::AGGTYPE_DOMINANT; + } + if (str == "first by index" || str == "first") { + return t_aggtype::AGGTYPE_FIRST; + } + if (str == "last by index") { + return t_aggtype::AGGTYPE_LAST_BY_INDEX; + } + if (str == "last minus first") { + return t_aggtype::AGGTYPE_LAST_MINUS_FIRST; + } + if (str == "py_agg") { + return t_aggtype::AGGTYPE_PY_AGG; + } + if (str == "and") { + return t_aggtype::AGGTYPE_AND; + } + if (str == "or") { + return t_aggtype::AGGTYPE_OR; + } + if (str == "last" || str == "last_value") { + return t_aggtype::AGGTYPE_LAST_VALUE; + } + if (str == "max_by" || str == "max by") { + return t_aggtype::AGGTYPE_MAX_BY; + } + if (str == "min_by" || str == "min by") { + return t_aggtype::AGGTYPE_MIN_BY; + } + if (str == "max") { + return t_aggtype::AGGTYPE_MAX; + } + if (str == "min") { + return t_aggtype::AGGTYPE_MIN; + } + if (str == "high" || str == "high_water_mark") { + return t_aggtype::AGGTYPE_HIGH_WATER_MARK; + } + if (str == "low" || str == "low_water_mark") { + return t_aggtype::AGGTYPE_LOW_WATER_MARK; + } + if (str == "high minus low") { + return t_aggtype::AGGTYPE_HIGH_MINUS_LOW; + } + if (str == "sum abs" || str == "sum_abs") { + return t_aggtype::AGGTYPE_SUM_ABS; + } + if (str == "abs sum" || str == "abs_sum") { + return t_aggtype::AGGTYPE_ABS_SUM; + } + if (str == "sum not null" || str == "sum_not_null") { + return t_aggtype::AGGTYPE_SUM_NOT_NULL; + } + if (str == "mean by count" || str == "mean_by_count") { + return t_aggtype::AGGTYPE_MEAN_BY_COUNT; + } + if (str == "identity") { + return t_aggtype::AGGTYPE_IDENTITY; + } + if (str == "distinct leaf" || str == "distinct_leaf") { + return t_aggtype::AGGTYPE_DISTINCT_LEAF; + } + if (str == "pct sum parent" || str == "pct_sum_parent") { + return t_aggtype::AGGTYPE_PCT_SUM_PARENT; + } + if (str == "pct sum total" || str == "pct_sum_total" + || str == "pct sum grand total" || str == "pct_sum_grand_total") { + return t_aggtype::AGGTYPE_PCT_SUM_GRAND_TOTAL; + } + if (str.find("udf_combiner_") != std::string::npos) { + return t_aggtype::AGGTYPE_UDF_COMBINER; + } + if (str.find("udf_reducer_") != std::string::npos) { + return t_aggtype::AGGTYPE_UDF_REDUCER; + } + if (str == "var" || str == "variance") { + return t_aggtype::AGGTYPE_VARIANCE; + } + if (str == "stddev" || str == "standard deviation") { + return t_aggtype::AGGTYPE_STANDARD_DEVIATION; + } + if (str == "gmv") { + return t_aggtype::AGGTYPE_GMV; + } + + std::stringstream ss; + ss << "Encountered unknown aggregate operation: '" << str << "'" + << "\n"; + PSP_COMPLAIN_AND_ABORT(ss.str()); + // use any as default + return t_aggtype::AGGTYPE_ANY; +} + +t_aggtype +_get_default_aggregate(t_dtype dtype) { + t_aggtype agg_op; + switch (dtype) { + case DTYPE_FLOAT64: + case DTYPE_FLOAT32: + case DTYPE_UINT8: + case DTYPE_UINT16: + case DTYPE_UINT32: + case DTYPE_UINT64: + case DTYPE_INT8: + case DTYPE_INT16: + case DTYPE_INT32: + case DTYPE_INT64: { + agg_op = t_aggtype::AGGTYPE_SUM; + } break; + default: { + agg_op = t_aggtype::AGGTYPE_COUNT; + } + } + return agg_op; +} + +std::string +_get_default_aggregate_string(t_dtype dtype) { + std::string agg_op_str; + switch (dtype) { + case DTYPE_FLOAT64: + case DTYPE_FLOAT32: + case DTYPE_UINT8: + case DTYPE_UINT16: + case DTYPE_UINT32: + case DTYPE_UINT64: + case DTYPE_INT8: + case DTYPE_INT16: + case DTYPE_INT32: + case DTYPE_INT64: { + agg_op_str = "sum"; + } break; + default: { + agg_op_str = "count"; + } + } + return agg_op_str; +} + +std::string +get_status_descr(t_status status) { + switch (status) { + case STATUS_INVALID: { + return "i"; + } + case STATUS_VALID: { + return "v"; + } + case STATUS_CLEAR: { + return "c"; + } + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected status found"); + } + } + return ""; +} + +void +check_init(bool init, const char* file, std::int32_t line) { + PSP_VERBOSE_ASSERT(init, "touching uninited object"); +} + +bool +is_neq_transition(t_value_transition t) { + return t > VALUE_TRANSITION_EQ_TT; +} + +std::string +value_transition_to_str(t_value_transition t) { + switch (t) { + case VALUE_TRANSITION_EQ_FF: + return "VALUE_TRANSITION_EQ_FF"; + case VALUE_TRANSITION_EQ_TT: + return "VALUE_TRANSITION_EQ_TT"; + case VALUE_TRANSITION_NEQ_FT: + return "VALUE_TRANSITION_NEQ_FT"; + case VALUE_TRANSITION_NEQ_TF: + return "VALUE_TRANSITION_NEQ_TF"; + case VALUE_TRANSITION_NEQ_TT: + return "VALUE_TRANSITION_NEQ_TT"; + case VALUE_TRANSITION_NEQ_TDF: + return "VALUE_TRANSITION_NEQ_TDF"; + case VALUE_TRANSITION_NEQ_TDT: + return "VALUE_TRANSITION_NEQ_TDT"; + case VALUE_TRANSITION_NVEQ_FT: + return "VALUE_TRANSITION_NVEQ_FT"; + default: + break; + } + + PSP_COMPLAIN_AND_ABORT("Unexpected value transition."); + return ""; +} + +t_uindex +root_pidx() { + return std::numeric_limits::max(); +} + +bool +is_internal_colname(const std::string& c) { + return c == "psp_"; +} + +template +t_dtype +type_to_dtype() { + return DTYPE_NONE; +} + +template <> +t_dtype +type_to_dtype() { + return DTYPE_INT64; +} + +template <> +t_dtype +type_to_dtype() { + return DTYPE_INT32; +} + +template <> +t_dtype +type_to_dtype() { + return DTYPE_INT16; +} + +template <> +t_dtype +type_to_dtype() { + return DTYPE_INT8; +} + +template <> +t_dtype +type_to_dtype() { + return DTYPE_UINT64; +} + +template <> +t_dtype +type_to_dtype() { + return DTYPE_UINT32; +} + +template <> +t_dtype +type_to_dtype() { + return DTYPE_UINT16; +} + +template <> +t_dtype +type_to_dtype() { + return DTYPE_UINT8; +} + +template <> +t_dtype +type_to_dtype() { + return DTYPE_FLOAT64; +} + +template <> +t_dtype +type_to_dtype() { + return DTYPE_FLOAT32; +} + +template <> +t_dtype +type_to_dtype() { + return DTYPE_BOOL; +} + +template <> +t_dtype +type_to_dtype() { + return DTYPE_TIME; +} + +template <> +t_dtype +type_to_dtype() { + return DTYPE_DATE; +} + +template <> +t_dtype +type_to_dtype() { + return DTYPE_STR; +} + +template <> +t_dtype +type_to_dtype() { + return DTYPE_OBJECT; +} + +} // end namespace perspective + +namespace std { + +void +string_to_lower(string& str) { + transform(str.begin(), str.end(), str.begin(), ::tolower); +} + +} // namespace std diff --git a/rust/perspective-server/cpp/perspective/src/cpp/base_impl_linux.cpp b/rust/perspective-server/cpp/perspective/src/cpp/base_impl_linux.cpp new file mode 100644 index 0000000..72ced59 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/base_impl_linux.cpp @@ -0,0 +1,26 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#ifdef __linux__ +#include +#include + +namespace perspective { + +std::string +get_error_str() { + // handled by perror + return std::string(); +} + +} // end namespace perspective +#endif diff --git a/rust/perspective-server/cpp/perspective/src/cpp/base_impl_osx.cpp b/rust/perspective-server/cpp/perspective/src/cpp/base_impl_osx.cpp new file mode 100644 index 0000000..a43d5f5 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/base_impl_osx.cpp @@ -0,0 +1,26 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#ifdef __APPLE__ +#include +#include + +namespace perspective { + +std::string +get_error_str() { + // handled by perror + return {}; +} + +} // end namespace perspective +#endif diff --git a/rust/perspective-server/cpp/perspective/src/cpp/base_impl_wasm.cpp b/rust/perspective-server/cpp/perspective/src/cpp/base_impl_wasm.cpp new file mode 100644 index 0000000..51268ac --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/base_impl_wasm.cpp @@ -0,0 +1,26 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#ifdef PSP_ENABLE_WASM +#include +#include + +namespace perspective { + +std::string +get_error_str() { + // handled by perror + return std::string(); +} + +} // end namespace perspective +#endif diff --git a/rust/perspective-server/cpp/perspective/src/cpp/base_impl_win.cpp b/rust/perspective-server/cpp/perspective/src/cpp/base_impl_win.cpp new file mode 100644 index 0000000..e5c2bf6 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/base_impl_win.cpp @@ -0,0 +1,47 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#ifdef WIN32 +#include +#include + +namespace perspective { + +std::string +get_error_str() { + DWORD errid = ::GetLastError(); + + if (errid == 0) { + return std::string(); + } + + LPSTR buf = 0; + + size_t size = FormatMessageA( + FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM + | FORMAT_MESSAGE_IGNORE_INSERTS, + NULL, + errid, + MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), + (LPSTR)&buf, + 0, + NULL + ); + + std::string message(buf, size); + LocalFree(buf); + + return message; +} + +} // end namespace perspective +#endif \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/cpp/binding.cpp b/rust/perspective-server/cpp/perspective/src/cpp/binding.cpp new file mode 100644 index 0000000..1144201 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/binding.cpp @@ -0,0 +1,16 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +using namespace perspective; diff --git a/rust/perspective-server/cpp/perspective/src/cpp/binding_api.cpp b/rust/perspective-server/cpp/perspective/src/cpp/binding_api.cpp new file mode 100644 index 0000000..4d5ae6b --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/binding_api.cpp @@ -0,0 +1,180 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include "perspective/exports.h" +#include "perspective/server.h" +#include +#include +#include +#include +#include +#include + +#if HEAP_INSTRUMENTS +#include + +#define UNINSTRUMENTED_MALLOC(x) emscripten_builtin_malloc(x) +#define UNINSTRUMENTED_FREE(x) emscripten_builtin_free(x) +#else +#define UNINSTRUMENTED_MALLOC(x) malloc(x) +#define UNINSTRUMENTED_FREE(x) free(x) +#endif + +using namespace perspective::server; + +#pragma pack(push, 1) +struct EncodedApiResp { + void* data; + std::uint32_t size; + std::uint32_t client_id; +}; + +struct EncodedApiEntries { + std::uint32_t size; + EncodedApiResp* entries; +}; +#pragma pack(pop) + +void +encode_api_response( + const ProtoServerResp& msg, EncodedApiResp* encoded +) { + auto* data = static_cast(UNINSTRUMENTED_MALLOC(msg.data.size())); + std::copy(msg.data.begin(), msg.data.end(), data); + + encoded->data = data; + encoded->size = msg.data.size(); + encoded->client_id = msg.client_id; +} + +EncodedApiEntries* +encode_api_responses(const std::vector>& msgs) { + auto* encoded = static_cast( + UNINSTRUMENTED_MALLOC(sizeof(EncodedApiEntries)) + ); + encoded->entries = static_cast( + UNINSTRUMENTED_MALLOC(sizeof(EncodedApiResp) * msgs.size()) + ); + + encoded->size = msgs.size(); + auto* encoded_mem = encoded->entries; + for (int i = 0; i < msgs.size(); i++) { + encode_api_response(msgs[i], &encoded_mem[i]); + } + + return encoded; +} + +extern "C" { + +PERSPECTIVE_EXPORT +ProtoServer* +psp_new_server(bool realtime_mode) { + return new ProtoServer(realtime_mode); +} + +PERSPECTIVE_EXPORT +EncodedApiEntries* +psp_handle_request( + ProtoServer* server, + std::uint32_t client_id, + char* msg_ptr, + std::size_t msg_len +) { + std::string msg(msg_ptr, msg_len); + auto msgs = server->handle_request(client_id, msg); + return encode_api_responses(msgs); +} + +PERSPECTIVE_EXPORT +EncodedApiEntries* +psp_poll(ProtoServer* server) { + auto responses = server->poll(); + return encode_api_responses(responses); +} + +PERSPECTIVE_EXPORT +std::size_t +psp_residency_prepare(ProtoServer* server) { + return server->residency_prepare(); +} + +PERSPECTIVE_EXPORT +const char* +psp_residency_victim_fname(ProtoServer* server, std::size_t i) { + return server->residency_victim_fname(i); +} + +PERSPECTIVE_EXPORT +void +psp_residency_commit(ProtoServer* server) { + server->residency_commit(); +} + +PERSPECTIVE_EXPORT +std::uint32_t +psp_new_session(ProtoServer* server) { + return server->new_session(); +} + +PERSPECTIVE_EXPORT +void +psp_close_session(ProtoServer* server, std::uint32_t client_id) { + server->close_session(client_id); +} + +PERSPECTIVE_EXPORT +std::size_t +psp_alloc(std::size_t size) { + // We use this to allocate stack traces for instrumentation with heap + // profiling builds. + auto* mem = (char*)UNINSTRUMENTED_MALLOC(size); + return (size_t)mem; +} + +PERSPECTIVE_EXPORT +void +psp_free(void* ptr) { + UNINSTRUMENTED_FREE(ptr); +} + +PERSPECTIVE_EXPORT +void +psp_delete_server(void* proto_server) { + auto* server = (ProtoServer*)proto_server; + delete server; +} + +PERSPECTIVE_EXPORT +bool +psp_is_memory64() { +#if UINTPTR_MAX == 0xffffffff + return false; +#elif UINTPTR_MAX == 0xffffffffffffffff + return true; +#endif +} + +PERSPECTIVE_EXPORT +std::int32_t +psp_num_cpus() { + return arrow::GetCpuThreadPoolCapacity(); +} + +PERSPECTIVE_EXPORT +void +psp_set_num_cpus(std::int32_t num_cpus) { + PSP_CHECK_ARROW_STATUS(arrow::SetCpuThreadPoolCapacity(num_cpus)); + +} + +} // end extern "C" diff --git a/rust/perspective-server/cpp/perspective/src/cpp/column.cpp b/rust/perspective-server/cpp/perspective/src/cpp/column.cpp new file mode 100644 index 0000000..95408f0 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/column.cpp @@ -0,0 +1,1071 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +SUPPRESS_WARNINGS_VC(4505) +#include +#include +#include +#include +#include + +#include + +#include + +namespace perspective { +// TODO : move to delegated constructors in C++11 + +t_column_recipe::t_column_recipe() : m_vlenidx(0), m_size(0) {} + +t_column::t_column() : + m_dtype(DTYPE_NONE), + m_init(false), + m_isvlen(false), + m_data(nullptr), + m_vocab(nullptr), + m_status(nullptr), + m_size(0), + m_status_enabled(false), + m_from_recipe(false) + +{ + LOG_CONSTRUCTOR("t_column"); +} + +t_column::t_column(const t_column_recipe& recipe) : + m_dtype(recipe.m_dtype), + m_init(false), + m_size(recipe.m_size), + m_status_enabled(recipe.m_status_enabled), + m_from_recipe(true) + +{ + LOG_CONSTRUCTOR("t_column"); + m_data = std::make_shared(recipe.m_data); + m_isvlen = is_vlen_dtype(recipe.m_dtype); + + if (m_isvlen) { + m_vocab = std::make_shared(recipe); + } else { + m_vocab = std::make_shared(); + } + + if (m_status_enabled) { + m_status = std::make_shared(recipe.m_status); + } else { + m_status = std::make_shared(); + } +} + +void +t_column::column_copy_helper(const t_column& other) { + m_dtype = other.m_dtype; + m_init = false; + m_isvlen = other.m_isvlen; + // Use clone recipes so that copying a disk-backed column produces a column + // with its own independent backing files rather than aliasing `other`'s. + m_data = std::make_shared(other.m_data->get_clone_recipe()); + m_vocab = std::make_shared( + other.m_vocab->get_vlendata()->get_clone_recipe(), + other.m_vocab->get_extents()->get_clone_recipe() + ); + m_status = std::make_shared(other.m_status->get_clone_recipe()); + + m_size = other.m_size; + m_status_enabled = other.m_status_enabled; + m_from_recipe = false; +} + +t_column::t_column(const t_column& c) { + PSP_VERBOSE_ASSERT(this != &c, "Assigning self"); + column_copy_helper(c); + m_init = false; +} + +t_column& +t_column::operator=(const t_column& c) { + PSP_VERBOSE_ASSERT(this != &c, "Assigning self"); + column_copy_helper(c); + m_init = false; + return *this; +} + +t_column::t_column( + t_dtype dtype, bool missing_enabled, const t_lstore_recipe& a +) : + t_column(dtype, missing_enabled, a, a.m_capacity / get_dtype_size(dtype)) {} + +t_column::t_column( + t_dtype dtype, + bool missing_enabled, + const t_lstore_recipe& a, + t_uindex row_capacity +) : + m_dtype(dtype), + m_init(false), + m_size(0), + m_status_enabled(missing_enabled), + m_from_recipe(false) { + + m_data = std::make_shared(a); + // TODO make sure that capacity from a + // is not causing an overrreserve in places + // most notably in valid columns + LOG_CONSTRUCTOR("t_column"); + m_isvlen = is_vlen_dtype(m_dtype); + + if (is_vlen_dtype(dtype)) { + t_lstore_recipe vlendata_args(a); + t_lstore_recipe extents_args(a); + + vlendata_args.m_capacity = DEFAULT_EMPTY_CAPACITY; + extents_args.m_capacity = DEFAULT_EMPTY_CAPACITY; + + vlendata_args.m_colname = a.m_colname + std::string("_vlendata"); + extents_args.m_colname = a.m_colname + std::string("_extents"); + + m_vocab = std::make_shared(vlendata_args, extents_args); + } else { + m_vocab = std::make_shared(); + } + + if (is_status_enabled()) { + t_lstore_recipe missing_args(a); + missing_args.m_capacity = row_capacity; + + missing_args.m_colname = a.m_colname + std::string("_missing"); + m_status = std::make_shared(missing_args); + } else { + m_status = std::make_shared(); + } +} + +bool +t_column::is_status_enabled() const { + return m_status_enabled; +} + +void +t_column::init() { + LOG_INIT("t_column"); + + m_data->init(); + + if (is_vlen_dtype(m_dtype)) { + m_vocab->init(m_from_recipe); + } + + if (is_status_enabled()) { + m_status->init(); + } + + if (is_deterministic_sized(m_dtype)) { + m_elemsize = get_dtype_size(m_dtype); + } + m_init = true; + COLUMN_CHECK_VALUES(); +} + +t_column::~t_column() { LOG_DESTRUCTOR("t_column"); } + +t_dtype +t_column::get_dtype() const { + return m_dtype; +} + +// extend based on dtype size +void +t_column::extend_dtype(t_uindex idx) { + t_uindex new_extents = idx * get_dtype_size(m_dtype); + m_data->reserve(new_extents); + m_data->set_size(new_extents); + m_size = m_data->size() / get_dtype_size(m_dtype); + + if (is_status_enabled()) { + t_uindex sz = idx * get_dtype_size(DTYPE_UINT8); + m_status->reserve(sz); + m_status->set_size(sz); + } +} + +t_uindex +t_column::get_interned(const std::string& s) { + COLUMN_CHECK_STRCOL(); + return m_vocab->get_interned(s); +} +t_uindex +t_column::get_interned(const char* s) { + COLUMN_CHECK_STRCOL(); + return m_vocab->get_interned(s); +} + +template <> +void +t_column::push_back(const char* elem) { + COLUMN_CHECK_STRCOL(); + if (elem == nullptr) { + m_data->push_back(static_cast(0)); + return; + } + + t_uindex idx = m_vocab->get_interned(elem); + m_data->push_back(idx); + ++m_size; +} + +template <> +void +t_column::push_back(char* elem) { + COLUMN_CHECK_STRCOL(); + t_uindex idx = m_vocab->get_interned(elem); + m_data->push_back(idx); + ++m_size; +} + +template <> +void +t_column::push_back(std::string elem) { + COLUMN_CHECK_STRCOL(); + push_back(elem.c_str()); + ++m_size; +} + +template <> +void +t_column::push_back(const char* elem, t_status status) { + COLUMN_CHECK_STRCOL(); + push_back(elem); + m_status->push_back(status); + ++m_size; +} + +template <> +void +t_column::push_back(char* elem, t_status status) { + COLUMN_CHECK_STRCOL(); + push_back(elem); + m_status->push_back(status); + ++m_size; +} + +template <> +void +t_column::push_back(std::string elem, t_status status) { + COLUMN_CHECK_STRCOL(); + push_back(std::move(elem)); + m_status->push_back(status); + ++m_size; +} + +template <> +PERSPECTIVE_EXPORT void +t_column::push_back(t_tscalar elem) { + elem.m_type = m_dtype; + + switch (m_dtype) { + case DTYPE_NONE: { + PSP_COMPLAIN_AND_ABORT("Encountered none"); + } break; + case DTYPE_INT64: { + push_back(elem.get(), elem.m_status); + } break; + case DTYPE_INT32: { + push_back(elem.get(), elem.m_status); + } break; + case DTYPE_INT16: { + push_back(elem.get(), elem.m_status); + } break; + case DTYPE_INT8: { + push_back(elem.get(), elem.m_status); + } break; + case DTYPE_UINT64: { + push_back(elem.get(), elem.m_status); + } break; + case DTYPE_UINT32: { + push_back(elem.get(), elem.m_status); + } break; + case DTYPE_UINT16: { + push_back(elem.get(), elem.m_status); + } break; + case DTYPE_UINT8: { + push_back(elem.get(), elem.m_status); + } break; + case DTYPE_FLOAT64: { + push_back(elem.get(), elem.m_status); + } break; + case DTYPE_FLOAT32: { + push_back(elem.get(), elem.m_status); + } break; + case DTYPE_BOOL: { + push_back(elem.get(), elem.m_status); + } break; + case DTYPE_TIME: { + push_back(elem.get(), elem.m_status); + } break; + case DTYPE_DATE: { + push_back(elem.get(), elem.m_status); + } break; + case DTYPE_STR: { + push_back(elem.get(), elem.m_status); + } break; + case DTYPE_OBJECT: { + push_back(elem.get(), elem.m_status); + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected type"); + } + } + ++m_size; +} + +const t_lstore& +t_column::data_lstore() const { + return *m_data; +} + +t_uindex +t_column::size() const { + return m_size; +} + +void +t_column::set_size(t_uindex size) { + reserve(size); +#ifdef PSP_COLUMN_VERIFY + PSP_VERBOSE_ASSERT( + size * get_dtype_size(m_dtype) <= m_data->capacity(), + "Not enough space reserved for column" + ); +#endif + m_size = size; + m_data->set_size(m_elemsize * size); + + if (is_status_enabled()) { + m_status->set_size(get_dtype_size(DTYPE_UINT8) * size); + } +} + +void +t_column::reserve(t_uindex size) { + m_data->reserve(get_dtype_size(m_dtype) * size); + if (is_status_enabled()) { + m_status->reserve(get_dtype_size(DTYPE_UINT8) * size); + } +} + +// object storage, specialize only for std::uint64_t +template <> +void +t_column::object_copied(t_uindex ptr) const {} + +void +t_column::notify_object_copied(t_uindex idx) const { + // if (*get_nth_status(idx) == STATUS_VALID) + // object_copied(*(get_nth(idx))); +} + +template <> +void +t_column::object_cleared(t_uindex ptr) const {} + +void +t_column::notify_object_cleared(t_uindex idx) const { + // if (*get_nth_status(idx) == STATUS_VALID) + // object_cleared(*(get_nth(idx))); +} + +t_lstore* +t_column::_get_data_lstore() { + return m_data.get(); +} + +t_vocab* +t_column::_get_vocab() { + return m_vocab.get(); +} + +t_uindex +t_column::get_vlenidx() const { + return m_vocab->get_vlenidx(); +} + +t_tscalar +t_column::get_scalar(t_uindex idx) const { + COLUMN_CHECK_ACCESS(idx); + t_tscalar rv; + rv.clear(); + + switch (m_dtype) { + case DTYPE_NONE: { + } break; + case DTYPE_INT64: { + rv.set(*(m_data->get_nth(idx))); + } break; + case DTYPE_INT32: { + rv.set(*(m_data->get_nth(idx))); + } break; + case DTYPE_INT16: { + rv.set(*(m_data->get_nth(idx))); + } break; + case DTYPE_INT8: { + rv.set(*(m_data->get_nth(idx))); + } break; + + case DTYPE_UINT64: { + rv.set(*(m_data->get_nth(idx))); + } break; + case DTYPE_UINT32: { + rv.set(*(m_data->get_nth(idx))); + } break; + case DTYPE_UINT16: { + rv.set(*(m_data->get_nth(idx))); + } break; + case DTYPE_UINT8: { + rv.set(*(m_data->get_nth(idx))); + } break; + + case DTYPE_FLOAT64: { + rv.set(*(m_data->get_nth(idx))); + } break; + case DTYPE_FLOAT32: { + rv.set(*(m_data->get_nth(idx))); + } break; + case DTYPE_BOOL: { + rv.set(*(m_data->get_nth(idx))); + } break; + case DTYPE_TIME: { + const t_time::t_rawtype* v = + m_data->get_nth(idx); + rv.set(t_time(*v)); + } break; + case DTYPE_DATE: { + const t_date::t_rawtype* v = + m_data->get_nth(idx); + rv.set(t_date(*v)); + } break; + case DTYPE_STR: { + COLUMN_CHECK_STRCOL(); + const t_uindex* sidx = m_data->get_nth(idx); + rv.set(m_vocab->unintern_c(*sidx)); + } break; + case DTYPE_F64PAIR: { + const std::pair* pair = + m_data->get_nth>(idx); + rv.set(pair->first / pair->second); + } break; + case DTYPE_OBJECT: { + // set as uint64_t + rv.set(*(m_data->get_nth(idx))); + + // Maintain DTYPE info + rv.m_type = DTYPE_OBJECT; + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected type"); + } + } + + if (is_status_enabled()) { + rv.m_status = *get_nth_status(idx); + } + return rv; +} + +void +t_column::unset(t_uindex idx) { + clear(idx, STATUS_CLEAR); +} + +void +t_column::clear(t_uindex idx) { + clear(idx, STATUS_INVALID); +} + +void +t_column::clear(t_uindex idx, t_status status) { + switch (m_dtype) { + case DTYPE_STR: { + t_uindex v = 0; + set_nth(idx, v, status); + } break; + case DTYPE_TIME: + case DTYPE_FLOAT64: + case DTYPE_UINT64: + case DTYPE_INT64: { + set_nth(idx, 0, status); + } break; + case DTYPE_DATE: + case DTYPE_FLOAT32: + case DTYPE_UINT32: + case DTYPE_INT32: { + set_nth(idx, 0, status); + } break; + case DTYPE_UINT16: + case DTYPE_INT16: { + set_nth(idx, 0, status); + } break; + case DTYPE_BOOL: + case DTYPE_UINT8: + case DTYPE_INT8: { + set_nth(idx, 0, status); + } break; + case DTYPE_F64PAIR: { + std::pair v; + v.first = 0; + v.second = 0; + set_nth>(idx, v, status); + } break; + case DTYPE_OBJECT: { + set_nth(idx, 0, status); + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected type"); + } + } +} + +template <> +char* +t_column::get_nth(t_uindex idx) { + PSP_COMPLAIN_AND_ABORT("Unsafe operation detected"); + ++idx; + return nullptr; +} + +template <> +const char* +t_column::get_nth(t_uindex idx) const { + COLUMN_CHECK_ACCESS(idx); + COLUMN_CHECK_STRCOL(); + const auto* sidx = get_nth(idx); + return m_vocab->unintern_c(*sidx); +} + +// idx is in items +const t_status* +t_column::get_nth_status(t_uindex idx) const { + PSP_VERBOSE_ASSERT(is_status_enabled(), "Status not available for column"); + COLUMN_CHECK_ACCESS(idx); + auto* status = m_status->get_nth(idx); + return status; +} + +bool +t_column::is_valid(t_uindex idx) const { + PSP_VERBOSE_ASSERT(is_status_enabled(), "Status not available for column"); + COLUMN_CHECK_ACCESS(idx); + t_status status = *m_status->get_nth(idx); + return status == STATUS_VALID; +} + +bool +t_column::is_cleared(t_uindex idx) const { + PSP_VERBOSE_ASSERT(is_status_enabled(), "Status not available for column"); + COLUMN_CHECK_ACCESS(idx); + t_status status = *m_status->get_nth(idx); + return status == STATUS_CLEAR; +} + +template <> +void +t_column::set_nth(t_uindex idx, const char* elem) { + COLUMN_CHECK_STRCOL(); + set_nth_body(idx, elem, STATUS_VALID); +} + +template <> +void +t_column::set_nth(t_uindex idx, std::string elem) { + COLUMN_CHECK_STRCOL(); + set_nth(idx, elem.c_str(), STATUS_VALID); +} + +template <> +void +t_column::set_nth( + t_uindex idx, const char* elem, t_status status +) { + COLUMN_CHECK_STRCOL(); + set_nth_body(idx, elem, status); +} + +template <> +void +t_column::set_nth( + t_uindex idx, std::string elem, t_status status +) { + COLUMN_CHECK_STRCOL(); + set_nth(idx, elem.c_str(), status); +} + +void +t_column::set_valid(t_uindex idx, bool valid) { + set_status(idx, valid ? STATUS_VALID : STATUS_INVALID); +} + +void +t_column::set_status(t_uindex idx, t_status status) { + PSP_VERBOSE_ASSERT(is_status_enabled(), "Status not available for column"); + m_status->set_nth(idx, status); +} + +void +t_column::set_valid_range(t_uindex offset, t_uindex len) { + if (!is_status_enabled() || len == 0) { + return; + } + static_assert( + sizeof(t_status) == 1, "set_valid_range assumes a 1-byte t_status" + ); + std::memset(m_status->get_nth(offset), STATUS_VALID, len); +} + +void +t_column::set_scalar(t_uindex idx, t_tscalar value) { + COLUMN_CHECK_ACCESS(idx); + value.m_type = m_dtype; + + switch (m_dtype) { + case DTYPE_NONE: { + } break; + case DTYPE_INT64: { + std::int64_t tgt = value.get(); + set_nth(idx, tgt, value.m_status); + } break; + case DTYPE_INT32: { + std::int32_t tgt = value.get(); + set_nth(idx, tgt, value.m_status); + } break; + case DTYPE_INT16: { + std::int16_t tgt = value.get(); + set_nth(idx, tgt, value.m_status); + } break; + case DTYPE_INT8: { + std::int8_t tgt = value.get(); + set_nth(idx, tgt, value.m_status); + } break; + case DTYPE_UINT64: { + std::uint64_t tgt = value.get(); + set_nth(idx, tgt, value.m_status); + } break; + case DTYPE_UINT32: { + std::uint32_t tgt = value.get(); + set_nth(idx, tgt, value.m_status); + } break; + case DTYPE_UINT16: { + std::uint16_t tgt = value.get(); + set_nth(idx, tgt, value.m_status); + } break; + case DTYPE_UINT8: { + std::uint8_t tgt = value.get(); + set_nth(idx, tgt, value.m_status); + } break; + case DTYPE_FLOAT64: { + double tgt = value.get(); + set_nth(idx, tgt, value.m_status); + } break; + case DTYPE_FLOAT32: { + float tgt = value.get(); + set_nth(idx, tgt, value.m_status); + } break; + case DTYPE_BOOL: { + bool tgt = value.get(); + set_nth(idx, tgt, value.m_status); + } break; + case DTYPE_TIME: { + t_time tgt = value.get(); + set_nth(idx, tgt, value.m_status); + } break; + case DTYPE_DATE: { + t_date tgt = value.get(); + set_nth(idx, tgt, value.m_status); + } break; + case DTYPE_STR: { + COLUMN_CHECK_STRCOL(); + const char* tgt = value.get_char_ptr(); + std::string empty; + + if (tgt != nullptr) { + PSP_VERBOSE_ASSERT( + value.m_type == DTYPE_STR, + "Setting non string scalar on string column" + ); + set_nth(idx, tgt, value.m_status); + } else { + set_nth(idx, empty.c_str(), value.m_status); + } + } break; + case DTYPE_OBJECT: { + std::uint64_t tgt = value.get(); + set_nth(idx, tgt, value.m_status); + } + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected type"); + } + } +} + +bool +t_column::is_vlen() const { + return is_vlen_dtype(m_dtype); +} + +void +t_column::append(const t_column& other) { + PSP_VERBOSE_ASSERT(m_dtype == other.m_dtype, "Mismatched dtypes detected"); + if (is_vlen()) { + if (size() == 0) { + + m_data->fill(*other.m_data); + + if (other.is_status_enabled()) { + m_status->fill(*other.m_status); + } + + m_vocab->fill( + *(other.m_vocab->get_vlendata()), + *(other.m_vocab->get_extents()), + other.m_vocab->get_vlenidx() + ); + + set_size(other.size()); + m_vocab->rebuild_map(); + } else { + for (t_uindex idx = 0, loop_end = other.size(); idx < loop_end; + ++idx) { + const char* s = other.get_nth(idx); + push_back(s); + } + + if (is_status_enabled()) { + m_status->append(*other.m_status); + } + } + } else { + m_data->append(*other.m_data); + + if (is_status_enabled()) { + m_status->append(*other.m_status); + } + } + COLUMN_CHECK_VALUES(); +} + +void +t_column::clear() { + // clear out the data store + m_data->set_size(0); + if (m_dtype == DTYPE_STR) { + m_data->clear(); + } + if (is_status_enabled()) { + m_status->clear(); + } + m_size = 0; +} + +void +t_column::clear_objects() const { + for (t_uindex idx = 0, loop_end = size(); idx < loop_end; ++idx) { + notify_object_cleared(idx); + } +} + +void +t_column::pprint() const { + for (t_uindex idx = 0, loop_end = size(); idx < loop_end; ++idx) { + std::cout << idx << ": " << get_scalar(idx) << std::endl; + }; +} + +t_column_recipe +t_column::get_recipe() const { + t_column_recipe rval; + rval.m_dtype = m_dtype; + rval.m_data = m_data->get_recipe(); + rval.m_isvlen = is_vlen_dtype(m_dtype); + + if (rval.m_isvlen) { + rval.m_vlendata = m_vocab->get_vlendata()->get_recipe(); + rval.m_extents = m_vocab->get_extents()->get_recipe(); + } + + rval.m_status_enabled = m_status_enabled; + if (m_status_enabled) { + rval.m_status = m_status->get_recipe(); + } + + rval.m_vlenidx = get_vlenidx(); + rval.m_size = m_size; + return rval; +} + +void +t_column::copy_vocabulary(const t_column* other) { +#ifdef PSP_COLUMN_VERIFY + other->verify(); +#endif + COLUMN_CHECK_STRCOL(); + m_vocab->copy_vocabulary(*(other->m_vocab)); + COLUMN_CHECK_VALUES(); +} + +void +t_column::pprint_vocabulary() const { + if (!is_vlen_dtype(m_dtype)) { + return; + } + m_vocab->pprint_vocabulary(); +} + +std::shared_ptr +t_column::clone() const { + auto rval = std::make_shared(*this); + rval->init(); + rval->set_size(size()); + rval->m_data->fill(*m_data); + + if (rval->is_status_enabled()) { + rval->m_status->fill(*m_status); + } + + if (is_vlen_dtype(get_dtype())) { + rval->m_vocab->clone(*m_vocab); + } + +#ifdef PSP_COLUMN_VERIFY + rval->verify(); +#endif + return rval; +} + +std::shared_ptr +t_column::clone(const t_mask& mask) const { + if (mask.count() == size()) { + return clone(); + } + + auto rval = std::make_shared(*this); + rval->init(); + rval->set_size(mask.size()); + + rval->m_data->fill(*m_data, mask, get_dtype_size(get_dtype())); + + if (rval->is_status_enabled()) { + rval->m_status->fill(*m_status, mask, sizeof(t_status)); + } + + if (is_vlen_dtype(get_dtype())) { + rval->m_vocab->clone(*m_vocab); + } +#ifdef PSP_COLUMN_VERIFY + rval->verify(); +#endif + return rval; +} + +void +t_column::ensure_resident() { + if (m_data) { + m_data->ensure_resident(); + } + if (m_status) { + m_status->ensure_resident(); + } + if (m_isvlen && m_vocab) { + m_vocab->get_vlendata()->ensure_resident(); + m_vocab->get_extents()->ensure_resident(); + } +} + +void +t_column::copy_from(const t_column& other) { + set_size(other.size()); + m_data->fill(*other.m_data); + + if (is_status_enabled() && other.is_status_enabled()) { + m_status->fill(*other.m_status); + } + + if (is_vlen_dtype(get_dtype())) { + m_vocab->clone(*other.m_vocab); + } + +#ifdef PSP_COLUMN_VERIFY + verify(); +#endif +} + +void +t_column::valid_raw_fill() { + m_status->raw_fill(STATUS_VALID); +} + +void +t_column::invalid_raw_fill() { + m_status->raw_fill(STATUS_INVALID); +} + +void +t_column::copy( + const t_column* other, const std::vector& indices, t_uindex offset +) { + PSP_VERBOSE_ASSERT( + m_dtype == other->get_dtype(), "Cannot copy from diff dtype" + ); + + switch (m_dtype) { + case DTYPE_NONE: { + return; + } + case DTYPE_INT64: { + copy_helper(other, indices, offset); + } break; + case DTYPE_INT32: { + copy_helper(other, indices, offset); + } break; + case DTYPE_INT16: { + copy_helper(other, indices, offset); + } break; + case DTYPE_INT8: { + copy_helper(other, indices, offset); + } break; + case DTYPE_UINT64: { + copy_helper(other, indices, offset); + } break; + case DTYPE_UINT32: { + copy_helper(other, indices, offset); + } break; + case DTYPE_UINT16: { + copy_helper(other, indices, offset); + } break; + case DTYPE_UINT8: { + copy_helper(other, indices, offset); + } break; + case DTYPE_FLOAT64: { + copy_helper(other, indices, offset); + } break; + case DTYPE_FLOAT32: { + copy_helper(other, indices, offset); + } break; + case DTYPE_BOOL: { + copy_helper(other, indices, offset); + } break; + case DTYPE_TIME: { + copy_helper(other, indices, offset); + } break; + case DTYPE_DATE: { + copy_helper(other, indices, offset); + } break; + case DTYPE_STR: { + copy_helper(other, indices, offset); + } break; + case DTYPE_OBJECT: { + copy_helper(other, indices, offset); + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected type"); + } + } +} + +template <> +void +t_column::copy_helper( + const t_column* other, const std::vector& indices, t_uindex offset +) { + t_uindex eidx = + std::min(other->size(), static_cast(indices.size())); + reserve(eidx + offset); + + for (t_uindex idx = 0; idx < eidx; ++idx) { + set_scalar(offset + idx, other->get_scalar(indices[idx])); + } + COLUMN_CHECK_VALUES(); +} + +template <> +void +t_column::fill( + std::vector& vec, const t_uindex* bidx, const t_uindex* eidx +) const { + + PSP_VERBOSE_ASSERT(eidx - bidx > 0, "Invalid pointers passed in"); + + for (t_uindex idx = 0, loop_end = eidx - bidx; idx < loop_end; ++idx) + + { + vec[idx] = get_nth(*(bidx + idx)); + } + COLUMN_CHECK_VALUES(); +} + +void +t_column::verify() const { + if (is_vlen_dtype(m_dtype) && m_init) { + m_vocab->verify(); + } + + verify_size(); +} + +void +t_column::verify_size(t_uindex idx) const { + if (m_dtype == DTYPE_USER_FIXED) { + return; + } + + PSP_VERBOSE_ASSERT( + idx * get_dtype_size(m_dtype) <= m_data->capacity(), + "Not enough space reserved for column" + ); + + PSP_VERBOSE_ASSERT( + idx * get_dtype_size(m_dtype) <= m_data->capacity(), + "Not enough space reserved for column" + ); + + if (is_status_enabled()) { + PSP_VERBOSE_ASSERT( + idx * get_dtype_size(DTYPE_UINT8) <= m_status->capacity(), + "Not enough space reserved for column" + ); + } + + if (is_vlen_dtype(m_dtype)) { + m_vocab->verify_size(); + } +} + +void +t_column::verify_size() const { + verify_size(size()); +} + +const char* +t_column::unintern_c(t_uindex idx) const { + return m_vocab->unintern_c(idx); +} + +void +t_column::_rebuild_map() { + m_vocab->rebuild_map(); +} + +void +t_column::borrow_vocabulary(const t_column& o) { + m_vocab = const_cast(o).m_vocab; +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/comparators.cpp b/rust/perspective-server/cpp/perspective/src/cpp/comparators.cpp new file mode 100644 index 0000000..1e22857 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/comparators.cpp @@ -0,0 +1,16 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include + +namespace perspective {} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/compat.cpp b/rust/perspective-server/cpp/perspective/src/cpp/compat.cpp new file mode 100644 index 0000000..bb3e281 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/compat.cpp @@ -0,0 +1,38 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include + +#ifndef WIN32 +#include +#include +#endif + +namespace perspective { + +std::pair +file_size_pair(t_handle h) { + t_uindex sz = file_size(h); + return std::pair(upper32(sz), lower32(sz)); +} + +t_rfmapping::t_rfmapping() = default; + +t_rfmapping::t_rfmapping(t_handle fd, void* base, t_uindex size) : + m_fd(fd), + m_base(base), + m_size(size) {} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/compat_impl_linux.cpp b/rust/perspective-server/cpp/perspective/src/cpp/compat_impl_linux.cpp new file mode 100644 index 0000000..60df4e4 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/compat_impl_linux.cpp @@ -0,0 +1,231 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include + +#ifndef PSP_ENABLE_WASM +#ifdef __linux__ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { +static void map_file_internal_( + const std::string& fname, + t_fflag fflag, + t_fflag fmode, + t_fflag creation_disposition, + t_fflag mprot, + t_fflag mflag, + bool is_read, + t_uindex size, + t_rfmapping& out +); + +t_uindex +file_size(t_handle h) { + struct stat st; + t_index rcode = fstat(h, &st); + PSP_VERBOSE_ASSERT(rcode == 0, "Error in stat"); + return st.st_size; +} + +void +close_file(t_handle h) { + t_index rcode = close(h); + PSP_VERBOSE_ASSERT(rcode == 0, "Error closing file."); +} + +void +flush_mapping(void* base, t_uindex len) { + t_index rcode = msync(base, len, MS_SYNC); + PSP_VERBOSE_ASSERT(rcode != -1, "Error in msync"); +} + +t_rfmapping::~t_rfmapping() { + t_index rcode = munmap(m_base, m_size); + PSP_VERBOSE_ASSERT(rcode == 0, "munmap failed."); + + rcode = close(m_fd); + PSP_VERBOSE_ASSERT(rcode == 0, "Error closing file."); +} + +static void +map_file_internal_( + const std::string& fname, + t_fflag fflag, + t_fflag fmode, + t_fflag creation_disposition, + t_fflag mprot, + t_fflag mflag, + bool is_read, + t_uindex size, + t_rfmapping& out +) { + t_file_handle fh(open(fname.c_str(), fflag, fmode)); + + PSP_VERBOSE_ASSERT(fh.valid(), "Error opening file"); + + if (is_read) { + size = file_size(fh.value()); + } else { + t_index rcode = ftruncate(fh.value(), size); + PSP_VERBOSE_ASSERT(rcode >= 0, "ftruncate failed."); + } + + void* ptr = mmap(0, size, mprot, mflag, fh.value(), 0); + + PSP_VERBOSE_ASSERT(ptr != MAP_FAILED, "error in mmap"); + + t_handle fd = fh.value(); + fh.release(); + + out.m_fd = fd; + out.m_base = ptr; + out.m_size = size; +} + +void +map_file_read(const std::string& fname, t_rfmapping& out) { + map_file_internal_( + fname, + O_RDONLY, + S_IRUSR, + 0, // no disposition + PROT_READ, + MAP_SHARED, + true, + 0, + out + ); +} + +void +map_file_write(const std::string& fname, t_uindex size, t_rfmapping& out) { + return map_file_internal_( + fname, + O_RDWR | O_TRUNC | O_CREAT, + S_IRUSR | S_IWUSR, + 0, // no disposition + PROT_WRITE | PROT_READ, + MAP_SHARED, + false, + size, + out + ); +} + +std::int64_t +psp_curtime() { + struct timespec t; + std::int32_t rcode = clock_gettime(CLOCK_MONOTONIC, &t); + PSP_VERBOSE_ASSERT(rcode == 0, "Failure in clock_gettime"); + std::int64_t ns = t.tv_nsec + t.tv_sec * 1000000000; + return ns; +} + +std::int64_t +get_page_size() { + static std::int64_t pgsize = getpagesize(); + return pgsize; +} + +std::int64_t +psp_curmem() { + static double multiplier = getpagesize() / 1024000.; + + struct t_statm { + long int m_size, m_resident, m_share, m_text, m_lib, m_data, m_dt; + }; + + t_statm result; + + const char* statm_path = "/proc/self/statm"; + + FILE* f = fopen(statm_path, "r"); + if (!f) { + perror(statm_path); + abort(); + } + + PSP_VERBOSE_ASSERT( + fscanf( + f, + "%ld %ld %ld %ld %ld %ld %ld", + &result.m_size, + &result.m_resident, + &result.m_share, + &result.m_text, + &result.m_lib, + &result.m_data, + &result.m_dt + ) == 7, + "Failed to read memory size" + ); + fclose(f); + return result.m_resident * multiplier; +} + +void +set_thread_name(std::thread& thr, const std::string& name) { +#ifdef PSP_PARALLEL_FOR + auto handle = thr.native_handle(); + pthread_setname_np(handle, name.c_str()); +#endif +} + +void +set_thread_name(const std::string& name) { + // prctl(PR_SET_NAME, name.c_str(), 0, 0, 0); + PSP_COMPLAIN_AND_ABORT("Not implemented"); +} + +void +rmfile(const std::string& fname) { + unlink(fname.c_str()); +} + +void +launch_proc(const std::string& cmdline) { + PSP_COMPLAIN_AND_ABORT("Not implemented"); +} + +std::string +cwd() { + PSP_COMPLAIN_AND_ABORT("Not implemented"); + return ""; +} + +void* +psp_dbg_malloc(size_t size) { + PSP_COMPLAIN_AND_ABORT("Not implemented"); + return nullptr; +} + +void +psp_dbg_free(void* mem) { + PSP_COMPLAIN_AND_ABORT("Not implemented"); +} + +} // end namespace perspective +#endif +#endif \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/cpp/compat_impl_osx.cpp b/rust/perspective-server/cpp/perspective/src/cpp/compat_impl_osx.cpp new file mode 100644 index 0000000..44cf408 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/compat_impl_osx.cpp @@ -0,0 +1,234 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#ifndef PSP_ENABLE_WASM +#ifdef __APPLE__ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { +static void map_file_internal_( + const std::string& fname, + t_fflag fflag, + t_fflag fmode, + t_fflag creation_disposition, + t_fflag mprot, + t_fflag mflag, + bool is_read, + t_uindex size, + t_rfmapping& out +); + +t_uindex +file_size(t_handle h) { + struct stat st; + t_index rcode = fstat(h, &st); + PSP_VERBOSE_ASSERT(rcode, == 0, "Error in stat"); + return st.st_size; +} + +void +close_file(t_handle h) { + t_index rcode = close(h); + PSP_VERBOSE_ASSERT(rcode, == 0, "Error closing file."); +} + +void +flush_mapping(void* base, t_uindex len) { + t_index rcode = msync(base, len, MS_SYNC); + PSP_VERBOSE_ASSERT(rcode, != -1, "Error in msync"); +} + +t_rfmapping::~t_rfmapping() { + t_index rcode = munmap(m_base, m_size); + PSP_VERBOSE_ASSERT(rcode, == 0, "munmap failed."); + + rcode = close(m_fd); + PSP_VERBOSE_ASSERT(rcode, == 0, "Error closing file."); +} + +static void +map_file_internal_( + const std::string& fname, + t_fflag fflag, + t_fflag fmode, + t_fflag creation_disposition, + t_fflag mprot, + t_fflag mflag, + bool is_read, + t_uindex size, + t_rfmapping& out +) { + t_file_handle fh(open(fname.c_str(), fflag, fmode)); + + PSP_VERBOSE_ASSERT(fh.valid(), "Error opening file"); + + if (is_read) { + size = file_size(fh.value()); + } else { + t_index rcode = ftruncate(fh.value(), size); + PSP_VERBOSE_ASSERT(rcode, >= 0, "ftruncate failed."); + } + + void* ptr = mmap(nullptr, size, mprot, mflag, fh.value(), 0); + + PSP_VERBOSE_ASSERT(ptr, != MAP_FAILED, "error in mmap"); + + t_handle fd = fh.value(); + fh.release(); + + out.m_fd = fd; + out.m_base = ptr; + out.m_size = size; +} + +void +map_file_read(const std::string& fname, t_rfmapping& out) { + map_file_internal_( + fname, + O_RDONLY, + S_IRUSR, + 0, // no disposition + PROT_READ, + MAP_SHARED, + true, + 0, + out + ); +} + +void +map_file_write(const std::string& fname, t_uindex size, t_rfmapping& out) { + return map_file_internal_( + fname, + O_RDWR | O_TRUNC | O_CREAT, + S_IRUSR | S_IWUSR, + 0, // no disposition + PROT_WRITE | PROT_READ, + MAP_SHARED, + false, + size, + out + ); +} + +std::int64_t +psp_curtime() { + // CLOCK_MONOTONIC and clock_gettime are not implemented in OSX < 10.12, + // and it breaks builds on conda-forge. Because this method is only called + // in logging and trace functions that are not called in the codebase, + // deprecate this method for OSX and return 0. + + // struct timespec t; + // std::int32_t rcode = clock_gettime(CLOCK_MONOTONIC, &t); + // PSP_VERBOSE_ASSERT(rcode, == 0, "Failure in clock_gettime"); + // std::int64_t ns = t.tv_nsec + t.tv_sec * 1000000000; + return 0; +} + +std::int64_t +get_page_size() { + static std::int64_t pgsize = getpagesize(); + return pgsize; +} + +std::int64_t +psp_curmem() { + static double multiplier = getpagesize() / 1024000.; + + struct t_statm { + long int m_size, m_resident, m_share, m_text, m_lib, m_data, m_dt; + }; + + t_statm result; + + const char* statm_path = "/proc/self/statm"; + + FILE* f = fopen(statm_path, "r"); + if (f == nullptr) { + perror(statm_path); + abort(); + } + + PSP_VERBOSE_ASSERT( + fscanf( + f, + "%ld %ld %ld %ld %ld %ld %ld", + &result.m_size, + &result.m_resident, + &result.m_share, + &result.m_text, + &result.m_lib, + &result.m_data, + &result.m_dt + ) == 7, + "Failed to read memory size" + ); + fclose(f); + return result.m_resident * multiplier; +} + +void +set_thread_name(std::thread& thr, const std::string& name) { +#ifdef PSP_PARALLEL_FOR + pthread_setname_np(name.c_str()); +#endif +} + +void +set_thread_name(const std::string& name) { + // prctl(PR_SET_NAME, name.c_str(), 0, 0, 0); + PSP_COMPLAIN_AND_ABORT("Not implemented"); +} + +void +rmfile(const std::string& fname) { + unlink(fname.c_str()); +} + +void +launch_proc(const std::string& cmdline) { + PSP_COMPLAIN_AND_ABORT("Not implemented"); +} + +std::string +cwd() { + PSP_COMPLAIN_AND_ABORT("Not implemented"); + return ""; +} + +void* +psp_dbg_malloc(size_t size) { + PSP_COMPLAIN_AND_ABORT("Not implemented"); + return nullptr; +} + +void +psp_dbg_free(void* mem) { + PSP_COMPLAIN_AND_ABORT("Not implemented"); +} + +} // end namespace perspective +#endif +#endif diff --git a/rust/perspective-server/cpp/perspective/src/cpp/compat_impl_wasm.cpp b/rust/perspective-server/cpp/perspective/src/cpp/compat_impl_wasm.cpp new file mode 100644 index 0000000..174f3f8 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/compat_impl_wasm.cpp @@ -0,0 +1,168 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#ifdef PSP_ENABLE_WASM + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { +static void map_file_internal_( + const std::string& fname, + t_fflag fflag, + t_fflag fmode, + t_fflag creation_disposition, + t_fflag mprot, + t_fflag mflag, + bool is_read, + t_uindex size, + t_rfmapping& out +); + +t_uindex +file_size(t_handle h) { + struct stat st {}; + if (fstat(h, &st) != 0) { + return 0; + } + return static_cast(st.st_size); +} + +void +close_file(t_handle h) { + // On WASM (NO_FILESYSTEM) disk stores are backed by the OPFS/node-fs bridge + // keyed by filename, not real fds — `create_file()` returns a sentinel (1). + // Closing it issued `fd_close(1)` (stdout) on every disk-column free; never + // touch the std streams (0/1/2). Real fds (none under NO_FILESYSTEM) close. + if (h > 2) { + close(h); + } +} + +void +flush_mapping(void* base, t_uindex len) { + PSP_COMPLAIN_AND_ABORT("Not implemented"); +} + +t_rfmapping::~t_rfmapping() {} + +static void +map_file_internal_( + const std::string& fname, + t_fflag fflag, + t_fflag fmode, + t_fflag creation_disposition, + t_fflag mprot, + t_fflag mflag, + bool is_read, + t_uindex size, + t_rfmapping& out +) { + PSP_COMPLAIN_AND_ABORT("Not implemented"); +} + +void +map_file_read(const std::string& fname, t_rfmapping& out) { + PSP_COMPLAIN_AND_ABORT("Not implemented"); +} + +void +map_file_write(const std::string& fname, t_uindex size, t_rfmapping& out) { + PSP_COMPLAIN_AND_ABORT("Not implemented"); +} + +std::int64_t +psp_curtime() { + // CLOCK_MONOTONIC and clock_gettime are not implemented in OSX < 10.12, + // and it breaks builds on conda-forge. Because this method is only called + // in logging and trace functions that are not called in the codebase, + // deprecate this method for OSX and return 0. + + // struct timespec t; + // std::int32_t rcode = clock_gettime(CLOCK_MONOTONIC, &t); + // PSP_VERBOSE_ASSERT(rcode, == 0, "Failure in clock_gettime"); + // std::int64_t ns = t.tv_nsec + t.tv_sec * 1000000000; + return 0; +} + +std::int64_t +get_page_size() { + PSP_COMPLAIN_AND_ABORT("Not implemented"); +} + +std::int64_t +psp_curmem() { + PSP_COMPLAIN_AND_ABORT("Not implemented"); +} + +void +set_thread_name(std::thread& thr, const std::string& name) { + PSP_COMPLAIN_AND_ABORT("Not implemented"); +} + +void +set_thread_name(const std::string& name) { + // prctl(PR_SET_NAME, name.c_str(), 0, 0, 0); + PSP_COMPLAIN_AND_ABORT("Not implemented"); +} + +void +rmfile(const std::string& fname) { +#ifdef PSP_HAS_OPFS_BRIDGE + // Close the kept-open OPFS sync access handle and delete the backing file. + // (`unlink` is a no-op under `NO_FILESYSTEM`, and would leak the open handle + // the residency bridge holds for this file.) + psp_opfs_remove(fname.c_str()); +#else + unlink(fname.c_str()); +#endif +} + +void +launch_proc(const std::string& cmdline) { + PSP_COMPLAIN_AND_ABORT("Not implemented"); +} + +std::string +cwd() { + PSP_COMPLAIN_AND_ABORT("Not implemented"); + return ""; +} + +void* +psp_dbg_malloc(size_t size) { + PSP_COMPLAIN_AND_ABORT("Not implemented"); + return nullptr; +} + +void +psp_dbg_free(void* mem) { + PSP_COMPLAIN_AND_ABORT("Not implemented"); +} + +} // end namespace perspective + +#endif diff --git a/rust/perspective-server/cpp/perspective/src/cpp/compat_impl_win.cpp b/rust/perspective-server/cpp/perspective/src/cpp/compat_impl_win.cpp new file mode 100644 index 0000000..5ed84bf --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/compat_impl_win.cpp @@ -0,0 +1,312 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#ifndef PSP_ENABLE_WASM +#ifdef WIN32 +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +static void map_file_internal_( + const std::string& fname, + t_fflag fflag, + t_fflag fmode, + t_fflag creation_disposition, + t_fflag mprot, + t_fflag mflag, + bool is_read, + t_uindex size, + t_rfmapping& out +); + +t_uindex +file_size(t_handle h) { + LARGE_INTEGER sz; + BOOL rb = GetFileSizeEx(h, &sz); + PSP_VERBOSE_ASSERT(rb != 0, "Error getting filesize."); + return sz.QuadPart; +} + +void +close_file(t_handle h) { + BOOL rb = CloseHandle(h); + PSP_VERBOSE_ASSERT(rb != 0, "Error closing file"); +} + +void +flush_mapping(void* base, t_uindex len) { + BOOL rb = FlushViewOfFile(base, size_t(len)); + PSP_VERBOSE_ASSERT(rb != 0, "Error flushing view"); +} + +t_rfmapping::~t_rfmapping() { + BOOL rb = UnmapViewOfFile(m_base); + PSP_VERBOSE_ASSERT(rb != 0, "Error unmapping view"); + + close_file(m_fd); +} + +static void +map_file_internal_( + const std::string& fname, + t_fflag fflag, + t_fflag fmode, + t_fflag creation_disposition, + t_fflag mprot, + t_fflag mflag, + bool is_read, + t_uindex size, + t_rfmapping& out +) { + t_file_handle fh(CreateFile( + fname.c_str(), + fflag, + FILE_SHARE_READ, + 0, // security + creation_disposition, + FILE_ATTRIBUTE_NORMAL, + 0 // template file + )); + + PSP_VERBOSE_ASSERT(fh.valid(), "Error opening file"); + + if (is_read) { + size = file_size(fh.value()); + } else { + LARGE_INTEGER sz; + sz.QuadPart = size; + + auto rb = SetFilePointerEx(fh.value(), sz, 0, FILE_BEGIN); + PSP_VERBOSE_ASSERT(rb, "Error setting fpointer"); + + rb = SetEndOfFile(fh.value()); + PSP_VERBOSE_ASSERT(rb, "Error setting eof"); + } + + t_handle m = CreateFileMapping( + fh.value(), + 0, // default security + mprot, + upper32(size), + lower32(size), + 0 // anonymous mapping + ); + + PSP_VERBOSE_ASSERT(m != 0, "Error creating filemapping"); + + void* ptr = MapViewOfFile( + m, + mflag, + 0, // 0 offset + 0, // 0 offset + 0 // entire file + ); + + PSP_VERBOSE_ASSERT(ptr != 0, "Error mapping file"); + + // Handle is safe to close once view is + // created for it + CloseHandle(m); + + t_handle fd = fh.value(); + fh.release(); + + out.m_fd = fd; + out.m_base = ptr; + out.m_size = size; +} + +void +map_file_read(const std::string& fname, t_rfmapping& out) { + map_file_internal_( + fname, + GENERIC_READ, + 0, // unused + OPEN_EXISTING, + PAGE_READONLY, + FILE_MAP_READ, + true, + 0, + out + ); +} + +void +map_file_write(const std::string& fname, t_uindex size, t_rfmapping& out) { + map_file_internal_( + fname, + GENERIC_READ | GENERIC_WRITE, + 0, // unused + CREATE_ALWAYS, + PAGE_READWRITE, + FILE_MAP_WRITE, + false, + static_cast(size), + out + ); +} + +int64_t +psp_curtime() { + return GetTickCount() * static_cast(1000000); +} + +int64_t +psp_curmem() { + PROCESS_MEMORY_COUNTERS mem; + GetProcessMemoryInfo(GetCurrentProcess(), &mem, sizeof(mem)); + return mem.WorkingSetSize / 1024; +} + +#pragma pack(push, 8) +typedef struct t_win_thrstruct { + DWORD dwType; // Must be 0x1000. + LPCSTR szName; // Pointer to name (in user addr space). + DWORD dwThreadID; // Thread ID (-1=caller thread). + DWORD dwFlags; // Reserved for future use, must be zero. +} THREADNAME_INFO; +#pragma pack(pop) + +static void +set_thread_name_win(uint32_t thrid, const std::string& name) { + const DWORD MS_VC_EXCEPTION = 0x406D1388; + t_win_thrstruct thrstruct; + thrstruct.dwType = 0x1000; + thrstruct.szName = name.c_str(); + thrstruct.dwThreadID = thrid; + thrstruct.dwFlags = 0; + + SUPPRESS_WARNINGS_VC(6320 6322) + __try { + RaiseException( + MS_VC_EXCEPTION, + 0, + sizeof(thrstruct) / sizeof(ULONG_PTR), + (ULONG_PTR*)&thrstruct + ); + } __except (EXCEPTION_EXECUTE_HANDLER) {} + RESTORE_WARNINGS_VC() +} + +void +set_thread_name(std::thread& thr, const std::string& name) { + auto thrid = ::GetThreadId(static_cast(thr.native_handle())); + set_thread_name_win(thrid, name); +} + +void +set_thread_name(const std::string& name) { + set_thread_name_win(GetCurrentThreadId(), name); +} + +void +rmfile(const std::string& fname) { + DeleteFile(fname.c_str()); +} + +void +launch_proc(const std::string& cmdline) { + STARTUPINFO si; + PROCESS_INFORMATION pi; + + ZeroMemory(&si, sizeof(si)); + si.cb = sizeof(si); + ZeroMemory(&pi, sizeof(pi)); + + if (!CreateProcess( + NULL, // No module name (use command line) + const_cast(cmdline.c_str()), // Command line + NULL, // Process handle not inheritable + NULL, // Thread handle not inheritable + FALSE, // Set handle inheritance to FALSE + 0, // No creation flags + NULL, // Use parent's environment block + NULL, // Use parent's starting directory + &si, // Pointer to STARTUPINFO structure + &pi + ) // Pointer to PROCESS_INFORMATION structure + ) { + std::cout << "CreateProcess failed => " << GetLastError() << "\n"; + return; + } + + // Wait until child process exits. + WaitForSingleObject(pi.hProcess, INFINITE); + + // Close process and thread handles. + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); +} + +std::string +cwd() { + char path[FILENAME_MAX]; + auto rc = GetCurrentDirectory(FILENAME_MAX, path); + PSP_VERBOSE_ASSERT(rc, "Error in cwd"); + return std::string(path); +} + +int64_t +get_page_size() { + SYSTEM_INFO info; + GetSystemInfo(&info); + return info.dwPageSize; +} + +void* +psp_dbg_malloc(size_t size) { + SYSTEM_INFO sys_info; + GetSystemInfo(&sys_info); + auto page = 2 * sys_info.dwPageSize; + assert((page & (static_cast(page) - 1)) == 0); + auto rounded_size = + (size + static_cast(page) - 1) & (-static_cast(page)); + BYTE* start = (BYTE*)VirtualAlloc( + NULL, rounded_size + page, MEM_COMMIT, PAGE_READWRITE + ); + DWORD old_protect; + BOOL res = + VirtualProtect(start + rounded_size, page, PAGE_NOACCESS, &old_protect); + assert(res); + UNREFERENCED_PARAMETER(res); + return start + (rounded_size - size); +} + +void +psp_dbg_free(void* mem) { + VirtualFree(mem, 0, MEM_RELEASE); +} + +void* +psp_page_aligned_malloc(int64_t size) { + return _aligned_malloc( + static_cast(size), static_cast(get_page_size()) + ); +} + +void +psp_page_aligned_free(void* mem) { + _aligned_free(mem); +} + +} // end namespace perspective + +#endif +#endif \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/cpp/computed_expression.cpp b/rust/perspective-server/cpp/perspective/src/cpp/computed_expression.cpp new file mode 100644 index 0000000..155538b --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/computed_expression.cpp @@ -0,0 +1,687 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include + +#include + +namespace perspective { + +computed_function::bucket t_computed_expression_parser::BUCKET_FN = + computed_function::bucket(); + +computed_function::hour_of_day t_computed_expression_parser::HOUR_OF_DAY_FN = + computed_function::hour_of_day(); + +computed_function::percent_of t_computed_expression_parser::PERCENT_OF_FN = + computed_function::percent_of(); + +computed_function::inrange_fn t_computed_expression_parser::INRANGE_FN = + computed_function::inrange_fn(); + +computed_function::min_fn t_computed_expression_parser::MIN_FN = + computed_function::min_fn(); + +computed_function::max_fn t_computed_expression_parser::MAX_FN = + computed_function::max_fn(); + +computed_function::coalesce t_computed_expression_parser::COALESCE_FN = + computed_function::coalesce(); + +computed_function::contains t_computed_expression_parser::CONTAINS_FN = + computed_function::contains(); + +computed_function::diff3 t_computed_expression_parser::diff3 = + computed_function::diff3(); + +computed_function::norm3 t_computed_expression_parser::norm3 = + computed_function::norm3(); + +computed_function::cross_product3 t_computed_expression_parser::cross_product3 = + computed_function::cross_product3(); + +computed_function::dot_product3 t_computed_expression_parser::dot_product3 = + computed_function::dot_product3(); + +computed_function::length t_computed_expression_parser::LENGTH_FN = + computed_function::length(); + +computed_function::is_null t_computed_expression_parser::IS_NULL_FN = + computed_function::is_null(); + +computed_function::is_not_null t_computed_expression_parser::IS_NOT_NULL_FN = + computed_function::is_not_null(); + +computed_function::to_integer t_computed_expression_parser::TO_INTEGER_FN = + computed_function::to_integer(); + +computed_function::to_float t_computed_expression_parser::TO_FLOAT_FN = + computed_function::to_float(); + +computed_function::to_boolean t_computed_expression_parser::TO_BOOLEAN_FN = + computed_function::to_boolean(); + +computed_function::make_date t_computed_expression_parser::MAKE_DATE_FN = + computed_function::make_date(); + +computed_function::make_datetime + t_computed_expression_parser::MAKE_DATETIME_FN = + computed_function::make_datetime(); + +computed_function::random t_computed_expression_parser::RANDOM_FN = + computed_function::random(); + +t_tscalar t_computed_expression_parser::TRUE_SCALAR = mktscalar(true); + +t_tscalar t_computed_expression_parser::FALSE_SCALAR = mktscalar(false); + +/****************************************************************************** + * + * t_computed_expression + */ + +t_computed_expression::t_computed_expression( + std::string expression_alias, + std::string expression_string, + std::string parsed_expression_string, + const std::vector>& column_ids, + t_dtype dtype +) : + m_expression_alias(std::move(expression_alias)), + m_expression_string(std::move(expression_string)), + m_parsed_expression_string(std::move(parsed_expression_string)), + m_column_ids(column_ids), + m_dtype(dtype) {} + +struct t_computed_expression_cache { + t_computed_expression_cache() = default; + t_computed_expression_cache(const t_computed_expression_cache&) = delete; + t_computed_expression_cache& operator=(const t_computed_expression_cache&) = + delete; + t_computed_expression_cache(t_computed_expression_cache&&) = delete; + t_computed_expression_cache& operator=(t_computed_expression_cache&&) = + delete; + + std::shared_ptr m_current_source_table; + t_uindex m_row_idx{0}; + std::vector> m_values; + std::vector> m_columns; + std::vector m_input_dtypes; + exprtk::symbol_table m_sym_table; + exprtk::expression m_expr; + std::unique_ptr m_function_store; +}; + +t_computed_expression::~t_computed_expression() = default; + +void +t_computed_expression::compute( + const std::shared_ptr& source_table, + const t_gstate::t_mapping& pkey_map, + const std::shared_ptr& destination_table, + t_expression_vocab& vocab, + t_regex_mapping& regex_mapping +) const { + auto num_input_columns = m_column_ids.size(); + + bool needs_build = !m_cache; + if (!needs_build) { + for (t_uindex cidx = 0; cidx < num_input_columns; ++cidx) { + const std::string& column_name = m_column_ids[cidx].second; + if (source_table->get_column(column_name)->get_dtype() + != m_cache->m_input_dtypes[cidx]) { + needs_build = true; + break; + } + } + } + + if (needs_build) { + m_cache = std::make_unique(); + auto& cache = *m_cache; + + cache.m_current_source_table = source_table; + cache.m_sym_table.add_constants(); // pi, infinity, etc. + + // is_type_validator = false: we are computing values, not type-checking. + cache.m_function_store = std::make_unique( + vocab, + regex_mapping, + false, + cache.m_current_source_table, + pkey_map, + cache.m_row_idx + ); + cache.m_function_store->register_computed_functions(cache.m_sym_table); + + // Size exactly once; the symbol table binds a T* into each + // m_values[cidx].second, so this storage must never be reallocated. + cache.m_values.resize(num_input_columns); + cache.m_columns.resize(num_input_columns); + cache.m_input_dtypes.resize(num_input_columns); + + for (t_uindex cidx = 0; cidx < num_input_columns; ++cidx) { + const std::string& column_id = m_column_ids[cidx].first; + const std::string& column_name = m_column_ids[cidx].second; + t_dtype dtype = source_table->get_column(column_name)->get_dtype(); + + cache.m_input_dtypes[cidx] = dtype; + + t_tscalar rval; + rval.clear(); + rval.m_type = dtype; + + cache.m_values[cidx] = + std::pair(column_id, rval); + cache.m_sym_table.add_variable( + column_id, cache.m_values[cidx].second + ); + } + + cache.m_expr.register_symbol_table(cache.m_sym_table); + + // Load-bearing for compile-once: exprtk constant-folds an all-literal + // function call (e.g. intern('x'), concat('a','b')) to a literal node at + // compile time ONLY when the function reports no side effects. Our + // functions inherit the igeneric_function/ifunction default + // has_side_effects() == true and never call disable_has_side_effects(), + // so such calls are not folded and re-evaluate on every value(). If a + // future exprtk bump flips that default, the first call's result would + // be frozen into the cached AST -- revisit this cache if so. + if (!m_computed_expression_parser.m_parser->compile( + m_parsed_expression_string, cache.m_expr + )) { + std::stringstream ss; + ss << "[t_computed_expression::compute] Failed to parse " + "expression: `" + << m_parsed_expression_string << "`, failed with error: " + << m_computed_expression_parser.m_parser->error() << '\n'; + + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + } + + auto& cache = *m_cache; + + // Re-point the per-call inputs the compiled function objects read through + // (the source table differs across the master/flattened/delta/prev/current + // tables within a single update), and re-fetch this call's source columns. + cache.m_current_source_table = source_table; + for (t_uindex cidx = 0; cidx < num_input_columns; ++cidx) { + cache.m_columns[cidx] = + source_table->get_column(m_column_ids[cidx].second); + } + + // create or get output column using m_expression_alias + auto output_column = + destination_table->add_column_sptr(m_expression_alias, m_dtype, true); + auto num_rows = source_table->size(); + output_column->reserve(num_rows); + + for (t_uindex ridx = 0; ridx < num_rows; ++ridx) { + for (t_uindex cidx = 0; cidx < num_input_columns; ++cidx) { + cache.m_values[cidx].second.set( + cache.m_columns[cidx]->get_scalar(ridx) + ); + } + cache.m_row_idx = ridx; + + t_tscalar value = cache.m_expr.value(); + + if (!value.is_valid() || value.is_none()) { + output_column->clear(ridx); + continue; + } + + output_column->set_scalar(ridx, value); + } + + // order()'s accumulator must still be reset at the end of every call. + cache.m_function_store->clear_computed_function_state(); +}; + +const std::string& +t_computed_expression::get_expression_alias() const { + return m_expression_alias; +} + +const std::string& +t_computed_expression::get_expression_string() const { + return m_expression_string; +} + +const std::string& +t_computed_expression::get_parsed_expression_string() const { + return m_parsed_expression_string; +} + +const std::vector>& +t_computed_expression::get_column_ids() const { + return m_column_ids; +} + +t_dtype +t_computed_expression::get_dtype() const { + return m_dtype; +} + +/****************************************************************************** + * + * t_computed_expression_parser + */ + +t_computed_expression_parser::t_computed_expression_parser() { + // Change ExprTk's default compilation options to only check for correctness + // of brackets and sequences. ExprTk defaults will replace "true" and + // "false" with 1 and 0, which we don't want. Using the tokens "true" and + // "false" will raise a syntax error, which is the correct behavior. + + m_parser = std::make_shared>( + exprtk::parser::settings_t::e_joiner + + exprtk::parser::settings_t::e_numeric_check + + exprtk::parser::settings_t::e_bracket_check + + exprtk::parser::settings_t::e_sequence_check + // exprtk::parser::settings_t::e_commutative_check; + // exprtk::parser::settings_t::e_strength_reduction; + ); + + m_parser->settings() + .disable_control_structure( + exprtk::parser::settings_store::e_ctrl_repeat_loop + ) + .disable_base_function( + exprtk::parser::settings_store::e_bf_inrange + ) + .disable_base_function( + exprtk::parser::settings_store::e_bf_min + ) + .disable_base_function( + exprtk::parser::settings_store::e_bf_max + ); +} + +std::shared_ptr +t_computed_expression_parser::precompute( + const std::string& expression_alias, + const std::string& expression_string, + const std::string& parsed_expression_string, + const std::vector>& column_ids, + const std::shared_ptr& source_table, + const t_gstate::t_mapping& pkey_map, + const std::shared_ptr& schema, + t_expression_vocab& vocab, + t_regex_mapping& regex_mapping +) const { + exprtk::symbol_table sym_table; + sym_table.add_constants(); + + t_uindex row_idx = 0; + + // Create a function store, with is_type_validator set to true as we are + // just getting the output types. + t_computed_function_store function_store( + vocab, regex_mapping, true, source_table, pkey_map, row_idx + ); + function_store.register_computed_functions(sym_table); + + std::vector values; + + auto num_input_columns = column_ids.size(); + values.resize(num_input_columns); + + // Add a new scalar of the input column type to the symtable, which + // will use the scalar to validate the output type of the expression. + for (t_uindex cidx = 0; cidx < num_input_columns; ++cidx) { + const std::string& column_id = column_ids[cidx].first; + const std::string& column_name = column_ids[cidx].second; + + t_tscalar rval; + rval.clear(); + rval.m_type = schema->get_dtype(column_name); + + // If we are accessing string columns, the scalar CANNOT have a + // nullptr as a string - use the empty string that we interned + // in the gnode's expression vocab in t_gnode::init() at idx 0 + if (rval.m_type == DTYPE_STR) { + rval.set(vocab.get_empty_string()); + rval.m_status = STATUS_INVALID; + } + + values[cidx] = rval; + + sym_table.add_variable(column_id, values[cidx]); + } + + exprtk::expression expr_definition; + expr_definition.register_symbol_table(sym_table); + + if (!m_parser->compile(parsed_expression_string, expr_definition)) { + std::stringstream ss; + ss << "[t_computed_expression_parser::precompute] Failed to parse " + "expression: `" + << parsed_expression_string + << "`, failed with error: " << m_parser->error() << '\n'; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + + t_tscalar v = expr_definition.value(); + function_store.clear_computed_function_state(); + + return std::make_shared( + expression_alias, + expression_string, + parsed_expression_string, + column_ids, + v.get_dtype() + ); +} + +t_dtype +t_computed_expression_parser::get_dtype( + const std::string& expression_alias, + const std::string& expression_string, + const std::string& parsed_expression_string, + const std::vector>& column_ids, + const std::shared_ptr& source_table, + const t_gstate::t_mapping& pkey_map, + const t_schema& schema, + t_expression_error& error, + t_expression_vocab& vocab, + t_regex_mapping& regex_mapping +) const { + exprtk::symbol_table sym_table; + sym_table.add_constants(); + + std::vector values; + + t_uindex row_idx = 0; + + // Create a function store, with is_type_validator set to true as we are + // just validating the output types. + t_computed_function_store function_store( + vocab, regex_mapping, true, source_table, pkey_map, row_idx + ); + function_store.register_computed_functions(sym_table); + + auto num_input_columns = column_ids.size(); + values.resize(num_input_columns); + + // Add a new scalar of the input column type to the symtable, which + // will use the scalar to validate the output type of the expression. + for (t_uindex cidx = 0; cidx < num_input_columns; ++cidx) { + const std::string& column_id = column_ids[cidx].first; + const std::string& column_name = column_ids[cidx].second; + + if (!schema.has_column(column_name)) { + error.m_error_message = + ("Value Error - Input column \"" + column_name + + "\" does not exist."); + error.m_line = 0; + error.m_column = 0; + return DTYPE_NONE; + } + + t_tscalar rval; + + // clear() is important as it sets the status to valid and the + // underlying union value to 0, which prevents any reading of + // uninitialized values out of the union. + rval.clear(); + rval.m_type = schema.get_dtype(column_name); + + // If we are accessing string columns, the scalar CANNOT have a + // nullptr as a string - use the empty string that we interned + // in the gnode's expression vocab in t_gnode::init() at idx 0 + if (rval.m_type == DTYPE_STR) { + rval.set(vocab.get_empty_string()); + rval.m_status = STATUS_INVALID; + } + + values[cidx] = rval; + + sym_table.add_variable(column_id, values[cidx]); + } + + exprtk::expression expr_definition; + expr_definition.register_symbol_table(sym_table); + + if (!m_parser->compile(parsed_expression_string, expr_definition)) { + // Error count should always be above 0 if there is a compile error - + // We simply take the first error and return it. + if (m_parser->error_count() > 0) { + auto parser_error = m_parser->get_error(0); + + // Given an error object and an expression, `update_error` maps the + // error to a line and column number inside the expression. + exprtk::parser_error::update_error( + parser_error, parsed_expression_string + ); + + // Take the error message and strip the ExprTk error code + std::string error_message(parser_error.diagnostic); + + // strip the Exprtk error codes such as "ERR001 -" + error.m_error_message = + error_message.substr(error_message.find("- ") + 2); + + error.m_line = parser_error.line_no; + error.m_column = parser_error.column_no; + } else { + // If for whatever reason the error count is at 0, output a generic + // parser error so that we report back a valid error object. + error.m_error_message = "Parser Error"; + error.m_line = 0; + error.m_column = 0; + } + + return DTYPE_NONE; + } + + t_tscalar v = expr_definition.value(); + t_dtype dtype = v.get_dtype(); + + function_store.clear_computed_function_state(); + + if (v.m_status == STATUS_CLEAR || dtype == DTYPE_NONE) { + error.m_error_message = + "Type Error - inputs do not resolve to a valid expression."; + error.m_line = 0; + error.m_column = 0; + return DTYPE_NONE; + } + + return dtype; +} + +t_validated_expression_map::t_validated_expression_map() = default; + +void +t_validated_expression_map::add_expression( + const std::string& expression_alias, const std::string& type_string +) { + // If the expression is in the error map, it should be removed as it is now + // valid. This can happen when validating multiple expressions with the same + // alias where the first instance is invalid, and the next occurence is + // valid, and so on. This maintains the property that an expression will + // exist in the schema OR the error map. + auto error_iter = m_expression_errors.find(expression_alias); + + if (error_iter != m_expression_errors.end()) { + m_expression_errors.erase(error_iter); + } + + m_expression_schema[expression_alias] = type_string; +} + +void +t_validated_expression_map::add_error( + const std::string& expression_alias, t_expression_error& error +) { + // If the expression is in the schema, it should be removed as it is now + // invalid. This can happen when validating multiple expressions with the + // same alias where the first instance is valid, and the next occurence is + // invalid, and so on. This maintains the property that an expression will + // exist in the schema OR the error map. + auto schema_iter = m_expression_schema.find(expression_alias); + + if (schema_iter != m_expression_schema.end()) { + m_expression_schema.erase(schema_iter); + } + + m_expression_errors[expression_alias] = error; +} + +std::map +t_validated_expression_map::get_expression_schema() const { + return m_expression_schema; +} + +std::map +t_validated_expression_map::get_expression_errors() const { + return m_expression_errors; +} + +t_computed_function_store::t_computed_function_store( + t_expression_vocab& vocab, + t_regex_mapping& regex_mapping, + bool is_type_validator, + const std::shared_ptr& source_table, + const t_gstate::t_mapping& pkey_map, + t_uindex& row_idx +) : + m_day_of_week_fn(computed_function::day_of_week(vocab, is_type_validator)), + m_month_of_year_fn( + computed_function::month_of_year(vocab, is_type_validator) + ), + m_intern_fn(computed_function::intern(vocab, is_type_validator)), + m_concat_fn(computed_function::concat(vocab, is_type_validator)), + m_order_fn(computed_function::order(is_type_validator)), + m_upper_fn(computed_function::upper(vocab, is_type_validator)), + m_lower_fn(computed_function::lower(vocab, is_type_validator)), + m_to_string_fn(computed_function::to_string(vocab, is_type_validator)), + m_match_fn(computed_function::match(regex_mapping)), + m_match_all_fn(computed_function::match_all(regex_mapping)), + m_search_fn( + computed_function::search(vocab, regex_mapping, is_type_validator) + ), + m_indexof_fn(computed_function::indexof(regex_mapping)), + m_substring_fn(computed_function::substring(vocab, is_type_validator)), + m_replace_fn( + computed_function::replace(vocab, regex_mapping, is_type_validator) + ), + m_replace_all_fn( + computed_function::replace_all(vocab, regex_mapping, is_type_validator) + ), + m_index_fn(computed_function::index(pkey_map, source_table, row_idx)), + m_col_fn( + computed_function::col(vocab, is_type_validator, source_table, row_idx) + ), + m_vlookup_fn(computed_function::vlookup( + vocab, is_type_validator, source_table, row_idx + )) {} + +void +t_computed_function_store::register_computed_functions( + exprtk::symbol_table& sym_table +) { + // General/numeric functions + sym_table.add_function("bucket", t_computed_expression_parser::BUCKET_FN); + sym_table.add_reserved_function( + "inrange", t_computed_expression_parser::INRANGE_FN + ); + sym_table.add_reserved_function( + "min", t_computed_expression_parser::MIN_FN + ); + sym_table.add_reserved_function( + "max", t_computed_expression_parser::MAX_FN + ); + sym_table.add_function( + "coalesce", t_computed_expression_parser::COALESCE_FN + ); + sym_table.add_function( + "contains", t_computed_expression_parser::CONTAINS_FN + ); + sym_table.add_reserved_function( + "diff3", t_computed_expression_parser::diff3 + ); + sym_table.add_reserved_function( + "norm3", t_computed_expression_parser::norm3 + ); + sym_table.add_reserved_function( + "cross_product3", t_computed_expression_parser::cross_product3 + ); + sym_table.add_reserved_function( + "dot_product3", t_computed_expression_parser::dot_product3 + ); + sym_table.add_function( + "percent_of", t_computed_expression_parser::PERCENT_OF_FN + ); + sym_table.add_function("is_null", t_computed_expression_parser::IS_NULL_FN); + sym_table.add_function( + "is_not_null", t_computed_expression_parser::IS_NOT_NULL_FN + ); + sym_table.add_function("random", t_computed_expression_parser::RANDOM_FN); + + // Date/datetime functions + sym_table.add_function( + "hour_of_day", t_computed_expression_parser::HOUR_OF_DAY_FN + ); + sym_table.add_function("day_of_week", m_day_of_week_fn); + sym_table.add_function("month_of_year", m_month_of_year_fn); + sym_table.add_function("today", computed_function::today); + sym_table.add_function("now", computed_function::now); + + // String functions + sym_table.add_function("intern", m_intern_fn); + sym_table.add_function("concat", m_concat_fn); + sym_table.add_function("order", m_order_fn); + sym_table.add_function("upper", m_upper_fn); + sym_table.add_function("lower", m_lower_fn); + sym_table.add_function("length", t_computed_expression_parser::LENGTH_FN); + + // Type conversion functions + sym_table.add_function( + "integer", t_computed_expression_parser::TO_INTEGER_FN + ); + sym_table.add_function("float", t_computed_expression_parser::TO_FLOAT_FN); + sym_table.add_function( + "boolean", t_computed_expression_parser::TO_BOOLEAN_FN + ); + sym_table.add_function("date", t_computed_expression_parser::MAKE_DATE_FN); + sym_table.add_function( + "datetime", t_computed_expression_parser::MAKE_DATETIME_FN + ); + sym_table.add_function("string", m_to_string_fn); + + // Regex functions + sym_table.add_function("match", m_match_fn); + sym_table.add_function("match_all", m_match_all_fn); + sym_table.add_function("search", m_search_fn); + sym_table.add_function("indexof", m_indexof_fn); + sym_table.add_function("substring", m_substring_fn); + sym_table.add_function("replace", m_replace_fn); + sym_table.add_function("replace_all", m_replace_all_fn); + sym_table.add_function("index", m_index_fn); + sym_table.add_function("col", m_col_fn); + sym_table.add_function("vlookup", m_vlookup_fn); + + // And scalar constants + sym_table.add_constant("True", t_computed_expression_parser::TRUE_SCALAR); + sym_table.add_constant("False", t_computed_expression_parser::FALSE_SCALAR); +} + +void +t_computed_function_store::clear_computed_function_state() { + m_order_fn.clear_order_map(); +} + +} // end namespace perspective \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/cpp/computed_function.cpp b/rust/perspective-server/cpp/perspective/src/cpp/computed_function.cpp new file mode 100644 index 0000000..0512039 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/computed_function.cpp @@ -0,0 +1,2397 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include + +#include + +namespace perspective::computed_function { + +intern::intern(t_expression_vocab& expression_vocab, bool is_type_validator) : + exprtk::igeneric_function("S"), + m_expression_vocab(expression_vocab), + m_is_type_validator(is_type_validator) { + // The sentinel is a string scalar pointing to an empty string + // that is stored in `expression_vocab`. Previously we were using + // string scalars with nullptrs to type check, which caused nullptr + // errors in strcmp(). + t_tscalar sentinel; + sentinel.clear(); + sentinel.set(m_expression_vocab.get_empty_string()); + sentinel.m_status = STATUS_INVALID; + m_sentinel = sentinel; +} + +intern::~intern() = default; + +t_tscalar +intern::operator()(t_parameter_list parameters) { + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_STR; + t_generic_type& gt = parameters[0]; + + // intern('abc') - with a scalar string + t_string_view temp_string(gt); + std::string temp_str = std::string(temp_string.begin(), temp_string.end()); + + // Intern the string into the vocabulary. + rval.set(m_expression_vocab.intern(temp_str)); + return rval; +} + +concat::concat(t_expression_vocab& expression_vocab, bool is_type_validator) : + m_expression_vocab(expression_vocab), + m_is_type_validator(is_type_validator) { + t_tscalar sentinel; + sentinel.clear(); + sentinel.set(m_expression_vocab.get_empty_string()); + sentinel.m_status = STATUS_INVALID; + + m_sentinel = sentinel; +} + +concat::~concat() = default; + +t_tscalar +concat::operator()(t_parameter_list parameters) { + std::string result; + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_STR; + + if (parameters.empty()) { + return rval; + } + + for (auto i = 0; i < parameters.size(); ++i) { + t_generic_type& gt = parameters[i]; + + if (gt.type == t_generic_type::e_scalar) { + t_scalar_view temp(gt); + t_tscalar temp_scalar = temp(); + + // Invalid type + if (temp_scalar.get_dtype() != DTYPE_STR + || temp_scalar.m_status == STATUS_CLEAR) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + // current param is the right type and we are type checking, + // so move on to the next param + if (m_is_type_validator) { + continue; + } + + // no longer in type-checking - return if the param is invalid. + if (!temp_scalar.is_valid()) { + return rval; + } + + // Read the string out from the scalar + result += temp_scalar.to_string(); + } else { + // An invalid call. + rval.m_status = STATUS_CLEAR; + return rval; + } + } + + // We know the params are valid - so return the sentinel string value. + if (result.empty() || m_is_type_validator) { + return m_sentinel; + } + + rval.set(m_expression_vocab.intern(result)); + return rval; +} + +upper::upper(t_expression_vocab& expression_vocab, bool is_type_validator) : + exprtk::igeneric_function("T"), + m_expression_vocab(expression_vocab), + m_is_type_validator(is_type_validator) { + t_tscalar sentinel; + sentinel.clear(); + sentinel.set(m_expression_vocab.get_empty_string()); + sentinel.m_status = STATUS_INVALID; + + m_sentinel = sentinel; +} + +upper::~upper() = default; + +t_tscalar +upper::operator()(t_parameter_list parameters) { + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_STR; + std::string temp_str; + + if (parameters.size() != 1) { + return rval; + } + + t_generic_type& gt = parameters[0]; + t_scalar_view temp(gt); + t_tscalar temp_scalar = temp(); + + if (temp_scalar.get_dtype() != DTYPE_STR + || temp_scalar.m_status == STATUS_CLEAR) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + if (!temp_scalar.is_valid()) { + return rval; + } + + temp_str = temp_scalar.to_string(); + + // don't try to intern an empty string as it will throw an error, but + // by this point we know the params are valid - so return the sentinel + // string value. + if (temp_str.empty() || m_is_type_validator) { + return m_sentinel; + } + + boost::to_upper(temp_str); + + rval.set(m_expression_vocab.intern(temp_str)); + return rval; +} + +lower::lower(t_expression_vocab& expression_vocab, bool is_type_validator) : + exprtk::igeneric_function("T"), + m_expression_vocab(expression_vocab), + m_is_type_validator(is_type_validator) { + t_tscalar sentinel; + sentinel.clear(); + sentinel.set(m_expression_vocab.get_empty_string()); + sentinel.m_status = STATUS_INVALID; + + m_sentinel = sentinel; +} + +lower::~lower() = default; + +t_tscalar +lower::operator()(t_parameter_list parameters) { + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_STR; + std::string temp_str; + + if (parameters.size() != 1) { + return rval; + } + + t_generic_type& gt = parameters[0]; + t_scalar_view temp(gt); + t_tscalar temp_scalar = temp(); + + if (temp_scalar.get_dtype() != DTYPE_STR + || temp_scalar.m_status == STATUS_CLEAR) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + if (!temp_scalar.is_valid() || temp_scalar.is_none()) { + return rval; + } + + temp_str = temp_scalar.to_string(); + // don't try to intern an empty string as it will throw an error, but + // by this point we know the params are valid - so return the sentinel + // string value. + if (temp_str.empty() || m_is_type_validator) { + return m_sentinel; + } + + boost::to_lower(temp_str); + + rval.set(m_expression_vocab.intern(temp_str)); + return rval; +} + +length::length() : exprtk::igeneric_function("T") {} + +length::~length() = default; + +t_tscalar +length::operator()(t_parameter_list parameters) { + std::string temp_str; + t_tscalar rval; + rval.clear(); + + // return a float to be compatible with the most comparisons - even + // though a uint would make most sense here, if this column returned a + // uint comparisons to numeric literals and other numeric columns would + // always be false, as comparisons are False across types. + rval.m_type = DTYPE_FLOAT64; + + if (parameters.size() != 1) { + return rval; + } + + t_generic_type& gt = parameters[0]; + t_scalar_view temp(gt); + t_tscalar temp_scalar = temp(); + + if (temp_scalar.get_dtype() != DTYPE_STR + || temp_scalar.m_status == STATUS_CLEAR) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + if (!temp_scalar.is_valid() || temp_scalar.is_none()) { + return rval; + } + + temp_str = temp_scalar.to_string(); + rval.set(static_cast(temp_str.length())); + return rval; +} + +order::order(bool is_type_validator) : + m_order_map({}), + m_order_idx(0), + m_is_type_validator(is_type_validator) { + t_tscalar sentinel; + sentinel.clear(); + sentinel.m_type = DTYPE_FLOAT64; + m_sentinel = sentinel; +} + +order::~order() = default; + +t_tscalar +order::operator()(t_parameter_list parameters) { + t_tscalar rval; + rval.clear(); + + // return a float to be compatible with the most comparisons - even + // though a uint would make most sense here, if this column returned a + // uint comparisons to numeric literals and other numeric columns would + // always be false, as comparisons are False across types. + rval.m_type = DTYPE_FLOAT64; + + if (parameters.size() <= 1) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + // Validate that the input column is of string type + const t_generic_type& input_scalar_gt = parameters[0]; + + // if first input is not scalar, return + if (input_scalar_gt.type != t_generic_type::e_scalar) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + t_scalar_view input_scalar_view(input_scalar_gt); + t_tscalar input_scalar = input_scalar_view(); + + // Invalid type + if (input_scalar.get_dtype() != DTYPE_STR + || input_scalar.m_status == STATUS_CLEAR) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + // generate the map if not generated + if (m_order_map.empty()) { + // Validate order parameters + for (auto i = 1; i < parameters.size(); ++i) { + // Because all strings are interned, there should be no string + // literals passed to any functions. + const t_generic_type& gt = parameters[i]; + + // Make sure all params are scalars too + if (gt.type != t_generic_type::e_scalar) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + t_scalar_view temp(gt); + t_tscalar temp_scalar = temp(); + + // Invalid type + if (temp_scalar.get_dtype() != DTYPE_STR + || temp_scalar.m_status == STATUS_CLEAR) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + // current param is the right type and we are type checking, + // so move on to the next param + if (m_is_type_validator) { + continue; + } + + // no longer in type-checking - return if the param is invalid. + if (!temp_scalar.is_valid()) { + return rval; + } + + // params[0] is the column, params[1] onward are sort params + if (i > 0) { + // Read the string param and assign to the order map, and + // then increment the internal counter. + std::string value = temp_scalar.to_string(); + m_order_map[value] = m_order_idx; + m_order_idx++; + } + } + } + + // We know the params are valid - so return the sentinel float value. + if (m_is_type_validator) { + return m_sentinel; + } + + // read from the map or add the param to the map + t_generic_type& gt = parameters[0]; + t_scalar_view temp(gt); + t_tscalar col_value = temp(); + + // Don't calculate order for invalid scalars. + if (!col_value.is_valid()) { + return rval; + } + + std::string key = col_value.to_string(); + auto found = m_order_map.find(key); + + if (found != m_order_map.end()) { + rval.set(found->second); + } else { + // if the value is not in the map, then put it at the end so that + // natural sorting can be applied. + rval.set(m_order_idx); + } + + return rval; +} + +void +order::clear_order_map() { + m_order_map.clear(); + m_order_idx = 0; +} + +match::match(t_regex_mapping& regex_mapping) : + exprtk::igeneric_function("TS"), + m_regex_mapping(regex_mapping) {} + +match::~match() = default; + +t_tscalar +match::operator()(t_parameter_list parameters) { + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_BOOL; + + // Parameters already validated + t_scalar_view str_view(parameters[0]); + t_string_view pattern_view(parameters[1]); + + t_tscalar str = str_view(); + std::string match_pattern = + std::string(pattern_view.begin(), pattern_view.end()); + + // Type-check: only operate on strings, and pattern must be > size 0 + if (str.get_dtype() != DTYPE_STR || str.m_status == STATUS_CLEAR + || match_pattern.empty()) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + RE2* compiled_pattern = m_regex_mapping.intern(match_pattern); + + if (compiled_pattern == nullptr) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + if (!str.is_valid()) { + return rval; + } + + const std::string& match_string = str.to_string(); + + // Get the pattern from the map and perform the match. + rval.set(RE2::PartialMatch(match_string, *compiled_pattern)); + + return rval; +} + +match_all::match_all(t_regex_mapping& regex_mapping) : + exprtk::igeneric_function("TS"), + m_regex_mapping(regex_mapping) {} + +match_all::~match_all() = default; + +t_tscalar +match_all::operator()(t_parameter_list parameters) { + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_BOOL; + + // Parameters already validated + t_scalar_view str_view(parameters[0]); + t_string_view pattern_view(parameters[1]); + + t_tscalar str = str_view(); + std::string match_pattern = + std::string(pattern_view.begin(), pattern_view.end()); + + // Type-check: only operate on strings, and pattern must be > size 0 + if (str.get_dtype() != DTYPE_STR || str.m_status == STATUS_CLEAR + || match_pattern.empty()) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + RE2* compiled_pattern = m_regex_mapping.intern(match_pattern); + + if (compiled_pattern == nullptr) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + if (!str.is_valid()) { + return rval; + } + + const std::string& match_string = str.to_string(); + + // Get the pattern from the map and perform the match. + rval.set(RE2::FullMatch(match_string, *compiled_pattern)); + + return rval; +} + +contains::contains() : exprtk::igeneric_function("TS") {} + +contains::~contains() = default; + +t_tscalar +contains::operator()(t_parameter_list parameters) { + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_BOOL; + + t_scalar_view str_view(parameters[0]); + t_string_view needle_view(parameters[1]); + + t_tscalar str = str_view(); + std::string needle = + std::string(needle_view.begin(), needle_view.end()); + + if (str.get_dtype() != DTYPE_STR || str.m_status == STATUS_CLEAR) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + if (!str.is_valid()) { + return rval; + } + + rval.set(str.to_string().find(needle) != std::string::npos); + return rval; +} + +search::search( + t_expression_vocab& expression_vocab, + t_regex_mapping& regex_mapping, + bool is_type_validator +) : + exprtk::igeneric_function("TS"), + m_expression_vocab(expression_vocab), + m_regex_mapping(regex_mapping), + m_is_type_validator(is_type_validator) {} + +search::~search() = default; + +t_tscalar +search::operator()(t_parameter_list parameters) { + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_STR; + + // Parameters already validated + t_scalar_view str_view(parameters[0]); + t_string_view pattern_view(parameters[1]); + + t_tscalar str = str_view(); + std::string match_pattern = + std::string(pattern_view.begin(), pattern_view.end()); + + // Type-check: only operate on strings, and pattern must be > size 0 + if (str.get_dtype() != DTYPE_STR || str.m_status == STATUS_CLEAR + || match_pattern.empty()) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + RE2* compiled_pattern = m_regex_mapping.intern(match_pattern); + + if (compiled_pattern == nullptr + || compiled_pattern->NumberOfCapturingGroups() < 1) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + if (!str.is_valid() || m_is_type_validator) { + return rval; + } + + re2::StringPiece result; + const std::string& match_string = str.to_string(); + bool found = RE2::PartialMatch(match_string, *compiled_pattern, &result); + + // Return null if no match, or if the match is size 0 - don't allow + // empty strings back out. + if (!found || result.empty()) { + return rval; + } + + rval.set(m_expression_vocab.intern(result.ToString())); + + return rval; +} + +indexof::indexof(t_regex_mapping& regex_mapping) : + exprtk::igeneric_function("TSV"), + m_regex_mapping(regex_mapping) {} + +indexof::~indexof() = default; + +t_tscalar +indexof::operator()(t_parameter_list parameters) { + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_BOOL; + + // Parameters already validated + t_scalar_view str_view(parameters[0]); + t_string_view pattern_view(parameters[1]); + t_vector_view output_vector(parameters[2]); + + t_tscalar str = str_view(); + std::string match_pattern = + std::string(pattern_view.begin(), pattern_view.end()); + + // Type-check: only operate on strings, and pattern must be > size 0, + // and output vector must be big enough to hold the output + if (str.get_dtype() != DTYPE_STR || str.m_status == STATUS_CLEAR + || match_pattern.empty() || output_vector.size() < 2) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + RE2* compiled_pattern = m_regex_mapping.intern(match_pattern); + + if (compiled_pattern == nullptr + || compiled_pattern->NumberOfCapturingGroups() < 1) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + if (!str.is_valid()) { + return rval; + } + + re2::StringPiece result; + const std::string& match_string = str.to_string(); + bool found = RE2::PartialMatch(match_string, *compiled_pattern, &result); + + if (!found) { + // no-op on the input vector + rval.set(false); + return rval; + } + + // re2::StringPiece::data is a ptr into the string being matched, + // so we can pointer math the start and end index of the match + std::size_t start_idx = result.data() - match_string.data(); + std::size_t end_idx = start_idx + result.size() - 1; + + if (start_idx < 0 || end_idx < 0 || end_idx >= match_string.size() + || (start_idx > end_idx)) { + rval.set(false); + return rval; + } + + t_tscalar start_scalar; + t_tscalar end_scalar; + + start_scalar.set(static_cast(start_idx)); + end_scalar.set(static_cast(end_idx)); + + output_vector[0] = start_scalar; + output_vector[1] = end_scalar; + + rval.set(true); + return rval; +} + +substring::substring( + t_expression_vocab& expression_vocab, bool is_type_validator +) : + m_expression_vocab(expression_vocab), + m_is_type_validator(is_type_validator) {} + +substring::~substring() = default; + +t_tscalar +substring::operator()(t_parameter_list parameters) { + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_STR; + auto num_params = parameters.size(); + + // substring(string, start_idx) or substring(string, start_idx, length) + if (num_params != 2 && num_params != 3) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + std::string search_string; + + // Must be able to check for negative indices from the user - + // std::size_t is unsigned so a user passing in -1 is automatically + // cast to 0, which is incorrect as we want to detect the -1 and return + // null. + std::int64_t start_idx; + + // npos == all chars until end of the string + std::int64_t substring_length = std::string::npos; + + for (auto i = 0; i < num_params; ++i) { + const t_generic_type& gt = parameters[i]; + + if (gt.type == t_generic_type::e_scalar) { + t_scalar_view temp_scalar_view(gt); + t_tscalar temp_scalar = temp_scalar_view(); + + // type check - first param must be string, 2nd and 3rd param + // must be numeric, all must be valid + t_dtype dtype = temp_scalar.get_dtype(); + + if ((i == 0 && dtype != DTYPE_STR) + || (i != 0 && !temp_scalar.is_numeric()) + || temp_scalar.m_status == STATUS_CLEAR) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + // Only check for types - bad indices will always return null + // but be valid expressions. + if (m_is_type_validator || !temp_scalar.is_valid()) { + return rval; + } + + // Passed type checking, assign values + if (i == 0) { + search_string = temp_scalar.to_string(); + } else if (i == 1) { + start_idx = temp_scalar.to_double(); + } else if (i == 2) { + substring_length = temp_scalar.to_double(); + } + } else { + // called with invalid params - exit + rval.m_status = STATUS_CLEAR; + return rval; + } + } + + // done type checking + if (m_is_type_validator) { + return rval; + } + + std::size_t length = search_string.length(); + + // Value check: strings cannot be 0 length, indices must be valid + if (length == 0 || start_idx < 0 + || (num_params == 3 && substring_length < 0) || start_idx >= length + || (substring_length != std::string::npos + && start_idx + substring_length > length)) { + return rval; + } + + rval.set(m_expression_vocab.intern( + search_string.substr(start_idx, substring_length) + )); + + return rval; +} + +replace::replace( + t_expression_vocab& expression_vocab, + t_regex_mapping& regex_mapping, + bool is_type_validator +) : + exprtk::igeneric_function("TS?"), + m_expression_vocab(expression_vocab), + m_regex_mapping(regex_mapping), + m_is_type_validator(is_type_validator) {} + +replace::~replace() = default; + +t_tscalar +replace::operator()(t_parameter_list parameters) { + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_STR; + + // the string to be replaced + t_scalar_view string_scalar_view(parameters[0]); + t_tscalar string_scalar = string_scalar_view(); + + // the replace pattern + t_string_view pattern_view(parameters[1]); + std::string match_pattern = + std::string(pattern_view.begin(), pattern_view.end()); + + // replacer can be a string literal, for the string '' as intern does + // not pick up on empty strings but we need to be able to replace + // with empty string. Thus, type-check replacer before continuing. + const t_generic_type& gt(parameters[2]); + t_tscalar replacer_scalar; + + if (gt.type == t_generic_type::e_scalar) { + t_scalar_view replacer_view(gt); + replacer_scalar = replacer_view(); + } else if (gt.type == t_generic_type::e_string) { + t_string_view replacer_view(gt); + std::string replacer_str = + std::string(replacer_view.begin(), replacer_view.end()); + + // only the empty string should be passed in as a string literal, + // all other strings must be interned first. + if (!replacer_str.empty()) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + // use the empty string from vocab + replacer_scalar.set(m_expression_vocab.get_empty_string()); + } else { + rval.m_status = STATUS_CLEAR; + return rval; + } + + if (string_scalar.m_type != DTYPE_STR || replacer_scalar.m_type != DTYPE_STR + || match_pattern.empty()) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + // typecheck the regex + RE2* compiled_pattern = m_regex_mapping.intern(match_pattern); + + if (compiled_pattern == nullptr) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + // done with type_checking + if (m_is_type_validator) { + return rval; + } + + // make a copy of search_str, as replace() will mutate it and we + // don't want to mutate the string in the vocab + std::string search_string = string_scalar.to_string(); + + if (search_string.empty()) { + return rval; + } + + // but we can take a reference to the replacer + const std::string& replacer_string = replacer_scalar.to_string(); + re2::StringPiece replacer(replacer_string); + + bool replaced = + RE2::Replace(&(search_string), *(compiled_pattern), replacer); + + if (!replaced) { + // Return the original result if the replacement didn't happen + return string_scalar; + } + + // Or the string with the replacement set + rval.set(m_expression_vocab.intern(search_string)); + + return rval; +} + +replace_all::replace_all( + t_expression_vocab& expression_vocab, + t_regex_mapping& regex_mapping, + bool is_type_validator +) : + exprtk::igeneric_function("TS?"), + m_expression_vocab(expression_vocab), + m_regex_mapping(regex_mapping), + m_is_type_validator(is_type_validator) {} + +replace_all::~replace_all() = default; + +t_tscalar +replace_all::operator()(t_parameter_list parameters) { + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_STR; + + // the string to be replaced + t_scalar_view string_scalar_view(parameters[0]); + t_tscalar string_scalar = string_scalar_view(); + + // the replace pattern + t_string_view pattern_view(parameters[1]); + std::string match_pattern = + std::string(pattern_view.begin(), pattern_view.end()); + + // replacer can be a string literal, for the string '' as intern does + // not pick up on empty strings but we need to be able to replace + // with empty string. Thus, type-check replacer before continuing. + const t_generic_type& gt(parameters[2]); + t_tscalar replacer_scalar; + + if (gt.type == t_generic_type::e_scalar) { + t_scalar_view replacer_view(gt); + replacer_scalar = replacer_view(); + } else if (gt.type == t_generic_type::e_string) { + t_string_view replacer_view(gt); + std::string replacer_str = + std::string(replacer_view.begin(), replacer_view.end()); + + // only the empty string should be passed in as a string literal, + // all other strings must be interned first. + if (!replacer_str.empty()) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + // use the empty string from vocab + replacer_scalar.set(m_expression_vocab.get_empty_string()); + } else { + rval.m_status = STATUS_CLEAR; + return rval; + } + + if (string_scalar.m_type != DTYPE_STR || replacer_scalar.m_type != DTYPE_STR + || match_pattern.empty()) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + // typecheck the regex + RE2* compiled_pattern = m_regex_mapping.intern(match_pattern); + + if (compiled_pattern == nullptr) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + // done with type_checking + if (m_is_type_validator) { + return rval; + } + + // make a copy of search_str, as replace() will mutate it and we + // don't want to mutate the string in the vocab + std::string search_string = string_scalar.to_string(); + + if (search_string.empty()) { + return rval; + } + + // but we can take a reference to the replacer + const std::string& replacer_string = replacer_scalar.to_string(); + re2::StringPiece replacer(replacer_string); + + std::size_t replaced = + RE2::GlobalReplace(&(search_string), *(compiled_pattern), replacer); + + if (replaced == 0) { + // Return the original result if the replacement didn't happen + return string_scalar; + } + + // Or the string with the replacement set + rval.set(m_expression_vocab.intern(search_string)); + + return rval; +} + +hour_of_day::hour_of_day() : exprtk::igeneric_function("T") {} + +hour_of_day::~hour_of_day() = default; + +t_tscalar +hour_of_day::operator()(t_parameter_list parameters) { + t_tscalar val; + t_tscalar rval; + rval.clear(); + + // return a float to be compatible with the most comparisons - even + // though a uint would make most sense here, if this column returned a + // uint comparisons to numeric literals and other numeric columns would + // always be false, as comparisons are False across types. + rval.m_type = DTYPE_FLOAT64; + + t_generic_type& gt = parameters[0]; + t_scalar_view temp(gt); + t_tscalar temp_scalar = temp(); + + t_dtype dtype = temp_scalar.get_dtype(); + bool valid_dtype = dtype == DTYPE_DATE || dtype == DTYPE_TIME; + + if (!valid_dtype || temp_scalar.m_status == STATUS_CLEAR) { + rval.m_status = STATUS_CLEAR; + } + + if (!temp_scalar.is_valid()) { + return rval; + } + + val.set(temp_scalar); + + if (val.get_dtype() == DTYPE_TIME) { + // Break down the UTC timestamp in UTC — the host timezone must not + // affect engine results (native and WASM builds must agree) + std::tm t = gmtime_from_epoch_ms(val.to_int64()); + + // Get the hour from the resulting `std::tm` + rval.set(static_cast(t.tm_hour)); + } else { + // Hour of day for date column is always 0 + rval.set(0.0); + } + + return rval; +} + +const std::string days_of_week[7] = { + "1 Sunday", + "2 Monday", + "3 Tuesday", + "4 Wednesday", + "5 Thursday", + "6 Friday", + "7 Saturday" +}; + +const std::string months_of_year[12] = { + "01 January", + "02 February", + "03 March", + "04 April", + "05 May", + "06 June", + "07 July", + "08 August", + "09 September", + "10 October", + "11 November", + "12 December" +}; + +day_of_week::day_of_week( + t_expression_vocab& expression_vocab, bool is_type_validator +) : + exprtk::igeneric_function("T"), + m_expression_vocab(expression_vocab), + m_is_type_validator(is_type_validator) { + t_tscalar sentinel; + sentinel.clear(); + sentinel.set(m_expression_vocab.get_empty_string()); + sentinel.m_status = STATUS_INVALID; + + m_sentinel = sentinel; +} + +day_of_week::~day_of_week() = default; + +t_tscalar +day_of_week::operator()(t_parameter_list parameters) { + t_tscalar val; + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_STR; + + t_generic_type& gt = parameters[0]; + t_scalar_view temp(gt); + t_tscalar temp_scalar = temp(); + t_dtype dtype = temp_scalar.get_dtype(); + bool valid_dtype = dtype == DTYPE_DATE || dtype == DTYPE_TIME; + + if (!valid_dtype || temp_scalar.m_status == STATUS_CLEAR) { + rval.m_status = STATUS_CLEAR; + } + + if (!temp_scalar.is_valid()) { + return rval; + } + + val.set(temp_scalar); + + if (m_is_type_validator) { + return m_sentinel; + } + + std::string result; + + if (val.get_dtype() == DTYPE_TIME) { + // Break down the UTC timestamp in UTC — the host timezone must not + // affect engine results (native and WASM builds must agree) + std::tm t = gmtime_from_epoch_ms(val.to_int64()); + + // Get the weekday from the resulting `std::tm` + result = days_of_week[t.tm_wday]; + } else { + // Retrieve the `t_date` struct from the scalar + t_date date_val = val.get(); + + // Construct a `date::year_month_day` value + date::year year{date_val.year()}; + + // date::month is [1-12], whereas `t_date.month()` is [0-11] + date::month month{static_cast(date_val.month()) + 1}; + date::day day{static_cast(date_val.day())}; + date::year_month_day ymd(year, month, day); + + // Construct a `date::year_month_weekday` from `date::sys_days` + // since epoch + auto weekday = + date::year_month_weekday(ymd).weekday_indexed().weekday(); + + result = days_of_week[(weekday - date::Sunday).count()]; + } + + rval.set(m_expression_vocab.intern(result)); + return rval; +} + +month_of_year::month_of_year( + t_expression_vocab& expression_vocab, bool is_type_validator +) : + exprtk::igeneric_function("T"), + m_expression_vocab(expression_vocab), + m_is_type_validator(is_type_validator) { + t_tscalar sentinel; + sentinel.clear(); + sentinel.set(m_expression_vocab.get_empty_string()); + sentinel.m_status = STATUS_INVALID; + + m_sentinel = sentinel; +} + +month_of_year::~month_of_year() = default; + +t_tscalar +month_of_year::operator()(t_parameter_list parameters) { + t_tscalar val; + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_STR; + + t_generic_type& gt = parameters[0]; + t_scalar_view temp(gt); + t_tscalar temp_scalar = temp(); + + t_dtype dtype = temp_scalar.get_dtype(); + bool valid_dtype = dtype == DTYPE_DATE || dtype == DTYPE_TIME; + + if (!valid_dtype || temp_scalar.m_status == STATUS_CLEAR) { + rval.m_status = STATUS_CLEAR; + } + + if (!temp_scalar.is_valid()) { + return rval; + } + + val.set(temp_scalar); + + if (m_is_type_validator) { + return m_sentinel; + } + + std::string result; + + if (val.get_dtype() == DTYPE_TIME) { + // Break down the UTC timestamp in UTC — the host timezone must not + // affect engine results (native and WASM builds must agree) + std::tm t = gmtime_from_epoch_ms(val.to_int64()); + + // Get the month string and write into the output column + result = months_of_year[t.tm_mon]; + } else { + t_date date_val = val.get(); + + // `t_date.month()` is [0-11] + std::int32_t month = date_val.month(); + result = months_of_year[month]; + } + + // Intern the string pointer so it does not fall out of reference and + // cause a memory error. + rval.set(m_expression_vocab.intern(result)); + return rval; +} + +tsl::hopscotch_map bucket::UNIT_MAP = { + {'s', t_date_bucket_unit::SECONDS}, + {'m', t_date_bucket_unit::MINUTES}, + {'h', t_date_bucket_unit::HOURS}, + {'D', t_date_bucket_unit::DAYS}, + {'W', t_date_bucket_unit::WEEKS}, + {'M', t_date_bucket_unit::MONTHS}, + {'Y', t_date_bucket_unit::YEARS} +}; + +bucket::bucket() : exprtk::igeneric_function("T?") {} + +bucket::~bucket() = default; + +t_tscalar +bucket::operator()(t_parameter_list parameters) { + t_tscalar val; + t_tscalar unit; + t_tscalar rval; + rval.clear(); + + // Parameters are already validated in the constructor by Exprtk + t_generic_type& gt_val = parameters[0]; // always a scalar + t_generic_type& gt_unit = parameters[1]; // scalar or string + + // Convert value to scalar + t_scalar_view temp_val(gt_val); + val.set(temp_val()); + + // Bucket by numeric value + t_scalar_view temp_unit2(gt_unit); + unit.set(temp_unit2()); + + if (val.is_numeric()) { + rval.m_type = DTYPE_FLOAT64; + + // type-check + if (!unit.is_numeric() || val.m_status == STATUS_CLEAR + || unit.m_status == STATUS_CLEAR) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + if (!val.is_valid() || !unit.is_valid()) { + return rval; + } + + rval.set(floor(val.to_double() / unit.to_double()) * unit.to_double()); + + return rval; + } + + // Must be a datetime - second parameter is a string + const auto unit_str = std::string(unit.get()); + char temp_unit = 0; + const auto len = unit_str.size(); + + unsigned long multiplicity; + t_date_bucket_unit date_unit; + if (len == 0) { + // Does not type-check! + rval.m_status = STATUS_CLEAR; + return rval; + } + if (len == 1) { + // No multiplicity explicity given, defaults to 1. + multiplicity = 1; + temp_unit = unit_str.at(0); + } else { + temp_unit = unit_str.at(len - 1); + std::string mult = unit_str.substr(0, len - 1); + if (!std::all_of(mult.begin(), mult.end(), ::isdigit)) { + // multiplicity is not a non-negative integer + rval.m_status = STATUS_CLEAR; + return rval; + } + multiplicity = std::stoul(mult); + } + std::string allowed_units = "smhDWMY"; + if (allowed_units.find(temp_unit) == std::string::npos) { + // std::cerr << "[bucket] unknown unit in bucket - the valid units " + // "are 's', 'm', 'h', 'D', 'W', 'M', and 'Y'." + // << '\n'; + rval.m_type = DTYPE_TIME; + rval.m_status = STATUS_CLEAR; + return rval; + } + date_unit = bucket::UNIT_MAP[temp_unit]; + + // type-check multiplicity + switch (date_unit) { + case t_date_bucket_unit::SECONDS: + case t_date_bucket_unit::MINUTES: + if (multiplicity != 1 && multiplicity != 5 && multiplicity != 10 + && multiplicity != 15 && multiplicity != 20 + && multiplicity != 30) { + rval.m_status = STATUS_CLEAR; + return rval; + } + break; + case t_date_bucket_unit::HOURS: + if (multiplicity != 1 && multiplicity != 2 && multiplicity != 3 + && multiplicity != 4 && multiplicity != 6 + && multiplicity != 12) { + rval.m_status = STATUS_CLEAR; + return rval; + } + break; + case t_date_bucket_unit::DAYS: + // TODO: day multiplicity. + case t_date_bucket_unit::WEEKS: + // TODO: week multiplicity + if (multiplicity != 1) { + rval.m_status = STATUS_CLEAR; + return rval; + } + break; + case t_date_bucket_unit::MONTHS: + if (multiplicity != 1 && multiplicity != 2 && multiplicity != 3 + && multiplicity != 4 && multiplicity != 6) { + rval.m_status = STATUS_CLEAR; + return rval; + } + break; + case t_date_bucket_unit::YEARS: + break; + default: + PSP_COMPLAIN_AND_ABORT("[bucket] invalid date bucket unit!"); + break; + } + t_dtype val_dtype = val.get_dtype(); + // type-check + if (val_dtype != DTYPE_DATE && val_dtype != DTYPE_TIME) { + rval.m_status = STATUS_CLEAR; + } + + // Depending on unit, datetime columns can result in a date column or a + // datetime column. + if (val_dtype == DTYPE_TIME) { + switch (date_unit) { + case t_date_bucket_unit::SECONDS: + case t_date_bucket_unit::MINUTES: + case t_date_bucket_unit::HOURS: { + rval.m_type = DTYPE_TIME; + } break; + case t_date_bucket_unit::DAYS: + case t_date_bucket_unit::WEEKS: + case t_date_bucket_unit::MONTHS: + case t_date_bucket_unit::YEARS: { + rval.m_type = DTYPE_DATE; + } break; + default: { + PSP_COMPLAIN_AND_ABORT("[bucket] invalid date bucket unit!"); + } break; + } + } else { + // but date columns will always output date columns + rval.m_type = DTYPE_DATE; + } + + if (!val.is_valid()) { + return rval; + } + + switch (date_unit) { + case t_date_bucket_unit::SECONDS: { + _second_bucket(val, rval, multiplicity); + } break; + case t_date_bucket_unit::MINUTES: { + _minute_bucket(val, rval, multiplicity); + } break; + case t_date_bucket_unit::HOURS: { + _hour_bucket(val, rval, multiplicity); + } break; + case t_date_bucket_unit::DAYS: { + _day_bucket(val, rval); + } break; + case t_date_bucket_unit::WEEKS: { + _week_bucket(val, rval); + } break; + case t_date_bucket_unit::MONTHS: { + _month_bucket(val, rval, multiplicity); + } break; + case t_date_bucket_unit::YEARS: { + _year_bucket(val, rval, multiplicity); + } break; + default: { + PSP_COMPLAIN_AND_ABORT("[bucket] invalid date bucket unit!"); + } break; + } + + return rval; +} + +/// @brief Buckets a given time into a date at multiplicity*T resolution. +/// @tparam T The std::chrono::duration to bucket by. +/// @param val The input date. +/// @param multiplicity How many Ts to put in a bucket. +/// @return The bucketed time. +template +t_time +bucket_time(t_tscalar& val, t_uindex multiplicity) { + std::chrono::milliseconds millis(val.to_int64()); + auto raw = std::chrono::duration_cast(millis).count(); + int64_t bucket = + floor(static_cast(raw) / multiplicity) * multiplicity; + T refined(bucket); + return t_time( + std::chrono::duration_cast(refined).count() + ); +} + +void +_second_bucket(t_tscalar& val, t_tscalar& rval, t_uindex multiplicity) { + switch (val.get_dtype()) { + case DTYPE_TIME: { + rval.set(bucket_time(val, multiplicity)); + } break; + default: { + // echo the original value back into the column. + rval.set(val); + } + } +} + +void +_minute_bucket(t_tscalar& val, t_tscalar& rval, t_uindex multiplicity) { + switch (val.get_dtype()) { + case DTYPE_TIME: { + rval.set(bucket_time(val, multiplicity)); + } break; + default: { + rval.set(val); + } break; + } +} + +void +_hour_bucket(t_tscalar& val, t_tscalar& rval, t_uindex multiplicity) { + switch (val.get_dtype()) { + case DTYPE_TIME: { + rval.set(bucket_time(val, multiplicity)); + } break; + default: { + rval.set(val); + } break; + } +} + +void +_day_bucket(t_tscalar& val, t_tscalar& rval) { + switch (val.get_dtype()) { + case DTYPE_TIME: { + // The UTC calendar day containing the UTC timestamp — the host + // timezone must not affect engine results (native and WASM + // builds must agree) + rval.set(t_date::from_epoch_ms(val.to_int64())); + } break; + case DTYPE_DATE: + default: { + // echo the original value back into the column. + rval.set(val); + } break; + } +} + +void +_week_bucket(t_tscalar& val, t_tscalar& rval) { + switch (val.get_dtype()) { + case DTYPE_DATE: { + // Retrieve the `t_date` struct from the scalar + t_date date_val = val.get(); + + // Construct a `date::year_month_day` value + date::year year{date_val.year()}; + + // date::month is [1-12], whereas `t_date.month()` is [0-11] + date::month month{static_cast(date_val.month()) + 1}; + date::day day{static_cast(date_val.day())}; + date::year_month_day ymd(year, month, day); + + // Convert to a `sys_days` representing no. of days since epoch + date::sys_days days_since_epoch = ymd; + + // Subtract Sunday from the ymd to get the beginning of the last + // day + ymd = days_since_epoch + - (date::weekday{days_since_epoch} - date::Monday); + + // Get the day of month and day of the week + std::int32_t year_int = static_cast(ymd.year()); + + // date::month is [1-12], whereas `t_date.month()` is [0-11] + std::uint32_t month_int = + static_cast(ymd.month()) - 1; + std::uint32_t day_int = static_cast(ymd.day()); + + // Return the new `t_date` + t_date new_date = t_date(year_int, month_int, day_int); + rval.set(new_date); + } break; + case DTYPE_TIME: { + // Take the ymd of the UTC timestamp in UTC — the host timezone + // must not affect engine results — and create a + // date::year_month_day. + std::tm t = gmtime_from_epoch_ms(val.to_int64()); + date::year year{1900 + t.tm_year}; + + // date::month is [1-12], whereas `std::tm::tm_mon` is [0-11] + date::month month{static_cast(t.tm_mon) + 1}; + date::day day{static_cast(t.tm_mday)}; + date::year_month_day ymd(year, month, day); + + // Convert to a `sys_days` representing no. of days since epoch + date::sys_days days_since_epoch = ymd; + + // Subtract Sunday from the ymd to get the beginning of the last + // day + ymd = days_since_epoch + - (date::weekday{days_since_epoch} - date::Monday); + + // Get the day of month and day of the week + std::int32_t year_int = static_cast(ymd.year()); + + // date::month is [1-12], whereas `t_date.month()` is [0-11] + std::uint32_t month_int = + static_cast(ymd.month()) - 1; + std::uint32_t day_int = static_cast(ymd.day()); + + // Return the new `t_date` + t_date new_date = t_date(year_int, month_int, day_int); + rval.set(new_date); + } break; + default: + break; + } +} + +void +_month_bucket(t_tscalar& val, t_tscalar& rval, t_uindex multiplicity) { + switch (val.get_dtype()) { + case DTYPE_DATE: { + t_date date_val = val.get(); + auto in_month = date_val.month(); + int8_t out_month = + floor(static_cast(in_month) / multiplicity) + * multiplicity; + rval.set(t_date(date_val.year(), out_month, 1)); + } break; + case DTYPE_TIME: { + // Break down the UTC timestamp in UTC — the host timezone must + // not affect engine results + std::tm t = gmtime_from_epoch_ms(val.to_int64()); + + // Use the `tm` to create the `t_date` + auto year = static_cast(t.tm_year + 1900); + std::int32_t month = static_cast(t.tm_mon); + if (multiplicity != 1) { + month = floor(static_cast(month) / multiplicity) + * multiplicity; + } + rval.set(t_date(year, month, 1)); + } break; + default: + break; + } +} + +void +_year_bucket(t_tscalar& val, t_tscalar& rval, t_uindex multiplicity) { + switch (val.get_dtype()) { + case DTYPE_DATE: { + t_date date_val = val.get(); + rval.set(t_date( + floor(static_cast(date_val.year()) / multiplicity) + * multiplicity, + 0, + 1 + )); + } break; + case DTYPE_TIME: { + // Break down the UTC timestamp in UTC — the host timezone must + // not affect engine results + std::tm t = gmtime_from_epoch_ms(val.to_int64()); + + // Use the `tm` to create the `t_date` + auto year = static_cast(t.tm_year + 1900); + if (multiplicity != 1) { + year = floor(static_cast(year) / multiplicity) + * multiplicity; + } + rval.set(t_date(year, 0, 1)); + } break; + default: + break; + } +} + +t_tscalar +now() { + t_tscalar rval; + auto now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch() + ) + .count(); + rval.set(t_time(now)); + return rval; +} + +t_tscalar +today() { + t_tscalar rval; + + auto now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch() + ) + .count(); + + // The current UTC calendar day, consistent with `bucket("x", 'D')` of + // `now()` — the host timezone must not affect engine results + rval.set(t_date::from_epoch_ms(now)); + return rval; +} + +inrange_fn::inrange_fn() : exprtk::igeneric_function("TTT") {} + +inrange_fn::~inrange_fn() = default; + +t_tscalar +inrange_fn::operator()(t_parameter_list parameters) { + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_BOOL; + + t_scalar_view _low(parameters[0]); + t_scalar_view _val(parameters[1]); + t_scalar_view _high(parameters[2]); + + t_tscalar low = _low(); + t_tscalar val = _val(); + t_tscalar high = _high(); + + // make sure we are comparing items of the same type, otherwise + // comparisons will fail. + t_dtype val_dtype = val.get_dtype(); + + if (low.get_dtype() != val_dtype || val_dtype != high.get_dtype()) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + // no need to type check - just check validity + if (!low.is_valid() || !val.is_valid() || !high.is_valid()) { + return rval; + } + + rval.set((low <= val) && (val <= high)); + return rval; +} + +min_fn::min_fn() = default; + +min_fn::~min_fn() = default; + +t_tscalar +min_fn::operator()(t_parameter_list parameters) { + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_FLOAT64; + + std::vector inputs; + inputs.resize(parameters.size()); + + // type check through all parameters first before calculating + for (auto i = 0; i < parameters.size(); ++i) { + t_generic_type& gt = parameters[i]; + + if (gt.type == t_generic_type::e_scalar) { + t_scalar_view _temp(gt); + t_tscalar temp = _temp(); + + if (!temp.is_numeric()) { + rval.m_status = STATUS_CLEAR; + return rval; + } // correct type - we will check for STATUS_VALID later + inputs[i] = temp; + continue; + + } // An invalid call - needs to fail at the type check. + rval.m_status = STATUS_CLEAR; + return rval; + } + + // types are now valid - we can calculate the value + for (auto i = 0; i < inputs.size(); ++i) { + const t_tscalar& val = inputs[i]; + + // correct type input but invalid - return + if (!val.is_valid()) { + return rval; + } + + if (i == 0 || (val.to_double() < rval.to_double())) { + rval.set(val.to_double()); + } + } + + return rval; +} + +max_fn::max_fn() = default; + +max_fn::~max_fn() = default; + +t_tscalar +max_fn::operator()(t_parameter_list parameters) { + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_FLOAT64; + + std::vector inputs; + inputs.resize(parameters.size()); + + // type check through all parameters first before calculating + for (auto i = 0; i < parameters.size(); ++i) { + t_generic_type& gt = parameters[i]; + + if (gt.type == t_generic_type::e_scalar) { + t_scalar_view _temp(gt); + t_tscalar temp = _temp(); + + if (!temp.is_numeric()) { + rval.m_status = STATUS_CLEAR; + return rval; + } // correct type - we will check for STATUS_VALID later + inputs[i] = temp; + continue; + + } // An invalid call - needs to fail at the type check. + rval.m_status = STATUS_CLEAR; + return rval; + } + + // types are now valid - we can calculate the value + for (auto i = 0; i < inputs.size(); ++i) { + const t_tscalar& val = inputs[i]; + + // correct type input but invalid - return + if (!val.is_valid()) { + return rval; + } + + if (i == 0 || (val.to_double() > rval.to_double())) { + rval.set(val.to_double()); + } + } + + return rval; +} + +coalesce::coalesce() = default; + +coalesce::~coalesce() = default; + +t_tscalar +coalesce::operator()(t_parameter_list parameters) { + t_tscalar rval; + rval.clear(); + + if (parameters.size() == 0) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + std::vector inputs; + inputs.resize(parameters.size()); + + // Pass 1: type-check all parameters and resolve the output dtype. + // Loose matching: any combination of numeric types promotes to FLOAT64, + // matching the precedent set by min_fn / max_fn. Non-numeric types + // must share an exact dtype. + bool all_numeric = true; + t_dtype first_dtype = DTYPE_NONE; + + for (auto i = 0; i < parameters.size(); ++i) { + t_generic_type& gt = parameters[i]; + if (gt.type != t_generic_type::e_scalar) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + t_scalar_view _temp(gt); + t_tscalar temp = _temp(); + inputs[i] = temp; + + auto dt = static_cast(temp.m_type); + bool numeric = is_numeric_type(dt); + + if (i == 0) { + first_dtype = dt; + all_numeric = numeric; + } else if (all_numeric && numeric) { + // both numeric so far - stays in promotion path + } else if (!all_numeric && dt == first_dtype) { + // exact-match path for non-numeric types + } else { + rval.m_status = STATUS_CLEAR; + return rval; + } + } + + rval.m_type = all_numeric ? DTYPE_FLOAT64 : first_dtype; + + // Pass 2: return the first valid, non-none scalar. During the type- + // validation pass all inputs are STATUS_INVALID sentinels, so this + // loop falls through and returns rval with the resolved dtype and + // STATUS_INVALID, which the validator accepts. + for (auto i = 0; i < inputs.size(); ++i) { + const t_tscalar& val = inputs[i]; + if (val.is_valid() && !val.is_none()) { + if (all_numeric) { + rval.set(val.to_double()); + } else { + rval.set(val); + } + return rval; + } + } + + return rval; +} + +diff3::diff3() : exprtk::igeneric_function("VVV") {} + +diff3::~diff3() = default; + +t_tscalar +diff3::operator()(t_parameter_list parameters) { + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_BOOL; + + t_vector_view v1(parameters[0]); + t_vector_view v2(parameters[1]); + t_vector_view out(parameters[2]); + + if (v1.size() < 3 || v2.size() < 3 || out.size() < 3) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + t_tscalar o1; + o1.set(v1[0] - v2[0]); + + t_tscalar o2; + o2.set(v1[1] - v2[1]); + + t_tscalar o3; + o3.set(v1[2] - v2[2]); + + out[0] = o1; + out[1] = o2; + out[2] = o3; + + rval.set(true); + return rval; +} + +norm3::norm3() : exprtk::igeneric_function("V") {} + +norm3::~norm3() = default; + +t_tscalar +norm3::operator()(t_parameter_list parameters) { + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_FLOAT64; + t_vector_view v1(parameters[0]); + if (v1.size() < 3) { + rval.m_status = STATUS_CLEAR; + return rval; + } + double a = v1[0].to_double(); + double b = v1[1].to_double(); + double c = v1[2].to_double(); + rval.set(sqrt(pow(a, 2) + pow(b, 2) + pow(c, 2))); + return rval; +} + +cross_product3::cross_product3() : + exprtk::igeneric_function("VVV") {} + +cross_product3::~cross_product3() = default; + +t_tscalar +cross_product3::operator()(t_parameter_list parameters) { + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_BOOL; + + // Parameters already validated + t_vector_view v1(parameters[0]); + t_vector_view v2(parameters[1]); + t_vector_view out(parameters[2]); + + if (v1.size() < 3 || v2.size() < 3 || out.size() < 3) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + // a2 * b3 - a3 * b2 + t_tscalar o1; + o1.set(v1[1] * v2[2] - v1[2] * v2[1]); + + // a3 * b1 - a1 * b3, + t_tscalar o2; + o2.set(v1[2] * v2[0] - v1[0] * v2[2]); + + // a1 * b2 - a2 * b1 + t_tscalar o3; + o3.set(v1[0] * v2[1] - v1[1] * v2[0]); + + out[0] = o1; + out[1] = o2; + out[2] = o3; + + rval.set(true); + return rval; +} + +dot_product3::dot_product3() : exprtk::igeneric_function("VV") {} + +dot_product3::~dot_product3() = default; + +t_tscalar +dot_product3::operator()(t_parameter_list parameters) { + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_FLOAT64; + + // Parameters already validated + t_vector_view v1(parameters[0]); + t_vector_view v2(parameters[1]); + + if (v1.size() < 3 || v2.size() < 3) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + rval.set(v1[0] * v2[0] + v1[1] * v2[1] + v1[2] * v2[2]); + return rval; +} + +percent_of::percent_of() : exprtk::igeneric_function("TT") {} + +percent_of::~percent_of() = default; + +t_tscalar +percent_of::operator()(t_parameter_list parameters) { + t_tscalar rval; + rval.clear(); + + // Return a float so we can use it in conditionals + rval.m_type = DTYPE_FLOAT64; + + t_generic_type& _x = parameters[0]; + t_generic_type& _y = parameters[1]; + + t_scalar_view _x_view(_x); + t_scalar_view _y_view(_y); + + t_tscalar x = _x_view(); + t_tscalar y = _y_view(); + + if (!x.is_numeric() || !y.is_numeric()) { + rval.m_status = STATUS_CLEAR; + } + + if (!x.is_valid() || !y.is_valid()) { + return rval; + } + + if (y.to_double() == 0) { + return rval; + } + + rval.set((x.to_double() / y.to_double()) * 100); + return rval; +} + +is_null::is_null() : exprtk::igeneric_function("T") {} + +is_null::~is_null() = default; + +t_tscalar +is_null::operator()(t_parameter_list parameters) { + t_tscalar val; + + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_BOOL; + + t_generic_type& gt = parameters[0]; + t_scalar_view temp(gt); + val.set(temp()); + + rval.set(val.is_none() || !val.is_valid()); + return rval; +} + +is_not_null::is_not_null() : exprtk::igeneric_function("T") {} + +is_not_null::~is_not_null() = default; + +t_tscalar +is_not_null::operator()(t_parameter_list parameters) { + t_tscalar val; + + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_BOOL; + + t_generic_type& gt = parameters[0]; + t_scalar_view temp(gt); + val.set(temp()); + rval.set(!val.is_none() && val.is_valid()); + + return rval; +} + +to_string::to_string( + t_expression_vocab& expression_vocab, bool is_type_validator +) : + exprtk::igeneric_function("T"), + m_expression_vocab(expression_vocab), + m_is_type_validator(is_type_validator) { + t_tscalar sentinel; + sentinel.clear(); + sentinel.set(m_expression_vocab.get_empty_string()); + sentinel.m_status = STATUS_INVALID; + m_sentinel = sentinel; +} + +to_string::~to_string() = default; + +t_tscalar +to_string::operator()(t_parameter_list parameters) { + t_tscalar val; + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_STR; + std::string temp_str; + + t_generic_type& gt = parameters[0]; + t_scalar_view temp(gt); + if (temp().get_dtype() == DTYPE_NONE) { + return rval; + } + + val.set(temp()); + + if (!val.is_valid()) { + return rval; + } + + temp_str = val.to_string(); + + // don't try to intern an empty string as it will throw an error, but + // by this point we know the params are valid - so return the sentinel + // string value. + if (temp_str.empty() || m_is_type_validator) { + return m_sentinel; + } + + rval.set(m_expression_vocab.intern(temp_str)); + return rval; +} + +to_integer::to_integer() : exprtk::igeneric_function("T") {} + +to_integer::~to_integer() = default; + +t_tscalar +to_integer::operator()(t_parameter_list parameters) { + t_tscalar val; + t_tscalar rval; + rval.clear(); + + // Use 32-bit integers for WASM +#if defined PSP_ENABLE_WASM && !defined(PSP_ENABLE_PYTHON) + rval.m_type = DTYPE_INT32; +#else + rval.m_type = DTYPE_INT64; +#endif + + t_generic_type& gt = parameters[0]; + t_scalar_view temp(gt); + if (temp().get_dtype() == DTYPE_NONE) { + return rval; + } + + val.set(temp()); + + if (!val.is_valid()) { + return rval; + } + + double number = 0; + + // Parse numbers inside strings + if (val.get_dtype() == DTYPE_STR) { + std::stringstream ss(val.to_string()); + ss >> number; + + if (ss.fail()) { + return rval; + } + } else { + number = val.to_double(); + } + +#if defined(PSP_ENABLE_WASM) && !defined(PSP_ENABLE_PYTHON) + // check for overflow + if (number > std::numeric_limits::max() + || number < std::numeric_limits::min()) { + return rval; + } + + rval.set(static_cast(number)); +#else + rval.set(static_cast(number)); +#endif + + return rval; +} + +to_float::to_float() : exprtk::igeneric_function("T") {} + +to_float::~to_float() = default; + +t_tscalar +to_float::operator()(t_parameter_list parameters) { + t_tscalar val; + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_FLOAT64; + + t_generic_type& gt = parameters[0]; + t_scalar_view temp(gt); + if (temp().get_dtype() == DTYPE_NONE) { + return rval; + } + + val.set(temp()); + if (!val.is_valid()) { + return rval; + } + + double number = 0; + + // Parse numbers inside strings + if (val.get_dtype() == DTYPE_STR) { + std::stringstream ss(val.to_string()); + ss >> number; + + if (ss.fail()) { + return rval; + } + } else { + number = val.to_double(); + } + + // Don't allow NaN to leak + if (std::isnan(number)) { + return rval; + } + + rval.set(number); + return rval; +} + +to_boolean::to_boolean() : exprtk::igeneric_function("T") {} + +to_boolean::~to_boolean() = default; + +t_tscalar +to_boolean::operator()(t_parameter_list parameters) { + t_tscalar val; + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_BOOL; + + const t_generic_type& gt = parameters[0]; + t_scalar_view temp(gt); + if (temp().get_dtype() == DTYPE_NONE) { + return rval; + } + + val.set(temp()); + + // handles STATUS_VALID, so no need to check separately + rval.set(val.as_bool()); + + if (!val.is_valid()) { + return rval; + } + + return rval; +} + +make_date::make_date() : exprtk::igeneric_function("TTT") {} + +make_date::~make_date() = default; + +t_tscalar +make_date::operator()(t_parameter_list parameters) { + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_DATE; + + // 0 = year, 1 = month, 2 = day + std::int32_t values[3]{0}; + + for (auto i = 0; i < parameters.size(); ++i) { + t_generic_type& gt = parameters[i]; + t_scalar_view temp(gt); + t_tscalar temp_scalar; + + temp_scalar.set(temp()); + + if (!temp_scalar.is_numeric()) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + if (!temp_scalar.is_valid()) { + return rval; + } + + std::int32_t value = temp_scalar.to_double(); + values[i] = value; + } + + // Disallow negative values + if (values[0] < 0 || values[1] <= 0 || values[1] > 12 || values[2] <= 0 + || values[2] > 31) { + return rval; + } + + // month is 0-11 in t_date + rval.set(t_date(values[0], values[1] - 1, values[2])); + return rval; +} + +make_datetime::make_datetime() : exprtk::igeneric_function("T") {} + +make_datetime::~make_datetime() = default; + +t_tscalar +make_datetime::operator()(t_parameter_list parameters) { + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_TIME; + + t_generic_type& gt = parameters[0]; + t_scalar_view temp(gt); + if (temp().get_dtype() == DTYPE_NONE) { + return rval; + } + + t_tscalar temp_scalar; + + temp_scalar.set(temp()); + t_dtype dtype = temp_scalar.get_dtype(); + + if (dtype != DTYPE_INT64 && dtype != DTYPE_FLOAT64) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + if (!temp_scalar.is_valid()) { + return rval; + } + + std::int64_t timestamp = temp_scalar.to_double(); + rval.set(t_time(timestamp)); + return rval; +} + +index::index( + const t_gstate::t_mapping& pkey_map, + const std::shared_ptr& source_table, + t_uindex& row_idx +) : + exprtk::igeneric_function("Z"), + m_pkey_map(pkey_map), + m_source_table(source_table), + m_row_idx(row_idx) {} + +index::~index() = default; + +t_tscalar +index::operator()(t_parameter_list parameters) { + t_tscalar rval; + rval.clear(); + + auto col = m_source_table->get_const_column("psp_okey"); + auto res = col->get_scalar(m_row_idx); + rval.set(res); + + return rval; +} + +col::col( + t_expression_vocab& expression_vocab, + bool is_type_validator, + const std::shared_ptr& source_table, + t_uindex& row_idx +) : + exprtk::igeneric_function("T"), + m_expression_vocab(expression_vocab), + m_is_type_validator(is_type_validator), + m_source_table(source_table), + m_row_idx(row_idx) {} +col::~col() = default; + +t_tscalar +col::operator()(t_parameter_list parameters) { + t_tscalar rval; + rval.clear(); + + t_scalar_view _colname_view(parameters[0]); + t_tscalar _colname = _colname_view(); + std::string temp_str = _colname.to_string(); + + if (_colname.get_dtype() != DTYPE_STR) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + if (!m_source_table->get_schema().has_column(temp_str)) { + rval.m_status = STATUS_CLEAR; + return rval; + } + auto col = m_source_table->get_const_column(temp_str); + auto res = col->get_scalar(m_row_idx); + rval.set(res); + rval.m_type = col->get_dtype(); + + return rval; +} + +vlookup::vlookup( + t_expression_vocab& expression_vocab, + bool is_type_validator, + const std::shared_ptr& source_table, + t_uindex& row_idx +) : + exprtk::igeneric_function("TT"), + m_expression_vocab(expression_vocab), + m_is_type_validator(is_type_validator), + m_source_table(source_table), + m_row_idx(row_idx) {} +vlookup::~vlookup() = default; + +t_tscalar +vlookup::operator()(t_parameter_list parameters) { + t_tscalar rval; + rval.clear(); + + t_generic_type& column_gt = parameters[0]; + t_scalar_view column_gt_view(column_gt); + t_tscalar column_name; + + column_name.set(column_gt_view()); + t_dtype column_name_dtype = column_name.get_dtype(); + + t_generic_type& index_gt = parameters[1]; + t_scalar_view index_gt_view(index_gt); + t_tscalar index; + + index.set(index_gt_view()); + + auto pkey_col = m_source_table->get_const_column("psp_pkey"); + + if (column_name_dtype != DTYPE_STR + || index.get_dtype() != pkey_col->get_dtype()) { + rval.m_status = STATUS_CLEAR; + return rval; + } + + if (!column_name.is_valid()) { + return rval; + } + + std::string col_name_str = column_name.to_string(); + if (!m_source_table->get_schema().has_column(col_name_str)) { + rval.m_status = STATUS_CLEAR; + return rval; + } + auto col = m_source_table->get_const_column(col_name_str); + + if (m_is_type_validator) { + rval.m_status = STATUS_VALID; + rval.m_type = col->get_dtype(); + return rval; + } + + auto idx = index.to_uint64(); + if (idx < col->size()) { + auto res = col->get_scalar(idx); + rval.set(res); + } + rval.m_type = col->get_dtype(); + + return rval; +} + +// Set up random number generator +std::default_random_engine random::RANDOM_ENGINE = std::default_random_engine(); +std::uniform_real_distribution random::DISTRIBUTION = + std::uniform_real_distribution(0, 1); + +random::random() : exprtk::igeneric_function("Z") {} + +random::~random() = default; + +t_tscalar +random::operator()(t_parameter_list parameters) { + t_tscalar rval; + rval.clear(); + rval.set(random::DISTRIBUTION(random::RANDOM_ENGINE)); + return rval; +} +} // namespace perspective::computed_function + // end namespace perspective \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/cpp/config.cpp b/rust/perspective-server/cpp/perspective/src/cpp/config.cpp new file mode 100644 index 0000000..53c5636 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/config.cpp @@ -0,0 +1,491 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include + +namespace perspective { + +// t_ctxunit +t_config::t_config(const std::vector& detail_columns) : + t_config(detail_columns, {}, FILTER_OP_AND, {}) {} + +// t_ctx0 +t_config::t_config( + const std::vector& detail_columns, + const std::vector& fterms, + t_filter_op combiner, + const std::vector>& expressions +) : + m_detail_columns(detail_columns), + m_fterms(fterms), + m_expressions(expressions), + m_combiner(combiner), + m_fmode(FMODE_SIMPLE_CLAUSES) { + setup(m_detail_columns); + m_is_trivial_config = m_row_pivots.empty() && m_col_pivots.empty() + && m_sortby.empty() && m_sortspecs.empty() && m_col_sortspecs.empty() + && m_detail_columns.empty() && m_fterms.empty() + && m_expressions.empty(); +} + +// t_ctx1 +t_config::t_config( + const std::vector& row_pivots, + const std::vector& aggregates, + const std::vector& fterms, + t_filter_op combiner, + const std::vector>& expressions +) : + m_aggregates(aggregates), + m_fterms(fterms), + m_expressions(expressions), + m_combiner(combiner), + m_is_trivial_config(false), + m_totals(TOTALS_BEFORE), + m_fmode(FMODE_SIMPLE_CLAUSES) { + for (const auto& p : row_pivots) { + m_row_pivots.emplace_back(p); + } + setup( + m_detail_columns, std::vector{}, std::vector{} + ); +} + +// t_ctx2 +t_config::t_config( + const std::vector& row_pivots, + const std::vector& col_pivots, + const std::vector& aggregates, + const t_totals totals, + const std::vector& fterms, + t_filter_op combiner, + const std::vector>& expressions, + bool column_only +) : + m_aggregates(aggregates), + m_fterms(fterms), + m_expressions(expressions), + m_combiner(combiner), + m_column_only(column_only), + m_is_trivial_config(false), + m_totals(totals), + m_fmode(FMODE_SIMPLE_CLAUSES) { + for (const auto& p : row_pivots) { + m_row_pivots.emplace_back(p); + } + for (const auto& p : col_pivots) { + m_col_pivots.emplace_back(p); + } + setup( + m_detail_columns, std::vector{}, std::vector{} + ); +} + +// Constructors used for C++ tests +t_config::t_config( + const std::vector& row_pivots, + const std::vector& col_pivots, + const std::vector& aggregates +) : + t_config( + row_pivots, col_pivots, aggregates, TOTALS_HIDDEN, FILTER_OP_AND, {} + ) {} + +t_config::t_config( + const std::vector& row_pivots, + const std::vector& col_pivots, + const std::vector& aggregates, + const t_totals totals, + t_filter_op combiner, + const std::vector& fterms +) : + m_aggregates(aggregates), + m_fterms(fterms), + m_combiner(combiner), + m_is_trivial_config(false), + m_totals(totals), + m_fmode(FMODE_SIMPLE_CLAUSES) { + for (const auto& p : row_pivots) { + m_row_pivots.emplace_back(p); + } + + for (const auto& p : col_pivots) { + m_col_pivots.emplace_back(p); + } + + setup( + m_detail_columns, std::vector{}, std::vector{} + ); +} + +t_config::t_config( + const std::vector& row_pivots, + const std::vector& aggregates +) : + m_row_pivots(row_pivots), + m_aggregates(aggregates), + m_is_trivial_config(false), + m_fmode(FMODE_SIMPLE_CLAUSES) { + setup( + m_detail_columns, std::vector{}, std::vector{} + ); +} + +t_config::t_config( + const std::vector& row_pivots, + const std::vector& aggregates +) : + m_aggregates(aggregates), + m_combiner(FILTER_OP_AND), + m_is_trivial_config(false), + m_totals(TOTALS_BEFORE), + m_fmode(FMODE_SIMPLE_CLAUSES) { + for (const auto& p : row_pivots) { + m_row_pivots.emplace_back(p); + } + + setup( + m_detail_columns, std::vector{}, std::vector{} + ); +} + +t_config::t_config( + const std::vector& row_pivots, const t_aggspec& agg +) : + m_aggregates(std::vector{agg}), + m_combiner(FILTER_OP_AND), + m_is_trivial_config(false), + m_totals(TOTALS_BEFORE), + m_fmode(FMODE_SIMPLE_CLAUSES) { + for (const auto& p : row_pivots) { + m_row_pivots.emplace_back(p); + } + + setup( + m_detail_columns, std::vector{}, std::vector{} + ); +} + +t_config::t_config() = default; + +void +t_config::setup(const std::vector& detail_columns) { + t_index count = 0; + for (const auto& detail_column : detail_columns) { + m_detail_colmap[detail_column] = count; + count++; + } +} + +void +t_config::setup( + const std::vector& detail_columns, + const std::vector& sort_pivot, + const std::vector& sort_pivot_by +) { + t_index count = 0; + for (const auto& detail_column : detail_columns) { + m_detail_colmap[detail_column] = count; + count++; + } + + m_has_pkey_agg = false; + + for (const auto& m_aggregate : m_aggregates) { + switch (m_aggregate.agg()) { + case AGGTYPE_AND: + case AGGTYPE_OR: + case AGGTYPE_ANY: + case AGGTYPE_FIRST: + case AGGTYPE_LAST_BY_INDEX: + case AGGTYPE_LAST_MINUS_FIRST: + case AGGTYPE_HIGH_MINUS_LOW: + case AGGTYPE_MEAN: + case AGGTYPE_WEIGHTED_MEAN: + case AGGTYPE_UNIQUE: + case AGGTYPE_Q1: + case AGGTYPE_Q3: + case AGGTYPE_MEDIAN: + case AGGTYPE_JOIN: + case AGGTYPE_DOMINANT: + case AGGTYPE_PY_AGG: + case AGGTYPE_MIN: + case AGGTYPE_MAX: + case AGGTYPE_MAX_BY: + case AGGTYPE_MIN_BY: + case AGGTYPE_SUM_NOT_NULL: + case AGGTYPE_SUM_ABS: + case AGGTYPE_ABS_SUM: + case AGGTYPE_GMV: + case AGGTYPE_MUL: + case AGGTYPE_DISTINCT_COUNT: + case AGGTYPE_DISTINCT_LEAF: + case AGGTYPE_VARIANCE: + case AGGTYPE_STANDARD_DEVIATION: + m_has_pkey_agg = true; + break; + default: + break; + } + + if (m_has_pkey_agg) { + break; + } + } + + for (t_index idx = 0, loop_end = sort_pivot.size(); idx < loop_end; ++idx) { + m_sortby[sort_pivot[idx]] = sort_pivot_by[idx]; + } + + populate_sortby(m_row_pivots); + populate_sortby(m_col_pivots); +} + +bool +t_config::is_trivial_config() const { + return m_is_trivial_config; +} + +void +t_config::populate_sortby(const std::vector& pivots) { + for (const auto& pivot : pivots) { + PSP_VERBOSE_ASSERT( + pivot.mode() == PIVOT_MODE_NORMAL, + "Only normal pivots supported for now" + ); + const std::string& pstr = pivot.colname(); + if (m_sortby.find(pstr) == m_sortby.end()) { + m_sortby[pstr] = pstr; + } + } +} + +t_index +t_config::get_colidx(const std::string& colname) const { + auto iter = m_detail_colmap.find(colname); + if (iter == m_detail_colmap.end()) { + return INVALID_INDEX; + } + return iter->second; +} + +std::string +t_config::repr() const { + std::stringstream ss; + ss << "t_config<" << this << ">"; + return ss.str(); +} + +t_uindex +t_config::get_num_aggregates() const { + return m_aggregates.size(); +} + +t_uindex +t_config::get_num_columns() const { + return m_detail_columns.size(); +} + +std::string +t_config::col_at(t_uindex idx) const { + if (idx >= m_detail_columns.size()) { + return ""; + } + return m_detail_columns[idx]; +} + +bool +t_config::has_pkey_agg() const { + return m_has_pkey_agg; +} + +std::string +t_config::get_totals_string() const { + switch (m_totals) { + case TOTALS_BEFORE: { + return "before"; + } break; + case TOTALS_HIDDEN: { + return "hidden"; + } break; + case TOTALS_AFTER: { + return "after"; + } break; + default: { + return "INVALID_TOTALS"; + } break; + } +} + +std::string +t_config::get_sort_by(const std::string& pivot) const { + std::string rval; + auto iter = m_sortby.find(pivot); + + if (iter == m_sortby.end()) { + return pivot; + } + rval = iter->second; + + return rval; +} + +bool +t_config::validate_colidx(t_index idx) const { + return idx >= 0 && idx < static_cast(get_num_columns()); +} + +std::vector +t_config::get_column_names() const { + return m_detail_columns; +} + +t_uindex +t_config::get_num_rpivots() const { + return m_row_pivots.size(); +} + +t_uindex +t_config::get_num_cpivots() const { + return m_col_pivots.size(); +} + +bool +t_config::is_column_only() const { + return m_column_only; +} + +const std::vector& +t_config::get_row_pivots() const { + return m_row_pivots; +} + +const std::vector& +t_config::get_column_pivots() const { + return m_col_pivots; +} + +std::vector> +t_config::get_sortby_pairs() const { + std::vector> rval(m_sortby.size()); + t_uindex idx = 0; + for (const auto& iter : m_sortby) { + rval[idx].first = iter.first; + rval[idx].second = iter.second; + ++idx; + } + return rval; +} + +const std::vector& +t_config::get_sortspecs() const { + return m_sortspecs; +} + +const std::vector& +t_config::get_col_sortspecs() const { + return m_col_sortspecs; +} + +const std::vector& +t_config::get_aggregates() const { + return m_aggregates; +} + +bool +t_config::has_filters() const { + switch (m_fmode) { + case FMODE_SIMPLE_CLAUSES: { + return !m_fterms.empty(); + } break; + default: { + return false; + } + } + return false; +} + +const std::vector& +t_config::get_fterms() const { + return m_fterms; +} + +std::vector> +t_config::get_expressions() const { + return m_expressions; +} + +t_filter_op +t_config::get_combiner() const { + return m_combiner; +} + +t_totals +t_config::get_totals() const { + return m_totals; +} + +std::vector +t_config::get_pivots() const { + std::vector rval = m_row_pivots; + for (const auto& piv : m_col_pivots) { + rval.push_back(piv); + } + return rval; +} + +std::string +t_config::get_parent_pkey_column() const { + return m_parent_pkey_column; +} + +std::string +t_config::get_child_pkey_column() const { + return m_child_pkey_column; +} + +const std::string& +t_config::get_grouping_label_column() const { + return m_grouping_label_column; +} + +std::string +t_config::unity_get_column_name(t_uindex idx) const { + if (m_aggregates.empty()) { + if (idx >= m_detail_columns.size()) { + return ""; + } + return m_detail_columns[idx]; + } + + return m_aggregates[idx % m_aggregates.size()].name(); +} + +std::string +t_config::unity_get_column_display_name(t_uindex idx) const { + if (m_aggregates.empty()) { + if (idx >= m_detail_columns.size()) { + return ""; + } + return m_detail_columns[idx]; + } + + return m_aggregates[idx % m_aggregates.size()].disp_name(); +} + +t_fmode +t_config::get_fmode() const { + return m_fmode; +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/context_base.cpp b/rust/perspective-server/cpp/perspective/src/cpp/context_base.cpp new file mode 100644 index 0000000..99af0c9 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/context_base.cpp @@ -0,0 +1,16 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include + +namespace perspective {} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/context_grouped_pkey.cpp b/rust/perspective-server/cpp/perspective/src/cpp/context_grouped_pkey.cpp new file mode 100644 index 0000000..737df3e --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/context_grouped_pkey.cpp @@ -0,0 +1,939 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +t_ctx_grouped_pkey::t_ctx_grouped_pkey() : m_depth(0), m_depth_set(false) {} + +t_ctx_grouped_pkey::t_ctx_grouped_pkey( + const t_schema& schema, const t_config& config +) : + m_depth(0), + m_depth_set(false) { + PSP_COMPLAIN_AND_ABORT("Not Implemented"); +} + +t_ctx_grouped_pkey::~t_ctx_grouped_pkey() = default; + +void +t_ctx_grouped_pkey::init() { + auto pivots = m_config.get_row_pivots(); + m_tree = std::make_shared( + pivots, m_config.get_aggregates(), m_schema, m_config + ); + m_tree->init(); + m_traversal = std::make_shared(m_tree); + + // Each context stores its own expression columns in separate + // `t_data_table`s so that each context's expressions are isolated + // and do not affect other contexts when they are calculated. + const auto& expressions = m_config.get_expressions(); + m_expression_tables = std::make_shared( + expressions, m_config.get_backing_store() + ); + + m_init = true; +} + +std::shared_ptr +t_ctx_grouped_pkey::get_expression_tables() const { + return m_expression_tables; +} + +t_index +t_ctx_grouped_pkey::get_row_count() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_traversal->size(); +} + +t_index +t_ctx_grouped_pkey::get_column_count() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_config.get_num_columns() + 1; +} + +t_index +t_ctx_grouped_pkey::open(t_header header, t_index idx) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return open(idx); +} + +std::string +t_ctx_grouped_pkey::repr() const { + std::stringstream ss; + ss << "t_ctx_grouped_pkey<" << this << ">"; + return ss.str(); +} + +t_index +t_ctx_grouped_pkey::open(t_index idx) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + // If we manually open/close a node, stop automatically expanding + m_depth_set = false; + m_depth = 0; + + if (idx >= t_index(m_traversal->size())) { + return 0; + } + + t_index retval = m_traversal->expand_node(m_sortby, idx); + m_rows_changed = (retval > 0); + return retval; +} + +t_index +t_ctx_grouped_pkey::close(t_index idx) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + // If we manually open/close a node, stop automatically expanding + m_depth_set = false; + m_depth = 0; + + if (idx >= t_index(m_traversal->size())) { + return 0; + } + + t_index retval = m_traversal->collapse_node(idx); + m_rows_changed = (retval > 0); + return retval; +} + +std::vector +t_ctx_grouped_pkey::get_data( + t_index start_row, t_index end_row, t_index start_col, t_index end_col +) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + t_uindex ctx_nrows = get_row_count(); + t_uindex ncols = get_column_count(); + auto ext = sanitize_get_data_extents( + ctx_nrows, ncols, start_row, end_row, start_col, end_col + ); + + t_index nrows = ext.m_erow - ext.m_srow; + t_index stride = ext.m_ecol - ext.m_scol; + std::vector values(nrows * stride); + std::vector tmpvalues(nrows * ncols); + + std::vector aggcols(m_config.get_num_aggregates()); + + if (aggcols.empty()) { + return values; + } + + auto* aggtable = m_tree->get_aggtable(); + + for (t_uindex aggidx = 0, loop_end = aggcols.size(); aggidx < loop_end; + ++aggidx) { + // resolve by column index (== aggidx); skips the per-iteration + // name->index map lookup + temp string and the schema deep-copy. + aggcols[aggidx] = aggtable->_get_const_column(aggidx); + } + + const std::vector& aggspecs = m_config.get_aggregates(); + + const std::string& grouping_label_col = + m_config.get_grouping_label_column(); + + for (t_index ridx = ext.m_srow; ridx < ext.m_erow; ++ridx) { + t_index nidx = m_traversal->get_tree_index(ridx); + t_index pnidx = m_tree->get_parent_idx(nidx); + + t_uindex agg_ridx = m_tree->get_aggidx(nidx); + t_index agg_pridx = + pnidx == INVALID_INDEX ? INVALID_INDEX : m_tree->get_aggidx(pnidx); + + t_tscalar tree_value = m_tree->get_value(nidx); + + if (m_has_label && ridx > 0) { + // Get pkey + auto iters = m_tree->get_pkeys_for_leaf(nidx); + tree_value.set( + get_value_from_gstate(grouping_label_col, iters.first->m_pkey) + ); + } + + tmpvalues[(ridx - ext.m_srow) * ncols] = tree_value; + + for (t_index aggidx = 0, loop_end = aggcols.size(); aggidx < loop_end; + ++aggidx) { + t_tscalar value = extract_aggregate( + aggspecs[aggidx], aggcols[aggidx], agg_ridx, agg_pridx + ); + + tmpvalues[(ridx - ext.m_srow) * ncols + 1 + aggidx].set(value); + } + } + + for (auto ridx = ext.m_srow; ridx < ext.m_erow; ++ridx) { + for (auto cidx = ext.m_scol; cidx < ext.m_ecol; ++cidx) { + auto insert_idx = (ridx - ext.m_srow) * stride + cidx - ext.m_scol; + auto src_idx = (ridx - ext.m_srow) * ncols + cidx; + values[insert_idx].set(tmpvalues[src_idx]); + } + } + return values; +} + +void +t_ctx_grouped_pkey::notify( + const t_data_table& flattened, + const t_data_table& delta, + const t_data_table& prev, + const t_data_table& current, + const t_data_table& transitions, + const t_data_table& existed +) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + rebuild(); +} + +void +t_ctx_grouped_pkey::step_begin() { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + reset_step_state(); +} + +void +t_ctx_grouped_pkey::step_end() { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + sort_by(m_sortby); + if (m_depth_set) { + set_depth(m_depth); + } +} + +std::vector +t_ctx_grouped_pkey::get_aggregates() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_config.get_aggregates(); +} + +std::vector +t_ctx_grouped_pkey::get_row_path(t_index idx) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return ctx_get_path(m_tree, m_traversal, idx); +} + +void +t_ctx_grouped_pkey::reset_sortby() { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + m_sortby = std::vector(); +} + +std::vector +t_ctx_grouped_pkey::get_expansion_state() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return ctx_get_expansion_state(m_tree, m_traversal); +} + +void +t_ctx_grouped_pkey::set_expansion_state(const std::vector& paths) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + ctx_set_expansion_state(*this, HEADER_ROW, m_tree, m_traversal, paths); +} + +void +t_ctx_grouped_pkey::expand_path(const std::vector& path) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + ctx_expand_path(*this, HEADER_ROW, m_tree, m_traversal, path); +} + +t_stree* +t_ctx_grouped_pkey::_get_tree() { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_tree.get(); +} + +t_tscalar +t_ctx_grouped_pkey::get_tree_value(t_index nidx) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_tree->get_value(nidx); +} + +std::vector +t_ctx_grouped_pkey::get_flattened_tree(t_index idx, t_depth stop_depth) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return ctx_get_flattened_tree( + idx, stop_depth, *(m_traversal), m_config, m_sortby + ); +} + +std::shared_ptr +t_ctx_grouped_pkey::get_traversal() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_traversal; +} + +void +t_ctx_grouped_pkey::sort_by(const std::vector& sortby) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + + m_sortby = sortby; + if (m_sortby.empty()) { + return; + } + m_traversal->sort_by(m_config, sortby, *this); +} + +void +t_ctx_grouped_pkey::set_depth(t_depth depth) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + t_depth final_depth = + std::min(m_config.get_num_rpivots() - 1, depth); + t_index retval = 0; + retval = m_traversal->set_depth(m_sortby, final_depth); + m_rows_changed = (retval > 0); + m_depth = depth; + m_depth_set = true; +} + +std::vector +t_ctx_grouped_pkey::get_pkeys( + const std::vector>& cells +) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + + if (!m_traversal->validate_cells(cells)) { + std::vector rval; + return rval; + } + + std::vector rval; + + tsl::hopscotch_set seen; + + for (const auto& c : cells) { + auto ptidx = m_traversal->get_tree_index(c.first); + + if (static_cast(ptidx) == static_cast(-1)) { + continue; + } + + if (seen.find(ptidx) == seen.end()) { + auto iters = m_tree->get_pkeys_for_leaf(ptidx); + for (auto iter = iters.first; iter != iters.second; ++iter) { + rval.push_back(iter->m_pkey); + } + seen.insert(ptidx); + } + + auto desc = m_tree->get_descendents(ptidx); + + for (auto d : desc) { + if (seen.find(d) != seen.end()) { + continue; + } + + auto iters = m_tree->get_pkeys_for_leaf(d); + for (auto iter = iters.first; iter != iters.second; ++iter) { + rval.push_back(iter->m_pkey); + } + seen.insert(d); + } + } + return rval; +} + +void +t_ctx_grouped_pkey::set_feature_state(t_ctx_feature feature, bool state) { + m_features[feature] = state; +} + +void +t_ctx_grouped_pkey::set_alerts_enabled(bool enabled_state) { + m_features[CTX_FEAT_ALERT] = enabled_state; + m_tree->set_alerts_enabled(enabled_state); +} + +void +t_ctx_grouped_pkey::set_deltas_enabled(bool enabled_state) { + m_features[CTX_FEAT_DELTA] = enabled_state; + m_tree->set_deltas_enabled(enabled_state); +} + +t_stepdelta +t_ctx_grouped_pkey::get_step_delta(t_index bidx, t_index eidx) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + bidx = std::min(bidx, t_index(m_traversal->size())); + eidx = std::min(eidx, t_index(m_traversal->size())); + + t_stepdelta rval( + m_rows_changed, m_columns_changed, get_cell_delta(bidx, eidx) + ); + m_tree->clear_deltas(); + return rval; +} + +std::vector +t_ctx_grouped_pkey::get_cell_delta(t_index bidx, t_index eidx) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + eidx = std::min(eidx, t_index(m_traversal->size())); + std::vector rval; + const auto& deltas = m_tree->get_deltas(); + for (t_index idx = bidx; idx < eidx; ++idx) { + t_index ptidx = m_traversal->get_tree_index(idx); + auto iterators = deltas->get().equal_range(ptidx); + for (auto iter = iterators.first; iter != iterators.second; ++iter) { + rval.emplace_back( + idx, iter->m_aggidx + 1, iter->m_old_value, iter->m_new_value + ); + } + } + return rval; +} + +void +t_ctx_grouped_pkey::reset(bool reset_expressions) { + auto pivots = m_config.get_row_pivots(); + m_tree = std::make_shared( + pivots, m_config.get_aggregates(), m_schema, m_config + ); + m_tree->init(); + m_tree->set_deltas_enabled(get_feature_state(CTX_FEAT_DELTA)); + m_traversal = std::make_shared(m_tree); + + if (reset_expressions) { + m_expression_tables->reset(); + } +} + +void +t_ctx_grouped_pkey::reset_step_state() { + m_rows_changed = false; + m_columns_changed = false; + if (t_env::log_progress()) { + std::cout << "t_ctx_grouped_pkey.reset_step_state " << repr() << '\n'; + } +} + +std::vector +t_ctx_grouped_pkey::get_trees() { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + std::vector rval(1); + rval[0] = m_tree.get(); + return rval; +} + +bool +t_ctx_grouped_pkey::has_deltas() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return true; +} + +template +void +rebuild_helper(t_column* /*unused*/) {} + +void +t_ctx_grouped_pkey::rebuild() { + auto tbl = m_gstate->get_pkeyed_table(); + + if (m_config.has_filters()) { + auto mask = filter_table_for_config(*tbl, m_config); + tbl = tbl->clone(mask); + } + + std::string child_col_name = m_config.get_child_pkey_column(); + + std::shared_ptr child_col_sptr = + tbl->get_const_column(child_col_name); + + const t_column* child_col = child_col_sptr.get(); + auto expansion_state = get_expansion_state(); + + std::sort( + expansion_state.begin(), + expansion_state.end(), + [](const t_path& a, const t_path& b) { + return a.path().size() < b.path().size(); + } + ); + + for (auto& p : expansion_state) { + std::reverse(p.path().begin(), p.path().end()); + } + + reset(); + + t_uindex nrows = child_col->size(); + + if (nrows == 0) { + return; + } + + struct t_datum { + t_uindex m_pidx; + t_tscalar m_parent; + t_tscalar m_child; + t_tscalar m_pkey; + bool m_is_rchild; + t_uindex m_idx; + }; + + const auto* sortby_col = + tbl->_get_const_column(m_config.get_sort_by(child_col_name)); + + const auto* parent_col = + tbl->_get_const_column(m_config.get_parent_pkey_column()); + + const auto* pkey_col = tbl->_get_const_column("psp_pkey"); + + std::vector data(nrows); + tsl::hopscotch_map child_ridx_map; + std::vector self_pkey_eq(nrows); + + for (t_uindex idx = 0; idx < nrows; ++idx) { + data[idx].m_child.set(child_col->get_scalar(idx)); + data[idx].m_pkey.set(pkey_col->get_scalar(idx)); + child_ridx_map[data[idx].m_child] = idx; + } + + for (t_uindex idx = 0; idx < nrows; ++idx) { + auto ppkey = parent_col->get_scalar(idx); + data[idx].m_parent.set(ppkey); + + auto p_iter = child_ridx_map.find(ppkey); + bool missing_parent = p_iter == child_ridx_map.end(); + + data[idx].m_is_rchild = + !ppkey.is_valid() || data[idx].m_child == ppkey || missing_parent; + data[idx].m_pidx = + data[idx].m_is_rchild ? 0 : child_ridx_map.at(data[idx].m_parent); + data[idx].m_idx = idx; + } + + struct t_datumcmp { + bool + operator()(const t_datum& a, const t_datum& b) const { + typedef std::tuple t_tuple; + return t_tuple(!a.m_is_rchild, a.m_parent, a.m_child) + < t_tuple(!b.m_is_rchild, b.m_parent, b.m_child); + } + }; + + t_datumcmp cmp; + + std::sort(data.begin(), data.end(), cmp); + + std::vector root_children; + + std::queue queue; + t_uindex nroot_children = 0; + while (nroot_children < nrows && data[nroot_children].m_is_rchild) { + queue.push(nroot_children); + ++nroot_children; + } + + tsl::hopscotch_map> p_range_map; + + t_uindex brange = nroot_children; + for (t_uindex idx = nroot_children; idx < nrows; ++idx) { + if (data[idx].m_parent != data[idx - 1].m_parent + && idx > nroot_children) { + p_range_map[data[idx - 1].m_parent] = + std::pair(brange, idx); + brange = idx; + } + } + + p_range_map[data.back().m_parent] = + std::pair(brange, nrows); + + // map from unsorted space to sorted space + tsl::hopscotch_map sortidx_map; + + for (t_uindex idx = 0; idx < nrows; ++idx) { + sortidx_map[data[idx].m_idx] = idx; + } + + while (!queue.empty()) { + // ridx is in sorted space + t_uindex ridx = queue.front(); + queue.pop(); + + const t_datum& rec = data[ridx]; + t_uindex pridx = rec.m_is_rchild ? 0 : sortidx_map.at(rec.m_pidx); + + auto sortby_value = + m_symtable.get_interned_tscalar(sortby_col->get_scalar(rec.m_idx)); + + t_uindex nidx = ridx + 1; + t_uindex pidx = rec.m_is_rchild ? 0 : pridx + 1; + + auto pnode = m_tree->get_node(pidx); + + auto value = m_symtable.get_interned_tscalar(rec.m_child); + + t_stnode node( + nidx, pidx, value, pnode.m_depth + 1, sortby_value, 1, nidx + ); + + m_tree->insert_node(node); + m_tree->add_pkey(nidx, m_symtable.get_interned_tscalar(rec.m_pkey)); + + auto riter = p_range_map.find(rec.m_child); + + if (riter != p_range_map.end()) { + auto range = riter->second; + t_uindex bidx = range.first; + t_uindex eidx = range.second; + + for (t_uindex cidx = bidx; cidx < eidx; ++cidx) { + queue.push(cidx); + } + } + } + + auto* aggtable = m_tree->_get_aggtable(); + aggtable->extend(nrows + 1); + + auto aggspecs = m_config.get_aggregates(); + t_uindex naggs = aggspecs.size(); + + std::vector aggindices(nrows); + + for (t_uindex idx = 0; idx < nrows; ++idx) { + aggindices[idx] = data[idx].m_idx; + } + + parallel_for( + int(naggs), + [&aggtable, &aggindices, &aggspecs, &tbl](int aggnum) { + const t_aggspec& spec = aggspecs[aggnum]; + if (spec.agg() == AGGTYPE_IDENTITY) { + auto* scol = + aggtable->_get_column(spec.get_first_depname()); + scol->copy( + tbl->_get_const_column(spec.get_first_depname()), + aggindices, + 1 + ); + } + } + ); + + m_traversal = std::make_shared(m_tree); + + set_expansion_state(expansion_state); + + if (!m_sortby.empty()) { + m_traversal->sort_by(m_config, m_sortby, *this); + } +} + +void +t_ctx_grouped_pkey::pprint() const { + m_traversal->pprint(); +} + +void +t_ctx_grouped_pkey::notify( + const t_data_table& flattened, bool /* is_registration */ +) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + + rebuild(); +} + +// aggregates should be presized to be same size +// as agg_indices +void +t_ctx_grouped_pkey::get_aggregates_for_sorting( + t_uindex nidx, + const std::vector& agg_indices, + std::vector& aggregates, + t_ctx2* /*unused*/ +) const { + for (t_uindex idx = 0, loop_end = agg_indices.size(); idx < loop_end; + ++idx) { + auto which_agg = agg_indices[idx]; + + if (which_agg < 0) { + aggregates[idx].set(m_tree->get_sortby_value(nidx)); + } else { + aggregates[idx].set(m_tree->get_aggregate(nidx, which_agg)); + } + } +} + +t_dtype +t_ctx_grouped_pkey::get_column_dtype(t_uindex idx) const { + if (idx == 0 || idx >= static_cast(get_column_count())) { + return DTYPE_NONE; + } + + auto* aggtable = m_tree->_get_aggtable(); + return aggtable->get_const_column(idx - 1)->get_dtype(); +} + +void +t_ctx_grouped_pkey::compute_expressions( + const std::shared_ptr& master, + const t_gstate::t_mapping& pkey_map, + t_expression_vocab& expression_vocab, + t_regex_mapping& regex_mapping +) { + // Clear the transitional expression tables on the context so they are + // ready for the next update. + m_expression_tables->clear_transitional_tables(); + + std::shared_ptr master_expression_table = + m_expression_tables->m_master; + + // Set the master table to the right size. + t_uindex num_rows = master->size(); + master_expression_table->reserve(num_rows); + master_expression_table->set_size(num_rows); + + const auto& expressions = m_config.get_expressions(); + for (const auto& expr : expressions) { + // Compute the expressions on the master table. + expr->compute( + master, + pkey_map, + master_expression_table, + expression_vocab, + regex_mapping + ); + } +} + +void +t_ctx_grouped_pkey::compute_expressions( + const std::shared_ptr& master, + const t_gstate::t_mapping& pkey_map, + const std::shared_ptr& flattened, + const std::shared_ptr& delta, + const std::shared_ptr& prev, + const std::shared_ptr& current, + const std::shared_ptr& transitions, + const std::shared_ptr& existed, + t_expression_vocab& expression_vocab, + t_regex_mapping& regex_mapping +) { + // Clear the tables so they are ready for this round of updates + m_expression_tables->clear_transitional_tables(); + + // All tables are the same size + t_uindex flattened_num_rows = flattened->size(); + m_expression_tables->reserve_transitional_table_size(flattened_num_rows); + m_expression_tables->set_transitional_table_size(flattened_num_rows); + + // Update the master expression table's size + t_uindex master_num_rows = master->size(); + m_expression_tables->m_master->reserve(master_num_rows); + m_expression_tables->m_master->set_size(master_num_rows); + + const auto& expressions = m_config.get_expressions(); + for (const auto& expr : expressions) { + // master: compute based on latest state of the gnode state table + expr->compute( + master, + pkey_map, + m_expression_tables->m_master, + expression_vocab, + regex_mapping + ); + + // flattened: compute based on the latest update dataset + expr->compute( + flattened, + pkey_map, + m_expression_tables->m_flattened, + expression_vocab, + regex_mapping + ); + + // delta: for each numerical column, the numerical delta between the + // previous value and the current value in the row. + expr->compute( + delta, + pkey_map, + m_expression_tables->m_delta, + expression_vocab, + regex_mapping + ); + + // prev: the values of the updated rows before this update was applied + expr->compute( + prev, + pkey_map, + m_expression_tables->m_prev, + expression_vocab, + regex_mapping + ); + + // current: the current values of the updated rows + expr->compute( + current, + pkey_map, + m_expression_tables->m_current, + expression_vocab, + regex_mapping + ); + } + + // Calculate the transitions now that the intermediate tables are computed + m_expression_tables->calculate_transitions(existed); +} + +t_uindex +t_ctx_grouped_pkey::num_expressions() const { + const auto& expressions = m_config.get_expressions(); + return expressions.size(); +} + +bool +t_ctx_grouped_pkey::is_expression_column(const std::string& colname) const { + const t_schema& schema = m_expression_tables->m_master->get_schema(); + return schema.has_column(colname); +} + +t_tscalar +t_ctx_grouped_pkey::get_value_from_gstate( + const std::string& colname, const t_tscalar& pkey +) const { + if (is_expression_column(colname)) { + return m_gstate->get_value( + *(m_expression_tables->m_master), colname, pkey + ); + } + std::shared_ptr master_table = m_gstate->get_table(); + return m_gstate->get_value(*master_table, colname, pkey); +} + +std::vector +t_ctx_grouped_pkey::unity_get_row_data(t_uindex idx) const { + auto rval = get_data(idx, idx + 1, 0, get_column_count()); + if (rval.empty()) { + return {}; + } + + return {rval.begin() + 1, rval.end()}; +} + +std::vector +t_ctx_grouped_pkey::unity_get_column_data(t_uindex idx) const { + PSP_COMPLAIN_AND_ABORT("Not implemented"); + return {}; +} + +std::vector +t_ctx_grouped_pkey::unity_get_row_path(t_uindex idx) const { + return get_row_path(idx); +} + +std::vector +t_ctx_grouped_pkey::unity_get_column_path(t_uindex idx) const { + return {}; +} + +t_uindex +t_ctx_grouped_pkey::unity_get_row_depth(t_uindex ridx) const { + return m_traversal->get_depth(ridx); +} + +t_uindex +t_ctx_grouped_pkey::unity_get_column_depth(t_uindex cidx) const { + return 0; +} + +std::string +t_ctx_grouped_pkey::unity_get_column_name(t_uindex idx) const { + return m_config.col_at(idx); +} + +std::string +t_ctx_grouped_pkey::unity_get_column_display_name(t_uindex idx) const { + return m_config.col_at(idx); +} + +std::vector +t_ctx_grouped_pkey::unity_get_column_names() const { + return m_config.get_column_names(); +} + +std::vector +t_ctx_grouped_pkey::unity_get_column_display_names() const { + return m_config.get_column_names(); +} + +t_uindex +t_ctx_grouped_pkey::unity_get_column_count() const { + return get_column_count() - 1; +} + +t_uindex +t_ctx_grouped_pkey::unity_get_row_count() const { + return get_row_count(); +} + +bool +t_ctx_grouped_pkey::unity_get_row_expanded(t_uindex idx) const { + return m_traversal->get_node_expanded(idx); +} + +bool +t_ctx_grouped_pkey::unity_get_column_expanded(t_uindex idx) const { + return false; +} + +void +t_ctx_grouped_pkey::clear_deltas() {} + +void +t_ctx_grouped_pkey::unity_init_load_step_end() {} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/context_handle.cpp b/rust/perspective-server/cpp/perspective/src/cpp/context_handle.cpp new file mode 100644 index 0000000..3b003f3 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/context_handle.cpp @@ -0,0 +1,52 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include + +namespace perspective { + +t_ctx_handle::t_ctx_handle() : m_ctx_type(ZERO_SIDED_CONTEXT), m_ctx(nullptr) {} + +t_ctx_handle::t_ctx_handle(void* ctx, t_ctx_type ctx_type) : + m_ctx_type(ctx_type), + m_ctx(ctx) {} + +std::string +t_ctx_handle::get_type_descr() const { + switch (m_ctx_type) { + case TWO_SIDED_CONTEXT: { + return "TWO_SIDED_CONTEXT"; + } break; + case ONE_SIDED_CONTEXT: { + return "ONE_SIDED_CONTEXT"; + } break; + case ZERO_SIDED_CONTEXT: { + return "ZERO_SIDED_CONTEXT"; + } break; + case UNIT_CONTEXT: { + return "UNIT_CONTEXT"; + } break; + case GROUPED_PKEY_CONTEXT: { + return "GROUPED_PKEY_CONTEXT"; + } break; + case GROUPED_COLUMNS_CONTEXT: { + return "GROUPED_COLUMNS_CONTEXT"; + } + default: { + PSP_COMPLAIN_AND_ABORT("Invalid context"); + } break; + } + return "ZERO_SIDED_CONTEXT"; +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/context_iterators.cpp b/rust/perspective-server/cpp/perspective/src/cpp/context_iterators.cpp new file mode 100644 index 0000000..5e7513b --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/context_iterators.cpp @@ -0,0 +1,246 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +t_trav_iter_node::t_trav_iter_node( + const t_tscalar& value, + bool expanded, + t_depth depth, + t_index ndesc, + t_index tnid, + t_index ridx, + t_index pridx +) : + value(value), + expanded(expanded), + depth(depth), + ndesc(ndesc), + tree_handle(tnid), + idx(ridx), + pidx(pridx) {} + +t_ctx2_trav_seq_iter::t_ctx2_trav_seq_iter(t_ctx2* ctx, t_header header) : + m_ctx(ctx), + m_header(header), + m_curidx(0) { + m_nrows = ctx->get_row_count(header); +} + +t_leaf_iter_node::t_leaf_iter_node( + const t_tscalar& value, + bool expanded, + t_depth depth, + t_index ndesc, + t_index tnid, + t_index ridx, + t_index pridx, + t_index nleaves +) : + value(value), + expanded(expanded), + depth(depth), + ndesc(ndesc), + tree_handle(tnid), + idx(ridx), + pidx(pridx), + nleaves(nleaves) {} + +bool +t_ctx2_trav_seq_iter::has_next() const { + return m_curidx < m_nrows; +} + +t_trav_iter_node* +t_ctx2_trav_seq_iter::next() { + auto node = m_ctx->get_trav_node(m_header, m_curidx); + auto iter_node = new t_trav_iter_node( + m_ctx->get_tree_value(m_header, node.m_tnid), + node.m_expanded, + node.m_depth, + node.m_ndesc, + node.m_tnid, + m_curidx, + m_curidx - node.m_rel_pidx + ); + + m_curidx += 1; + return iter_node; +} + +t_ctx2_leaf_iter::t_ctx2_leaf_iter(const t_ctx2* ctx, t_header header) : + m_ctx(ctx), + m_header(header), + m_curidx(0) { + m_nrows = ctx->get_row_count(header); +} + +bool +t_ctx2_leaf_iter::has_next() const { + return m_curidx < m_nrows; +} + +t_leaf_iter_node* +t_ctx2_leaf_iter::next() { + auto node = m_ctx->get_trav_node(m_header, m_curidx); + t_index nleaves = m_ctx->get_trav_num_tree_leaves(m_header, m_curidx); + + t_leaf_iter_node* iter_node = new t_leaf_iter_node( + m_ctx->get_tree_value(m_header, node.m_tnid), + node.m_expanded, + node.m_depth, + node.m_ndesc, + node.m_tnid, + m_curidx, + m_curidx - node.m_rel_pidx, + nleaves + ); + + m_curidx += 1; + return iter_node; +} + +t_ctx1_tree_iter::t_ctx1_tree_iter(t_ctx1* ctx, t_index root, t_depth depth) : + m_curidx(0), + m_ctx(ctx) { + m_agg_indices = std::vector(ctx->get_aggregates().size()); + + for (t_uindex idx = 0, loop_end = m_agg_indices.size(); idx < loop_end; + ++idx) { + m_agg_indices[idx] = idx; + } + + m_has_next = !(m_agg_indices.empty()); + if (m_has_next) { + t_depth rdepth = ctx->get_trav_depth(root); + m_edepth = rdepth + depth; + m_edepth = std::min(m_edepth, ctx->get_num_levels() - 1); + m_vec = m_ctx->get_flattened_tree(root, m_edepth); + if (m_vec.size() == 1 && m_vec[0].m_nchild == 0) { + m_has_next = false; + } + + m_aggcols = ctx->_get_tree()->get_aggcols(m_agg_indices); + } +} + +t_ctx1_tree_iter::t_ctx1_tree_iter( + t_ctx1* ctx, + t_index root, + t_depth depth, + const std::vector& aggregates +) : + m_curidx(0), + m_ctx(ctx), + m_agg_indices(aggregates) { + m_has_next = !(m_agg_indices.empty()); + if (m_has_next) { + t_depth rdepth = ctx->get_trav_depth(root); + m_edepth = rdepth + depth; + m_edepth = std::min(m_edepth, ctx->get_num_levels() - 1); + m_vec = m_ctx->get_flattened_tree(root, m_edepth); + if (m_vec.size() == 1 && m_vec[0].m_nchild == 0) { + m_has_next = false; + } + + m_aggcols = ctx->_get_tree()->get_aggcols(aggregates); + } +} + +bool +t_ctx1_tree_iter::has_next() const { + return m_has_next; +} + +PyObject* +t_ctx1_tree_iter::next() { + t_ftreenode& ftnode = m_vec[m_curidx]; + t_index bidx; + t_index nrows; + if (m_ctx->get_row_pivots().size() == 0 || m_edepth == 0) { + bidx = 0; + nrows = 1; + } else { + bidx = ftnode.m_fcidx; + nrows = ftnode.m_nchild; + } + npy_intp dims[] = {static_cast(nrows)}; + t_py_handle dtype( + Py_BuildValue("[(s, s), (s, s)]", "id", "i4", "field_label", "O") + ); + t_index n_aggs = m_agg_indices.size(); + + for (int i = 0; i < n_aggs; i++) { + std::stringstream field_name; + field_name << "field_" << i + 1; + t_py_handle d_field( + Py_BuildValue("(s,s)", field_name.str().c_str(), "f8") + ); + PyList_Append(dtype.m_pyo, d_field.m_pyo); + } + PyArray_Descr* descr; + PyArray_DescrConverter(dtype.m_pyo, &descr); + t_py_handle array(PyArray_SimpleNewFromDescr(1, dims, descr)); + PyArrayObject* array_ = reinterpret_cast(array.m_pyo); + + t_stree* tree = m_ctx->_get_tree(); + const std::vector& aggspecs = + m_ctx->get_config().get_aggregates(); + + for (int i = 0; i < nrows; i++) { + t_ftreenode& c_ftnode = m_vec[bidx + i]; + t_index cidx = c_ftnode.m_idx; + void* elem_ptr = PyArray_GETPTR1(array_, i); + t_py_handle val(PyTuple_New(n_aggs + 2)); + PyTuple_SetItem(val.m_pyo, 0, PyInt_FromLong(cidx)); + + t_tscalar tree_value = m_ctx->get_tree_value(cidx); + PyObject* py_tree_value = tscalar_to_python(tree_value); + PyTuple_SetItem(val.m_pyo, 1, py_tree_value); + + t_index p_cidx = tree->get_parent_idx(cidx); + + t_uindex agg_ridx = tree->get_aggidx(cidx); + t_index agg_pridx = + p_cidx == INVALID_INDEX ? INVALID_INDEX : tree->get_aggidx(p_cidx); + + for (int j = 0; j < n_aggs; j++) { + t_tscalar agg_scalar = extract_aggregate( + aggspecs[j], m_aggcols[j], agg_ridx, agg_pridx + ); + double aggdbl = agg_scalar.to_double(); + PyTuple_SetItem(val.m_pyo, j + 2, PyFloat_FromDouble(aggdbl)); + } + PyArray_SETITEM(array_, static_cast(elem_ptr), val.m_pyo); + } + long is_leaf = ftnode.m_depth + 1 == m_edepth; + PyObject* rval = Py_BuildValue("OO", PyBool_FromLong(is_leaf), array.m_pyo); + m_curidx++; + + if (m_vec[m_curidx].m_depth == t_depth(-1) + || m_ctx->get_row_pivots().size() == 0 || m_edepth == 0 + || static_cast(m_curidx) >= m_vec.size()) { + m_has_next = false; + } + return rval; +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/context_one.cpp b/rust/perspective-server/cpp/perspective/src/cpp/context_one.cpp new file mode 100644 index 0000000..909f503 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/context_one.cpp @@ -0,0 +1,944 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +#include + +namespace perspective { + +t_ctx1::t_ctx1(const t_schema& schema, const t_config& pivot_config) : + t_ctxbase(schema, pivot_config), + m_depth(0), + m_depth_set(false) {} + +t_ctx1::~t_ctx1() = default; + +void +t_ctx1::init() { + auto pivots = m_config.get_row_pivots(); + m_tree = std::make_shared( + pivots, m_config.get_aggregates(), m_schema, m_config + ); + m_tree->init(); + m_traversal = std::make_shared(m_tree); + + // Each context stores its own expression columns in separate + // `t_data_table`s so that each context's expressions are isolated + // and do not affect other contexts when they are calculated. + const auto& expressions = m_config.get_expressions(); + m_expression_tables = std::make_shared( + expressions, m_config.get_backing_store() + ); + + m_init = true; +} + +t_index +t_ctx1::get_row_count() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_traversal->size(); +} + +t_index +t_ctx1::get_column_count() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_config.get_num_aggregates() + 1; +} + +t_index +t_ctx1::open(t_header header, t_index idx) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return open(idx); +} + +std::string +t_ctx1::repr() const { + std::stringstream ss; + ss << "t_ctx1<" << this << ">"; + return ss.str(); +} + +t_index +t_ctx1::open(t_index idx) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + // If we manually open/close a node, stop automatically expanding + m_depth_set = false; + m_depth = 0; + + if (idx >= t_index(m_traversal->size())) { + return 0; + } + + t_index retval = m_traversal->expand_node(m_sortby, idx); + m_rows_changed = (retval > 0); + return retval; +} + +t_index +t_ctx1::close(t_index idx) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + // If we manually open/close a node, stop automatically expanding + m_depth_set = false; + m_depth = 0; + + if (idx >= t_index(m_traversal->size())) { + return 0; + } + + t_index retval = m_traversal->collapse_node(idx); + m_rows_changed = (retval > 0); + return retval; +} + +std::pair +t_ctx1::get_min_max(const std::string& colname) const { + auto rval = std::make_pair(mknone(), mknone()); + auto* aggtable = m_tree->get_aggtable(); + const t_schema& aggschema = aggtable->get_schema(); + const auto* col = aggtable->_get_const_column(colname); + auto colidx = aggschema.get_colidx(colname); + auto depth = m_config.get_num_rpivots(); + const std::vector& aggspecs = m_config.get_aggregates(); + bool is_finished = false; + while (!is_finished && depth > 0) { + for (std::size_t i = 0; i < m_traversal->size(); i++) { + t_index nidx = m_traversal->get_tree_index(i); + t_index pnidx = m_tree->get_parent_idx(nidx); + if (m_tree->get_depth(nidx) != depth) { + continue; + } + + t_uindex agg_ridx = m_tree->get_aggidx(nidx); + t_index agg_pridx = pnidx == INVALID_INDEX + ? INVALID_INDEX + : m_tree->get_aggidx(pnidx); + t_tscalar val = + extract_aggregate(aggspecs[colidx], col, agg_ridx, agg_pridx); + + if (!val.is_valid()) { + continue; + } + + is_finished = true; + if (rval.first.is_none() || (!val.is_none() && val < rval.first)) { + rval.first = val; + } + + if (val > rval.second) { + rval.second = val; + } + } + + depth--; + } + + return rval; +} + +std::vector +t_ctx1::get_data( + t_index start_row, t_index end_row, t_index start_col, t_index end_col +) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + t_uindex ctx_nrows = get_row_count(); + t_uindex ncols = get_column_count(); + auto ext = sanitize_get_data_extents( + ctx_nrows, ncols, start_row, end_row, start_col, end_col + ); + + t_index nrows = ext.m_erow - ext.m_srow; + t_index stride = ext.m_ecol - ext.m_scol; + + std::vector values(nrows * stride); + + std::vector aggcols(m_config.get_num_aggregates()); + + auto* aggtable = m_tree->get_aggtable(); + auto none = mknone(); + + for (t_uindex aggidx = 0, loop_end = aggcols.size(); aggidx < loop_end; + ++aggidx) { + // `aggidx` is the column's position in the aggtable schema, so resolve + // by index — avoids the per-iteration name->index map lookup + a temp + // std::string, and the up-front schema deep-copy. + aggcols[aggidx] = aggtable->_get_const_column(aggidx); + } + + const std::vector& aggspecs = m_config.get_aggregates(); + + for (t_index ridx = ext.m_srow; ridx < ext.m_erow; ++ridx) { + t_index nidx = m_traversal->get_tree_index(ridx); + t_index pnidx = m_tree->get_parent_idx(nidx); + + t_uindex agg_ridx = m_tree->get_aggidx(nidx); + t_index agg_pridx = + pnidx == INVALID_INDEX ? INVALID_INDEX : m_tree->get_aggidx(pnidx); + + t_index row_off = (ridx - ext.m_srow) * stride; + + if (ext.m_scol == 0) { + t_tscalar tree_value = m_tree->get_value(nidx); + values[row_off] = tree_value; + } + + for (t_index aggidx = 0, loop_end = aggcols.size(); aggidx < loop_end; + ++aggidx) { + t_index col = 1 + aggidx; + if (col < ext.m_scol || col >= ext.m_ecol) { + continue; + } + t_tscalar value = extract_aggregate( + aggspecs[aggidx], aggcols[aggidx], agg_ridx, agg_pridx + ); + if (!value.is_valid()) { + value.set(none); + } + values[row_off + col - ext.m_scol].set(value); + } + } + + return values; +} + +std::vector +t_ctx1::get_data(const std::vector& rows) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + t_uindex nrows = rows.size(); + t_uindex ncols = get_column_count(); + + std::vector values(nrows * ncols); + + std::vector aggcols(m_config.get_num_aggregates()); + + auto* aggtable = m_tree->get_aggtable(); + auto none = mknone(); + + for (t_uindex aggidx = 0, loop_end = aggcols.size(); aggidx < loop_end; + ++aggidx) { + // `aggidx` is the column's position in the aggtable schema, so resolve + // by index — avoids the per-iteration name->index map lookup + a temp + // std::string, and the up-front schema deep-copy. + aggcols[aggidx] = aggtable->_get_const_column(aggidx); + } + + const std::vector& aggspecs = m_config.get_aggregates(); + + for (t_uindex idx = 0; idx < nrows; ++idx) { + t_uindex ridx = rows[idx]; + t_index nidx = m_traversal->get_tree_index(ridx); + t_index pnidx = m_tree->get_parent_idx(nidx); + + t_uindex agg_ridx = m_tree->get_aggidx(nidx); + t_index agg_pridx = + pnidx == INVALID_INDEX ? INVALID_INDEX : m_tree->get_aggidx(pnidx); + + t_tscalar tree_value = m_tree->get_value(nidx); + values[idx * ncols] = tree_value; + + for (t_index aggidx = 0, loop_end = aggcols.size(); aggidx < loop_end; + ++aggidx) { + t_tscalar value = extract_aggregate( + aggspecs[aggidx], aggcols[aggidx], agg_ridx, agg_pridx + ); + if (!value.is_valid()) { + value.set(none); + } + values[idx * ncols + 1 + aggidx].set(value); + } + } + + return values; +} + +void +t_ctx1::notify(const t_data_table& flattened, bool /* is_registration */) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + + notify_sparse_tree( + m_tree, + m_traversal, + true, + m_config.get_aggregates(), + m_config.get_sortby_pairs(), + m_sortby, + flattened, + m_config, + *m_gstate, + *(m_expression_tables->m_master) + ); +} + +void +t_ctx1::notify( + const t_data_table& flattened, + const t_data_table& delta, + const t_data_table& prev, + const t_data_table& current, + const t_data_table& transitions, + const t_data_table& existed +) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + + notify_sparse_tree( + m_tree, + m_traversal, + true, + m_config.get_aggregates(), + m_config.get_sortby_pairs(), + m_sortby, + flattened, + delta, + prev, + current, + transitions, + existed, + m_config, + *m_gstate, + *(m_expression_tables->m_master) + ); +} + +void +t_ctx1::step_begin() { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + reset_step_state(); +} + +void +t_ctx1::step_end() { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + if (m_total_only) { + m_traversal->rebuild_for_total(); + } else if (m_leaves_only) { + m_traversal->rebuild_from_leaves(m_sortby); + } else { + sort_by(m_sortby); + if (m_depth_set) { + set_depth(m_depth); + } + } +} + +t_aggspec +t_ctx1::get_aggregate(t_uindex idx) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + if (idx >= m_config.get_num_aggregates()) { + return {}; + } + return m_config.get_aggregates()[idx]; +} + +t_tscalar +t_ctx1::get_aggregate_name(t_uindex idx) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + t_tscalar s; + if (idx >= m_config.get_num_aggregates()) { + return s; + } + s.set(m_config.get_aggregates()[idx].name_scalar()); + return s; +} + +std::vector +t_ctx1::get_aggregates() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_config.get_aggregates(); +} + +std::vector +t_ctx1::get_row_path(t_index idx) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + if (idx < 0) { + return {}; + } + return ctx_get_path(m_tree, m_traversal, idx); +} + +void +t_ctx1::reset_sortby() { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + m_sortby = std::vector(); +} + +void +t_ctx1::sort_by(const std::vector& sortby) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + m_sortby = sortby; + if (m_sortby.empty()) { + return; + } + m_traversal->sort_by(m_config, sortby, *(m_tree)); +} + +void +t_ctx1::set_depth(t_depth depth) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + if (m_config.get_num_rpivots() == 0) { + return; + } + depth = std::min(m_config.get_num_rpivots() - 1, depth); + t_index retval = 0; + retval = m_traversal->set_depth(m_sortby, depth); + m_rows_changed = (retval > 0); + m_depth = depth; + m_depth_set = true; +} + +void +t_ctx1::set_leaves_only(bool enabled) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + m_leaves_only = enabled; + m_traversal->set_leaves_only(enabled, m_config.get_num_rpivots()); +} + +void +t_ctx1::set_total_only(bool enabled) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + m_total_only = enabled; + m_traversal->set_total_only(enabled); +} + +std::vector +t_ctx1::get_pkeys(const std::vector>& cells +) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + + if (!m_traversal->validate_cells(cells)) { + std::vector rval; + return rval; + } + + std::vector rval; + std::vector tindices(cells.size()); + for (const auto& c : cells) { + auto ptidx = m_traversal->get_tree_index(c.first); + auto pkeys = m_tree->get_pkeys(ptidx); + + rval.insert(std::end(rval), std::begin(pkeys), std::end(pkeys)); + } + return rval; +} + +bool +t_ctx1::get_deltas_enabled() const { + return m_features[CTX_FEAT_DELTA]; +} + +void +t_ctx1::set_feature_state(t_ctx_feature feature, bool state) { + m_features[feature] = state; +} + +void +t_ctx1::set_alerts_enabled(bool enabled_state) { + m_features[CTX_FEAT_ALERT] = enabled_state; + m_tree->set_alerts_enabled(enabled_state); +} + +void +t_ctx1::set_deltas_enabled(bool enabled_state) { + m_features[CTX_FEAT_DELTA] = enabled_state; + m_tree->set_deltas_enabled(enabled_state); +} + +/** + * @brief Returns updated cells. + * + * @param bidx + * @param eidx + * @return t_stepdelta + */ +t_stepdelta +t_ctx1::get_step_delta(t_index bidx, t_index eidx) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + bidx = std::min(bidx, t_index(m_traversal->size())); + eidx = std::min(eidx, t_index(m_traversal->size())); + + t_stepdelta rval( + m_rows_changed, m_columns_changed, get_cell_delta(bidx, eidx) + ); + m_tree->clear_deltas(); + return rval; +} + +/** + * @brief Returns a `t_rowdelta` object containing: + * - the row indices that have been updated + * - the data from those updated rows + * + * @return t_rowdelta + */ +t_rowdelta +t_ctx1::get_row_delta() { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + std::vector rows = get_rows_changed(); + std::vector data = get_data(rows); + t_rowdelta rval(m_rows_changed, rows.size(), data); + m_tree->clear_deltas(); + return rval; +} + +std::vector +t_ctx1::get_rows_changed() { + std::vector rows; + const auto& deltas = m_tree->get_deltas(); + auto eidx = t_uindex(m_traversal->size()); + + // `idx` increases monotonically and is pushed at most once, so `rows` is + // already unique and sorted: the per-iteration `std::find` (which made this + // O(n^2)) and the trailing `std::sort` were both dead work. + for (t_uindex idx = 0; idx < eidx; ++idx) { + t_index ptidx = m_traversal->get_tree_index(idx); + // Retrieve delta from storage and check if the row has been changed + auto iterators = deltas->get().equal_range(ptidx); + if (iterators.first != iterators.second) { + rows.push_back(idx); + } + } + + return rows; +} + +std::vector +t_ctx1::get_cell_delta(t_index bidx, t_index eidx) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + eidx = std::min(eidx, t_index(m_traversal->size())); + std::vector rval; + const auto& deltas = m_tree->get_deltas(); + for (t_index idx = bidx; idx < eidx; ++idx) { + t_index ptidx = m_traversal->get_tree_index(idx); + auto iterators = deltas->get().equal_range(ptidx); + for (auto iter = iterators.first; iter != iterators.second; ++iter) { + rval.emplace_back( + idx, iter->m_aggidx + 1, iter->m_old_value, iter->m_new_value + ); + } + } + return rval; +} + +void +t_ctx1::reset(bool reset_expressions) { + auto pivots = m_config.get_row_pivots(); + m_tree = std::make_shared( + pivots, m_config.get_aggregates(), m_schema, m_config + ); + m_tree->init(); + m_tree->set_deltas_enabled(get_feature_state(CTX_FEAT_DELTA)); + m_traversal = std::make_shared(m_tree); + if (m_leaves_only) { + m_traversal->set_leaves_only(true, m_config.get_num_rpivots()); + } + + if (reset_expressions) { + m_expression_tables->reset(); + } +} + +void +t_ctx1::reset_step_state() { + m_rows_changed = false; + m_columns_changed = false; + if (t_env::log_progress()) { + std::cout << "t_ctx1.reset_step_state " << repr() << '\n'; + } +} + +t_index +t_ctx1::sidedness() const { + return 1; +} + +std::vector +t_ctx1::get_trees() { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + std::vector rval(1); + rval[0] = m_tree.get(); + return rval; +} + +bool +t_ctx1::has_deltas() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_tree->has_deltas(); +} + +void +t_ctx1::pprint() const { + std::cout << "\t" << '\n'; + for (auto idx = 1; idx < get_column_count(); ++idx) { + std::cout << get_aggregate(idx - 1).agg_str() << ", " << '\n'; + } + + std::vector aggcols(m_config.get_num_aggregates()); + auto* aggtable = m_tree->get_aggtable(); + auto none = mknone(); + + for (t_uindex aggidx = 0, loop_end = aggcols.size(); aggidx < loop_end; + ++aggidx) { + // `aggidx` is the column's position in the aggtable schema, so resolve + // by index — avoids the per-iteration name->index map lookup + a temp + // std::string, and the up-front schema deep-copy. + aggcols[aggidx] = aggtable->_get_const_column(aggidx); + } + + const std::vector& aggspecs = m_config.get_aggregates(); + + for (auto ridx = 0; ridx < get_row_count(); ++ridx) { + t_index nidx = m_traversal->get_tree_index(ridx); + t_index pnidx = m_tree->get_parent_idx(nidx); + + t_uindex agg_ridx = m_tree->get_aggidx(nidx); + t_index agg_pridx = + pnidx == INVALID_INDEX ? INVALID_INDEX : m_tree->get_aggidx(pnidx); + + std::cout << get_row_path(ridx) << " => "; + for (t_index aggidx = 0, loop_end = aggcols.size(); aggidx < loop_end; + ++aggidx) { + t_tscalar value = extract_aggregate( + aggspecs[aggidx], aggcols[aggidx], agg_ridx, agg_pridx + ); + if (!value.is_valid()) { + value.set(none); // todo: fix null handling + } + + std::cout << value << ", "; + } + + std::cout << "\n"; + } + + std::cout << "=================" << '\n'; +} + +t_index +t_ctx1::get_row_idx(const std::vector& path) const { + auto nidx = m_tree->resolve_path(0, path); + if (nidx == INVALID_INDEX) { + return nidx; + } + + return m_traversal->get_traversal_index(nidx); +} + +t_dtype +t_ctx1::get_column_dtype(t_uindex idx) const { + if (idx == 0 || idx >= static_cast(get_column_count())) { + return DTYPE_NONE; + } + return m_tree->get_aggtable()->get_const_column(idx - 1)->get_dtype(); +} + +t_depth +t_ctx1::get_trav_depth(t_index idx) const { + return m_traversal->get_depth(idx); +} + +void +t_ctx1::compute_expressions( + const std::shared_ptr& master, + const t_gstate::t_mapping& pkey_map, + t_expression_vocab& expression_vocab, + t_regex_mapping& regex_mapping +) { + // Clear the transitional expression tables on the context so they are + // ready for the next update. + m_expression_tables->clear_transitional_tables(); + + std::shared_ptr master_expression_table = + m_expression_tables->m_master; + + // Set the master table to the right size. + t_uindex num_rows = master->size(); + master_expression_table->reserve(num_rows); + master_expression_table->set_size(num_rows); + + const auto& expressions = m_config.get_expressions(); + for (const auto& expr : expressions) { + // Compute the expressions on the master table. + expr->compute( + master, + pkey_map, + master_expression_table, + expression_vocab, + regex_mapping + ); + } +} + +void +t_ctx1::compute_expressions( + const std::shared_ptr& master, + const t_gstate::t_mapping& pkey_map, + const std::shared_ptr& flattened, + const std::shared_ptr& delta, + const std::shared_ptr& prev, + const std::shared_ptr& current, + const std::shared_ptr& transitions, + const std::shared_ptr& existed, + t_expression_vocab& expression_vocab, + t_regex_mapping& regex_mapping +) { + // Clear the tables so they are ready for this round of updates + m_expression_tables->clear_transitional_tables(); + + // All tables are the same size + t_uindex flattened_num_rows = flattened->size(); + m_expression_tables->reserve_transitional_table_size(flattened_num_rows); + m_expression_tables->set_transitional_table_size(flattened_num_rows); + + // Update the master expression table's size + t_uindex master_num_rows = master->size(); + m_expression_tables->m_master->reserve(master_num_rows); + m_expression_tables->m_master->set_size(master_num_rows); + + const auto& expressions = m_config.get_expressions(); + for (const auto& expr : expressions) { + // master: compute based on latest state of the gnode state table + expr->compute( + master, + pkey_map, + m_expression_tables->m_master, + expression_vocab, + regex_mapping + ); + + // flattened: compute based on the latest update dataset + expr->compute( + flattened, + pkey_map, + m_expression_tables->m_flattened, + expression_vocab, + regex_mapping + ); + + // delta: for each numerical column, the numerical delta between the + // previous value and the current value in the row. + expr->compute( + delta, + pkey_map, + m_expression_tables->m_delta, + expression_vocab, + regex_mapping + ); + + // prev: the values of the updated rows before this update was applied + expr->compute( + prev, + pkey_map, + m_expression_tables->m_prev, + expression_vocab, + regex_mapping + ); + + // current: the current values of the updated rows + expr->compute( + current, + pkey_map, + m_expression_tables->m_current, + expression_vocab, + regex_mapping + ); + } + + // Calculate the transitions now that the intermediate tables are computed + m_expression_tables->calculate_transitions(existed); +} + +bool +t_ctx1::is_expression_column(const std::string& colname) const { + const t_schema& schema = m_expression_tables->m_master->get_schema(); + return schema.has_column(colname); +} + +t_uindex +t_ctx1::num_expressions() const { + const auto& expressions = m_config.get_expressions(); + return expressions.size(); +} + +std::shared_ptr +t_ctx1::get_expression_tables() const { + return m_expression_tables; +} + +std::vector +t_ctx1::unity_get_row_data(t_uindex idx) const { + auto rval = get_data(idx, idx + 1, 0, get_column_count()); + if (rval.empty()) { + return {}; + } + + return {rval.begin() + 1, rval.end()}; +} + +std::vector +t_ctx1::unity_get_column_data(t_uindex idx) const { + PSP_COMPLAIN_AND_ABORT("Not implemented"); + return {}; +} + +std::vector +t_ctx1::unity_get_row_path(t_uindex idx) const { + return get_row_path(idx); +} + +std::vector +t_ctx1::unity_get_column_path(t_uindex idx) const { + return {}; +} + +t_uindex +t_ctx1::unity_get_row_depth(t_uindex ridx) const { + return m_traversal->get_depth(ridx); +} + +t_uindex +t_ctx1::unity_get_column_depth(t_uindex cidx) const { + return 0; +} + +std::string +t_ctx1::unity_get_column_name(t_uindex idx) const { + return m_config.unity_get_column_name(idx); +} + +std::string +t_ctx1::unity_get_column_display_name(t_uindex idx) const { + return m_config.unity_get_column_display_name(idx); +} + +std::vector +t_ctx1::unity_get_column_names() const { + std::vector rv; + + for (t_uindex idx = 0, loop_end = unity_get_column_count(); idx < loop_end; + ++idx) { + rv.push_back(unity_get_column_name(idx)); + } + return rv; +} + +std::vector +t_ctx1::unity_get_column_display_names() const { + std::vector rv; + + for (t_uindex idx = 0, loop_end = unity_get_column_count(); idx < loop_end; + ++idx) { + rv.push_back(unity_get_column_display_name(idx)); + } + return rv; +} + +t_uindex +t_ctx1::unity_get_column_count() const { + return get_column_count() - 1; +} + +t_uindex +t_ctx1::unity_get_row_count() const { + return get_row_count(); +} + +bool +t_ctx1::unity_get_row_expanded(t_uindex idx) const { + return m_traversal->get_node_expanded(idx); +} + +bool +t_ctx1::unity_get_column_expanded(t_uindex idx) const { + return false; +} + +void +t_ctx1::clear_deltas() { + m_tree->clear_deltas(); +} + +void +t_ctx1::unity_init_load_step_end() {} + +std::shared_ptr +t_ctx1::get_table() const { + const t_schema& schema = m_tree->get_aggtable()->get_schema(); + const auto& pivots = m_config.get_row_pivots(); + auto tbl = std::make_shared(schema, m_tree->size()); + tbl->init(); + tbl->extend(m_tree->size()); + + std::vector aggcols = tbl->get_columns(); + auto n_aggs = aggcols.size(); + std::vector pivcols; + + std::stringstream ss; + pivcols.reserve(pivots.size()); + for (const auto& c : pivots) { + pivcols.push_back( + tbl->add_column(c.colname(), m_schema.get_dtype(c.colname()), true) + ); + } + + auto idx = 0; + for (auto nidx : m_tree->dfs()) { + auto depth = m_tree->get_depth(nidx); + if (depth > 0) { + pivcols[depth - 1]->set_scalar(idx, m_tree->get_value(nidx)); + } + for (t_uindex aggnum = 0; aggnum < n_aggs; ++aggnum) { + auto aggscalar = m_tree->get_aggregate(nidx, aggnum); + aggcols[aggnum]->set_scalar(idx, aggscalar); + } + ++idx; + } + return tbl; +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/context_two.cpp b/rust/perspective-server/cpp/perspective/src/cpp/context_two.cpp new file mode 100644 index 0000000..15f0589 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/context_two.cpp @@ -0,0 +1,1507 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace perspective { + +t_ctx2::t_ctx2() : + m_row_depth(0), + m_row_depth_set(false), + m_column_depth(0), + m_column_depth_set(false) {} + +t_ctx2::t_ctx2(const t_schema& schema, const t_config& pivot_config) : + t_ctxbase(schema, pivot_config), + m_row_depth(0), + m_row_depth_set(false), + m_column_depth(0), + m_column_depth_set(false) {} + +t_ctx2::~t_ctx2() = default; + +t_uindex +t_ctx2::get_num_trees() const { + return m_config.get_num_rpivots() + 1; +} + +std::string +t_ctx2::repr() const { + std::stringstream ss; + ss << "t_ctx2<" << this << ">"; + return ss.str(); +} + +void +t_ctx2::init() { + m_trees = std::vector>(get_num_trees()); + + for (t_uindex treeidx = 0, tree_loop_end = m_trees.size(); + treeidx < tree_loop_end; + ++treeidx) { + std::vector pivots; + if (treeidx > 0) { + pivots.insert( + pivots.end(), + m_config.get_row_pivots().begin(), + m_config.get_row_pivots().begin() + treeidx + ); + } + + pivots.insert( + pivots.end(), + m_config.get_column_pivots().begin(), + m_config.get_column_pivots().end() + ); + + m_trees[treeidx] = std::make_shared( + pivots, m_config.get_aggregates(), m_schema, m_config + ); + + m_trees[treeidx]->init(); + + // Tell the tree how many of its `m_pivots` are row pivots and + // what the next missing row pivot is (if any). AGGTYPE_GMV uses + // this to roll up across row dimensions that aren't embedded in + // this particular tree — e.g. m_trees[0] has no row pivots, so + // a tree-leaf at e.g. [AAPL] needs to be partitioned by + // `row_pivots[0]` from the gstate to compute the gmv value. + const auto& row_pivots = m_config.get_row_pivots(); + std::string next_row_pivot; + if (treeidx < row_pivots.size()) { + next_row_pivot = row_pivots[treeidx].colname(); + } + m_trees[treeidx]->set_gmv_row_pivot_meta(treeidx, next_row_pivot); + } + + m_rtraversal = std::make_shared(rtree()); + + m_ctraversal = std::make_shared(ctree()); + + // Each context stores its own expression columns in separate + // `t_data_table`s so that each context's expressions are isolated + // and do not affect other contexts when they are calculated. + const auto& expressions = m_config.get_expressions(); + m_expression_tables = std::make_shared( + expressions, m_config.get_backing_store() + ); + + m_init = true; +} + +t_uindex +t_ctx2::num_expressions() const { + const auto& expressions = m_config.get_expressions(); + return expressions.size(); +} + +std::shared_ptr +t_ctx2::get_expression_tables() const { + return m_expression_tables; +} + +void +t_ctx2::step_begin() { + reset_step_state(); +} + +void +t_ctx2::step_end() { + if (m_total_only) { + m_rtraversal->rebuild_for_total(); + } else if (m_leaves_only) { + m_rtraversal->rebuild_from_leaves(m_sortby); + } else { + if (m_row_depth_set) { + set_depth(HEADER_ROW, m_row_depth); + } + } + if (m_column_depth_set) { + set_depth(HEADER_COLUMN, m_column_depth); + } +} + +t_index +t_ctx2::get_row_count() const { + return m_rtraversal->size(); +} + +t_index +t_ctx2::get_column_count() const { + return get_num_view_columns(); +} + +t_index +t_ctx2::open(t_header header, t_index idx) { + t_index retval; + + if (header == HEADER_ROW) { + if (!m_rtraversal->is_valid_idx(idx)) { + return 0; + } + m_row_depth_set = false; + m_row_depth = 0; + if (m_sortby.empty()) { + retval = m_rtraversal->expand_node(idx); + } else { + retval = m_rtraversal->expand_node(m_sortby, idx); + } + m_rows_changed = (retval > 0); + } else { + if (!m_ctraversal->is_valid_idx(idx)) { + return 0; + } + retval = m_ctraversal->expand_node(idx); + m_column_depth_set = false; + m_column_depth = 0; + m_columns_changed = (retval > 0); + } + + return retval; +} + +t_index +t_ctx2::close(t_header header, t_index idx) { + t_index retval; + + switch (header) { + case HEADER_ROW: { + if (!m_rtraversal->is_valid_idx(idx)) { + return 0; + } + m_row_depth_set = false; + m_row_depth = 0; + retval = m_rtraversal->collapse_node(idx); + m_rows_changed = (retval > 0); + } break; + case HEADER_COLUMN: { + if (!m_ctraversal->is_valid_idx(idx)) { + return 0; + } + m_column_depth_set = false; + m_column_depth = 0; + retval = m_ctraversal->collapse_node(idx); + m_columns_changed = (retval > 0); + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Invalid header type detected."); + return INVALID_INDEX; + } break; + } + return retval; +} + +t_totals +t_ctx2::get_totals() const { + return m_config.get_totals(); +} + +std::vector +t_ctx2::get_ctraversal_indices() const { + switch (m_config.get_totals()) { + case TOTALS_BEFORE: { + t_index nelems = m_ctraversal->size(); + PSP_VERBOSE_ASSERT(nelems > 0, "nelems is <= 0"); + std::vector rval(nelems); + for (t_index cidx = 0; cidx < nelems; ++cidx) { + rval[cidx] = cidx; + } + return rval; + } break; + case TOTALS_AFTER: { + std::vector col_order; + m_ctraversal->post_order(0, col_order); + return col_order; + } break; + case TOTALS_HIDDEN: { + std::vector leaves; + m_ctraversal->get_leaves(leaves); + std::vector rval(leaves.size() + 1); + rval[0] = 0; + for (t_uindex idx = 1, loop_end = rval.size(); idx < loop_end; + ++idx) { + rval[idx] = leaves[idx - 1]; + } + return rval; + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unknown total type"); + } + } + return {}; +} + +std::pair +t_ctx2::get_min_max(const std::string& colname) const { + t_uindex ctx_nrows = get_row_count(); + t_uindex ctx_ncols = get_column_count(); + auto rval = std::make_pair(mknone(), mknone()); + t_uindex scol = + m_trees[0]->get_aggtable()->get_schema().get_colidx(colname); + auto cells_info = resolve_cells(0, ctx_nrows, 0, ctx_ncols); + auto n_aggs = m_config.get_num_aggregates(); + t_uindex ntrees = m_trees.size(); + std::vector aggcols(ntrees * n_aggs); + for (t_uindex treeidx = 0; treeidx < ntrees; ++treeidx) { + auto* aggtable = m_trees[treeidx]->get_aggtable(); + for (t_uindex aggidx = 0; aggidx < t_uindex(n_aggs); ++aggidx) { + // resolve by column index (== aggidx); skips the name->index map + // lookup + temp string and the schema deep-copy. + aggcols[treeidx * n_aggs + aggidx] = + aggtable->_get_const_column(aggidx); + } + } + + const std::vector& aggspecs = m_config.get_aggregates(); + bool is_finished = false; + t_uindex row_depth = m_row_depth + 1; + while (!is_finished && row_depth > 0) { + for (const auto& cinfo : cells_info) { + if (cinfo.m_idx < 0 || cinfo.m_agg_index != scol) { + continue; + } + t_index nidx = m_rtraversal->get_tree_index(cinfo.m_ridx); + if (rtree()->get_depth(nidx) != row_depth) { + continue; + } + + auto col_depth = ctree()->get_depth(m_ctraversal->get_tree_index( + calc_translated_colidx(n_aggs, cinfo.m_cidx) + )); + if (m_config.get_num_cpivots() != col_depth) { + continue; + } + + const auto* aggcol = + aggcols[cinfo.m_treenum * n_aggs + cinfo.m_agg_index]; + t_index p_idx = + m_trees[cinfo.m_treenum]->get_parent_idx(cinfo.m_idx); + t_uindex agg_ridx = + m_trees[cinfo.m_treenum]->get_aggidx(cinfo.m_idx); + t_uindex agg_pridx = p_idx == INVALID_INDEX + ? INVALID_INDEX + : m_trees[cinfo.m_treenum]->get_aggidx(p_idx); + + auto val = extract_aggregate( + aggspecs[cinfo.m_agg_index], aggcol, agg_ridx, agg_pridx + ); + + if (!val.is_valid()) { + continue; + } + + is_finished = true; + if (rval.first.is_none() || (!val.is_none() && val < rval.first)) { + rval.first = val; + } + + if (val > rval.second) { + rval.second = val; + } + } + + row_depth--; + } + + return rval; +} + +std::vector +t_ctx2::get_data( + t_index start_row, t_index end_row, t_index start_col, t_index end_col +) const { + t_uindex ctx_nrows = get_row_count(); + t_uindex ctx_ncols = get_column_count(); + auto ext = sanitize_get_data_extents( + ctx_nrows, ctx_ncols, start_row, end_row, start_col, end_col + ); + + auto cells_info = resolve_cells(ext.m_srow, ext.m_erow, ext.m_scol, ext.m_ecol); + + t_index nrows = ext.m_erow - ext.m_srow; + t_index stride = ext.m_ecol - ext.m_scol; + std::vector retval(nrows * stride); + + t_tscalar empty = mknone(); + + t_uindex ntrees = m_trees.size(); + t_uindex naggs = m_config.get_num_aggregates(); + std::vector aggcols(ntrees * naggs); + + for (t_uindex treeidx = 0; treeidx < ntrees; ++treeidx) { + auto* aggtable = m_trees[treeidx]->get_aggtable(); + for (t_uindex aggidx = 0; aggidx < naggs; ++aggidx) { + // resolve by column index (== aggidx); skips the per-iteration + // name->index map lookup + temp string and the schema deep-copy. + aggcols[treeidx * naggs + aggidx] = + aggtable->_get_const_column(aggidx); + } + } + + const std::vector& aggspecs = m_config.get_aggregates(); + + for (t_index ridx = ext.m_srow; ridx < ext.m_erow; ++ridx) { + if (ext.m_scol == 0) { + retval[(ridx - ext.m_srow) * stride].set( + rtree()->get_value(m_rtraversal->get_tree_index(ridx)) + ); + } + + for (t_index cidx = std::max(ext.m_scol, t_index(1)); cidx < ext.m_ecol; + ++cidx) { + t_index insert_idx = + (ridx - ext.m_srow) * stride + (cidx - ext.m_scol); + const t_cellinfo& cinfo = cells_info[insert_idx]; + + if (cinfo.m_idx < 0) { + retval[insert_idx].set(empty); + } else { + const auto* aggcol = + aggcols[cinfo.m_treenum * naggs + cinfo.m_agg_index]; + + t_index p_idx = + m_trees[cinfo.m_treenum]->get_parent_idx(cinfo.m_idx); + + t_uindex agg_ridx = + m_trees[cinfo.m_treenum]->get_aggidx(cinfo.m_idx); + + t_uindex agg_pridx = p_idx == INVALID_INDEX + ? INVALID_INDEX + : m_trees[cinfo.m_treenum]->get_aggidx(p_idx); + + auto value = extract_aggregate( + aggspecs[cinfo.m_agg_index], aggcol, agg_ridx, agg_pridx + ); + + if (!value.is_valid()) { + value.set(empty); + } + + retval[insert_idx].set(value); + } + } + } + + return retval; +} + +std::vector +t_ctx2::get_data(const std::vector& rows) const { + t_uindex nrows = rows.size(); + t_uindex start_col = 0; + t_uindex end_col = get_column_count(); + + // Perspective generates extra headers for columns in the sort, which + // needs to be skipped when generating row deltas. + bool should_skip_column_headers = !m_sortby.empty() && start_col < end_col; + + if (should_skip_column_headers) { + auto depth = m_config.get_num_cpivots(); + + // find the first column that isn't a generated sort header, i.e. + // its column path has to be the same as the number of column pivots. + for (t_uindex i = 0; i < end_col; ++i) { + if (unity_get_column_path(i + 1).size() == depth) { + start_col = i; + break; + } + } + } + + t_uindex ncols = end_col - start_col; + + // Get data out of the context at the specified row and column indices + std::vector> cells; + for (t_uindex idx = 0; idx < nrows; ++idx) { + t_uindex ridx = rows[idx]; + for (t_uindex cidx = start_col; cidx < end_col; ++cidx) { + cells.emplace_back(std::pair(ridx, cidx)); + } + } + + auto cells_info = resolve_cells(cells); + std::vector rval(nrows * ncols); + + t_tscalar empty = mknone(); + + t_uindex ntrees = m_trees.size(); + t_uindex naggs = m_config.get_num_aggregates(); + std::vector aggcols(ntrees * naggs); + + for (t_uindex treeidx = 0; treeidx < ntrees; ++treeidx) { + auto* aggtable = m_trees[treeidx]->get_aggtable(); + for (t_uindex aggidx = 0; aggidx < naggs; ++aggidx) { + // resolve by column index (== aggidx); skips the per-iteration + // name->index map lookup + temp string and the schema deep-copy. + aggcols[treeidx * naggs + aggidx] = + aggtable->_get_const_column(aggidx); + } + } + + const std::vector& aggspecs = m_config.get_aggregates(); + + for (t_uindex ridx = 0; ridx < nrows; ++ridx) { + for (t_uindex cidx = 1; cidx < ncols; ++cidx) { + t_uindex insert_idx = ridx * ncols + cidx; + const t_cellinfo& cinfo = cells_info[insert_idx]; + + if (cinfo.m_idx < 0) { + rval[insert_idx].set(empty); + } else { + const auto* aggcol = + aggcols[cinfo.m_treenum * naggs + cinfo.m_agg_index]; + + t_index p_idx = + m_trees[cinfo.m_treenum]->get_parent_idx(cinfo.m_idx); + + t_index agg_ridx = + m_trees[cinfo.m_treenum]->get_aggidx(cinfo.m_idx); + + t_index agg_pridx = p_idx == INVALID_INDEX + ? INVALID_INDEX + : m_trees[cinfo.m_treenum]->get_aggidx(p_idx); + + auto value = extract_aggregate( + aggspecs[cinfo.m_agg_index], aggcol, agg_ridx, agg_pridx + ); + + if (!value.is_valid()) { + value.set(empty); + } + + rval[insert_idx].set(value); + } + } + } + + return rval; +} + +void +t_ctx2::column_sort_by(const std::vector& sortby) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + m_column_sortby = sortby; + m_ctraversal->sort_by(m_config, sortby, *(ctree())); +} + +void +t_ctx2::sort_by(const std::vector& sortby) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + m_sortby = sortby; + if (m_sortby.empty()) { + return; + } + m_rtraversal->sort_by(m_config, sortby, *(rtree()), this); +} + +void +t_ctx2::reset_sortby() { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + m_sortby = std::vector(); +} + +void +t_ctx2::notify(const t_data_table& flattened, bool /* is_registration */) { + for (t_uindex tree_idx = 0, loop_end = m_trees.size(); tree_idx < loop_end; + ++tree_idx) { + if (is_rtree_idx(tree_idx) != 0U) { + notify_sparse_tree( + rtree(), + m_rtraversal, + true, + m_config.get_aggregates(), + m_config.get_sortby_pairs(), + m_sortby, + flattened, + m_config, + *m_gstate, + *(m_expression_tables->m_master) + ); + } else if (is_ctree_idx(tree_idx) != 0U) { + notify_sparse_tree( + ctree(), + m_ctraversal, + true, + m_config.get_aggregates(), + m_config.get_sortby_pairs(), + m_column_sortby, + flattened, + m_config, + *m_gstate, + *(m_expression_tables->m_master) + ); + } else { + notify_sparse_tree( + m_trees[tree_idx], + std::shared_ptr(nullptr), + false, + m_config.get_aggregates(), + m_config.get_sortby_pairs(), + std::vector(), + flattened, + m_config, + *m_gstate, + *(m_expression_tables->m_master) + ); + } + } + if (!m_sortby.empty()) { + sort_by(m_sortby); + } + + if (!m_column_sortby.empty()) { + column_sort_by(m_column_sortby); + } +} + +void +t_ctx2::notify( + const t_data_table& flattened, + const t_data_table& delta, + const t_data_table& prev, + const t_data_table& current, + const t_data_table& transitions, + const t_data_table& existed +) { + + for (t_uindex tree_idx = 0, loop_end = m_trees.size(); tree_idx < loop_end; + ++tree_idx) { + if (is_rtree_idx(tree_idx) != 0U) { + notify_sparse_tree( + rtree(), + m_rtraversal, + true, + m_config.get_aggregates(), + m_config.get_sortby_pairs(), + m_sortby, + flattened, + delta, + prev, + current, + transitions, + existed, + m_config, + *m_gstate, + *(m_expression_tables->m_master) + ); + } else if (is_ctree_idx(tree_idx) != 0U) { + notify_sparse_tree( + ctree(), + m_ctraversal, + true, + m_config.get_aggregates(), + m_config.get_sortby_pairs(), + m_column_sortby, + flattened, + delta, + prev, + current, + transitions, + existed, + m_config, + *m_gstate, + *(m_expression_tables->m_master) + ); + } else { + notify_sparse_tree( + m_trees[tree_idx], + std::shared_ptr(nullptr), + false, + m_config.get_aggregates(), + m_config.get_sortby_pairs(), + std::vector(), + flattened, + delta, + prev, + current, + transitions, + existed, + m_config, + *m_gstate, + *(m_expression_tables->m_master) + ); + } + } + + if (!m_sortby.empty()) { + sort_by(m_sortby); + } + + if (!m_column_sortby.empty()) { + column_sort_by(m_column_sortby); + } +} + +t_uindex +t_ctx2::calc_translated_colidx(t_uindex n_aggs, t_uindex cidx) const { + switch (m_config.get_totals()) { + case TOTALS_BEFORE: { + return (cidx - 1) / n_aggs; + } break; + case TOTALS_AFTER: { + return (cidx - 1) / n_aggs; + } break; + case TOTALS_HIDDEN: { + return (cidx - 1) / n_aggs + 1; + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unknown totals type encountered."); + return -1; + } + } +} + +std::vector +t_ctx2::resolve_cells(const std::vector>& cells +) const { + std::vector rval(cells.size()); + + t_index n_aggs = m_config.get_num_aggregates(); + std::vector c_tvindices = get_ctraversal_indices(); + + std::vector> col_paths(m_ctraversal->size()); + + for (t_index cidx = 0, loop_end = c_tvindices.size(); cidx < loop_end; + ++cidx) { + auto translated = c_tvindices[cidx]; + const t_tvnode& c_tvnode = m_ctraversal->get_node(translated); + col_paths[cidx].reserve(m_config.get_num_cpivots()); + col_paths[cidx] = get_column_path(c_tvnode); + } + + t_uindex ncols = get_num_view_columns(); + + for (t_index idx = 0, loop_end = cells.size(); idx < loop_end; ++idx) { + const auto& cell = cells[idx]; + + if (cell.first >= m_rtraversal->size() || cell.second == 0 + || cell.second >= ncols) { + rval[idx].m_idx = INVALID_INDEX; + continue; + } + + const t_tvnode& r_tvnode = m_rtraversal->get_node(cell.first); + + t_index r_ptidx = r_tvnode.m_tnid; + t_depth r_depth = r_tvnode.m_depth; + std::vector r_path = get_row_path(r_tvnode); + t_index agg_idx = (cell.second - 1) % n_aggs; + t_uindex translated_cidx = calc_translated_colidx(n_aggs, cell.second); + if (translated_cidx >= c_tvindices.size()) { + rval[idx].m_idx = INVALID_INDEX; + continue; + } + + t_index c_tvidx = c_tvindices[translated_cidx]; + + rval[idx].m_ridx = cell.first; + rval[idx].m_cidx = cell.second; + + if (c_tvidx >= t_index(m_ctraversal->size())) { + rval[idx].m_idx = INVALID_INDEX; + continue; + } + + const t_tvnode& c_tvnode = m_ctraversal->get_node(c_tvidx); + t_index c_ptidx = c_tvnode.m_tnid; + const std::vector& c_path = col_paths[translated_cidx]; + + rval[idx].m_agg_index = agg_idx; + + if (cell.first == 0 && !m_leaves_only) { + rval[idx].m_idx = c_ptidx; + rval[idx].m_treenum = 0; + } else if (c_path.empty()) { + rval[idx].m_idx = r_ptidx; + rval[idx].m_treenum = m_trees.size() - 1; + } else { + t_index tree_idx = r_depth; + rval[idx].m_treenum = tree_idx; + + if (r_depth + 1 == static_cast(m_trees.size())) { + rval[idx].m_idx = + m_trees[tree_idx]->resolve_path(r_ptidx, c_path); + } else { + t_index path_ptidx = m_trees[tree_idx]->resolve_path(0, r_path); + if (path_ptidx < 0) { + rval[idx].m_idx = INVALID_INDEX; + } else { + rval[idx].m_idx = + m_trees[tree_idx]->resolve_path(path_ptidx, c_path); + } + } + } + } + + return rval; +} + +std::vector +t_ctx2::resolve_cells( + t_uindex srow, t_uindex erow, t_uindex scol, t_uindex ecol +) const { + t_uindex nrows = erow - srow; + t_uindex ncols_range = ecol - scol; + std::vector rval(nrows * ncols_range); + + t_index n_aggs = m_config.get_num_aggregates(); + std::vector c_tvindices = get_ctraversal_indices(); + + std::vector> col_paths(m_ctraversal->size()); + + for (t_index cidx = 0, loop_end = c_tvindices.size(); cidx < loop_end; + ++cidx) { + auto translated = c_tvindices[cidx]; + const t_tvnode& c_tvnode = m_ctraversal->get_node(translated); + col_paths[cidx].reserve(m_config.get_num_cpivots()); + col_paths[cidx] = get_column_path(c_tvnode); + } + + t_uindex view_ncols = get_num_view_columns(); + + for (t_uindex ridx = srow; ridx < erow; ++ridx) { + bool row_valid = ridx < m_rtraversal->size(); + t_index r_ptidx = 0; + t_depth r_depth = 0; + std::vector r_path; + if (row_valid) { + const t_tvnode& r_tvnode = m_rtraversal->get_node(ridx); + r_ptidx = r_tvnode.m_tnid; + r_depth = r_tvnode.m_depth; + r_path = get_row_path(r_tvnode); + } + + for (t_uindex cidx = scol; cidx < ecol; ++cidx) { + t_uindex out_idx = (ridx - srow) * ncols_range + (cidx - scol); + + if (!row_valid || cidx == 0 || cidx >= view_ncols) { + rval[out_idx].m_idx = INVALID_INDEX; + continue; + } + + t_index agg_idx = (cidx - 1) % n_aggs; + t_uindex translated_cidx = calc_translated_colidx(n_aggs, cidx); + if (translated_cidx >= c_tvindices.size()) { + rval[out_idx].m_idx = INVALID_INDEX; + continue; + } + + t_index c_tvidx = c_tvindices[translated_cidx]; + + rval[out_idx].m_ridx = ridx; + rval[out_idx].m_cidx = cidx; + + if (c_tvidx >= t_index(m_ctraversal->size())) { + rval[out_idx].m_idx = INVALID_INDEX; + continue; + } + + const t_tvnode& c_tvnode = m_ctraversal->get_node(c_tvidx); + t_index c_ptidx = c_tvnode.m_tnid; + const std::vector& c_path = col_paths[translated_cidx]; + + rval[out_idx].m_agg_index = agg_idx; + + if (ridx == 0 && !m_leaves_only) { + rval[out_idx].m_idx = c_ptidx; + rval[out_idx].m_treenum = 0; + } else if (c_path.empty()) { + rval[out_idx].m_idx = r_ptidx; + rval[out_idx].m_treenum = m_trees.size() - 1; + } else { + t_index tree_idx = r_depth; + rval[out_idx].m_treenum = tree_idx; + + if (r_depth + 1 == static_cast(m_trees.size())) { + rval[out_idx].m_idx = + m_trees[tree_idx]->resolve_path(r_ptidx, c_path); + } else { + t_index path_ptidx = + m_trees[tree_idx]->resolve_path(0, r_path); + if (path_ptidx < 0) { + rval[out_idx].m_idx = INVALID_INDEX; + } else { + rval[out_idx].m_idx = + m_trees[tree_idx]->resolve_path(path_ptidx, c_path); + } + } + } + } + } + + return rval; +} + +t_index +t_ctx2::sidedness() const { + return 2; +} + +t_uindex +t_ctx2::is_rtree_idx(t_uindex idx) const { + return static_cast(idx == m_trees.size() - 1); +} + +t_uindex +t_ctx2::is_ctree_idx(t_uindex idx) const { + return static_cast(idx == 0); +} + +std::shared_ptr +t_ctx2::rtree() { + return m_trees.back(); +} + +std::shared_ptr +t_ctx2::rtree() const { + return m_trees.back(); +} + +std::shared_ptr +t_ctx2::ctree() { + return m_trees.front(); +} + +std::shared_ptr +t_ctx2::ctree() const { + return m_trees.front(); +} + +t_uindex +t_ctx2::get_num_view_columns() const { + switch (m_config.get_totals()) { + case TOTALS_AFTER: + case TOTALS_BEFORE: { + return m_ctraversal->size() * m_config.get_num_aggregates() + 1; + } break; + case TOTALS_HIDDEN: { + t_index nitems = + (m_ctraversal->size() - 1) * m_config.get_num_aggregates(); + return nitems + 1; + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unknown totals type"); + return -1; + } + } + PSP_COMPLAIN_AND_ABORT("Not implemented"); + return 0; +} + +std::vector +t_ctx2::get_row_path(t_index idx) const { + if (idx < 0) { + return {}; + } + return ctx_get_path(rtree(), m_rtraversal, idx); +} + +std::vector +t_ctx2::get_row_path(const t_tvnode& node) const { + std::vector rval; + m_trees.back()->get_path(node.m_tnid, rval); + return rval; +} + +std::vector +t_ctx2::get_column_path(t_index idx) const { + if (idx < 0) { + return {}; + } + return ctx_get_path(ctree(), m_ctraversal, idx); +} + +std::vector +t_ctx2::get_column_path(const t_tvnode& node) const { + std::vector rval; + m_trees[0]->get_path(node.m_tnid, rval); + return rval; +} + +std::vector +t_ctx2::get_column_path_userspace(t_index idx) const { + t_index translated_idx = translate_column_index(idx); + if (translated_idx == INVALID_INDEX) { + return {}; + } + return get_column_path(translated_idx); +} + +t_index +t_ctx2::translate_column_index(t_index idx) const { + t_index rval = INVALID_INDEX; + + switch (m_config.get_totals()) { + case TOTALS_BEFORE: { + rval = (idx - 1) / m_config.get_num_aggregates(); + } break; + case TOTALS_AFTER: { + std::vector col_order; + m_ctraversal->post_order(0, col_order); + rval = col_order[(idx - 1) / m_config.get_num_aggregates()]; + } break; + case TOTALS_HIDDEN: { + std::vector leaves; + m_ctraversal->get_leaves(leaves); + rval = leaves[(idx - 1) / m_config.get_num_aggregates()]; + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unknown totals type encountered."); + } + } + + return rval; +} + +std::vector +t_ctx2::get_aggregates() const { + return m_config.get_aggregates(); +} + +t_tscalar +t_ctx2::get_aggregate_name(t_uindex idx) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + t_tscalar s; + if (idx >= m_config.get_num_aggregates()) { + return s; + } + s.set(m_config.get_aggregates()[idx].name_scalar()); + return s; +} + +void +t_ctx2::set_depth(t_header header, t_depth depth) { + t_depth new_depth; + + switch (header) { + case HEADER_ROW: { + if (m_config.get_num_rpivots() == 0) { + return; + } + new_depth = + std::min(m_config.get_num_rpivots() - 1, depth); + m_rtraversal->set_depth(m_sortby, new_depth); + m_row_depth = new_depth; + m_row_depth_set = true; + } break; + case HEADER_COLUMN: { + if (m_config.get_num_cpivots() == 0) { + return; + } + new_depth = + std::min(m_config.get_num_cpivots() - 1, depth); + m_ctraversal->set_depth(m_column_sortby, new_depth); + m_column_depth = new_depth; + m_column_depth_set = true; + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Invalid header"); + } break; + } +} + +void +t_ctx2::set_leaves_only(bool enabled) { + m_leaves_only = enabled; + m_rtraversal->set_leaves_only(enabled, m_config.get_num_rpivots()); +} + +void +t_ctx2::set_total_only(bool enabled) { + m_total_only = enabled; + m_rtraversal->set_total_only(enabled); +} + +std::vector +t_ctx2::get_pkeys(const std::vector>& cells +) const { + tsl::hopscotch_set all_pkeys; + + auto tree_info = resolve_cells(cells); + for (const auto& cinfo : tree_info) { + + if (cinfo.m_idx != INVALID_INDEX) { + auto node_pkeys = m_trees[cinfo.m_treenum]->get_pkeys(cinfo.m_idx); + std::copy( + node_pkeys.begin(), + node_pkeys.end(), + std::inserter(all_pkeys, all_pkeys.end()) + ); + } + } + + std::vector rval(all_pkeys.size()); + std::copy(all_pkeys.begin(), all_pkeys.end(), rval.begin()); + return rval; +} + +/** + * @brief Returns updated cells. + * + * @param bidx + * @param eidx + * @return t_stepdelta + */ +t_stepdelta +t_ctx2::get_step_delta(t_index bidx, t_index eidx) { + t_uindex start_row = bidx; + t_uindex end_row = eidx; + t_uindex start_col = 1; + t_uindex end_col = get_num_view_columns(); + t_stepdelta rval; + rval.rows_changed = true; + rval.columns_changed = true; + std::vector& updvec = rval.cells; + + t_uindex ctx_nrows = get_row_count(); + t_uindex ctx_ncols = get_column_count(); + auto ext = sanitize_get_data_extents( + ctx_nrows, ctx_ncols, start_row, end_row, start_col, end_col + ); + + std::vector> cells; + + for (t_index ridx = ext.m_srow; ridx < ext.m_erow; ++ridx) { + for (t_uindex cidx = 1; cidx < end_col; ++cidx) { + cells.emplace_back(std::pair(ridx, cidx)); + } + } + + auto cells_info = resolve_cells(cells); + + for (const auto& c : cells_info) { + if (c.m_idx < 0) { + continue; + } + + const auto& deltas = m_trees[c.m_treenum]->get_deltas(); + + auto iterators = deltas->get().equal_range(c.m_idx); + + for (auto iter = iterators.first; iter != iterators.second; ++iter) { + updvec.emplace_back( + c.m_ridx, c.m_cidx, iter->m_old_value, iter->m_new_value + ); + } + } + + clear_deltas(); + return rval; +} + +/** + * @brief Returns the row indices that have been updated with new data. + * + * @return t_rowdelta + */ +t_rowdelta +t_ctx2::get_row_delta() { + std::vector rows = get_rows_changed(); + std::vector data = get_data(rows); + t_rowdelta rval(true, rows.size(), data); + clear_deltas(); + return rval; +} + +std::vector +t_ctx2::get_rows_changed() { + t_uindex nrows = get_row_count(); + t_uindex ncols = get_num_view_columns(); + std::vector rows; + std::vector> cells; + if (ncols > 1) { + cells.reserve(nrows * (ncols - 1)); + } + + // get cells and imbue with additional information + for (t_uindex ridx = 0; ridx < nrows; ++ridx) { + for (t_uindex cidx = 1; cidx < ncols; ++cidx) { + cells.emplace_back(ridx, cidx); + } + } + + auto cells_info = resolve_cells(cells); + + // Cells are visited in row-major order and `resolve_cells` preserves it, so + // `m_ridx` is non-decreasing across `cells_info`. Tracking the last appended + // row dedups in O(1) with no allocation and leaves `rows` already ascending, + // replacing both the old O(n^2) `std::find` and the trailing `std::sort`. + for (const auto& c : cells_info) { + if (c.m_idx < 0) { + continue; + } + const auto& deltas = m_trees[c.m_treenum]->get_deltas(); + auto iterators = deltas->get().equal_range(c.m_idx); + auto ridx = c.m_ridx; + bool has_delta = iterators.first != iterators.second; + if (has_delta && (rows.empty() || rows.back() != ridx)) { + rows.push_back(ridx); + } + } + + return rows; +} + +void +t_ctx2::reset(bool reset_expressions) { + for (t_uindex treeidx = 0, tree_loop_end = m_trees.size(); + treeidx < tree_loop_end; + ++treeidx) { + std::vector pivots; + if (treeidx > 0) { + pivots.insert( + pivots.end(), + m_config.get_row_pivots().begin(), + m_config.get_row_pivots().begin() + treeidx + ); + } + + pivots.insert( + pivots.end(), + m_config.get_column_pivots().begin(), + m_config.get_column_pivots().end() + ); + + m_trees[treeidx] = std::make_shared( + pivots, m_config.get_aggregates(), m_schema, m_config + ); + m_trees[treeidx]->init(); + m_trees[treeidx]->set_deltas_enabled(get_feature_state(CTX_FEAT_DELTA)); + + // See [t_ctx2::init] — gmv needs to know how many row pivots + // this particular tree carries vs. what's still in the gstate. + const auto& row_pivots = m_config.get_row_pivots(); + std::string next_row_pivot; + if (treeidx < row_pivots.size()) { + next_row_pivot = row_pivots[treeidx].colname(); + } + m_trees[treeidx]->set_gmv_row_pivot_meta(treeidx, next_row_pivot); + } + + m_rtraversal = std::make_shared(rtree()); + m_ctraversal = std::make_shared(ctree()); + if (m_leaves_only) { + m_rtraversal->set_leaves_only(true, m_config.get_num_rpivots()); + } + + if (reset_expressions) { + m_expression_tables->reset(); + } +} + +bool +t_ctx2::get_deltas_enabled() const { + return m_features[CTX_FEAT_DELTA]; +} + +void +t_ctx2::reset_step_state() { + m_rows_changed = false; + m_columns_changed = false; +} + +void +t_ctx2::clear_deltas() { + for (auto& tr : m_trees) { + tr->clear_deltas(); + } +} + +void +t_ctx2::set_feature_state(t_ctx_feature feature, bool state) { + m_features[feature] = state; +} + +void +t_ctx2::set_alerts_enabled(bool enabled_state) { + m_features[CTX_FEAT_ALERT] = enabled_state; + for (auto& tr : m_trees) { + tr->set_alerts_enabled(enabled_state); + } +} + +void +t_ctx2::set_deltas_enabled(bool enabled_state) { + m_features[CTX_FEAT_DELTA] = enabled_state; + for (auto& tr : m_trees) { + tr->set_deltas_enabled(enabled_state); + } +} + +std::vector +t_ctx2::get_trees() { + std::vector rval(m_trees.size()); + t_uindex count = 0; + for (auto& t : m_trees) { + rval[count] = t.get(); + ++count; + } + return rval; +} + +bool +t_ctx2::has_deltas() const { + bool has_deltas = false; + for (const auto& m_tree : m_trees) { + has_deltas = has_deltas || m_tree->has_deltas(); + } + return has_deltas; +} + +void +t_ctx2::pprint() const {} + +t_dtype +t_ctx2::get_column_dtype(t_uindex idx) const { + t_uindex naggs = m_config.get_num_aggregates(); + + if (idx == 0) { + return DTYPE_NONE; + } + + return rtree() + ->get_aggtable() + ->get_const_column((idx - 1) % naggs) + ->get_dtype(); +} + +void +t_ctx2::compute_expressions( + const std::shared_ptr& master, + const t_gstate::t_mapping& pkey_map, + t_expression_vocab& expression_vocab, + t_regex_mapping& regex_mapping +) { + // Clear the transitional expression tables on the context so they are + // ready for the next update. + m_expression_tables->clear_transitional_tables(); + + std::shared_ptr master_expression_table = + m_expression_tables->m_master; + + // Set the master table to the right size. + t_uindex num_rows = master->size(); + master_expression_table->reserve(num_rows); + master_expression_table->set_size(num_rows); + + const auto& expressions = m_config.get_expressions(); + for (const auto& expr : expressions) { + // Compute the expressions on the master table. + expr->compute( + master, + pkey_map, + master_expression_table, + expression_vocab, + regex_mapping + ); + } +} + +void +t_ctx2::compute_expressions( + const std::shared_ptr& master, + const t_gstate::t_mapping& pkey_map, + const std::shared_ptr& flattened, + const std::shared_ptr& delta, + const std::shared_ptr& prev, + const std::shared_ptr& current, + const std::shared_ptr& transitions, + const std::shared_ptr& existed, + t_expression_vocab& expression_vocab, + t_regex_mapping& regex_mapping +) { + // Clear the tables so they are ready for this round of updates + m_expression_tables->clear_transitional_tables(); + + // All tables are the same size + t_uindex flattened_num_rows = flattened->size(); + m_expression_tables->reserve_transitional_table_size(flattened_num_rows); + m_expression_tables->set_transitional_table_size(flattened_num_rows); + + // Update the master expression table's size + t_uindex master_num_rows = master->size(); + m_expression_tables->m_master->reserve(master_num_rows); + m_expression_tables->m_master->set_size(master_num_rows); + + const auto& expressions = m_config.get_expressions(); + for (const auto& expr : expressions) { + // master: compute based on latest state of the gnode state table + expr->compute( + master, + pkey_map, + m_expression_tables->m_master, + expression_vocab, + regex_mapping + ); + + // flattened: compute based on the latest update dataset + expr->compute( + flattened, + pkey_map, + m_expression_tables->m_flattened, + expression_vocab, + regex_mapping + ); + + // delta: for each numerical column, the numerical delta between the + // previous value and the current value in the row. + expr->compute( + delta, + pkey_map, + m_expression_tables->m_delta, + expression_vocab, + regex_mapping + ); + + // prev: the values of the updated rows before this update was applied + expr->compute( + prev, + pkey_map, + m_expression_tables->m_prev, + expression_vocab, + regex_mapping + ); + + // current: the current values of the updated rows + expr->compute( + current, + pkey_map, + m_expression_tables->m_current, + expression_vocab, + regex_mapping + ); + } + + // Calculate the transitions now that the intermediate tables are computed + m_expression_tables->calculate_transitions(existed); +} + +bool +t_ctx2::is_expression_column(const std::string& colname) const { + const t_schema& schema = m_expression_tables->m_master->get_schema(); + return schema.has_column(colname); +} + +std::vector +t_ctx2::unity_get_row_data(t_uindex idx) const { + auto rval = get_data(idx, idx + 1, 0, get_column_count()); + if (rval.empty()) { + return {}; + } + + return {rval.begin() + 1, rval.end()}; +} + +std::vector +t_ctx2::unity_get_column_data(t_uindex idx) const { + PSP_COMPLAIN_AND_ABORT("Not implemented"); + return {}; +} + +std::vector +t_ctx2::unity_get_row_path(t_uindex idx) const { + return get_row_path(idx); +} + +std::vector +t_ctx2::unity_get_column_path(t_uindex idx) const { + auto rv = get_column_path_userspace(idx); + return rv; +} + +t_uindex +t_ctx2::unity_get_row_depth(t_uindex idx) const { + return get_row_path(idx).size(); +} + +t_uindex +t_ctx2::unity_get_column_depth(t_uindex idx) const { + return get_column_path(idx).size(); +} + +std::string +t_ctx2::unity_get_column_name(t_uindex idx) const { + return m_config.unity_get_column_name(idx); +} + +std::string +t_ctx2::unity_get_column_display_name(t_uindex idx) const { + return m_config.unity_get_column_display_name(idx); +} + +std::vector +t_ctx2::unity_get_column_names() const { + std::vector rv; + + for (t_uindex idx = 0, loop_end = unity_get_column_count(); idx < loop_end; + ++idx) { + rv.push_back(unity_get_column_name(idx)); + } + return rv; +} + +std::vector +t_ctx2::unity_get_column_display_names() const { + std::vector rv; + + for (t_uindex idx = 0, loop_end = unity_get_column_count(); idx < loop_end; + ++idx) { + rv.push_back(unity_get_column_display_name(idx)); + } + return rv; +} + +t_uindex +t_ctx2::unity_get_column_count() const { + if (m_config.get_totals() != TOTALS_HIDDEN) { + return get_column_count() - 1; + } + std::vector leaves; + m_ctraversal->get_leaves(leaves); + return leaves.size() * m_config.get_num_aggregates(); +} + +t_uindex +t_ctx2::unity_get_row_count() const { + return get_row_count(); +} + +bool +t_ctx2::unity_get_row_expanded(t_uindex idx) const { + return m_rtraversal->get_node_expanded(idx); +} + +bool +t_ctx2::unity_get_column_expanded(t_uindex idx) const { + + return m_ctraversal->get_node_expanded( + calc_translated_colidx(idx, m_config.get_num_aggregates()) + ); +} + +void +t_ctx2::unity_init_load_step_end() {} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/context_unit.cpp b/rust/perspective-server/cpp/perspective/src/cpp/context_unit.cpp new file mode 100644 index 0000000..e7918ea --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/context_unit.cpp @@ -0,0 +1,497 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include +#include +#include + +#include + +namespace perspective { + +t_ctxunit::t_ctxunit() = default; + +t_ctxunit::t_ctxunit(const t_schema& schema, const t_config& config) : + t_ctxbase(schema, config), + m_has_delta(false) {} + +t_ctxunit::~t_ctxunit() = default; + +void +t_ctxunit::init() { + m_init = true; +} + +void +t_ctxunit::step_begin() { + if (!m_init) { + return; + } + + m_delta_pkeys.clear(); + m_rows_changed = false; + m_columns_changed = false; +} + +void +t_ctxunit::step_end() {} + +/** + * @brief Notify the context with new data when the `t_gstate` master table is + * not empty, and being updated with new data. + * + * @param flattened + * @param delta + * @param prev + * @param curr + * @param transitions + * @param existed + */ +void +t_ctxunit::notify( + const t_data_table& flattened, + const t_data_table& delta, + const t_data_table& prev, + const t_data_table& curr, + const t_data_table& transitions, + const t_data_table& existed +) { + t_uindex nrecs = flattened.size(); + + std::shared_ptr pkey_sptr = + flattened.get_const_column("psp_pkey"); + std::shared_ptr op_sptr = + flattened.get_const_column("psp_op"); + const t_column* pkey_col = pkey_sptr.get(); + const t_column* op_col = op_sptr.get(); + + bool delete_encountered = false; + + for (t_uindex idx = 0; idx < nrecs; ++idx) { + // pkeys are always integers >= 0 - no need to use internal + // symtable to dereference. + t_tscalar pkey = pkey_col->get_scalar(idx); + std::uint8_t op_ = *(op_col->get_nth(idx)); + t_op op = static_cast(op_); + + switch (op) { + case OP_INSERT: { + } break; + case OP_DELETE: { + delete_encountered = true; + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected OP"); + } break; + } + + // add the pkey for row delta + add_delta_pkey(pkey); + } + + m_has_delta = !m_delta_pkeys.empty() || delete_encountered; +} + +/** + * @brief Notify the context with new data after the `t_gstate`'s master table + * has been updated for the first time with data. + * + * @param flattened + */ +void +t_ctxunit::notify(const t_data_table& flattened, bool is_registration) { + t_uindex nrecs = flattened.size(); + std::shared_ptr pkey_sptr = + flattened.get_const_column("psp_pkey"); + const t_column* pkey_col = pkey_sptr.get(); + + m_has_delta = true; + + // During `_register_context`, no subscriber exists yet — skip the + // hopscotch_set insertions no observer can see. A unit context has no + // traversal/sort state to build either, so this is effectively O(1). + if (is_registration) { + return; + } + + // TODO: pkey and idx are equal, except idx is not a t_tscalar. I don't + // think there is a difference between accessing the pkey column and + // creating a brand new scalar, as get_scalar always returns a copy. We + // could also simply store the row indices but there are some problems + // with correctness that I couldn't nail down. + for (t_uindex idx = 0; idx < nrecs; ++idx) { + t_tscalar pkey = pkey_col->get_scalar(idx); + + // Add primary key to track row delta + add_delta_pkey(pkey); + } +} + +std::pair +t_ctxunit::get_min_max(const std::string& colname) const { + auto col = m_gstate->get_table()->get_const_column(colname); + auto rval = std::make_pair(mknone(), mknone()); + for (std::size_t i = 0; i < col->size(); i++) { + t_tscalar val = col->get_scalar(i); + if (!val.is_valid()) { + continue; + } + + if (rval.first.is_none() || (!val.is_none() && val < rval.first)) { + rval.first = val; + } + + if (val > rval.second) { + rval.second = val; + } + } + + return rval; +} + +/** + * @brief Given a start/end row and column, return the data for the subset. + * + * @param start_row + * @param end_row + * @param start_col + * @param end_col + * @return std::vector + */ +std::vector +t_ctxunit::get_data( + t_index start_row, t_index end_row, t_index start_col, t_index end_col +) const { + t_uindex ctx_nrows = get_row_count(); + t_uindex ctx_ncols = get_column_count(); + + auto ext = sanitize_get_data_extents( + ctx_nrows, ctx_ncols, start_row, end_row, start_col, end_col + ); + + t_index num_rows = ext.m_erow - ext.m_srow; + t_index stride = ext.m_ecol - ext.m_scol; + std::vector values(num_rows * stride); + + auto none = mknone(); + + const t_data_table& master_table = *(m_gstate->get_table()); + + for (t_index cidx = ext.m_scol; cidx < ext.m_ecol; ++cidx) { + const std::string& colname = m_config.col_at(cidx); + + std::vector out_data(num_rows); + + // Read directly from the row indices on the table - they will + // always correspond exactly. + m_gstate->read_column( + master_table, colname, start_row, end_row, out_data + ); + + for (t_index ridx = ext.m_srow; ridx < ext.m_erow; ++ridx) { + auto v = out_data[ridx - ext.m_srow]; + + // todo: fix null handling + if (!v.is_valid()) { + v.set(none); + } + + values[(ridx - ext.m_srow) * stride + (cidx - ext.m_scol)] = v; + } + } + + return values; +} + +/** + * @brief Given a vector of row indices, which may not be contiguous, + * return the underlying data for these rows. + * + * @param rows a vector of row indices + * @return std::vector a vector of scalars containing data + */ +std::vector +t_ctxunit::get_data(const std::vector& rows) const { + t_uindex stride = get_column_count(); + std::vector values(rows.size() * stride); + + auto none = mknone(); + + const t_data_table& master_table = *(m_gstate->get_table()); + + for (t_uindex cidx = 0; cidx < stride; ++cidx) { + std::vector out_data(rows.size()); + const std::string& colname = m_config.col_at(cidx); + m_gstate->read_column(master_table, colname, rows, out_data); + + for (t_uindex ridx = 0; ridx < rows.size(); ++ridx) { + auto v = out_data[ridx]; + + if (!v.is_valid()) { + v.set(none); + } + + values[(ridx)*stride + (cidx)] = v; + } + } + + return values; +} + +std::vector +t_ctxunit::get_data(const std::vector& pkeys) const { + t_uindex stride = get_column_count(); + std::vector values(pkeys.size() * stride); + + auto none = mknone(); + + const t_data_table& master_table = *(m_gstate->get_table()); + + for (t_uindex cidx = 0; cidx < stride; ++cidx) { + std::vector out_data(pkeys.size()); + const std::string& colname = m_config.col_at(cidx); + + m_gstate->read_column(master_table, colname, pkeys, out_data); + + for (t_uindex ridx = 0; ridx < pkeys.size(); ++ridx) { + auto v = out_data[ridx]; + + if (!v.is_valid()) { + v.set(none); + } + + values[(ridx)*stride + (cidx)] = v; + } + } + + return values; +} + +/** + * @brief Returns a vector of primary keys for the specified cells, + * reading from the gnode_state's master table instead of from a traversal. + * + * @param cells + * @return std::vector + */ +std::vector +t_ctxunit::get_pkeys(const std::vector>& cells +) const { + // Validate cells + t_index num_rows = get_row_count(); + + for (const auto& cell : cells) { + t_index ridx = cell.first; + if (ridx >= num_rows) { + return {}; + } + } + + std::set all_rows; + + for (const auto& cell : cells) { + all_rows.insert(cell.first); + } + + const t_data_table& master_table = *(m_gstate->get_table()); + std::shared_ptr pkey_sptr = + master_table.get_const_column("psp_pkey"); + + std::vector rval(all_rows.size()); + + t_uindex i = 0; + for (auto ridx : all_rows) { + rval[i] = pkey_sptr->get_scalar(ridx); + i++; + } + + return rval; +} + +/** + * @brief Returns a string column name using the context's config. + * + * @param idx + * @return t_tscalar + */ +t_tscalar +t_ctxunit::get_column_name(t_index idx) { + std::string empty; + + if (idx >= get_column_count()) { + return m_symtable.get_interned_tscalar(empty.c_str()); + } + + return m_symtable.get_interned_tscalar(m_config.col_at(idx).c_str()); +} + +/** + * @brief Returns a `t_rowdelta` struct containing data from updated rows + * and the updated row indices. + * + * @return t_rowdelta + */ +t_rowdelta +t_ctxunit::get_row_delta() { + bool rows_changed = m_rows_changed; + std::vector pkey_vector( + m_delta_pkeys.begin(), m_delta_pkeys.end() + ); + + // Sort pkeys - they will always be integers >= 0, as the table has + // no index set. + std::sort(pkey_vector.begin(), pkey_vector.end()); + + std::vector data = get_data(pkey_vector); + t_rowdelta rval(rows_changed, pkey_vector.size(), data); + clear_deltas(); + + return rval; +} + +const tsl::hopscotch_set& +t_ctxunit::get_delta_pkeys() const { + return m_delta_pkeys; +} + +std::vector +t_ctxunit::get_column_names() const { + return m_schema.columns(); +} + +void +t_ctxunit::reset() { + m_has_delta = false; +} + +bool +t_ctxunit::get_deltas_enabled() const { + return true; +} + +void +t_ctxunit::set_deltas_enabled(bool enabled_state) {} + +t_index +t_ctxunit::sidedness() const { + return 0; +} + +t_index +t_ctxunit::get_row_count() const { + return m_gstate->num_rows(); +} + +t_index +t_ctxunit::get_column_count() const { + return m_config.get_num_columns(); +} + +std::vector +t_ctxunit::unity_get_row_data(t_uindex idx) const { + return get_data(idx, idx + 1, 0, get_column_count()); +} + +std::vector +t_ctxunit::unity_get_row_path(t_uindex idx) const { + return {}; +} + +std::vector +t_ctxunit::unity_get_column_path(t_uindex idx) const { + return {}; +} + +t_uindex +t_ctxunit::unity_get_row_depth(t_uindex ridx) const { + return 0; +} + +t_uindex +t_ctxunit::unity_get_column_depth(t_uindex cidx) const { + return 0; +} + +std::vector +t_ctxunit::unity_get_column_names() const { + return get_column_names(); +} + +t_uindex +t_ctxunit::unity_get_column_count() const { + return get_column_count(); +} + +t_uindex +t_ctxunit::unity_get_row_count() const { + return get_row_count(); +} + +bool +t_ctxunit::unity_get_row_expanded(t_uindex idx) const { + return false; +} + +bool +t_ctxunit::unity_get_column_expanded(t_uindex idx) const { + return false; +} + +/** + * @brief Mark a primary key as updated by adding it to the tracking set. + * + * @param pkey + */ +void +t_ctxunit::add_delta_pkey(t_tscalar pkey) { + m_delta_pkeys.insert(pkey); +} + +bool +t_ctxunit::has_deltas() const { + return m_has_delta; +} + +t_dtype +t_ctxunit::get_column_dtype(t_uindex idx) const { + if (idx >= static_cast(get_column_count())) { + return DTYPE_NONE; + } + + auto cname = m_config.col_at(idx); + + if (!m_schema.has_column(cname)) { + return DTYPE_NONE; + } + + return m_schema.get_dtype(cname); +} + +void +t_ctxunit::clear_deltas() { + m_has_delta = false; +} + +std::string +t_ctxunit::repr() const { + std::stringstream ss; + ss << "t_ctxunit<" << this << ">"; + return ss.str(); +} + +void +t_ctxunit::pprint() const {} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/context_zero.cpp b/rust/perspective-server/cpp/perspective/src/cpp/context_zero.cpp new file mode 100644 index 0000000..2dde9d2 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/context_zero.cpp @@ -0,0 +1,1009 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace perspective { + +t_ctx0::t_ctx0() = default; + +t_ctx0::t_ctx0(const t_schema& schema, const t_config& config) : + t_ctxbase(schema, config), + m_has_delta(false) {} + +t_ctx0::~t_ctx0() { m_traversal.reset(); } + +void +t_ctx0::init() { + m_traversal = std::make_shared(); + m_deltas = std::make_shared(); + + // Each context stores its own expression columns in separate + // `t_data_table`s so that each context's expressions are isolated + // and do not affect other contexts when they are calculated. + const auto& expressions = m_config.get_expressions(); + m_expression_tables = std::make_shared( + expressions, m_config.get_backing_store() + ); + + m_init = true; +} + +/** + * @brief When the gnode notifies the context with new data, clear deltas + * and prepare to reconcile new data with old. + */ +void +t_ctx0::step_begin() { + if (!m_init) { + return; + } + + m_deltas = std::make_shared(); + m_delta_pkeys.clear(); + m_rows_changed = false; + m_columns_changed = false; + m_traversal->step_begin(); +} + +/** + * @brief After all new rows have been processed, trigger the traversal's + * step_end() method which will reconcile traversal state. + */ +void +t_ctx0::step_end() { + if (!has_deltas()) { + return; + } + + m_traversal->step_end(); +} + +/** + * @brief Given new data from the gnode, add/update/remove each row from the + * newly-processed data from the traversal. + * + * @param flattened + * @param delta + * @param prev + * @param curr + * @param transitions + * @param existed + */ +void +t_ctx0::notify( + const t_data_table& flattened, + const t_data_table& delta, + const t_data_table& prev, + const t_data_table& curr, + const t_data_table& transitions, + const t_data_table& existed +) { + + t_uindex nrecs = flattened.size(); + const t_column* pkey_col = flattened._get_const_column("psp_pkey"); + const t_column* op_col = flattened._get_const_column("psp_op"); + const t_column* existed_col = existed._get_const_column("psp_existed"); + + bool delete_encountered = false; + bool has_filters = m_config.has_filters(); + t_mask msk_prev, msk_curr; + if (has_filters) { + msk_prev = filter_table_for_config(prev, m_config); + msk_curr = filter_table_for_config(curr, m_config); + } + + for (t_uindex idx = 0; idx < nrecs; ++idx) { + t_tscalar pkey = + m_symtable.get_interned_tscalar(pkey_col->get_scalar(idx)); + std::uint8_t op_ = *(op_col->get_nth(idx)); + t_op op = static_cast(op_); + bool row_existed = *(existed_col->get_nth(idx)); + + switch (op) { + case OP_INSERT: { + if (has_filters) { + bool filter_curr = msk_curr.get(idx); + bool filter_prev = msk_prev.get(idx) && row_existed; + + if (filter_prev) { + if (filter_curr) { + m_traversal->update_row( + *m_gstate, + *(m_expression_tables->m_master), + m_config, + pkey + ); + } else { + m_traversal->delete_row(pkey); + } + } else { + if (filter_curr) { + m_traversal->add_row( + *m_gstate, + *(m_expression_tables->m_master), + m_config, + pkey + ); + } + } + } else { + if (row_existed) { + m_traversal->update_row( + *m_gstate, + *(m_expression_tables->m_master), + m_config, + pkey + ); + } else { + m_traversal->add_row( + *m_gstate, + *(m_expression_tables->m_master), + m_config, + pkey + ); + } + } + } break; + case OP_DELETE: { + m_traversal->delete_row(pkey); + delete_encountered = true; + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected OP"); + } break; + } + + add_delta_pkey(pkey); + } + + m_has_delta = + !m_deltas->empty() || !m_delta_pkeys.empty() || delete_encountered; +} + +/** + * @brief Given new data from the gnode after its first update (going from + * 0 rows to n > 0 rows), add each row to the traversal. + * + * @param flattened + */ +void +t_ctx0::notify(const t_data_table& flattened, bool is_registration) { + t_uindex nrecs = flattened.size(); + std::shared_ptr pkey_sptr = + flattened.get_const_column("psp_pkey"); + std::shared_ptr op_sptr = + flattened.get_const_column("psp_op"); + const t_column* pkey_col = pkey_sptr.get(); + const t_column* op_col = op_sptr.get(); + + m_has_delta = true; + + // During `_register_context`, no subscriber exists yet to observe the + // delta set produced here — skip populating `m_delta_pkeys` so we don't + // allocate one hopscotch_set entry per row. The first real update goes + // through the 6-arg `notify` which tracks deltas normally. + const bool track_deltas = !is_registration; + + // Fast path: unsorted, unfiltered, empty traversal (i.e. initial + // registration of a pass-through ctx0). Skip the `m_new_elems` + // hopscotch_map round-trip and the subsequent `step_end` rebuild of + // `m_index`; append pkeys directly into `m_index`, then finalize + // (pkey-sort + `m_pkeyidx` population). This matches the row order + // the existing `add_row`/`step_end` path produces for an empty sort. + const bool can_bulk_load = !m_config.has_filters() + && m_traversal->empty_sort_by() && m_traversal->size() == 0; + if (can_bulk_load) { + m_traversal->bulk_load_reserve(nrecs); + if (track_deltas) { + m_delta_pkeys.reserve(nrecs); + } + for (t_uindex idx = 0; idx < nrecs; ++idx) { + t_tscalar pkey = + m_symtable.get_interned_tscalar(pkey_col->get_scalar(idx)); + std::uint8_t op_ = *(op_col->get_nth(idx)); + if (static_cast(op_) == OP_INSERT) { + m_traversal->bulk_load_append(pkey); + } + if (track_deltas) { + add_delta_pkey(pkey); + } + } + m_traversal->bulk_load_finalize(); + return; + } + + if (m_config.has_filters()) { + t_mask msk = filter_table_for_config(flattened, m_config); + + for (t_uindex idx = 0; idx < nrecs; ++idx) { + t_tscalar pkey = + m_symtable.get_interned_tscalar(pkey_col->get_scalar(idx)); + std::uint8_t op_ = *(op_col->get_nth(idx)); + t_op op = static_cast(op_); + + switch (op) { + case OP_INSERT: { + if (msk.get(idx)) { + m_traversal->add_row( + *m_gstate, + *(m_expression_tables->m_master), + m_config, + pkey + ); + } + } break; + default: + break; + } + + if (track_deltas) { + add_delta_pkey(pkey); + } + } + + return; + } + + for (t_uindex idx = 0; idx < nrecs; ++idx) { + t_tscalar pkey = + m_symtable.get_interned_tscalar(pkey_col->get_scalar(idx)); + std::uint8_t op_ = *(op_col->get_nth(idx)); + t_op op = static_cast(op_); + + switch (op) { + case OP_INSERT: { + m_traversal->add_row( + *m_gstate, *(m_expression_tables->m_master), m_config, pkey + ); + } break; + default: + break; + } + + if (track_deltas) { + add_delta_pkey(pkey); + } + } +} + +/** + * @brief Gives the min and max value (by t_tscalar comparison) of the leaf + * nodes of a given column. + * + * @param colname + * @return std::pair + */ +std::pair +t_ctx0::get_min_max(const std::string& colname) const { + std::pair rval(mknone(), mknone()); + t_uindex ctx_nrows = get_row_count(); + std::vector values(ctx_nrows); + std::vector pkeys = m_traversal->get_pkeys(0, ctx_nrows); + std::vector out_data(pkeys.size()); + + read_column_from_gstate(colname, pkeys, out_data); + + for (t_index ridx = 0; ridx < m_traversal->size(); ++ridx) { + auto val = out_data[ridx]; + if (!val.is_valid()) { + continue; + } + + if (rval.first.is_none() || (!val.is_none() && val < rval.first)) { + rval.first = val; + } + + if (val > rval.second) { + rval.second = val; + } + } + + return rval; +} + +/** + * @brief Given a start/end row and column index, return the underlying data + * for the requested subset. + * + * @param start_row + * @param end_row + * @param start_col + * @param end_col + * @return std::vector + */ +std::vector +t_ctx0::get_data( + t_index start_row, t_index end_row, t_index start_col, t_index end_col +) const { + t_uindex ctx_nrows = get_row_count(); + t_uindex ctx_ncols = get_column_count(); + auto ext = sanitize_get_data_extents( + ctx_nrows, ctx_ncols, start_row, end_row, start_col, end_col + ); + + t_index nrows = ext.m_erow - ext.m_srow; + t_index stride = ext.m_ecol - ext.m_scol; + std::vector values(nrows * stride); + + std::vector pkeys = + m_traversal->get_pkeys(ext.m_srow, ext.m_erow); + auto none = mknone(); + + std::vector row_indices; + m_gstate->resolve_pkeys(pkeys, row_indices); + + std::shared_ptr master_table = m_gstate->get_table(); + t_index col_count = ext.m_ecol - ext.m_scol; + std::vector columns(col_count); + for (t_index cidx = ext.m_scol; cidx < ext.m_ecol; ++cidx) { + const std::string& colname = m_config.col_at(cidx); + if (is_expression_column(colname)) { + columns[cidx - ext.m_scol] = + m_expression_tables->m_master->_get_const_column(colname); + } else { + columns[cidx - ext.m_scol] = + master_table->_get_const_column(colname); + } + } + + constexpr t_uindex INVALID_ROW = t_uindex(-1); + for (t_index ridx = 0; ridx < nrows; ++ridx) { + t_uindex row_idx = row_indices[ridx]; + if (row_idx == INVALID_ROW) { + continue; + } + for (t_index cidx = 0; cidx < col_count; ++cidx) { + auto v = columns[cidx]->get_scalar(row_idx); + if (!v.is_valid()) { + v.set(none); + } + values[ridx * stride + cidx] = v; + } + } + + return values; +} + +/** + * @brief Given a vector of row indices, which may not be contiguous, + * return the underlying data for these rows. + * + * @param rows a vector of row indices + * @return std::vector a vector of scalars containing the data + */ +std::vector +t_ctx0::get_data(const std::vector& rows) const { + t_uindex stride = get_column_count(); + std::vector values(rows.size() * stride); + std::vector pkeys = m_traversal->get_pkeys(rows); + + auto none = mknone(); + + std::vector row_indices; + m_gstate->resolve_pkeys(pkeys, row_indices); + + std::shared_ptr master_table = m_gstate->get_table(); + std::vector columns(stride); + for (t_uindex cidx = 0; cidx < stride; ++cidx) { + const std::string& colname = m_config.col_at(cidx); + if (is_expression_column(colname)) { + columns[cidx] = + m_expression_tables->m_master->_get_const_column(colname); + } else { + columns[cidx] = master_table->_get_const_column(colname); + } + } + + constexpr t_uindex INVALID_ROW = t_uindex(-1); + for (t_uindex ridx = 0; ridx < rows.size(); ++ridx) { + t_uindex row_idx = row_indices[ridx]; + if (row_idx == INVALID_ROW) { + continue; + } + for (t_uindex cidx = 0; cidx < stride; ++cidx) { + auto v = columns[cidx]->get_scalar(row_idx); + if (!v.is_valid()) { + v.set(none); + } + values[ridx * stride + cidx] = v; + } + } + + return values; +} + +void +t_ctx0::sort_by() { + reset_sortby(); +} + +void +t_ctx0::sort_by(const std::vector& sortby) { + if (sortby.empty()) { + return; + } + m_traversal->sort_by( + *m_gstate, *(m_expression_tables->m_master), m_config, sortby + ); +} + +void +t_ctx0::reset_sortby() { + m_traversal->sort_by( + *m_gstate, + *(m_expression_tables->m_master), + m_config, + std::vector() + ); +} + +t_tscalar +t_ctx0::get_column_name(t_index idx) { + std::string empty; + + if (idx >= get_column_count()) { + return m_symtable.get_interned_tscalar(empty.c_str()); + } + + return m_symtable.get_interned_tscalar(m_config.col_at(idx).c_str()); +} + +std::vector +t_ctx0::get_pkeys(const std::vector>& cells +) const { + if (!m_traversal->validate_cells(cells)) { + std::vector rval; + return rval; + } + return m_traversal->get_pkeys(cells); +} + +std::vector +t_ctx0::get_all_pkeys(const std::vector>& cells +) const { + if (!m_traversal->validate_cells(cells)) { + std::vector rval; + return rval; + } + return m_traversal->get_all_pkeys(cells); +} + +/** + * @brief Returns a `t_rowdelta` struct containing data from updated rows and + * the updated row indices. + * + * @return t_rowdelta + */ +t_rowdelta +t_ctx0::get_row_delta() { + bool rows_changed = m_rows_changed || !m_traversal->empty_sort_by(); + std::vector rows = m_traversal->get_row_indices(m_delta_pkeys); + std::sort(rows.begin(), rows.end()); + std::vector data = get_data(rows); + t_rowdelta rval(rows_changed, rows.size(), data); + clear_deltas(); + return rval; +} + +const tsl::hopscotch_set& +t_ctx0::get_delta_pkeys() const { + return m_delta_pkeys; +} + +std::vector +t_ctx0::get_column_names() const { + return m_config.get_column_names(); +} + +std::vector +t_ctx0::get_sort_by() const { + return m_traversal->get_sort_by(); +} + +void +t_ctx0::reset(bool reset_expressions) { + m_traversal->reset(); + m_deltas = std::make_shared(); + m_has_delta = false; + + if (reset_expressions) { + m_expression_tables->reset(); + } +} + +t_index +t_ctx0::sidedness() const { + return 0; +} + +void +t_ctx0::calc_step_delta(const t_data_table& flattened) { + // Calculate step deltas when the `t_gstate` master table is updated with + // data for the first time, so every single row is a new delta. + t_uindex nrows = flattened.size(); + const auto& column_names = m_config.get_column_names(); + const t_column* pkey_col = flattened._get_const_column("psp_pkey"); + + // Add every row and every column to the delta + for (const auto& name : column_names) { + auto cidx = m_config.get_colidx(name); + const t_column* flattened_column = + flattened._get_const_column(name); + + for (t_uindex ridx = 0; ridx < nrows; ++ridx) { + m_deltas->insert(t_zcdelta( + get_interned_tscalar(pkey_col->get_scalar(ridx)), + cidx, + mknone(), + get_interned_tscalar(flattened_column->get_scalar(ridx)) + )); + } + } +} + +void +t_ctx0::calc_step_delta( + const t_data_table& flattened, + const t_data_table& prev, + const t_data_table& curr, + const t_data_table& transitions +) { + // Calculate step deltas when the `t_gstate` master table already has + // data, so we can take transitions into account. + t_uindex nrows = flattened.size(); + + PSP_VERBOSE_ASSERT(prev.size() == nrows, "Shape violation detected"); + PSP_VERBOSE_ASSERT(curr.size() == nrows, "Shape violation detected"); + + const t_column* pkey_col = flattened._get_const_column("psp_pkey"); + + const auto& column_names = m_config.get_column_names(); + + for (const auto& name : column_names) { + auto cidx = m_config.get_colidx(name); + const t_column* tcol = transitions._get_const_column(name); + const t_column* pcol = prev._get_const_column(name); + const t_column* ccol = curr._get_const_column(name); + + for (t_uindex ridx = 0; ridx < nrows; ++ridx) { + const auto* trans_ = tcol->get_nth(ridx); + std::uint8_t trans = *trans_; + auto tr = static_cast(trans); + + switch (tr) { + case VALUE_TRANSITION_NVEQ_FT: + case VALUE_TRANSITION_NEQ_FT: + case VALUE_TRANSITION_NEQ_TDT: { + m_deltas->insert(t_zcdelta( + get_interned_tscalar(pkey_col->get_scalar(ridx)), + cidx, + mknone(), + get_interned_tscalar(ccol->get_scalar(ridx)) + )); + } break; + case VALUE_TRANSITION_NEQ_TT: { + m_deltas->insert(t_zcdelta( + get_interned_tscalar(pkey_col->get_scalar(ridx)), + cidx, + get_interned_tscalar(pcol->get_scalar(ridx)), + get_interned_tscalar(ccol->get_scalar(ridx)) + )); + } break; + default: { + } + } + } + } +} + +/** + * @brief + * + * @param bidx + * @param eidx + * @return std::vector + */ +std::vector +t_ctx0::get_cell_delta(t_index bidx, t_index eidx) const { + tsl::hopscotch_set pkeys; + t_tscalar prev_pkey; + prev_pkey.set(t_none()); + + bidx = std::min(bidx, m_traversal->size()); + eidx = std::min(eidx, m_traversal->size()); + + std::vector rval; + + if (m_traversal->empty_sort_by()) { + std::vector pkey_vec = m_traversal->get_pkeys(bidx, eidx); + for (t_index idx = 0, loop_end = pkey_vec.size(); idx < loop_end; + ++idx) { + const t_tscalar& pkey = pkey_vec[idx]; + t_index row = bidx + idx; + std::pair< + t_zcdeltas::index::type::iterator, + t_zcdeltas::index::type::iterator> + iters = m_deltas->get().equal_range(pkey); + for (t_zcdeltas::index::type::iterator iter = + iters.first; + iter != iters.second; + ++iter) { + t_cellupd cellupd; + cellupd.row = row; + cellupd.column = iter->m_colidx; + cellupd.old_value = iter->m_old_value; + cellupd.new_value = iter->m_new_value; + rval.push_back(cellupd); + } + } + } else { + for (const auto& iter : m_deltas->get()) { + if (prev_pkey != iter.m_pkey) { + pkeys.insert(iter.m_pkey); + prev_pkey = iter.m_pkey; + } + } + + tsl::hopscotch_map r_indices; + m_traversal->get_row_indices(pkeys, r_indices); + + for (const auto& iter : m_deltas->get()) { + t_index row = r_indices[iter.m_pkey]; + if (bidx <= row && row <= eidx) { + t_cellupd cellupd; + cellupd.row = row; + cellupd.column = iter.m_colidx; + cellupd.old_value = iter.m_old_value; + cellupd.new_value = iter.m_new_value; + rval.push_back(cellupd); + } + } + } + return rval; +} + +/** + * @brief Returns updated cells. + * + * @param bidx + * @param eidx + * @return t_stepdelta + */ +t_stepdelta +t_ctx0::get_step_delta(t_index bidx, t_index eidx) { + bidx = std::min(bidx, m_traversal->size()); + eidx = std::min(eidx, m_traversal->size()); + bool rows_changed = m_rows_changed || !m_traversal->empty_sort_by(); + t_stepdelta rval( + rows_changed, m_columns_changed, get_cell_delta(bidx, eidx) + ); + m_deltas->clear(); + clear_deltas(); + return rval; +} + +void +t_ctx0::compute_expressions( + const std::shared_ptr& master, + const t_gstate::t_mapping& pkey_map, + t_expression_vocab& expression_vocab, + t_regex_mapping& regex_mapping +) { + // Clear the transitional expression tables on the context so they are + // ready for the next update. + m_expression_tables->clear_transitional_tables(); + + std::shared_ptr master_expression_table = + m_expression_tables->m_master; + + // Set the master table to the right size. + t_uindex num_rows = master->size(); + master_expression_table->reserve(num_rows); + master_expression_table->set_size(num_rows); + + const auto& expressions = m_config.get_expressions(); + for (const auto& expr : expressions) { + // Compute the expressions on the master table. + expr->compute( + master, + pkey_map, + master_expression_table, + expression_vocab, + regex_mapping + ); + } +} + +// TODO rewrite const& +void +t_ctx0::compute_expressions( + const std::shared_ptr& master, + const t_gstate::t_mapping& pkey_map, + const std::shared_ptr& flattened, + const std::shared_ptr& delta, + const std::shared_ptr& prev, + const std::shared_ptr& current, + const std::shared_ptr& transitions, + const std::shared_ptr& existed, + t_expression_vocab& expression_vocab, + t_regex_mapping& regex_mapping +) { + // Clear the tables so they are ready for this round of updates + m_expression_tables->clear_transitional_tables(); + + // All tables are the same size + t_uindex flattened_num_rows = flattened->size(); + m_expression_tables->reserve_transitional_table_size(flattened_num_rows); + m_expression_tables->set_transitional_table_size(flattened_num_rows); + + // Update the master expression table's size + t_uindex master_num_rows = master->size(); + m_expression_tables->m_master->reserve(master_num_rows); + m_expression_tables->m_master->set_size(master_num_rows); + + const auto& expressions = m_config.get_expressions(); + for (const auto& expr : expressions) { + // master: compute based on latest state of the gnode state table + expr->compute( + master, + pkey_map, + m_expression_tables->m_master, + expression_vocab, + regex_mapping + ); + + // flattened: compute based on the latest update dataset + expr->compute( + flattened, + pkey_map, + m_expression_tables->m_flattened, + expression_vocab, + regex_mapping + ); + + // delta: for each numerical column, the numerical delta between the + // previous value and the current value in the row. + expr->compute( + delta, + pkey_map, + m_expression_tables->m_delta, + expression_vocab, + regex_mapping + ); + + // prev: the values of the updated rows before this update was applied + expr->compute( + prev, + pkey_map, + m_expression_tables->m_prev, + expression_vocab, + regex_mapping + ); + + // current: the current values of the updated rows + expr->compute( + current, + pkey_map, + m_expression_tables->m_current, + expression_vocab, + regex_mapping + ); + } + + // Calculate the transitions now that the intermediate tables are computed + m_expression_tables->calculate_transitions(existed); +} + +bool +t_ctx0::is_expression_column(const std::string& colname) const { + const t_schema& schema = m_expression_tables->m_master->get_schema(); + return schema.has_column(colname); +} + +t_uindex +t_ctx0::num_expressions() const { + const auto& expressions = m_config.get_expressions(); + return expressions.size(); +} + +std::shared_ptr +t_ctx0::get_expression_tables() const { + return m_expression_tables; +} + +void +t_ctx0::read_column_from_gstate( + const std::string& colname, + const std::vector& pkeys, + std::vector& out_data +) const { + + if (is_expression_column(colname)) { + m_gstate->read_column( + *(m_expression_tables->m_master), colname, pkeys, out_data + ); + } else { + std::shared_ptr master_table = m_gstate->get_table(); + m_gstate->read_column(*master_table, colname, pkeys, out_data); + } +} + +t_index +t_ctx0::get_row_count() const { + return m_traversal->size(); +} + +t_index +t_ctx0::get_column_count() const { + return m_config.get_num_columns(); +} + +/** + * @brief Mark a primary key as updated by adding it to the tracking set. + * + * @param pkey + */ +void +t_ctx0::add_delta_pkey(t_tscalar pkey) { + m_delta_pkeys.insert(pkey); +} + +void +t_ctx0::reset_step_state() { + m_traversal->reset_step_state(); +} + +void +t_ctx0::disable() { + m_features[CTX_FEAT_ENABLED] = false; +} + +void +t_ctx0::enable() { + m_features[CTX_FEAT_ENABLED] = true; +} + +bool +t_ctx0::get_deltas_enabled() const { + return m_features[CTX_FEAT_DELTA]; +} + +void +t_ctx0::set_deltas_enabled(bool enabled_state) { + m_features[CTX_FEAT_DELTA] = enabled_state; +} + +std::vector +t_ctx0::get_trees() { + return {}; +} + +bool +t_ctx0::has_deltas() const { + return m_has_delta; +} + +t_dtype +t_ctx0::get_column_dtype(t_uindex idx) const { + if (idx >= static_cast(get_column_count())) { + return DTYPE_NONE; + } + + auto cname = m_config.col_at(idx); + + if (!m_schema.has_column(cname)) { + return DTYPE_NONE; + } + + return m_schema.get_dtype(cname); +} + +std::vector +t_ctx0::unity_get_row_data(t_uindex idx) const { + return get_data(idx, idx + 1, 0, get_column_count()); +} + +std::vector +t_ctx0::unity_get_column_data(t_uindex idx) const { + PSP_COMPLAIN_AND_ABORT("Not implemented"); + return {}; +} + +std::vector +t_ctx0::unity_get_row_path(t_uindex idx) const { + return {}; +} + +std::vector +t_ctx0::unity_get_column_path(t_uindex idx) const { + return {}; +} + +t_uindex +t_ctx0::unity_get_row_depth(t_uindex ridx) const { + return 0; +} + +t_uindex +t_ctx0::unity_get_column_depth(t_uindex cidx) const { + return 0; +} + +std::string +t_ctx0::unity_get_column_name(t_uindex idx) const { + return m_config.col_at(idx); +} + +std::string +t_ctx0::unity_get_column_display_name(t_uindex idx) const { + return m_config.col_at(idx); +} + +std::vector +t_ctx0::unity_get_column_names() const { + return m_config.get_column_names(); +} + +std::vector +t_ctx0::unity_get_column_display_names() const { + return m_config.get_column_names(); +} + +t_uindex +t_ctx0::unity_get_column_count() const { + return get_column_count(); +} + +t_uindex +t_ctx0::unity_get_row_count() const { + return get_row_count(); +} + +bool +t_ctx0::unity_get_row_expanded(t_uindex idx) const { + return false; +} + +bool +t_ctx0::unity_get_column_expanded(t_uindex idx) const { + return false; +} + +void +t_ctx0::clear_deltas() { + m_has_delta = false; +} + +void +t_ctx0::unity_init_load_step_end() {} + +std::string +t_ctx0::repr() const { + std::stringstream ss; + ss << "t_ctx0<" << this << ">"; + return ss.str(); +} + +void +t_ctx0::pprint() const {} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/data.cpp b/rust/perspective-server/cpp/perspective/src/cpp/data.cpp new file mode 100644 index 0000000..620bf0a --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/data.cpp @@ -0,0 +1,30 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include + +namespace perspective { +t_data::t_data() = default; + +t_data::t_data(const std::vector& data) : m_data(data) {} + +const std::vector& +t_data::data() const { + return m_data; +} + +std::vector& +t_data::data() { + return m_data; +} +} // namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/data_slice.cpp b/rust/perspective-server/cpp/perspective/src/cpp/data_slice.cpp new file mode 100644 index 0000000..ff96780 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/data_slice.cpp @@ -0,0 +1,201 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include + +namespace perspective { + +template +t_data_slice::t_data_slice( + std::shared_ptr ctx, + t_uindex start_row, + t_uindex end_row, + t_uindex start_col, + t_uindex end_col, + t_uindex row_offset, + t_uindex col_offset, + const std::vector& slice, + const std::vector>& column_names +) : + m_ctx(ctx), + m_start_row(start_row), + m_end_row(end_row), + m_start_col(start_col), + m_end_col(end_col), + m_row_offset(row_offset), + m_col_offset(col_offset), + m_slice(slice), + m_column_names(column_names) { + m_stride = m_end_col - m_start_col; +} + +template +t_data_slice::t_data_slice( + std::shared_ptr ctx, + t_uindex start_row, + t_uindex end_row, + t_uindex start_col, + t_uindex end_col, + t_uindex row_offset, + t_uindex col_offset, + const std::vector& slice, + const std::vector>& column_names, + const std::vector& column_indices +) : + m_ctx(ctx), + m_start_row(start_row), + m_end_row(end_row), + m_start_col(start_col), + m_end_col(end_col), + m_row_offset(row_offset), + m_col_offset(col_offset), + m_slice(slice), + m_column_names(column_names), + m_column_indices(column_indices) { + m_stride = m_end_col - m_start_col; +} + +template +t_data_slice::~t_data_slice() = default; + +// Public API +template +t_tscalar +t_data_slice::get(t_uindex ridx, t_uindex cidx) const { + ridx += m_row_offset; + t_uindex idx = get_slice_idx(ridx, cidx); + t_tscalar rv; + if (idx >= m_slice.size()) { + rv.clear(); + } else { + rv = m_slice.operator[](idx); + } + return rv; +} + +template +std::vector +t_data_slice::get_pkeys(t_uindex ridx, t_uindex cidx) const { + std::pair pair{ridx, cidx}; + std::vector> vec{pair}; + return m_ctx->get_pkeys(vec); +} + +template +std::vector +t_data_slice::get_column_slice(t_uindex cidx) const { + std::vector column_data; + column_data.reserve(m_end_row); + + for (t_uindex ridx = 0; ridx < m_end_row; ++ridx) { + ridx += m_row_offset; + t_tscalar value = get(ridx, cidx); + column_data.push_back(value); + } + + return column_data; +} + +template +std::vector +t_data_slice::get_row_path(t_uindex ridx) const { + return m_ctx->unity_get_row_path(ridx); +} + +template +t_uindex +t_data_slice::num_rows() const { + return m_end_row - m_start_row; +} + +// Getters +template +std::shared_ptr +t_data_slice::get_context() const { + return m_ctx; +} + +template +const std::vector& +t_data_slice::get_slice() const { + return m_slice; +} + +template +const std::vector>& +t_data_slice::get_column_names() const { + return m_column_names; +} + +template +const std::vector& +t_data_slice::get_column_indices() const { + return m_column_indices; +} + +template +bool +t_data_slice::is_column_only() const { + return false; +} + +template <> +bool +t_data_slice::is_column_only() const { + auto config = m_ctx->get_config(); + return config.is_column_only(); +} + +template +t_uindex +t_data_slice::get_stride() const { + return m_stride; +} + +template +t_uindex +t_data_slice::get_row_offset() const { + return m_row_offset; +} + +template +t_uindex +t_data_slice::get_col_offset() const { + return m_col_offset; +} + +template +t_get_data_extents +t_data_slice::get_data_extents() const { + auto nrows = m_ctx->get_row_count(); + auto ncols = m_ctx->get_column_count(); + t_get_data_extents ext = sanitize_get_data_extents( + nrows, ncols, m_start_row, m_end_row, m_start_col, m_end_col + ); + return ext; +} + +// Private +template +t_uindex +t_data_slice::get_slice_idx(t_uindex ridx, t_uindex cidx) const { + t_uindex idx = (ridx - m_start_row) * m_stride + (cidx - m_start_col); + return idx; +} + +// Explicitly instantiate data slice for each context +template class t_data_slice; +template class t_data_slice; +template class t_data_slice; +template class t_data_slice; +} // end namespace perspective \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/cpp/data_table.cpp b/rust/perspective-server/cpp/perspective/src/cpp/data_table.cpp new file mode 100644 index 0000000..213ac72 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/data_table.cpp @@ -0,0 +1,936 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +namespace perspective { + +void +t_data_table::set_capacity(t_uindex idx) { + m_capacity = idx; +#ifdef PSP_TABLE_VERIFY + if (m_init) { + for (auto c : m_columns) { + c->verify_size(); + c->verify_size(m_capacity); + } + } + +#endif +} + +t_data_table::t_data_table(t_schema s, t_uindex init_cap) : + m_schema(std::move(s)), + m_size(0), + m_backing_store(BACKING_STORE_MEMORY), + m_init(false) { + PSP_TRACE_SENTINEL(); + LOG_CONSTRUCTOR("t_data_table"); + set_capacity(init_cap); +} + +t_data_table::t_data_table( + std::string name, + std::string dirname, + t_schema s, + t_uindex init_cap, + t_backing_store backing_store +) : + m_name(std::move(name)), + m_dirname(std::move(dirname)), + m_schema(std::move(s)), + m_size(0), + m_backing_store(backing_store), + m_init(false) { + PSP_TRACE_SENTINEL(); + LOG_CONSTRUCTOR("t_data_table"); + set_capacity(init_cap); +} + +// THIS CONSTRUCTOR INITS. Do not use in production. +t_data_table::t_data_table( + const t_schema& s, const std::vector>& v +) : + m_schema(s), + m_size(0), + m_backing_store(BACKING_STORE_MEMORY), + m_init(false) { + PSP_TRACE_SENTINEL(); + LOG_CONSTRUCTOR("t_data_table"); + auto ncols = s.size(); + PSP_VERBOSE_ASSERT( + std::all_of( + v.begin(), + v.end(), + [ncols](const std::vector& vec) { + return vec.size() == ncols; + } + ), + "Mismatched row size found" + ); + set_capacity(v.size()); + init(); + extend(v.size()); + std::vector cols = get_columns(); + for (t_uindex cidx = 0; cidx < ncols; ++cidx) { + auto* col = cols[cidx]; + for (t_uindex ridx = 0, loop_end = v.size(); ridx < loop_end; ++ridx) { + col->set_scalar(ridx, v[ridx][cidx]); + } + } +} + +t_data_table::~t_data_table() { + PSP_TRACE_SENTINEL(); + LOG_DESTRUCTOR("t_data_table"); +} + +const std::string& +t_data_table::name() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_name; +} + +void +t_data_table::init(bool make_columns) { + PSP_TRACE_SENTINEL(); + LOG_INIT("t_data_table"); + m_columns = std::vector>(m_schema.size()); + + if (make_columns) { + for (t_uindex idx = 0; idx < int(m_schema.size()); ++idx) { + const std::string& colname = m_schema.m_columns[idx]; + t_dtype dtype = m_schema.m_types[idx]; + m_columns[idx] = + make_column(colname, dtype, m_schema.m_status_enabled[idx]); + m_columns[idx]->init(); + } + } + + m_init = true; +} + +std::shared_ptr +t_data_table::make_column( + const std::string& colname, t_dtype dtype, bool status_enabled +) { + t_lstore_recipe a( + m_dirname, + m_name + std::string("_") + colname, + m_capacity * get_dtype_size(dtype), + m_backing_store + ); + return std::make_shared(dtype, status_enabled, a, m_capacity); +} + +t_uindex +t_data_table::num_columns() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_schema.size(); +} + +t_uindex +t_data_table::num_rows() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_size; +} + +t_uindex +t_data_table::size() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return num_rows(); +} + +t_dtype +t_data_table::get_dtype(const std::string& colname) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_schema.get_dtype(colname); +} + +std::shared_ptr +t_data_table::get_column_safe(std::string_view colname) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + t_uindex idx = m_schema.get_colidx_safe(colname.data()); + if (idx == -1) { + return nullptr; + } + ensure_col_resident(idx); + return m_columns[idx]; +} + +std::shared_ptr +t_data_table::get_column(std::string_view colname) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + t_uindex idx = m_schema.get_colidx(colname.data()); + ensure_col_resident(idx); + return m_columns[idx]; +} + +std::shared_ptr +t_data_table::get_column(std::string_view colname) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + t_uindex idx = m_schema.get_colidx(colname.data()); + ensure_col_resident(idx); + return m_columns[idx]; +} + +std::shared_ptr +t_data_table::get_column_safe(std::string_view colname) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + t_uindex idx = m_schema.get_colidx_safe(colname.data()); + if (idx == -1) { + return nullptr; + } + ensure_col_resident(idx); + return m_columns[idx]; +} + +std::shared_ptr +t_data_table::get_const_column(std::string_view colname) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + t_uindex idx = m_schema.get_colidx(colname.data()); + ensure_col_resident(idx); + return m_columns[idx]; +} + +std::shared_ptr +t_data_table::get_const_column_safe(std::string_view colname) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + t_uindex idx = m_schema.get_colidx_safe(colname.data()); + if (idx == -1) { + return nullptr; + } + ensure_col_resident(idx); + return m_columns[idx]; +} + +std::shared_ptr +t_data_table::get_const_column(t_uindex idx) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + ensure_col_resident(idx); + return m_columns[idx]; +} + +std::shared_ptr +t_data_table::get_const_column_safe(t_uindex idx) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + if (idx == -1) { + return nullptr; + } + ensure_col_resident(idx); + return m_columns[idx]; +} + +std::vector +t_data_table::get_columns() { + std::vector rval(m_columns.size()); + t_uindex idx = 0; + for (const auto& c : m_columns) { + ensure_col_resident(idx); + rval[idx] = c.get(); + ++idx; + } + return rval; +} + +std::vector +t_data_table::get_const_columns() const { + std::vector rval(m_columns.size()); + t_uindex idx = 0; + for (const auto& c : m_columns) { + ensure_col_resident(idx); + rval[idx] = c.get(); + ++idx; + } + return rval; +} + +void +t_data_table::extend(t_uindex nelems) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + PSP_VERBOSE_ASSERT(m_init, "Table not inited"); + for (t_uindex idx = 0, loop_end = m_schema.size(); idx < loop_end; ++idx) { + // Restore before reallocating: extend/reserve realloc `m_base`, which on + // an evicted store would discard its disk-resident data. + ensure_col_resident(idx); + m_columns[idx]->extend_dtype(nelems); + } + m_size = std::max(nelems, m_size); + set_capacity(std::max(nelems, m_capacity)); +} + +void +t_data_table::set_size(t_uindex size) { + PSP_TRACE_SENTINEL(); + for (t_uindex idx = 0, loop_end = m_schema.size(); idx < loop_end; ++idx) { + ensure_col_resident(idx); + m_columns[idx]->set_size(size); + } + m_size = size; +} + +void +t_data_table::set_table_size(t_uindex size) { + m_size = size; +} + +void +t_data_table::reserve(t_uindex capacity) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + for (t_uindex idx = 0, loop_end = m_schema.size(); idx < loop_end; ++idx) { + ensure_col_resident(idx); + m_columns[idx]->reserve(capacity); + } + set_capacity(std::max(capacity, m_capacity)); +} + +t_column* +t_data_table::_get_column(std::string_view colname) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + t_uindex idx = m_schema.get_colidx(colname.data()); + ensure_col_resident(idx); + return m_columns[idx].get(); +} + +const t_column* +t_data_table::_get_const_column(std::string_view colname) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + t_uindex idx = m_schema.get_colidx(colname.data()); + ensure_col_resident(idx); + return m_columns[idx].get(); +} + +const t_column* +t_data_table::_get_const_column(t_uindex idx) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + ensure_col_resident(idx); + return m_columns[idx].get(); +} + +const t_column* +t_data_table::_get_const_column_safe(std::string_view colname) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + t_uindex idx = m_schema.get_colidx_safe(colname.data()); + if (idx == t_uindex(-1)) { + return nullptr; + } + ensure_col_resident(idx); + return m_columns[idx].get(); +} + +const t_schema& +t_data_table::get_schema() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_schema; +} + +std::shared_ptr +t_data_table::flatten() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + PSP_VERBOSE_ASSERT(is_pkey_table(), "Not a pkeyed table"); + std::shared_ptr flattened = std::make_shared( + "", "", m_schema, DEFAULT_EMPTY_CAPACITY, BACKING_STORE_MEMORY + ); + flattened->init(); + flatten_body>(flattened); + return flattened; +} + +bool +t_data_table::is_pkey_table() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_schema.is_pkey(); +} + +bool +t_data_table::is_same_shape(t_data_table& tbl) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_schema == tbl.m_schema; +} + +void +t_data_table::pprint() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + pprint(size(), &std::cout); +} + +// void +// t_data_table::pprint(const std::string& fname) const { + +// PSP_TRACE_SENTINEL(); +// PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); +// std::ofstream file; +// file.open(fname); +// pprint(size(), &file); +// } + +void +t_data_table::pprint(t_uindex nrows, std::ostream* os) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + + if (os == nullptr) { + os = &std::cout; + } + + t_uindex nrows_ = nrows != 0U ? nrows : num_rows(); + nrows_ = std::min(nrows_, num_rows()); + + t_uindex ncols = num_columns(); + + std::vector columns(ncols); + for (t_uindex idx = 0; idx < ncols; ++idx) { + columns[idx] = m_columns[idx].get(); + (*os) << m_schema.m_columns[idx] << ", "; + } + + (*os) << '\n'; + (*os) << "==========================" << '\n'; + + for (t_uindex ridx = 0; ridx < nrows_; ++ridx) { + for (t_uindex cidx = 0; cidx < ncols; ++cidx) { + (*os) << columns[cidx]->get_scalar(ridx).to_string() << ", "; + } + (*os) << '\n'; + } +} + +void +t_data_table::pprint(const std::vector& vec) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + t_uindex nrows = vec.size(); + t_uindex ncols = num_columns(); + + std::vector columns(ncols); + for (t_uindex idx = 0; idx < ncols; ++idx) { + columns[idx] = m_columns[idx].get(); + std::cout << m_schema.m_columns[idx] << ", "; + } + + std::cout << '\n'; + std::cout << "==========================" << '\n'; + + for (t_uindex ridx = 0; ridx < nrows; ++ridx) { + for (t_uindex cidx = 0; cidx < ncols; ++cidx) { + std::cout << columns[cidx]->get_scalar(vec[ridx]) << ", "; + } + std::cout << '\n'; + } +} + +void +t_data_table::append(const t_data_table& other) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + + t_uindex cursize = size(); + + std::vector src_cols; + std::vector dst_cols; + + src_cols.reserve(other.m_schema.m_columns.size()); + dst_cols.reserve(m_schema.m_columns.size()); + + std::set incoming; + + for (const auto& cname : other.m_schema.m_columns) { + t_dtype col_dtype = get_column(cname)->get_dtype(); + t_dtype other_col_dtype = other.get_const_column(cname)->get_dtype(); + if (col_dtype != other_col_dtype) { + std::stringstream ss; + ss << "Mismatched dtypes for `" << cname + << "`: attempted to append column of dtype `" + << get_dtype_descr(other_col_dtype) + << "` to existing column of dtype `" + << get_dtype_descr(col_dtype) << "`" << '\n'; + std::cout << ss.str(); + PSP_COMPLAIN_AND_ABORT(ss.str()) + } + src_cols.push_back(other._get_const_column(cname)); + dst_cols.push_back(_get_column(cname)); + incoming.insert(cname); + } + t_uindex other_size = other.num_rows(); + + for (const auto& cname : m_schema.m_columns) { + if (incoming.find(cname) == incoming.end()) { + get_column(cname)->extend_dtype(cursize + other_size); + } + } + + for (t_uindex colidx = 0; colidx < int(src_cols.size()); ++colidx) { + dst_cols[colidx]->append(*(src_cols[colidx])); + } + + set_capacity(std::max(m_capacity, m_size + other.num_rows())); + set_size(m_size + other.num_rows()); +} + +void +t_data_table::clear() { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + for (const auto& m_column : m_columns) { + m_column->clear(); + } + m_size = 0; +} + +t_mask +t_data_table::filter_cpp( + t_filter_op combiner, const std::vector& fterms_ +) const { + auto* self = const_cast(this); + auto fterms = fterms_; + + t_mask mask(size()); + t_uindex fterm_size = fterms.size(); + std::vector indices(fterm_size); + std::vector columns(fterm_size); + + for (t_uindex idx = 0; idx < fterm_size; ++idx) { + indices[idx] = m_schema.get_colidx(fterms[idx].m_colname); + columns[idx] = _get_const_column(fterms[idx].m_colname); + fterms[idx].coerce_numeric(columns[idx]->get_dtype()); + if (fterms[idx].m_use_interned) { + t_tscalar& thr = fterms[idx].m_threshold; + auto col = self->get_column(fterms[idx].m_colname); + auto interned = col->get_interned(thr.get_char_ptr()); + thr.set(interned); + } + } + + switch (combiner) { + case FILTER_OP_AND: { + t_tscalar cell_val; + + for (t_uindex ridx = 0, rloop_end = size(); ridx < rloop_end; + ++ridx) { + bool pass = true; + + for (t_uindex cidx = 0; cidx < fterm_size; ++cidx) { + if (!pass) { + break; + } + + // TODO we can make this faster by not constructing these on + // every iteration? + + const auto& ft = fterms[cidx]; + bool tval; + + if (ft.m_use_interned) { + cell_val.set(*(columns[cidx]->get_nth(ridx))); + cell_val.set_status(*(columns[cidx]->get_nth_status(ridx + ))); + } else { + cell_val = columns[cidx]->get_scalar(ridx); + } + + tval = ft(cell_val); + if (!tval) { + pass = false; + break; + } + } + + mask.set(ridx, pass); + } + } break; + case FILTER_OP_OR: { + for (t_uindex ridx = 0, rloop_end = size(); ridx < rloop_end; + ++ridx) { + bool pass = false; + for (t_uindex cidx = 0; cidx < fterm_size; ++cidx) { + t_tscalar cell_val = columns[cidx]->get_scalar(ridx); + if (fterms[cidx](cell_val)) { + pass = true; + break; + } + } + mask.set(ridx, pass); + } + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unknown filter op"); + } break; + } + + return mask; +} + +t_uindex +t_data_table::get_capacity() const { + return m_capacity; +} + +t_data_table* +t_data_table::clone_(const t_mask& mask) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + t_schema schema = m_schema; + + auto* rval = new t_data_table("", "", schema, 5, BACKING_STORE_MEMORY); + rval->init(); + + for (const auto& cname : schema.m_columns) { + rval->set_column(cname, get_const_column(cname)->clone(mask)); + } + + rval->set_size(mask.count()); + return rval; +} + +std::shared_ptr +t_data_table::clone(const t_mask& mask) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + auto* tbl = clone_(mask); + return std::shared_ptr(tbl); +} + +std::shared_ptr +t_data_table::clone() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + t_schema schema = m_schema; + auto rval = + std::make_shared("", "", schema, 5, BACKING_STORE_MEMORY); + rval->init(); + + for (const auto& cname : schema.m_columns) { + rval->set_column(cname, get_const_column(cname)->clone()); + } + rval->set_size(size()); + return rval; +} + +std::shared_ptr +t_data_table::borrow(const std::vector& columns) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + std::vector dtypes; + dtypes.reserve(columns.size()); + + for (const auto& col : columns) { + dtypes.push_back(m_schema.get_dtype(col)); + } + + t_schema borrowed_schema = t_schema(columns, dtypes); + auto rval = std::make_shared( + "", "", borrowed_schema, 5, BACKING_STORE_MEMORY + ); + rval->init(); + + for (const auto& cname : borrowed_schema.m_columns) { + rval->set_column(cname, get_column(cname)); + } + + rval->set_size(size()); + return rval; +} + +std::shared_ptr +t_data_table::join(const std::shared_ptr& other_table) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + + if (size() != other_table->size()) { + std::stringstream ss; + ss << "[t_data_table::join] Cannot join two tables of unequal sizes! " + "Current size: " + << size() << ", size of other table: " << other_table->size() + << '\n'; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + + // join both schemas + t_schema schema = m_schema; + const t_schema& other_schema = other_table->get_schema(); + std::vector other_columns; + + for (const std::string& column : other_schema.m_columns) { + // Only append unique columns + if (!schema.has_column(column)) { + schema.add_column(column, other_schema.get_dtype(column)); + other_columns.push_back(column); + } + } + + std::shared_ptr rval = std::make_shared( + "", "", schema, DEFAULT_EMPTY_CAPACITY, BACKING_STORE_MEMORY + ); + + // init() without initializing the t_columns on the returned table, as + // they will be immediately replaced by the columns from the tables + // we are joining together. + rval->init(false); + + // borrow columns from the current table + for (const std::string& column : m_schema.m_columns) { + rval->set_column(column, get_column(column)); + } + + // and the columns we need from the other table + for (const std::string& column : other_columns) { + rval->set_column(column, other_table->get_column(column)); + } + + // Set size and capacity on the table only - don't mutate any of the + // columns. + rval->set_table_size(size()); + rval->set_capacity(std::max(get_capacity(), other_table->get_capacity())); + + return rval; +} + +std::shared_ptr +t_data_table::add_column_sptr( + const std::string& name, t_dtype dtype, bool status_enabled +) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + + if (m_schema.has_column(name)) { + return m_columns.at(m_schema.get_colidx(name)); + } + m_schema.add_column(name, dtype); + m_columns.push_back(make_column(name, dtype, status_enabled)); + m_columns.back()->init(); + m_columns.back()->reserve( + std::max(size(), std::max(static_cast(8), m_capacity)) + ); + m_columns.back()->set_size(size()); + return m_columns.back(); +} + +t_column* +t_data_table::add_column( + const std::string& name, t_dtype dtype, bool status_enabled +) { + return add_column_sptr(name, dtype, status_enabled).get(); +} + +void +t_data_table::drop_column(const std::string& name) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + + if (m_schema.has_column(name)) { + auto idx = m_schema.get_colidx(name); + auto col = m_columns[idx]; + // FIXME: make sure that we can erase columns from m_schema eventually. + m_columns[idx]->clear(); + } +} + +void +t_data_table::promote_column( + std::string_view col_name, + t_dtype new_dtype, + std::int32_t iter_limit, + bool fill +) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + const auto* name = col_name.data(); + + if (!m_schema.has_column(name)) { + std::cout << "Cannot promote a column that does not exist." << '\n'; + return; + } + + t_dtype current_dtype = m_schema.get_dtype(name); + if (current_dtype == new_dtype) { + return; + } + + t_uindex idx = m_schema.get_colidx(name); + std::shared_ptr current_col = m_columns[idx]; + + // create the new column and copy data + std::shared_ptr promoted_col = + make_column(name, new_dtype, current_col->is_status_enabled()); + promoted_col->init(); + promoted_col->reserve( + std::max(size(), std::max(static_cast(8), m_capacity)) + ); + promoted_col->set_size(size()); + + if (fill) { + for (auto i = 0; i < iter_limit; ++i) { + switch (new_dtype) { + case DTYPE_INT64: { + auto* val = current_col->get_nth(i); + auto fval = static_cast(*val); + promoted_col->set_nth(i, fval); + } break; + case DTYPE_FLOAT64: { + auto* val = current_col->get_nth(i); + auto fval = static_cast(*val); + promoted_col->set_nth(i, fval); + } break; + case DTYPE_STR: { + auto* val = current_col->get_nth(i); + std::string fval = std::to_string(*val); + promoted_col->set_nth(i, fval); + } break; + default: { + PSP_COMPLAIN_AND_ABORT( + "Columns can only be promoted to " + "int64, float64, or string type." + ); + } + } + } + } + + // finally, mutate schema and columns + m_schema.retype_column(name, new_dtype); + set_column(idx, promoted_col); +} + +void +t_data_table::set_column(t_uindex idx, std::shared_ptr col) { + m_columns[idx] = std::move(col); +} + +void +t_data_table::set_column( + const std::string& name, std::shared_ptr col +) { + t_uindex idx = m_schema.get_colidx(name); + set_column(idx, std::move(col)); +} + +t_column* +t_data_table::clone_column( + const std::string& existing_col, const std::string& new_colname +) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + + if (!m_schema.has_column(existing_col)) { + std::cout << "Cannot clone non existing column: " << existing_col + << '\n'; + return nullptr; + } + + t_uindex idx = m_schema.get_colidx(existing_col); + + m_schema.add_column(new_colname, m_columns[idx]->get_dtype()); + m_columns.push_back(m_columns[idx]->clone()); + m_columns.back()->reserve( + std::max(size(), std::max(static_cast(8), m_capacity)) + ); + m_columns.back()->set_size(size()); + return m_columns.back().get(); +} + +std::string +t_data_table::repr() const { + std::stringstream ss; + ss << "t_data_table<" << this << ">"; + return ss.str(); +} + +void +t_data_table::verify() const { + for (const auto& c : m_columns) { + c->verify_size(m_capacity); + c->verify(); + } + + for (const auto& c : m_columns) { + PSP_VERBOSE_ASSERT( + c, || (size() == c->size()), "Ragged table encountered" + ); + } +} + +void +t_data_table::reset() { + for (const auto& m_column : m_columns) { + if (m_column->get_dtype() == DTYPE_OBJECT) { + m_column->clear_objects(); + } + m_column->clear(); + } + m_size = 0; + m_capacity = DEFAULT_EMPTY_CAPACITY; + init(); +} + +std::vector +t_data_table::get_scalvec() const { + auto nrows = size(); + auto cols = get_const_columns(); + auto ncols = cols.size(); + std::vector rv; + rv.reserve(nrows * ncols); + for (t_uindex idx = 0; idx < nrows; ++idx) { + for (t_uindex cidx = 0; cidx < ncols; ++cidx) { + rv.push_back(cols[cidx]->get_scalar(idx)); + } + } + return rv; +} + +std::shared_ptr +t_data_table::operator[](const std::string& name) { + if (!m_schema.has_column(name)) { + return {nullptr}; + } + return m_columns[m_schema.get_colidx(name)]; +} + +bool +operator==(const t_data_table& lhs, const t_data_table& rhs) { + return lhs.get_scalvec() == rhs.get_scalvec(); +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/date.cpp b/rust/perspective-server/cpp/perspective/src/cpp/date.cpp new file mode 100644 index 0000000..c94ff43 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/date.cpp @@ -0,0 +1,172 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include + +namespace perspective { + +t_date::t_date() : m_storage(0) {} + +void +t_date::set_psp_date(t_uindex dt) + +{ + m_storage = t_rawtype(dt); +} + +t_date::t_date(std::int16_t year, std::int8_t month, std::int8_t day) { + set_year_month_day(year, month, day); +} + +void +t_date::set_year_month_day( + std::int16_t year, std::int8_t month, std::int8_t day +) { + set_year(year); + set_month(month); + set_day(day); +} + +void +t_date::set_year(std::int16_t year) { + m_storage = (m_storage & ~YEAR_MASK) | (year << YEAR_SHIFT); +} +void +t_date::set_month(std::int8_t month) { + m_storage = (m_storage & ~MONTH_MASK) | (month << MONTH_SHIFT); +} +void +t_date::set_day(std::int8_t day) { + m_storage = (m_storage & ~DAY_MASK) | (day << DAY_SHIFT); +} + +t_date::t_date(std::uint32_t raw_val) : m_storage(raw_val) {} + +std::uint32_t +t_date::raw_value() const { + return m_storage; +} +// Index such that +// a.consecutive_day_idx()-b.consecutive_day_idx() is +// number of days a is after b. +//(Start point is unspecified, may not be stable and +// only works for dates after 1900AD.) +std::int32_t +t_date::consecutive_day_idx() const { + std::int32_t m = month(); + std::int32_t y = year(); + std::int32_t yP = y - 1; + std::int32_t leap_selector = + (y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)) ? 1 : 0; + return day() + 365 * y + yP / 4 - yP / 100 + yP / 400 + + CUMULATIVE_DAYS[leap_selector][m - 1]; +} + +std::int64_t +t_date::as_epoch_ms() const { + // `to_gmtime` months are [1-12], `t_date` months are [0-11] + return to_gmtime(year(), month() + 1, day(), 0, 0, 0) * 1000; +} + +t_date +t_date::from_epoch_ms(std::int64_t ms) { + std::tm t = gmtime_from_epoch_ms(ms); + return { + static_cast(t.tm_year + 1900), + static_cast(t.tm_mon), + static_cast(t.tm_mday) + }; +} + +t_date +from_consecutive_day_idx(std::int32_t idx) { + auto y = static_cast(idx / 365.2425); + std::int32_t yP = y - 1; + std::int32_t idx_year_removed = + idx - (y * 365 + yP / 4 - yP / 100 + yP / 400); + std::int32_t tgt_substraction = + (y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)) ? 366 : 365; + if (idx_year_removed > tgt_substraction) { + idx_year_removed -= tgt_substraction; + y += 1; + } + std::int32_t yearkind = + (y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)) ? 1 : 0; + const std::int32_t* const pos = std::lower_bound( + CUMULATIVE_DAYS[yearkind], + CUMULATIVE_DAYS[yearkind] + 13, + idx_year_removed + ) + - 1; + // NOLINTNEXTLINE + return t_date( + y, + std::distance(CUMULATIVE_DAYS[yearkind], pos) + 1, + idx_year_removed - *pos /*+1*/ + ); +} + +bool +operator<(const t_date& a, const t_date& b) { + return a.m_storage < b.m_storage; +} +bool +operator<=(const t_date& a, const t_date& b) { + return a.m_storage <= b.m_storage; +} +bool +operator>(const t_date& a, const t_date& b) { + return a.m_storage > b.m_storage; +} +bool +operator>=(const t_date& a, const t_date& b) { + return a.m_storage >= b.m_storage; +} +bool +operator==(const t_date& a, const t_date& b) { + return a.m_storage == b.m_storage; +} +bool +operator!=(const t_date& a, const t_date& b) { + return a.m_storage != b.m_storage; +} + +std::int32_t +t_date::year() const { + return ((m_storage & YEAR_MASK) >> YEAR_SHIFT); +} +std::int32_t +t_date::month() const { + return ((m_storage & MONTH_MASK) >> MONTH_SHIFT); +} +std::int32_t +t_date::day() const { + return ((m_storage & DAY_MASK) >> DAY_SHIFT); +} +std::string +t_date::str() const { + std::stringstream ss; + ss << year() << "-" << str_(month() + 1) << "-" << str_(day()); + return ss.str(); +} + +} // end namespace perspective + +namespace std { +std::ostream& +operator<<(std::ostream& os, const perspective::t_date& t) { + os << t.str(); + return os; +} +} // namespace std diff --git a/rust/perspective-server/cpp/perspective/src/cpp/date_parser.cpp b/rust/perspective-server/cpp/perspective/src/cpp/date_parser.cpp new file mode 100644 index 0000000..a89f511 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/date_parser.cpp @@ -0,0 +1,54 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include +#include + +namespace perspective { + +// Milliseconds & timezones are not currently handled +const std::string t_date_parser::VALID_FORMATS[12] = { + "%Y%m%dT%H%M%S", // ISO "%Y%m%dT%H%M%S%F%q" + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%d %H:%M:%S", // ISO extended + "%A, %d %b %Y %H:%M:%S", // RFC 0822 + "%Y-%m-%d\\%H:%M:%S" + "%m-%d-%Y", + "%m/%d/%Y", + "%m-%d-%Y", + "%m %d %Y", + "%m/%d/%Y", + "%m/%d/%y", + "%d %m %Y" +}; + +t_date_parser::t_date_parser() {} + +bool +t_date_parser::is_valid(std::string const& datestring) { + for (const std::string& fmt : VALID_FORMATS) { + if (fmt != "") { + std::tm t = {}; + std::stringstream ss(datestring); + ss.imbue(std::locale::classic()); + ss >> std::get_time(&t, fmt.c_str()); + if (!ss.fail()) { + return true; + } + } + } + return false; +} +} // end namespace perspective \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/cpp/dense_nodes.cpp b/rust/perspective-server/cpp/perspective/src/cpp/dense_nodes.cpp new file mode 100644 index 0000000..665af1a --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/dense_nodes.cpp @@ -0,0 +1,46 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include + +namespace std { + +std::ostream& +operator<<(std::ostream& os, const perspective::t_dense_tnode& s) { + std::cout << "t_dtnode"; + return os; +} +} // namespace std +namespace perspective { + +void +fill_dense_tnode( + t_dense_tnode* node, + t_uindex idx, + t_uindex pidx, + t_uindex fcidx, + t_uindex nchild, + t_uindex flidx, + t_uindex nleaves +) { + node->m_idx = idx; + node->m_pidx = pidx; + node->m_fcidx = fcidx; + node->m_nchild = nchild; + node->m_flidx = flidx; + node->m_nleaves = nleaves; +} +} // namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/dense_tree.cpp b/rust/perspective-server/cpp/perspective/src/cpp/dense_tree.cpp new file mode 100644 index 0000000..cb88d0c --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/dense_tree.cpp @@ -0,0 +1,503 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace perspective { + +t_dtree::t_dtree( + t_dssptr ds, + const std::vector& pivots, + const std::vector>& sortby_colvec +) : + m_levels_pivoted(0), + m_ds(std::move(ds)), + m_pivots(pivots), + m_nidx(0), + m_backing_store(BACKING_STORE_MEMORY), + m_init(false), + m_sortby_colvec(sortby_colvec) {} + +t_uindex +t_dtree::size() const { + return m_nodes.size(); +} + +std::pair +t_dtree::get_level_markers(t_uindex idx) const { + PSP_VERBOSE_ASSERT(idx < m_levels.size(), "Unexpected lvlidx"); + return m_levels[idx]; +} + +t_dtree::t_dtree( + std::string dirname, + t_dssptr ds, + const std::vector& pivots, + t_backing_store backing_store, + const std::vector>& sortby_colvec +) : + m_dirname(std::move(dirname)), + m_levels_pivoted(0), + m_ds(std::move(ds)), + m_pivots(pivots), + m_nidx(0), + m_backing_store(backing_store), + m_init(false), + m_sortby_colvec(sortby_colvec) {} + +void +t_dtree::init() { + t_lstore_recipe leaf_args( + m_dirname, leaves_colname(), DEFAULT_CAPACITY, m_backing_store + ); + m_leaves = t_column(DTYPE_UINT64, false, leaf_args, DEFAULT_CAPACITY); + m_leaves.init(); + + t_lstore_recipe node_args( + m_dirname, nodes_colname(), DEFAULT_CAPACITY, m_backing_store + ); + + m_values = std::vector(m_pivots.size() + 1); + + m_has_sortby = std::vector(m_values.size()); + m_has_sortby[0] = false; + + m_sortby_columns.clear(); + for (auto& sidx : m_sortby_colvec) { + m_sortby_columns[sidx.first] = sidx.second; + } + + t_lstore_recipe root_args( + m_dirname, values_colname("_root_"), DEFAULT_CAPACITY, m_backing_store + ); + + m_values[0] = t_column(DTYPE_STR, true, leaf_args, DEFAULT_CAPACITY); + m_values[0].init(); + + m_sortby_dpthcol.emplace_back(""); + + for (t_uindex idx = 0, loop_end = m_pivots.size(); idx < loop_end; ++idx) { + auto colname = m_pivots[idx].colname(); + t_lstore_recipe leaf_args( + m_dirname, + values_colname(colname), + DEFAULT_CAPACITY, + m_backing_store + ); + + auto siter = m_sortby_columns.find(colname); + bool has_sortby = + siter != m_sortby_columns.end() && siter->second != colname; + m_has_sortby[idx + 1] = has_sortby; + std::string sortby_column = has_sortby ? siter->second : colname; + m_sortby_dpthcol.push_back(sortby_column); + t_dtype dtype = m_ds->get_dtype(colname); + m_values[idx + 1] = t_column(dtype, true, leaf_args, DEFAULT_CAPACITY); + m_values[idx + 1].init(); + } + + m_init = true; +} + +std::string +t_dtree::repr() const { + std::stringstream ss; + ss << m_ds->name() << "_tree_" << this; + return ss.str(); +} + +std::string +t_dtree::leaves_colname() const { + return repr() + std::string("_leaves"); +} + +std::string +t_dtree::nodes_colname() const { + return repr() + std::string("_nodes"); +} + +std::string +t_dtree::values_colname(const std::string& tbl_colname) const { + return repr() + std::string("_valuespan_") + tbl_colname; +} + +void +t_dtree::check_pivot(const t_filter& filter, t_uindex level) { + if (level <= m_levels_pivoted) { + return; + } + + PSP_VERBOSE_ASSERT( + level <= m_pivots.size() + 1, "Erroneous level passed in" + ); + + pivot(filter, level); +} + +void +t_dtree::pivot(const t_filter& filter, t_uindex level) { + if (level <= m_levels_pivoted) { + return; + } + + PSP_VERBOSE_ASSERT( + level <= m_pivots.size() + 1, "Erroneous level passed in" + ); + + t_uindex ncols = m_pivots.size(); + + t_uindex nidx = m_nidx; + + t_uindex nrows; + const t_mask* mask = nullptr; + + if (ncols > 0) { + if (filter.has_filter()) { + nrows = filter.count(); + mask = filter.cmask().get(); + } else { + nrows = m_ds->num_rows(); + } + } else { + nrows = m_pivots.empty() ? m_ds->num_rows() : 1; + } + + t_uindex nbidx; + t_uindex neidx; + + if (m_levels_pivoted == 0) { + m_leaves.extend(nrows); + auto* leaves = m_leaves.get_nth(0); + std::iota(leaves, leaves + nrows, t_uindex(0)); + + nbidx = 0; + neidx = 1; + } else { + nbidx = m_levels[m_levels_pivoted].first; + neidx = m_levels[m_levels_pivoted].second; + } + + for (t_uindex pidx = m_levels_pivoted; pidx < level; pidx++) { + const t_column* pivcol; + if (pidx == 0) { + m_nodes.emplace_back(); + t_tnode* root = &m_nodes.back(); + fill_dense_tnode(root, nidx, nidx, 1, 0, 0, nrows); + nidx++; + m_values[0].push_back(std::string("Grand Aggregate")); + m_levels.emplace_back(nbidx, neidx); + } else { + const t_pivot& pivot = m_pivots[pidx - 1]; + std::string pivot_colname = pivot.colname(); + pivcol = m_ds->_get_const_column(pivot_colname); + t_dtype piv_dtype = pivcol->get_dtype(); + + t_uindex next_neidx = 0; + + switch (piv_dtype) { + case DTYPE_STR: { + next_neidx = t_pivot_processor()( + pivcol, + &m_nodes, + &(m_values[pidx]), + &m_leaves, + nbidx, + neidx, + mask + ); + } break; + case DTYPE_INT64: { + next_neidx = t_pivot_processor()( + pivcol, + &m_nodes, + &(m_values[pidx]), + &m_leaves, + nbidx, + neidx, + mask + ); + } break; + case DTYPE_INT32: { + next_neidx = t_pivot_processor()( + pivcol, + &m_nodes, + &(m_values[pidx]), + &m_leaves, + nbidx, + neidx, + mask + ); + } break; + case DTYPE_INT16: { + next_neidx = t_pivot_processor()( + pivcol, + &m_nodes, + &(m_values[pidx]), + &m_leaves, + nbidx, + neidx, + mask + ); + } break; + case DTYPE_INT8: { + next_neidx = t_pivot_processor()( + pivcol, + &m_nodes, + &(m_values[pidx]), + &m_leaves, + nbidx, + neidx, + mask + ); + } break; + case DTYPE_UINT64: { + next_neidx = t_pivot_processor()( + pivcol, + &m_nodes, + &(m_values[pidx]), + &m_leaves, + nbidx, + neidx, + mask + ); + } break; + case DTYPE_UINT32: { + next_neidx = t_pivot_processor()( + pivcol, + &m_nodes, + &(m_values[pidx]), + &m_leaves, + nbidx, + neidx, + mask + ); + } break; + case DTYPE_UINT16: { + next_neidx = t_pivot_processor()( + pivcol, + &m_nodes, + &(m_values[pidx]), + &m_leaves, + nbidx, + neidx, + mask + ); + } break; + case DTYPE_UINT8: { + next_neidx = t_pivot_processor()( + pivcol, + &m_nodes, + &(m_values[pidx]), + &m_leaves, + nbidx, + neidx, + mask + ); + } break; + case DTYPE_FLOAT64: { + next_neidx = t_pivot_processor()( + pivcol, + &m_nodes, + &(m_values[pidx]), + &m_leaves, + nbidx, + neidx, + mask + ); + } break; + case DTYPE_FLOAT32: { + next_neidx = t_pivot_processor()( + pivcol, + &m_nodes, + &(m_values[pidx]), + &m_leaves, + nbidx, + neidx, + mask + ); + } break; + case DTYPE_BOOL: { + + next_neidx = t_pivot_processor()( + pivcol, + &m_nodes, + &(m_values[pidx]), + &m_leaves, + nbidx, + neidx, + mask + ); + } break; + case DTYPE_TIME: { + next_neidx = t_pivot_processor()( + pivcol, + &m_nodes, + &(m_values[pidx]), + &m_leaves, + nbidx, + neidx, + mask + ); + } break; + case DTYPE_DATE: { + next_neidx = t_pivot_processor()( + pivcol, + &m_nodes, + &(m_values[pidx]), + &m_leaves, + nbidx, + neidx, + mask + ); + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Not supported yet"); + } break; + } + nbidx = neidx; + neidx = next_neidx; + m_levels.emplace_back(nbidx, neidx); + } + + m_levels_pivoted = pidx; + } + + m_nidx = neidx; +} + +t_uindex +t_dtree::get_depth(t_index idx) const { + return get_span_index(idx).first; +} + +void +t_dtree::pprint(const t_filter& filter) const { + std::string indent(" "); + + for (auto idx : dfs()) { + t_uindex depth = get_depth(idx); + + for (t_uindex didx = 0; didx < depth; ++didx) { + std::cout << indent; + } + + const t_dense_tnode* node = get_node_ptr(idx); + + std::cout << get_value(filter, idx) << " idx => " << node->m_idx + << " pidx => " << node->m_pidx << " fcidx => " + << node->m_fcidx << " nchild => " << node->m_nchild + << " flidx => " << node->m_flidx << " nleaves => " + << node->m_nleaves << '\n'; + } +} + +t_uindex +t_dtree::last_level() const { + return m_pivots.size(); +} + +const t_dtree::t_tnode* +t_dtree::get_node_ptr(t_index nidx) const { + return &m_nodes.at(nidx); +} + +t_tscalar +t_dtree::_get_value(const t_filter& filter, t_index nidx, bool sort_value) + const { + + std::pair spi = get_span_index(nidx); + t_index dpth = spi.first; + t_uindex scalar_idx = spi.second; + + if (sort_value || nidx == 0) { + const t_column& col = m_values[dpth]; + return col.get_scalar(scalar_idx); + } + const std::string& colname = m_sortby_dpthcol[dpth]; + const t_column& col = *(m_ds->get_const_column(colname)); + const auto* node = get_node_ptr(nidx); + t_uindex lfidx = *(m_leaves.get_nth(node->m_flidx)); + return col.get_scalar(lfidx); +} + +t_tscalar +t_dtree::get_value(const t_filter& filter, t_index nidx) const { + return _get_value(filter, nidx, true); +} + +t_tscalar +t_dtree::get_sortby_value(const t_filter& filter, t_index nidx) const { + return _get_value(filter, nidx, false); +} + +std::pair +t_dtree::get_span_index(t_index idx) const { + for (t_uindex i = 0, loop_end = m_levels.size(); i < loop_end; i++) { + t_index bidx = m_levels[i].first; + t_index eidx = m_levels[i].second; + + if ((idx >= bidx) && (idx < eidx)) { + return std::pair(i, idx - bidx); + } + } + + PSP_COMPLAIN_AND_ABORT("Reached unreachable."); + return std::pair(0, 0); +} + +const t_column* +t_dtree::get_leaf_cptr() const { + return &m_leaves; +} + +t_bfs_iter +t_dtree::bfs() const { + return {this}; +} + +t_dfs_iter +t_dtree::dfs() const { + return {this}; +} + +t_index +t_dtree::get_parent(t_index idx) const { + const t_tnode* n = get_node_ptr(idx); + return n->m_pidx; +} + +const std::vector& +t_dtree::get_pivots() const { + return m_pivots; +} + +void +t_dtree::get_child_indices(t_index nidx, std::vector& v) const { + const auto* nptr = get_node_ptr(nidx); + for (t_index idx = nptr->m_fcidx + nptr->m_nchild - 1, + loop_end = nptr->m_fcidx; + idx >= loop_end; + --idx) { + v.push_back(idx); + } +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/dense_tree_context.cpp b/rust/perspective-server/cpp/perspective/src/cpp/dense_tree_context.cpp new file mode 100644 index 0000000..a9247d2 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/dense_tree_context.cpp @@ -0,0 +1,290 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include +#include +#include + +namespace perspective { + +t_dtree_ctx::t_dtree_ctx( + std::shared_ptr strands, + std::shared_ptr strand_deltas, + const t_dtree& tree, + const std::vector& aggspecs +) : + m_strands(std::move(std::move(strands))), + m_strand_deltas(std::move(std::move(strand_deltas))), + m_tree(tree), + m_aggspecs(aggspecs), + m_init(false) { + std::vector depvec = {t_dep("psp_strand_count", DEPTYPE_COLUMN)}; + + m_aggspecs.emplace_back("psp_strand_count_sum", AGGTYPE_SUM, depvec); + + t_uindex aggidx = 0; + for (const auto& spec : m_aggspecs) { + m_aggspecmap[spec.name()] = aggidx; + ++aggidx; + } +} + +void +t_dtree_ctx::init() { + build_aggregates(); + m_init = true; +} + +t_uindex +t_dtree_ctx::get_num_aggcols() const { + return m_aggspecs.size(); +} + +void +t_dtree_ctx::build_aggregates() { + std::vector columns; + std::vector dtypes; + + const t_schema& delta_schema = m_strand_deltas->get_schema(); + + for (const auto& spec : m_aggspecs) { + auto cinfo = spec.get_output_specs(delta_schema); + for (const auto& ci : cinfo) { + PSP_VERBOSE_ASSERT( + ci.m_type != DTYPE_NONE, "NULL type encountered" + ); + columns.push_back(ci.m_name); + dtypes.push_back(ci.m_type); + } + } + + t_schema aggschema(columns, dtypes); + + m_aggregates = std::make_shared(aggschema, m_tree.size()); + + m_aggregates->init(); + m_aggregates->set_size(m_tree.size()); + + for (const auto& aggspec : m_aggspecs) { + const std::vector& deps = aggspec.get_dependencies(); + + const t_data_table* tbl = + aggspec.is_non_delta() ? m_strands.get() : m_strand_deltas.get(); + + std::vector> icolumns; + icolumns.reserve(deps.size()); + for (const auto& d : deps) { + icolumns.push_back(tbl->get_const_column(d.name())); + } + + auto output_col = m_aggregates->get_column(aggspec.name()); + + t_aggregate agg(m_tree, aggspec.agg(), icolumns, output_col); + agg.init(); + } +} + +const t_data_table& +t_dtree_ctx::get_aggtable() const { + return *(m_aggregates); +} + +const t_dtree& +t_dtree_ctx::get_tree() const { + return m_tree; +} + +const std::vector& +t_dtree_ctx::get_aggspecs() const { + return m_aggspecs; +} + +const t_aggspec& +t_dtree_ctx::get_aggspec(const std::string& aggname) const { + auto iter = m_aggspecmap.find(aggname); + PSP_VERBOSE_ASSERT(iter != m_aggspecmap.end(), "Failed to find aggspec"); + PSP_VERBOSE_ASSERT( + static_cast(iter->second) < m_aggspecs.size(), + "Invalid aggspec index" + ); + return m_aggspecs[iter->second]; +} + +void +t_dtree_ctx::pprint(const t_filter& fltr) const { + std::vector aggcols; + + t_uindex ncols = 0; + for (const std::string& colname : m_aggregates->get_schema().m_columns) { + aggcols.push_back(m_aggregates->_get_const_column(colname)); + std::cout << colname << ", "; + ++ncols; + } + std::cout << "\n====================================\n"; + + for (auto idx : m_tree.dfs()) { + auto depth = m_tree.get_depth(idx); + for (t_uindex spidx = 0; spidx < static_cast(depth); + ++spidx) { + std::cout << "\t"; + } + + auto value = m_tree.get_value(fltr, idx); + std::cout << "(" << idx << "). " << value << " => "; + + for (t_uindex aggidx = 0; aggidx < ncols; ++aggidx) { + std::cout << aggcols[aggidx]->get_scalar(idx) << ", "; + } + std::cout << "\n"; + } +} + +std::pair +t_dtree_ctx::get_leaf_iterators(t_index idx) const { + const t_dense_tnode* node = m_tree.get_node_ptr(idx); + const auto* lbaseptr = m_tree.get_leaf_cptr()->get_nth(0); + return std::pair( + lbaseptr + node->m_flidx, lbaseptr + node->m_flidx + node->m_nleaves + ); +} + +std::shared_ptr +t_dtree_ctx::get_pkey_col() const { + return m_strands->get_const_column("psp_pkey"); +} + +std::shared_ptr +t_dtree_ctx::get_strand_count_col() const { + return m_strand_deltas->get_const_column("psp_strand_count"); +} + +std::shared_ptr +t_dtree_ctx::get_strands() const { + return m_strands; +} + +std::shared_ptr +t_dtree_ctx::get_strand_deltas() const { + return m_strand_deltas; +} + +void +t_dtree_ctx::pprint_strands() const { + std::vector columns; + const auto* scount_col = + m_strand_deltas->_get_const_column("psp_strand_count"); + const auto* pkey_col = m_strands->_get_const_column("psp_pkey"); + auto strand_schema = m_strands->get_schema(); + + t_uindex width = 18; + + std::vector colnames = {"psp_pkey", "psp_strand_count"}; + + for (const auto& colname : strand_schema.m_columns) { + const auto* col = m_strands->_get_const_column(colname); + if (col != pkey_col) { + columns.push_back(col); + colnames.push_back(colname); + } + } + + auto strand_delta_schema = m_strand_deltas->get_schema(); + for (const auto& colname : strand_delta_schema.m_columns) { + const auto* col = m_strand_deltas->_get_const_column(colname); + if (col != scount_col) { + columns.push_back(col); + std::stringstream ss; + ss << "delta(" << colname << ")"; + colnames.push_back(ss.str()); + } + } + + for (const auto& c : colnames) { + std::cout << std::setw(width) << c; + } + + std::cout << "\n====================================\n"; + + for (t_uindex idx = 0, loop_end = m_strands->size(); idx < loop_end; + ++idx) { + + std::vector vec; + vec.push_back(pkey_col->get_scalar(idx)); + t_tscalar sc; + sc.set(t_index(*(scount_col->get(idx)))); + vec.push_back(sc); + + for (const auto* col : columns) { + vec.push_back(col->get_scalar(idx)); + } + + std::cout << idx << "."; + for (const auto& val : vec) { + std::cout << std::setw(width) << val; + } + + std::cout << '\n'; + } +} + +void +t_dtree_ctx::pprint_strands_tree() const { + typedef std::pair t_colname_cptr_pair; + + std::vector columns; + + columns.emplace_back( + "psp_pkey", m_strands->_get_const_column("psp_pkey") + ); + + columns.emplace_back( + "psp_strand_count", + m_strand_deltas->_get_const_column("psp_strand_count") + ); + + for (const auto& piv : m_tree.get_pivots()) { + columns.emplace_back( + piv.colname(), m_strands->_get_const_column(piv.colname()) + ); + } + + for (auto dptidx : m_tree.dfs()) { + std::cout << "nidx(" << dptidx << ") => " << '\n'; + + t_depth ndepth = m_tree.get_depth(dptidx); + + auto liters = get_leaf_iterators(dptidx); + + for (const auto* lfiter = liters.first; lfiter != liters.second; + ++lfiter) { + for (t_uindex idx = 0; idx < ndepth; ++idx) { + std::cout << "\t"; + } + + std::cout << "\tleaf# " << *lfiter << "\n"; + + for (const auto& colinfo : columns) { + for (t_uindex idx = 0; idx < ndepth + 1; ++idx) { + std::cout << "\t"; + } + std::cout << " " << colinfo.first << ": " + << colinfo.second->get_scalar(*lfiter) << "\n"; + } + } + + std::cout << '\n'; + } +} +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/dependency.cpp b/rust/perspective-server/cpp/perspective/src/cpp/dependency.cpp new file mode 100644 index 0000000..9a5f8ff --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/dependency.cpp @@ -0,0 +1,82 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include + +#include + +namespace perspective { + +t_dep::t_dep(const t_dep_recipe& v) : + m_name(v.m_name), + m_disp_name(v.m_disp_name), + m_type(v.m_type), + m_imm(v.m_imm), + m_dtype(v.m_dtype) {} + +t_dep::t_dep(const std::string& name, t_deptype type) : + m_name(name), + m_disp_name(name), + m_type(type), + m_dtype(DTYPE_NONE) {} + +t_dep::t_dep(t_tscalar imm) : + m_type(DEPTYPE_SCALAR), + m_imm(imm), + m_dtype(DTYPE_NONE) {} + +t_dep::t_dep( + std::string name, std::string disp_name, t_deptype type, t_dtype dtype +) : + m_name(std::move(name)), + m_disp_name(std::move(disp_name)), + m_type(type), + m_dtype(dtype) {} + +const std::string& +t_dep::name() const { + return m_name; +} + +const std::string& +t_dep::disp_name() const { + return m_disp_name; +} + +t_deptype +t_dep::type() const { + return m_type; +} + +t_tscalar +t_dep::imm() const { + return m_imm; +} + +t_dtype +t_dep::dtype() const { + return m_dtype; +} + +t_dep_recipe +t_dep::get_recipe() const { + t_dep_recipe rv; + rv.m_name = m_name; + rv.m_disp_name = m_disp_name; + rv.m_type = m_type; + rv.m_imm = m_imm; + rv.m_dtype = m_dtype; + return rv; +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/expression_tables.cpp b/rust/perspective-server/cpp/perspective/src/cpp/expression_tables.cpp new file mode 100644 index 0000000..988337a --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/expression_tables.cpp @@ -0,0 +1,198 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include + +#include + +namespace perspective { + +t_expression_tables::t_expression_tables( + const std::vector>& expressions, + t_backing_store backing_store +) { + t_schema schema; + t_schema transitions_schema; + + for (const auto& expr : expressions) { + const std::string& alias = expr->get_expression_alias(); + schema.add_column(alias, expr->get_dtype()); + transitions_schema.add_column(alias, DTYPE_UINT8); + } + + // Only the persistent `m_master` table honors on-disk backing; the + // transitional tables are per-update scratch (cleared every update, sized + // to the update batch) so disk-backing them is pure I/O churn with no + // memory-relief benefit, and they stay in memory. + std::string master_dirname; + if (backing_store == BACKING_STORE_DISK && !expressions.empty()) { + master_dirname = create_backing_store_dir("perspective_expr_"); + } + + m_master = std::make_shared( + "", master_dirname, schema, DEFAULT_EMPTY_CAPACITY, backing_store + ); + m_flattened = std::make_shared( + "", "", schema, DEFAULT_EMPTY_CAPACITY, BACKING_STORE_MEMORY + ); + m_prev = std::make_shared( + "", "", schema, DEFAULT_EMPTY_CAPACITY, BACKING_STORE_MEMORY + ); + m_current = std::make_shared( + "", "", schema, DEFAULT_EMPTY_CAPACITY, BACKING_STORE_MEMORY + ); + m_delta = std::make_shared( + "", "", schema, DEFAULT_EMPTY_CAPACITY, BACKING_STORE_MEMORY + ); + m_transitions = std::make_shared( + "", "", transitions_schema, DEFAULT_EMPTY_CAPACITY, BACKING_STORE_MEMORY + ); + + m_master->init(); + m_flattened->init(); + m_prev->init(); + m_current->init(); + m_delta->init(); + m_transitions->init(); +} + +t_data_table* +t_expression_tables::get_table() const { + return m_master.get(); +} + +void +t_expression_tables::set_flattened( + const std::shared_ptr& flattened +) const { + t_uindex flattened_num_rows = flattened->size(); + reserve_transitional_table_size(flattened_num_rows); + set_transitional_table_size(flattened_num_rows); + const t_schema& schema = m_flattened->get_schema(); + const std::vector& column_names = schema.m_columns; + for (const auto& colname : column_names) { + // Deep-copy into the (in-memory) transitional column in place rather + // than `clone()`-ing the source. `clone()` inherits the source's + // backing store via its recipe, and for an on-disk expression + // `m_master` that produces a broken disk clone which aliases the + // master's backing file. `copy_from` preserves `m_flattened`'s own + // (memory) backing store. + m_flattened->get_column(colname)->copy_from( + *flattened->get_column(colname) + ); + } +} + +void +t_expression_tables::calculate_transitions( + const std::shared_ptr& existed +) { + const t_schema& schema = m_transitions->get_schema(); + const std::vector& column_names = schema.m_columns; + const t_column& existed_column = + *(existed->get_const_column("psp_existed")); + + auto num_cols = column_names.size(); + + parallel_for( + int(num_cols), + [&column_names, &existed_column, this](int cidx) { + const std::string& cname = column_names[cidx]; + const t_column& prev_column = *(m_prev->get_const_column(cname)); + const t_column& current_column = + *(m_current->get_const_column(cname)); + std::shared_ptr transition_column = + m_transitions->get_column(cname); + + for (t_uindex ridx = 0; ridx < transition_column->size(); ++ridx) { + bool row_existed = + existed_column.get_nth(ridx) != nullptr; + + t_tscalar prev_value = prev_column.get_scalar(ridx); + t_tscalar curr_value = current_column.get_scalar(ridx); + + bool prev_valid = prev_column.is_valid(ridx); + bool curr_valid = current_column.is_valid(ridx); + bool prev_curr_eq = + prev_valid && curr_valid && (prev_value == curr_value); + + t_value_transition transition; + + // Use a small subset of `t_value_transitions` that are + // relevant - I have not implemented the code paths in + // `calc_transitions` that are not referenced elsewhere, i.e. + // by a context or by a tree implementation. + if (row_existed) { + if (prev_curr_eq) { + // Row existed before, and the current value is + // the same as the previous value. + transition = VALUE_TRANSITION_EQ_TT; + } else { + if (!prev_valid && curr_valid) { + // Previous value was a null, new value is valid. + transition = VALUE_TRANSITION_NEQ_FT; + } else { + // Previous value was not null, new value is + // not null, and previous value != new value + transition = VALUE_TRANSITION_NEQ_TT; + } + } + } else { + // Row did not exist before and was added + transition = VALUE_TRANSITION_NEQ_FT; + } + + transition_column->set_nth(ridx, transition); + } + } + ); +} + +void +t_expression_tables::reserve_transitional_table_size(t_uindex size) const { + m_flattened->reserve(size); + m_prev->reserve(size); + m_current->reserve(size); + m_delta->reserve(size); + m_transitions->reserve(size); +} + +void +t_expression_tables::set_transitional_table_size(t_uindex size) const { + m_flattened->set_size(size); + m_prev->set_size(size); + m_current->set_size(size); + m_delta->set_size(size); + m_transitions->set_size(size); +} + +void +t_expression_tables::clear_transitional_tables() const { + m_flattened->clear(); + m_prev->clear(); + m_current->clear(); + m_delta->clear(); + m_transitions->clear(); +} + +void +t_expression_tables::reset() const { + m_master->reset(); + m_flattened->reset(); + m_prev->reset(); + m_current->reset(); + m_delta->reset(); + m_transitions->reset(); +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/expression_vocab.cpp b/rust/perspective-server/cpp/perspective/src/cpp/expression_vocab.cpp new file mode 100644 index 0000000..bbb8052 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/expression_vocab.cpp @@ -0,0 +1,81 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include + +namespace perspective { + +t_expression_vocab::t_expression_vocab() { + // Pre-reserve each page to its full size so that `intern()` never + // triggers a realloc inside `t_vocab`; previously-returned `const char*` + // pointers must remain valid for the lifetime of the vocab. + m_max_vocab_size = 16 * 64 * 64; + + // Always start with one vocab + allocate_new_vocab(); +} + +const char* +t_expression_vocab::intern(const char* str) { + std::size_t bytelength = strlen(str); + std::size_t hash = boost::hash_range(str, str + bytelength); + t_uindex existing_idx; + + for (auto& current_vocab : m_vocabs) { + if (current_vocab.string_exists(str, hash, existing_idx)) { + return current_vocab.unintern_c(existing_idx); + } + } + + if (m_current_vocab_size + bytelength + 1 > m_max_vocab_size) { + allocate_new_vocab(); + } + + t_vocab& current_vocab = m_vocabs.front(); + m_current_vocab_size += bytelength + 1; + t_uindex interned_idx = current_vocab.get_interned(str, bytelength, hash); + return current_vocab.unintern_c(interned_idx); +} + +const char* +t_expression_vocab::intern(const std::string& str) { + return intern(str.c_str()); +} + +void +t_expression_vocab::clear() { + m_vocabs.clear(); + allocate_new_vocab(); +} + +const char* +t_expression_vocab::get_empty_string() const { + return m_empty_string.c_str(); +} + +void +t_expression_vocab::pprint() const { + for (const auto& vocab : m_vocabs) { + vocab.pprint_vocabulary(); + } +} + +void +t_expression_vocab::allocate_new_vocab() { + t_vocab vocab; + vocab.init(false); + vocab.reserve(m_max_vocab_size, m_max_vocab_size / 64); + m_vocabs.insert(m_vocabs.begin(), std::move(vocab)); + m_current_vocab_size = 0; +} + +} // end namespace perspective \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/cpp/extract_aggregate.cpp b/rust/perspective-server/cpp/perspective/src/cpp/extract_aggregate.cpp new file mode 100644 index 0000000..ecc607e --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/extract_aggregate.cpp @@ -0,0 +1,124 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include + +namespace perspective { + +t_tscalar +extract_aggregate( + const t_aggspec& aggspec, + const t_column* aggcol, + t_uindex ridx, + t_index pridx +) { + + switch (aggspec.agg()) { + case AGGTYPE_PCT_SUM_PARENT: { + t_tscalar cv = aggcol->get_scalar(ridx); + + if (pridx == INVALID_INDEX) { + return mktscalar(100.0); + } + + double pv = aggcol->get_scalar(pridx).to_double(); + t_tscalar ret; + if (pv == 0) { + ret.set(t_none()); + } else { + ret.set(100.0 * (cv.to_double() / pv)); + } + return ret; + } break; + case AGGTYPE_PCT_SUM_GRAND_TOTAL: { + t_tscalar cv = aggcol->get_scalar(ridx); + double pv = aggcol->get_scalar(ROOT_AGGIDX).to_double(); + t_tscalar ret; + if (pv == 0) { + ret.set(t_none()); + } else { + ret.set(100.0 * (cv.to_double() / pv)); + } + return ret; + } break; + case AGGTYPE_SUM: + case AGGTYPE_SUM_ABS: + case AGGTYPE_ABS_SUM: + case AGGTYPE_GMV: + case AGGTYPE_SUM_NOT_NULL: + case AGGTYPE_MUL: + case AGGTYPE_COUNT: + case AGGTYPE_ANY: + case AGGTYPE_DOMINANT: + case AGGTYPE_Q1: + case AGGTYPE_Q3: + case AGGTYPE_MEDIAN: + case AGGTYPE_FIRST: + case AGGTYPE_AND: + case AGGTYPE_OR: + case AGGTYPE_LAST_BY_INDEX: + case AGGTYPE_LAST_VALUE: + case AGGTYPE_LAST_MINUS_FIRST: + case AGGTYPE_MAX: + case AGGTYPE_MAX_BY: + case AGGTYPE_MIN_BY: + case AGGTYPE_MIN: + case AGGTYPE_HIGH_WATER_MARK: + case AGGTYPE_LOW_WATER_MARK: + case AGGTYPE_HIGH_MINUS_LOW: + case AGGTYPE_SCALED_DIV: + case AGGTYPE_SCALED_ADD: + case AGGTYPE_SCALED_MUL: + case AGGTYPE_UDF_COMBINER: + case AGGTYPE_UDF_REDUCER: + case AGGTYPE_JOIN: + case AGGTYPE_IDENTITY: + case AGGTYPE_DISTINCT_COUNT: + case AGGTYPE_DISTINCT_LEAF: + case AGGTYPE_VARIANCE: + case AGGTYPE_STANDARD_DEVIATION: { + t_tscalar rval = aggcol->get_scalar(ridx); + return rval; + } break; + case AGGTYPE_UNIQUE: { + t_tscalar rval = aggcol->get_scalar(ridx); + if (!rval.is_valid()) { + t_tscalar rv; + rv.set(t_none()); + return rv; + } + return rval; + } break; + case AGGTYPE_MEAN_BY_COUNT: + case AGGTYPE_WEIGHTED_MEAN: + case AGGTYPE_MEAN: { + const auto* pair = aggcol->get_nth>(ridx); + t_tscalar rval; + double second = pair->second; + if (second != 0) { + double mean = pair->first / second; + rval.set(mean); + } else { + rval.set(t_none()); + } + return rval; + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected agg type"); + } + } + + return mknone(); +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/filter.cpp b/rust/perspective-server/cpp/perspective/src/cpp/filter.cpp new file mode 100644 index 0000000..824f6bb --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/filter.cpp @@ -0,0 +1,168 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include + +#include + +namespace perspective { + +t_fterm::t_fterm() = default; + +t_fterm::t_fterm( + std::string colname, + t_filter_op op, + t_tscalar threshold, + const std::vector& bag, + bool negated, + bool is_primary +) : + m_colname(std::move(colname)), + m_op(op), + m_threshold(threshold), + m_bag(bag), + m_negated(negated), + m_is_primary(is_primary) { + m_use_interned = (op == FILTER_OP_EQ || op == FILTER_OP_NE) + && threshold.m_type == DTYPE_STR; +} + +t_fterm::t_fterm( + std::string colname, + t_filter_op op, + t_tscalar threshold, + const std::vector& bag +) : + m_colname(std::move(colname)), + m_op(op), + m_threshold(threshold), + m_bag(bag), + m_negated(false), + m_is_primary(false) { + m_use_interned = (op == FILTER_OP_EQ || op == FILTER_OP_NE) + && threshold.m_type == DTYPE_STR; +} + +void +t_fterm::coerce_numeric(t_dtype dtype) { + m_threshold.set(m_threshold.coerce_numeric_dtype(dtype)); + for (auto& f : m_bag) { + f.set(f.coerce_numeric_dtype(dtype)); + } +} + +std::string +t_fterm::get_expr() const { + std::stringstream ss; + + ss << m_colname << " "; + + switch (m_op) { + case FILTER_OP_LT: + case FILTER_OP_LTEQ: + case FILTER_OP_GT: + case FILTER_OP_GTEQ: + case FILTER_OP_EQ: + case FILTER_OP_NE: + case FILTER_OP_CONTAINS: { + ss << filter_op_to_str(m_op) << " "; + ss << m_threshold.to_string(true); + } break; + case FILTER_OP_NOT_IN: + case FILTER_OP_IN: { + ss << " " << filter_op_to_str(m_op) << " ("; + for (auto v : m_bag) { + ss << v.to_string(true) << ", "; + } + ss << " )"; + } break; + case FILTER_OP_BEGINS_WITH: + case FILTER_OP_ENDS_WITH: { + ss << "." << filter_op_to_str(m_op) << "( " + << m_threshold.to_string(true) << " )"; + } break; + default: { + ss << " is failed_compilation"; + } + } + + return ss.str(); +} + +t_filter::t_filter() : m_mode(SELECT_MODE_ALL) {} + +t_filter::t_filter(const std::vector& columns) : + m_mode(SELECT_MODE_ALL), + m_columns(columns) {} + +t_filter::t_filter( + const std::vector& columns, t_uindex bidx, t_uindex eidx +) : + m_mode(SELECT_MODE_RANGE), + m_bidx(bidx), + m_eidx(eidx), + m_columns(columns) {} + +t_filter::t_filter( + const std::vector& columns, t_uindex mask_size +) : + m_mode(SELECT_MODE_MASK), + m_columns(columns), + m_mask(std::make_shared(mask_size)) {} + +t_uindex +t_filter::num_cols() const { + return m_columns.size(); +} + +bool +t_filter::has_filter() const { + return m_mode != SELECT_MODE_ALL; +} + +const std::vector& +t_filter::columns() const { + return m_columns; +} + +t_select_mode +t_filter::mode() const { + return m_mode; +} + +t_uindex +t_filter::bidx() const { + return m_bidx; +} + +t_uindex +t_filter::eidx() const { + return m_eidx; +} + +t_maskcsptr +t_filter::cmask() const { + return t_maskcsptr(m_mask); +} + +t_masksptr +t_filter::mask() const { + return t_masksptr(m_mask); +} + +t_uindex +t_filter::count() const { + return m_mask->count(); +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/flat_traversal.cpp b/rust/perspective-server/cpp/perspective/src/cpp/flat_traversal.cpp new file mode 100644 index 0000000..4aed03a --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/flat_traversal.cpp @@ -0,0 +1,497 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include +#include +#include + +#include + +namespace perspective { + +t_ftrav::t_ftrav() : m_step_deletes(0), m_step_inserts(0) { + m_index = std::make_shared>(); +} + +void +t_ftrav::init() { + m_index = std::make_shared>(); +} + +std::vector +t_ftrav::get_all_pkeys(const std::vector>& cells +) const { + // assumes the code calling this has already validated + // cells + std::vector rval; + rval.reserve(cells.size()); + std::vector* index = m_index.get(); + for (const auto& cell : cells) { + rval.push_back((*index)[cell.first].m_pkey); + } + return rval; +} + +std::vector +t_ftrav::get_pkeys(const std::vector>& cells +) const { + tsl::hopscotch_set all_pkeys; + + std::set all_rows; + + for (const auto& cell : cells) { + all_rows.insert(cell.first); + } + + std::vector rval(all_rows.size()); + std::set::iterator it; + t_index count = 0; + for (it = all_rows.begin(); it != all_rows.end(); ++it) { + rval[count] = (*m_index)[*it].m_pkey; + ++count; + } + return rval; +} + +std::vector +t_ftrav::get_pkeys(t_index begin_row, t_index end_row) const { + t_index index_size = m_index->size(); + end_row = std::min(end_row, index_size); + std::vector rval(end_row - begin_row); + for (t_index ridx = begin_row; ridx < end_row; ++ridx) { + rval[ridx - begin_row] = (*m_index)[ridx].m_pkey; + } + return rval; +} + +std::vector +t_ftrav::get_pkeys(const std::vector& rows) const { + std::vector rval; + rval.reserve(rows.size()); + for (unsigned long long ridx : rows) { + rval.push_back((*m_index)[ridx].m_pkey); + } + return rval; +} + +std::vector +t_ftrav::get_pkeys() const { + return get_pkeys(0, size()); +} + +t_tscalar +t_ftrav::get_pkey(t_index idx) const { + return (*m_index)[idx].m_pkey; +} + +void +t_ftrav::fill_sort_elem( + const t_gstate& gstate, + const t_data_table& expression_master_table, + const t_config& config, + t_tscalar pkey, + t_mselem& out_elem +) { + t_index sortby_size = m_sortby.size(); + out_elem.m_row.reserve(sortby_size); + out_elem.m_pkey = pkey; + + for (const t_sortspec& sort : m_sortby) { + // maintain backwards compatibility + std::string colname; + + if (!sort.m_colname.empty()) { + colname = config.get_sort_by(sort.m_colname); + } else { + colname = config.col_at(sort.m_agg_index); + } + + const std::string& sortby_colname = config.get_sort_by(colname); + + out_elem.m_row.push_back( + m_symtable.get_interned_tscalar(get_from_gstate( + gstate, expression_master_table, sortby_colname, pkey + )) + ); + } +} + +void +t_ftrav::fill_sort_elem( + const t_gstate& gstate, + const t_config& config, + const std::vector& row, + t_mselem& out_elem +) const { + t_index sortby_size = m_sortby.size(); + out_elem.m_row.reserve(sortby_size); + out_elem.m_pkey = mknone(); + + for (const t_sortspec& sort : m_sortby) { + std::string colname; + + if (!sort.m_colname.empty()) { + colname = config.get_sort_by(sort.m_colname); + } else { + colname = config.col_at(sort.m_agg_index); + } + + const std::string& sortby_colname = config.get_sort_by(colname); + + out_elem.m_row.push_back( + get_interned_tscalar(row.at(config.get_colidx(sortby_colname))) + ); + } +} + +void +t_ftrav::sort_by( + const t_gstate& gstate, + const t_data_table& expression_master_table, + const t_config& config, + const std::vector& sortby +) { + if (sortby.empty()) { + return; + } + t_multisorter sorter(get_sort_orders(sortby)); + t_index size = m_index->size(); + auto sort_elems = + std::make_shared>(static_cast(size)); + m_sortby = sortby; + + for (t_index idx = 0; idx < size; ++idx) { + t_mselem& elem = (*sort_elems)[idx]; + t_tscalar pkey = (*m_index)[idx].m_pkey; + fill_sort_elem(gstate, expression_master_table, config, pkey, elem); + } + + std::swap(m_index, sort_elems); + std::sort(m_index->begin(), m_index->end(), sorter); + m_pkeyidx.clear(); + for (t_index idx = 0, loop_end = m_index->size(); idx < loop_end; ++idx) { + m_pkeyidx[(*m_index)[idx].m_pkey] = idx; + } +} + +t_index +t_ftrav::size() const { + return m_index->size(); +} + +void +t_ftrav::get_row_indices( + const tsl::hopscotch_set& pkeys, + tsl::hopscotch_map& out_map +) const { + for (t_index idx = 0, loop_end = size(); idx < loop_end; ++idx) { + const t_tscalar& pkey = (*m_index)[idx].m_pkey; + if (pkeys.find(pkey) != pkeys.end()) { + out_map[pkey] = idx; + } + } +} + +void +t_ftrav::get_row_indices( + t_index bidx, + t_index eidx, + const tsl::hopscotch_set& pkeys, + tsl::hopscotch_map& out_map +) const { + for (t_index idx = bidx; idx < eidx; ++idx) { + const t_tscalar& pkey = (*m_index)[idx].m_pkey; + if (pkeys.find(pkey) != pkeys.end()) { + out_map[pkey] = idx; + } + } +} + +/** + * @brief Given a set of primary keys, return the corresponding row indices. + * + * @param pkeys + * @return std::vector + */ +std::vector +t_ftrav::get_row_indices(const tsl::hopscotch_set& pkeys) const { + std::vector rows; + for (t_uindex idx = 0, loop_end = size(); idx < loop_end; ++idx) { + const t_tscalar& pkey = (*m_index)[idx].m_pkey; + if (pkeys.find(pkey) != pkeys.end()) { + rows.push_back(idx); + } + } + return rows; +} + +void +t_ftrav::reset() { + if (m_index != nullptr) { + m_index->clear(); + } +} + +void +t_ftrav::check_size() { + tsl::hopscotch_set pkey_set; + for (t_index idx = 0, loop_end = m_index->size(); idx < loop_end; ++idx) { + if (pkey_set.find((*m_index)[idx].m_pkey) != pkey_set.end()) { + std::cout << "Duplicate entry for " << (*m_index)[idx].m_pkey + << '\n'; + PSP_COMPLAIN_AND_ABORT("Exiting"); + } + + pkey_set.insert((*m_index)[idx].m_pkey); + } +} + +bool +t_ftrav::validate_cells(const std::vector>& cells +) const { + t_index trav_size = size(); + + for (const auto& cell : cells) { + t_index ridx = cell.first; + if (ridx >= trav_size) { + return false; + } + } + return true; +} + +void +t_ftrav::step_begin() { + m_step_deletes = 0; + m_step_inserts = 0; + m_new_elems.clear(); +} + +void +t_ftrav::step_end() { + // Fast path: if no incremental work happened this step, `m_index` and + // `m_pkeyidx` are already in their final shape (either unchanged, or + // populated directly via `bulk_load_append`). Skip the O(N) rebuild. + if (m_step_inserts == 0 && m_step_deletes == 0 && m_new_elems.empty()) { + return; + } + + // The new number of rows in this traversal + t_index new_size = m_index->size() + m_step_inserts - m_step_deletes; + + auto new_index = std::make_shared>(); + new_index->reserve(new_size); + + t_uindex i = 0; + t_multisorter sorter(get_sort_orders(m_sortby)); + + std::vector new_rows; + new_rows.reserve(m_new_elems.size()); + + for (tsl::hopscotch_map::const_iterator pkelem_iter = + m_new_elems.begin(); + pkelem_iter != m_new_elems.end(); + ++pkelem_iter) { + new_rows.push_back(pkelem_iter->second); + } + + // TODO: int/float/date/datetime pkeys are already sorted here, so if + // there was a way to assert that `psp_pkey` is a string typed column, + // we can conditional the sort on whether m_sortby.size() > 0 or if + // psp_pkey is a string column. + std::sort(new_rows.begin(), new_rows.end(), sorter); + + for (auto& new_elem : new_rows) { + while (i < m_index->size()) { + const t_mselem& old_elem = (*m_index)[i]; + + if (old_elem.m_deleted) { + i++; + m_pkeyidx.erase(old_elem.m_pkey); + } else if (old_elem.m_updated) { + i++; + } else if (sorter(old_elem, new_elem)) { + m_pkeyidx[old_elem.m_pkey] = new_index->size(); + new_index->push_back(old_elem); + i++; + } else { + break; + } + } + + m_pkeyidx[new_elem.m_pkey] = new_index->size(); + new_index->push_back(new_elem); + } + + // reconcile old rows that are marked as removed or updated. + while (i < m_index->size()) { + const t_mselem& old_elem = (*m_index)[i++]; + + if (old_elem.m_deleted) { + m_pkeyidx.erase(old_elem.m_pkey); + } else if (!old_elem.m_updated) { + // Add back cells that have not changed during this step. + m_pkeyidx[old_elem.m_pkey] = new_index->size(); + new_index->push_back(old_elem); + } + } + + std::swap(new_index, m_index); + m_new_elems.clear(); +} + +void +t_ftrav::add_row( + const t_gstate& gstate, + const t_data_table& expression_master_table, + const t_config& config, + t_tscalar pkey +) { + t_mselem mselem; + fill_sort_elem(gstate, expression_master_table, config, pkey, mselem); + m_new_elems[pkey] = mselem; + ++m_step_inserts; +} + +void +t_ftrav::update_row( + const t_gstate& gstate, + const t_data_table& expression_master_table, + const t_config& config, + t_tscalar pkey +) { + if (m_sortby.empty()) { + return; + } + auto pkiter = m_pkeyidx.find(pkey); + if (pkiter == m_pkeyidx.end()) { + add_row(gstate, expression_master_table, config, pkey); + return; + } + t_mselem mselem; + fill_sort_elem(gstate, expression_master_table, config, pkey, mselem); + (*m_index)[pkiter->second].m_updated = true; + m_new_elems[pkey] = mselem; +} + +void +t_ftrav::delete_row(t_tscalar pkey) { + auto pkiter = m_pkeyidx.find(pkey); + if (pkiter == m_pkeyidx.end()) { + return; + } + (*m_index)[pkiter->second].m_deleted = true; + m_new_elems.erase(pkey); + ++m_step_deletes; +} + +std::vector +t_ftrav::get_sort_by() const { + return m_sortby; +} + +bool +t_ftrav::empty_sort_by() const { + return m_sortby.empty(); +} + +void +t_ftrav::reset_step_state() { + m_step_deletes = 0; + m_step_inserts = 0; + m_new_elems.clear(); +} + +t_uindex +t_ftrav::lower_bound_row_idx( + const t_gstate& gstate, + const t_config& config, + const std::vector& row +) const { + t_multisorter sorter(get_sort_orders(m_sortby)); + t_mselem target_val; + + fill_sort_elem(gstate, config, row, target_val); + + auto iter = + std::lower_bound(m_index->begin(), m_index->end(), target_val, sorter); + + return std::distance(m_index->begin(), iter); +} + +t_index +t_ftrav::get_row_idx(t_tscalar pkey) const { + auto pkiter = m_pkeyidx.find(pkey); + if (pkiter == m_pkeyidx.end()) { + return -1; + } + return pkiter->second; +} + +t_tscalar +t_ftrav::get_from_gstate( + const t_gstate& gstate, + const t_data_table& expression_master_table, + const std::string& colname, + t_tscalar pkey +) const { + const t_schema& expression_schema = expression_master_table.get_schema(); + + if (expression_schema.has_column(colname)) { + return gstate.get(expression_master_table, colname, pkey); + } + std::shared_ptr master_table = gstate.get_table(); + return gstate.get(*master_table, colname, pkey); +} + +void +t_ftrav::bulk_load_reserve(t_uindex n) { + m_index->reserve(n); + m_pkeyidx.reserve(n); +} + +void +t_ftrav::bulk_load_append(t_tscalar pkey) { + // Pre-condition: `empty_sort_by()` and the caller has ownership of + // step framing (i.e. we're inside a `step_begin`/`step_end` pair for + // an initial registration). `m_step_inserts` is intentionally NOT + // incremented so that `step_end` takes its short-circuit path. + // `m_pkeyidx` is populated in `bulk_load_finalize` after sorting, + // not here — the pkey-to-index mapping only has meaning once the + // final sort order is determined. + m_index->emplace_back(); + m_index->back().m_pkey = pkey; +} + +void +t_ftrav::bulk_load_finalize() { + // Match the order the existing `add_row`/`step_end` path would + // produce for an empty sort spec: `cmp_mselem` with zero sort + // columns falls through to `a.m_pkey < b.m_pkey`, so sort + // `m_index` by pkey and then rebuild `m_pkeyidx` against the final + // row positions. + std::sort( + m_index->begin(), + m_index->end(), + [](const t_mselem& a, const t_mselem& b) { + return a.m_pkey < b.m_pkey; + } + ); + for (t_uindex i = 0, loop_end = m_index->size(); i < loop_end; ++i) { + m_pkeyidx[(*m_index)[i].m_pkey] = i; + } +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/get_data_extents.cpp b/rust/perspective-server/cpp/perspective/src/cpp/get_data_extents.cpp new file mode 100644 index 0000000..ff14340 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/get_data_extents.cpp @@ -0,0 +1,48 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include + +namespace perspective { +t_get_data_extents +sanitize_get_data_extents( + t_index nrows, + t_index ncols, + t_index start_row, + t_index end_row, + t_index start_col, + t_index end_col +) { + start_row = std::min(start_row, nrows); + end_row = std::min(end_row, nrows); + + start_row = std::max(t_index(0), start_row); + end_row = std::max(t_index(0), end_row); + end_row = std::max(start_row, end_row); + + start_col = std::min(start_col, ncols); + end_col = std::min(end_col, ncols); + + start_col = std::max(t_index(0), start_col); + end_col = std::max(t_index(0), end_col); + end_col = std::max(start_col, end_col); + + t_get_data_extents rval; + rval.m_srow = start_row; + rval.m_erow = end_row; + rval.m_scol = start_col; + rval.m_ecol = end_col; + + return rval; +} +} // namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/gnode.cpp b/rust/perspective-server/cpp/perspective/src/cpp/gnode.cpp new file mode 100644 index 0000000..7245ffb --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/gnode.cpp @@ -0,0 +1,1818 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace perspective { + +t_tscalar +calc_delta(t_value_transition trans, t_tscalar oval, t_tscalar nval) { + return nval.difference(oval); +} + +t_tscalar +calc_newer(t_value_transition trans, t_tscalar oval, t_tscalar nval) { + if (nval.is_valid()) { + return nval; + } + return oval; +} + +t_tscalar +calc_negate(t_tscalar val) { + return val.negate(); +} + +t_gnode::t_gnode( + t_schema input_schema, + t_schema output_schema, + t_backing_store backing_store +) : + m_mode(NODE_PROCESSING_SIMPLE_DATAFLOW) +#ifdef PSP_PARALLEL_FOR + , + m_lock(nullptr) +#endif + , + m_gnode_type(GNODE_TYPE_PKEYED), + m_input_schema(std::move(input_schema)), + m_output_schema(std::move(output_schema)), + m_init(false), + m_id(0), + m_last_input_port_id(0), + m_backing_store(backing_store), + m_pool_cleanup([]() {}) { + PSP_TRACE_SENTINEL(); + LOG_CONSTRUCTOR("t_gnode"); + + std::vector trans_types(m_output_schema.size()); + for (auto& trans_type : trans_types) { + trans_type = DTYPE_UINT8; + } + + t_schema trans_schema(m_output_schema.columns(), trans_types); + t_schema existed_schema( + std::vector{"psp_existed"}, + std::vector{DTYPE_BOOL} + ); + + m_transitional_schemas = std::vector{ + m_input_schema, + m_output_schema, + m_output_schema, + m_output_schema, + trans_schema, + existed_schema + }; + m_epoch = std::chrono::high_resolution_clock::now(); +} + +t_gnode::~t_gnode() { + PSP_TRACE_SENTINEL(); + LOG_DESTRUCTOR("t_gnode"); + m_pool_cleanup(); +} + +void +t_gnode::init() { + PSP_TRACE_SENTINEL(); + + m_gstate = std::make_shared( + m_input_schema, m_output_schema, m_backing_store + ); + m_gstate->init(); + + // Create and store the main input port, which is always port 0. The next + // input port will be port 1, and so on + std::shared_ptr input_port = + std::make_shared(PORT_MODE_PKEYED, m_input_schema); + + input_port->init(); + + m_input_ports[0] = input_port; + + for (t_uindex idx = 0, loop_end = m_transitional_schemas.size(); + idx < loop_end; + ++idx) { + t_port_mode mode = idx == 0 ? PORT_MODE_PKEYED : PORT_MODE_RAW; + + std::shared_ptr port = + std::make_shared(mode, m_transitional_schemas[idx]); + + port->init(); + m_oports.push_back(port); + } + + for (const auto& iter : m_input_ports) { + std::shared_ptr input_port = iter.second; + input_port->get_table()->flatten(); + } + + // Initialize expression-related state + m_expression_vocab = std::make_shared(); + m_expression_regex_mapping = std::make_shared(); + + m_init = true; +} + +t_uindex +t_gnode::make_input_port() { + PSP_VERBOSE_ASSERT( + m_init, "Cannot `make_input_port` on an uninited gnode." + ); + std::shared_ptr input_port = + std::make_shared(PORT_MODE_PKEYED, m_input_schema); + input_port->init(); + + t_uindex port_id = m_last_input_port_id + 1; + m_input_ports[port_id] = input_port; + + // increment the global input port id + m_last_input_port_id = port_id; + + return port_id; +} + +void +t_gnode::remove_input_port(t_uindex port_id) { + PSP_VERBOSE_ASSERT( + m_init, "Cannot `remove_input_port` on an uninited gnode." + ); + + if (m_input_ports.count(port_id) == 0) { + std::cerr << "Input port `" << port_id + << "` cannot be removed, as it does not exist."; + return; + } + + std::shared_ptr input_port = m_input_ports[port_id]; + + // clear the table at the port + input_port->clear(); + + // remove from the map + m_input_ports.erase(port_id); +} + +t_value_transition +t_gnode::calc_transition( + bool prev_existed, + bool row_pre_existed, + bool exists, + bool prev_valid, + bool cur_valid, + bool prev_cur_eq, + bool prev_pkey_eq +) { + t_value_transition trans = VALUE_TRANSITION_EQ_FF; + + if (!row_pre_existed && !cur_valid && !t_env::backout_invalid_neq_ft()) { + trans = VALUE_TRANSITION_NEQ_FT; + } else if (row_pre_existed && !prev_valid && !cur_valid && !t_env::backout_eq_invalid_invalid()) { + trans = VALUE_TRANSITION_EQ_TT; + } else if (!prev_existed && !exists) { + trans = VALUE_TRANSITION_EQ_FF; + } else if (row_pre_existed && exists && !prev_valid && cur_valid && !t_env::backout_nveq_ft()) { + trans = VALUE_TRANSITION_NVEQ_FT; + } else if (prev_existed && exists && prev_cur_eq) { + trans = VALUE_TRANSITION_EQ_TT; + } else if (!prev_existed && exists) { + trans = VALUE_TRANSITION_NEQ_FT; + } else if (prev_existed && !exists) { + trans = VALUE_TRANSITION_NEQ_TF; + } else if (prev_existed && exists && !prev_cur_eq) { + trans = VALUE_TRANSITION_NEQ_TT; + } else if (prev_pkey_eq) { + // prev op must have been a delete + trans = VALUE_TRANSITION_NEQ_TDT; + } else { + PSP_COMPLAIN_AND_ABORT("Hit unexpected condition"); + } + return trans; +} + +t_mask +t_gnode::_process_mask_existed_rows(t_process_state& process_state) { + // Make sure `existed_data_table` has enough space to write without resizing + auto flattened_num_rows = process_state.m_flattened_data_table->num_rows(); + process_state.m_existed_data_table->set_size(flattened_num_rows); + + t_column* op_col = + process_state.m_flattened_data_table->_get_column("psp_op"); + process_state.m_op_base = op_col->get_nth(0); + t_column* pkey_col = + process_state.m_flattened_data_table->_get_column("psp_pkey"); + + process_state.m_added_offset.resize(flattened_num_rows); + process_state.m_prev_pkey_eq_vec.resize(flattened_num_rows); + + t_mask mask(flattened_num_rows); + t_uindex added_count = 0; + t_tscalar prev_pkey; + prev_pkey.clear(); + + t_column* existed_column = + process_state.m_existed_data_table->_get_column("psp_existed"); + + for (t_uindex idx = 0; idx < flattened_num_rows; ++idx) { + t_tscalar pkey = pkey_col->get_scalar(idx); + std::uint8_t op_ = process_state.m_op_base[idx]; + t_op op = static_cast(op_); + + PSP_VERBOSE_ASSERT( + idx < process_state.m_lookup.size(), + "process_state.m_lookup[idx] out of bounds" + ); + bool row_pre_existed = process_state.m_lookup[idx].m_exists; + process_state.m_prev_pkey_eq_vec[idx] = pkey == prev_pkey; + + process_state.m_added_offset[idx] = added_count; + + switch (op) { + case OP_INSERT: { + row_pre_existed = + row_pre_existed && !process_state.m_prev_pkey_eq_vec[idx]; + mask.set(idx, true); + existed_column->set_nth(added_count, row_pre_existed); + ++added_count; + } break; + case OP_DELETE: { + if (row_pre_existed) { + mask.set(idx, true); + existed_column->set_nth(added_count, row_pre_existed); + ++added_count; + } else { + mask.set(idx, false); + } + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unknown OP"); + } + } + + prev_pkey = pkey; + } + + PSP_VERBOSE_ASSERT(mask.count() == added_count, "Expected equality"); + return mask; +} + +t_process_table_result +t_gnode::_process_table(t_uindex port_id) { + m_was_updated = false; + + t_process_table_result result; + result.m_flattened_data_table = nullptr; + result.m_should_notify_userspace = false; + + std::shared_ptr flattened = nullptr; + + if (m_input_ports.count(port_id) == 0) { + std::cerr << "Cannot process table on port `" << port_id + << "` as it does not exist." << '\n'; + return result; + } + + std::shared_ptr& input_port = m_input_ports[port_id]; + + if (input_port->get_table()->size() == 0) { + return result; + } + + m_was_updated = true; + flattened = input_port->get_table()->flatten(); + + PSP_GNODE_VERIFY_TABLE(flattened); + PSP_GNODE_VERIFY_TABLE(get_table()); + + t_uindex flattened_num_rows = flattened->num_rows(); + + std::vector row_lookup(flattened_num_rows); + t_column* pkey_col = flattened->_get_column("psp_pkey"); + + for (t_uindex idx = 0; idx < flattened_num_rows; ++idx) { + // See if each primary key in flattened already exist in the dataset + t_tscalar pkey = pkey_col->get_scalar(idx); + row_lookup[idx] = m_gstate->lookup(pkey); + } + + // first update - master table is empty + if (m_gstate->mapping_size() == 0) { + m_gstate->update_master_table(flattened); + m_oports[PSP_PORT_FLATTENED]->set_table(flattened); + + _compute_expressions(flattened); + + // Update all contexts registered with the gnode with data. + _update_contexts_from_state(m_gstate->get_pkeyed_table()); + + input_port->release(); + release_outputs(); + +#ifdef PSP_GNODE_VERIFY + auto state_table = get_table(); + PSP_GNODE_VERIFY_TABLE(state_table); +#endif + // Make sure user is notified after first update. + result.m_should_notify_userspace = true; + return result; + } + + input_port->release_or_clear(); + + // Use `t_process_state` to manage intermediate structures + t_process_state _process_state; + + _process_state.m_state_data_table = get_table_sptr(); + _process_state.m_flattened_data_table = flattened; + _process_state.m_lookup = row_lookup; + + // Get data tables for process state + _process_state.m_delta_data_table = m_oports[PSP_PORT_DELTA]->get_table(); + _process_state.m_prev_data_table = m_oports[PSP_PORT_PREV]->get_table(); + _process_state.m_current_data_table = + m_oports[PSP_PORT_CURRENT]->get_table(); + _process_state.m_transitions_data_table = + m_oports[PSP_PORT_TRANSITIONS]->get_table(); + _process_state.m_existed_data_table = + m_oports[PSP_PORT_EXISTED]->get_table(); + + // Clear delta, prev, current, transitions, existed on EACH call. + _process_state.clear_transitional_data_tables(); + + // And re-reserved for the amount of data in `flattened` + _process_state.reserve_transitional_data_tables(flattened_num_rows); + + t_mask existed_mask = _process_mask_existed_rows(_process_state); + auto mask_count = existed_mask.count(); + + // mask_count = flattened_num_rows - number of rows that were removed + _process_state.set_size_transitional_data_tables(mask_count); + + // Only real columns from the gstate table here + const std::vector& column_names = + get_output_schema().m_columns; + t_uindex ncols = column_names.size(); + + parallel_for( + int(ncols), + [&_process_state, &column_names, this](int colidx) { + const std::string& cname = column_names[colidx]; + auto* fcolumn = + _process_state.m_flattened_data_table->_get_column(cname); + auto* scolumn = + _process_state.m_state_data_table->_get_column(cname); + auto* dcolumn = + _process_state.m_delta_data_table->_get_column(cname); + auto* pcolumn = + _process_state.m_prev_data_table->_get_column(cname); + auto* ccolumn = + _process_state.m_current_data_table->_get_column(cname); + auto* tcolumn = + _process_state.m_transitions_data_table->_get_column(cname); + + t_dtype col_dtype = fcolumn->get_dtype(); + + switch (col_dtype) { + case DTYPE_INT64: { + _process_column( + fcolumn, + scolumn, + dcolumn, + pcolumn, + ccolumn, + tcolumn, + _process_state + ); + } break; + case DTYPE_INT32: { + _process_column( + fcolumn, + scolumn, + dcolumn, + pcolumn, + ccolumn, + tcolumn, + _process_state + ); + } break; + case DTYPE_INT16: { + _process_column( + fcolumn, + scolumn, + dcolumn, + pcolumn, + ccolumn, + tcolumn, + _process_state + ); + } break; + case DTYPE_INT8: { + _process_column( + fcolumn, + scolumn, + dcolumn, + pcolumn, + ccolumn, + tcolumn, + _process_state + ); + } break; + case DTYPE_UINT64: { + _process_column( + fcolumn, + scolumn, + dcolumn, + pcolumn, + ccolumn, + tcolumn, + _process_state + ); + } break; + case DTYPE_UINT32: { + _process_column( + fcolumn, + scolumn, + dcolumn, + pcolumn, + ccolumn, + tcolumn, + _process_state + ); + } break; + case DTYPE_UINT16: { + _process_column( + fcolumn, + scolumn, + dcolumn, + pcolumn, + ccolumn, + tcolumn, + _process_state + ); + } break; + case DTYPE_UINT8: { + _process_column( + fcolumn, + scolumn, + dcolumn, + pcolumn, + ccolumn, + tcolumn, + _process_state + ); + } break; + case DTYPE_FLOAT64: { + _process_column( + fcolumn, + scolumn, + dcolumn, + pcolumn, + ccolumn, + tcolumn, + _process_state + ); + } break; + case DTYPE_FLOAT32: { + _process_column( + fcolumn, + scolumn, + dcolumn, + pcolumn, + ccolumn, + tcolumn, + _process_state + ); + } break; + case DTYPE_BOOL: { + _process_column( + fcolumn, + scolumn, + dcolumn, + pcolumn, + ccolumn, + tcolumn, + _process_state + ); + } break; + case DTYPE_TIME: { + _process_column( + fcolumn, + scolumn, + dcolumn, + pcolumn, + ccolumn, + tcolumn, + _process_state + ); + } break; + case DTYPE_DATE: { + _process_column( + fcolumn, + scolumn, + dcolumn, + pcolumn, + ccolumn, + tcolumn, + _process_state + ); + } break; + case DTYPE_STR: { + _process_column( + fcolumn, + scolumn, + dcolumn, + pcolumn, + ccolumn, + tcolumn, + _process_state + ); + } break; + case DTYPE_OBJECT: + default: { + PSP_COMPLAIN_AND_ABORT("Unsupported column dtype"); + } + } + } + ); + + /** + * After all columns have been processed (transitional tables written into), + * `_process_state.m_flattened_data_table` contains the accumulated state + * of the dataset that updates the master table on `m_gstate`, including + * added rows, updated in-place rows, and rows to be removed. + * + * `existed_mask` is a bitset marked true for `OP_INSERT`, and false for + * `OP_DELETE`. If there are any `OP_DELETE`s, the next step returns a + * new `t_data_table` with the deleted rows masked out. + */ + std::shared_ptr flattened_masked; + + if (existed_mask.count() == _process_state.m_flattened_data_table->size()) { + flattened_masked = _process_state.m_flattened_data_table; + } else { + flattened_masked = + _process_state.m_flattened_data_table->clone(existed_mask); + } + + PSP_GNODE_VERIFY_TABLE(flattened_masked); + +#ifdef PSP_GNODE_VERIFY + { + auto updated_table = get_table(); + PSP_GNODE_VERIFY_TABLE(updated_table); + } +#endif + + m_gstate->update_master_table(flattened_masked); + +#ifdef PSP_GNODE_VERIFY + { + auto updated_table = get_table(); + PSP_GNODE_VERIFY_TABLE(updated_table); + } +#endif + + m_oports[PSP_PORT_FLATTENED]->set_table(flattened_masked); + + _compute_expressions(get_table_sptr(), flattened_masked); + + result.m_flattened_data_table = flattened_masked; + result.m_should_notify_userspace = true; + + return result; +} + +template <> +void +t_gnode::_process_column( + const t_column* fcolumn, + const t_column* scolumn, + t_column* dcolumn, + t_column* pcolumn, + t_column* ccolumn, + t_column* tcolumn, + const t_process_state& process_state +) { + pcolumn->borrow_vocabulary(*scolumn); + + for (t_uindex idx = 0, loop_end = fcolumn->size(); idx < loop_end; ++idx) { + std::uint8_t op_ = process_state.m_op_base[idx]; + t_op op = static_cast(op_); + t_uindex added_count = process_state.m_added_offset[idx]; + + const t_rlookup& rlookup = process_state.m_lookup[idx]; + bool row_pre_existed = rlookup.m_exists; + auto prev_pkey_eq = process_state.m_prev_pkey_eq_vec[idx]; + + switch (op) { + case OP_INSERT: { + row_pre_existed = row_pre_existed && !prev_pkey_eq; + + const char* prev_value = nullptr; + bool prev_valid = false; + + const auto* cur_value = fcolumn->get_nth(idx); + std::string curs(cur_value); + + bool cur_valid = fcolumn->is_valid(idx); + + if (row_pre_existed) { + prev_value = scolumn->get_nth(rlookup.m_idx); + prev_valid = scolumn->is_valid(rlookup.m_idx); + } + + bool exists = cur_valid; + bool prev_existed = row_pre_existed && prev_valid; + bool prev_cur_eq = (prev_value != nullptr) + && (cur_value != nullptr) + && strcmp(prev_value, cur_value) == 0; + + auto trans = calc_transition( + prev_existed, + row_pre_existed, + exists, + prev_valid, + cur_valid, + prev_cur_eq, + prev_pkey_eq + ); + + if (prev_valid) { + pcolumn->set_nth( + added_count, + *(scolumn->get_nth(rlookup.m_idx)) + ); + } + + pcolumn->set_valid(added_count, prev_valid); + + if (cur_valid) { + ccolumn->set_nth(added_count, cur_value); + } + + if (!cur_valid && prev_valid) { + ccolumn->set_nth(added_count, prev_value); + } + + ccolumn->set_valid( + added_count, cur_valid ? cur_valid : prev_valid + ); + + tcolumn->set_nth(idx, trans); + } break; + case OP_DELETE: { + if (row_pre_existed) { + const auto* prev_value = + scolumn->get_nth(rlookup.m_idx); + + bool prev_valid = scolumn->is_valid(rlookup.m_idx); + + pcolumn->set_nth(added_count, prev_value); + + pcolumn->set_valid(added_count, prev_valid); + + ccolumn->set_nth(added_count, prev_value); + + ccolumn->set_valid(added_count, prev_valid); + + tcolumn->set_nth( + added_count, VALUE_TRANSITION_NEQ_TDF + ); + } + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unknown OP"); + } + } + } +} + +void +t_gnode::send(t_uindex port_id, const t_data_table& fragments) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "Cannot `send` to an uninited gnode."); + + // Single lookup instead of `count()` + `operator[]` (which would also + // default-insert a null port on a miss). + auto port_iter = m_input_ports.find(port_id); + if (port_iter == m_input_ports.end()) { + std::cerr << "Cannot send table to port `" << port_id + << "`, which does not exist." << '\n'; + return; + } + + port_iter->second->send(fragments); +} + +void +t_gnode::init_bulk(const std::shared_ptr& data_table) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "Cannot `init_bulk` on an uninited gnode."); + PSP_VERBOSE_ASSERT( + m_gstate->mapping_size() == 0, + "`init_bulk` requires an empty gnode state." + ); + PSP_GIL_UNLOCK(); + PSP_WRITE_LOCK(*m_lock); + + PSP_GNODE_VERIFY_TABLE(data_table); + + m_was_updated = true; + + m_gstate->init_from_table(data_table); + + PSP_GNODE_VERIFY_TABLE(get_table_sptr()); + + // Registered contexts / expressions: on a freshly-constructed `Table` + // these collections are empty and the calls are no-ops, but keep them + // for parity with `_process_table`'s first-update path so a context + // registered between `Table` construction and `init_bulk` is still + // notified. + _compute_expressions(data_table); + _update_contexts_from_state(m_gstate->get_pkeyed_table()); +} + +bool +t_gnode::process(t_uindex port_id) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "Cannot `process` on an uninited gnode."); + PSP_GIL_UNLOCK(); + PSP_WRITE_LOCK(*m_lock); + t_process_table_result result = _process_table(port_id); + + if (result.m_flattened_data_table) { + notify_contexts(result.m_flattened_data_table); + } + + // Whether the user should be notified - False if process_table exited + // early, True otherwise. + return result.m_should_notify_userspace; +} + +t_uindex +t_gnode::mapping_size() const { + return m_gstate->mapping_size(); +} + +t_data_table* +t_gnode::_get_otable(t_uindex port_id) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "Cannot `_get_otable` on an uninited gnode."); + PSP_VERBOSE_ASSERT(port_id < m_oports.size(), "Invalid port number"); + return m_oports[port_id]->get_table().get(); +} + +t_data_table* +t_gnode::_get_itable(t_uindex port_id) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "Cannot `_get_itable` on an uninited gnode."); + PSP_VERBOSE_ASSERT( + m_input_ports.count(port_id) != 0, "Invalid port number" + ); + return m_input_ports[port_id]->get_table().get(); +} + +t_data_table* +t_gnode::get_table() { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "Cannot `get_table` on an uninited gnode."); + return m_gstate->get_table().get(); +} + +const t_data_table* +t_gnode::get_table() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "Cannot `get_table` on an uninited gnode."); + return m_gstate->get_table().get(); +} + +std::shared_ptr +t_gnode::get_table_sptr() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "Cannot `get_table_sptr` on an uninited gnode."); + return m_gstate->get_table(); +} + +std::shared_ptr +t_gnode::get_pkeyed_table() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "Cannot `get_pkeyed_table` on an uninited gnode."); + return m_gstate->get_pkeyed_table(); +} + +/** + * Convenience method for promoting a column. This is a hack used to + * interop with javascript more efficiently, and does not handle all + * possible type conversions. Non-public. + */ +void +t_gnode::promote_column(const std::string& name, t_dtype new_type) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "Cannot `promote_column` on an uninited gnode."); + get_table()->promote_column(name, new_type, 0, false); + _get_otable(0)->promote_column(name, new_type, 0, false); + + for (const auto& iter : m_input_ports) { + std::shared_ptr input_port = iter.second; + std::shared_ptr input_table = input_port->get_table(); + input_table->promote_column(name, new_type, 0, false); + } + + m_output_schema.retype_column(name, new_type); + m_input_schema.retype_column(name, new_type); + m_transitional_schemas[0].retype_column(name, new_type); +} + +void +t_gnode::pprint() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + m_gstate->pprint(); +} + +template +void +t_gnode::set_ctx_state(void* ptr) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + auto* ctx = static_cast(ptr); + ctx->set_state(m_gstate); +} + +template <> +void +t_gnode::update_context_from_state( + t_ctxunit* ctx, + const std::string& name, + std::shared_ptr flattened, + bool is_registration +) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + PSP_VERBOSE_ASSERT( + m_mode == NODE_PROCESSING_SIMPLE_DATAFLOW, + "Only simple dataflows supported currently" + ) + + if (flattened->size() == 0) { + return; + } + + // Need to cast shared ptr to a const reference before passing to notify, + // reference is valid as `notify` is not async + const auto& const_flattened = const_cast(*flattened); + + ctx->step_begin(); + ctx->notify(const_flattened, is_registration); + ctx->step_end(); +} + +void +t_gnode::_update_contexts_from_state(std::shared_ptr tbl) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + + t_index num_contexts = m_contexts.size(); + + // Iterate over contexts in parallel + std::vector context_names(num_contexts); + std::vector context_handles(num_contexts); + + t_index count = 0; + + for (const auto& iter : m_contexts) { + context_names[count] = iter.first; + context_handles[count] = iter.second; + count++; + } + + auto update_contexts_helper = [this, + &context_names, + &context_handles, + &tbl](t_index ctx_idx) { + const std::string& name = context_names[ctx_idx]; + const t_ctx_handle& ctxh = context_handles[ctx_idx]; + + // This is the first-update-to-previously-empty-table path; a client + // may have subscribed between context creation and this update and + // expects to observe all rows as deltas, so we pass + // `is_registration = false`. + switch (ctxh.get_type()) { + case TWO_SIDED_CONTEXT: { + auto* ctx = static_cast(ctxh.m_ctx); + // Do not reset the expression tables on the context, + // as they've already been computed. + ctx->reset(false); + update_context_from_state(ctx, name, tbl, false); + } break; + case ONE_SIDED_CONTEXT: { + auto* ctx = static_cast(ctxh.m_ctx); + ctx->reset(false); + update_context_from_state(ctx, name, tbl, false); + } break; + case ZERO_SIDED_CONTEXT: { + auto* ctx = static_cast(ctxh.m_ctx); + ctx->reset(false); + update_context_from_state(ctx, name, tbl, false); + } break; + case UNIT_CONTEXT: { + auto* ctx = static_cast(ctxh.m_ctx); + ctx->reset(); + update_context_from_state(ctx, name, tbl, false); + } break; + case GROUPED_PKEY_CONTEXT: { + auto* ctx = static_cast(ctxh.m_ctx); + ctx->reset(false); + update_context_from_state( + ctx, name, tbl, false + ); + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected context type"); + } break; + } + }; + + parallel_for(int(num_contexts), [&update_contexts_helper](int ctx_idx) { + update_contexts_helper(ctx_idx); + }); +} + +/** + * @brief Separate `notify_context` for `t_ctxunit` as it does not need to + * compute any expressions. + * + * @tparam + * @param flattened + * @param ctxh + * @param name + */ +template <> +void +t_gnode::notify_context( + std::shared_ptr flattened, + const t_ctx_handle& ctxh, + const std::string& name +) { + auto* ctx = ctxh.get(); + + std::shared_ptr delta = m_oports[PSP_PORT_DELTA]->get_table(); + std::shared_ptr prev = m_oports[PSP_PORT_PREV]->get_table(); + std::shared_ptr current = + m_oports[PSP_PORT_CURRENT]->get_table(); + std::shared_ptr transitions = + m_oports[PSP_PORT_TRANSITIONS]->get_table(); + const t_data_table& existed = *(m_oports[PSP_PORT_EXISTED]->get_table()); + + ctx->step_begin(); + + // pass the tables as const references - the destructors for all of the + // joined tables will be called after this function finishes executing, + // as the contexts do not retain a reference to these tables. + ctx->notify( + *(flattened), *(delta), *(prev), *(current), *(transitions), existed + ); + + ctx->step_end(); +} + +std::vector +t_gnode::get_registered_contexts() const { + std::vector rval; + rval.reserve(m_contexts.size()); + + for (const auto& kv : m_contexts) { + std::stringstream ss; + const auto& ctxh = kv.second; + ss << "(ctx_name => " << kv.first << ", "; + + switch (ctxh.m_ctx_type) { + case TWO_SIDED_CONTEXT: { + const auto* ctx = static_cast(ctxh.m_ctx); + ss << ctx->repr() << ")"; + } break; + case ONE_SIDED_CONTEXT: { + const auto* ctx = static_cast(ctxh.m_ctx); + ss << ctx->repr() << ")"; + } break; + case ZERO_SIDED_CONTEXT: { + const auto* ctx = static_cast(ctxh.m_ctx); + ss << ctx->repr() << ")"; + } break; + case UNIT_CONTEXT: { + const auto* ctx = static_cast(ctxh.m_ctx); + ss << ctx->repr() << ")"; + } break; + case GROUPED_PKEY_CONTEXT: { + const auto* ctx = + static_cast(ctxh.m_ctx); + ss << ctx->repr() << ")"; + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected context type"); + } break; + } + + rval.push_back(ss.str()); + } + + return rval; +} + +namespace { + +// Build a `t_schema` containing only `psp_pkey`, `psp_op`, and any columns +// from `extra_cols` that exist in `input_schema`. Used to narrow the schema +// passed to `t_gstate::get_pkeyed_table()` so that when rows have been +// deleted (triggering the mask-and-clone branch), only columns the target +// context actually reads from the flattened table are cloned — rather than +// every column in the master table. +t_schema +make_minimal_pkeyed_schema( + const t_schema& input_schema, + const std::vector& extra_cols +) { + std::vector cols{"psp_pkey", "psp_op"}; + std::vector types{ + input_schema.get_dtype("psp_pkey"), + input_schema.get_dtype("psp_op") + }; + for (const std::string& colname : extra_cols) { + if (colname == "psp_pkey" || colname == "psp_op") { + continue; + } + if (!input_schema.has_column(colname)) { + continue; + } + if (std::find(cols.begin(), cols.end(), colname) != cols.end()) { + continue; + } + cols.push_back(colname); + types.push_back(input_schema.get_dtype(colname)); + } + return {cols, types}; +} + +} // anonymous namespace + +void +t_gnode::_register_context( + const std::string& name, t_ctx_type type, std::uintptr_t ptr +) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + void* ptr_ = reinterpret_cast(ptr); + t_ctx_handle ch(ptr_, type); + m_contexts[name] = ch; + + bool should_update = m_gstate->mapping_size() > 0; + + t_expression_vocab& expression_vocab = *(m_expression_vocab); + t_regex_mapping& expression_regex_mapping = *(m_expression_regex_mapping); + + switch (type) { + case TWO_SIDED_CONTEXT: { + set_ctx_state(ptr_); + auto* ctx = static_cast(ptr_); + ctx->reset(); + + if (should_update) { + ctx->compute_expressions( + m_gstate->get_table(), + m_gstate->get_pkey_map(), + expression_vocab, + expression_regex_mapping + ); + ctx->get_expression_tables()->set_flattened( + m_gstate->get_pkeyed_table( + ctx->get_expression_tables()->m_master->get_schema(), + ctx->get_expression_tables()->m_master + ) + ); + + update_context_from_state( + ctx, name, m_gstate->get_pkeyed_table(), true + ); + } + } break; + case ONE_SIDED_CONTEXT: { + set_ctx_state(ptr_); + auto* ctx = static_cast(ptr_); + ctx->reset(); + if (should_update) { + ctx->compute_expressions( + m_gstate->get_table(), + m_gstate->get_pkey_map(), + expression_vocab, + expression_regex_mapping + ); + ctx->get_expression_tables()->set_flattened( + m_gstate->get_pkeyed_table( + ctx->get_expression_tables()->m_master->get_schema(), + ctx->get_expression_tables()->m_master + ) + ); + + update_context_from_state( + ctx, name, m_gstate->get_pkeyed_table(), true + ); + } + } break; + case ZERO_SIDED_CONTEXT: { + set_ctx_state(ptr_); + auto* ctx = static_cast(ptr_); + ctx->reset(); + if (should_update) { + ctx->compute_expressions( + m_gstate->get_table(), + m_gstate->get_pkey_map(), + expression_vocab, + expression_regex_mapping + ); + ctx->get_expression_tables()->set_flattened( + m_gstate->get_pkeyed_table( + ctx->get_expression_tables()->m_master->get_schema(), + ctx->get_expression_tables()->m_master + ) + ); + + // `t_ctx0::notify` only reads `psp_pkey`, `psp_op`, and + // non-expression filter columns from the flattened table. + // Expression filter columns are supplied by the expression + // table that `update_context_from_state` joins in. + const t_config& config = ctx->get_config(); + const t_schema& expr_schema = + ctx->get_expression_tables()->m_master->get_schema(); + std::vector extra_cols; + if (config.has_filters()) { + for (const t_fterm& fterm : config.get_fterms()) { + if (expr_schema.has_column(fterm.m_colname)) { + continue; + } + extra_cols.push_back(fterm.m_colname); + } + } + t_schema narrow_schema = make_minimal_pkeyed_schema( + m_gstate->get_input_schema(), extra_cols + ); + update_context_from_state( + ctx, + name, + m_gstate->get_pkeyed_table( + narrow_schema, m_gstate->get_table() + ), + true + ); + } + } break; + case UNIT_CONTEXT: { + set_ctx_state(ptr_); + auto* ctx = static_cast(ptr_); + ctx->reset(); + + // No expressions here to deal with + if (should_update) { + // `t_ctxunit::notify` only reads `psp_pkey` from the + // flattened table, so narrow the pkeyed table to the + // identifying columns only. + t_schema narrow_schema = make_minimal_pkeyed_schema( + m_gstate->get_input_schema(), {} + ); + update_context_from_state( + ctx, + name, + m_gstate->get_pkeyed_table( + narrow_schema, m_gstate->get_table() + ), + true + ); + } + } break; + case GROUPED_PKEY_CONTEXT: { + set_ctx_state(ptr_); + auto* ctx = static_cast(ptr_); + ctx->reset(); + + if (should_update) { + ctx->compute_expressions( + m_gstate->get_table(), + m_gstate->get_pkey_map(), + expression_vocab, + expression_regex_mapping + ); + ctx->get_expression_tables()->set_flattened( + m_gstate->get_pkeyed_table( + ctx->get_expression_tables()->m_master->get_schema(), + ctx->get_expression_tables()->m_master + ) + ); + + update_context_from_state( + ctx, name, m_gstate->get_pkeyed_table(), true + ); + } + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected context type"); + } break; + } +} + +void +t_gnode::_unregister_context(const std::string& name) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + if (m_contexts.count(name) != 0) { + m_contexts.erase(name); + } +} + +void +t_gnode::notify_contexts(std::shared_ptr flattened) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + + t_index num_contexts = m_contexts.size(); + std::vector context_names(num_contexts); + std::vector ctxhvec(num_contexts); + + t_index ctxh_count = 0; + + for (const auto& iter : m_contexts) { + context_names[ctxh_count] = iter.first; + ctxhvec[ctxh_count] = iter.second; + ctxh_count++; + } + + auto notify_context_helper = + [this, &context_names, &ctxhvec, &flattened](t_index ctx_idx) { + const std::string& name = context_names[ctx_idx]; + const t_ctx_handle& ctxh = ctxhvec[ctx_idx]; + + switch (ctxh.get_type()) { + case TWO_SIDED_CONTEXT: { + notify_context(flattened, ctxh, name); + } break; + case ONE_SIDED_CONTEXT: { + notify_context(flattened, ctxh, name); + } break; + case ZERO_SIDED_CONTEXT: { + notify_context(flattened, ctxh, name); + } break; + case UNIT_CONTEXT: { + notify_context(flattened, ctxh, name); + } break; + case GROUPED_PKEY_CONTEXT: { + notify_context(flattened, ctxh, name); + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected context type"); + } break; + } + }; + + parallel_for(int(num_contexts), [¬ify_context_helper](int ctx_idx) { + notify_context_helper(ctx_idx); + }); +} + +/****************************************************************************** + * + * Expressions + */ + +void +t_gnode::_compute_expressions( + const std::shared_ptr& flattened_masked +) { + t_expression_vocab& expression_vocab = *(m_expression_vocab); + t_regex_mapping& expression_regex_mapping = *(m_expression_regex_mapping); + + for (const auto& iter : m_contexts) { + const t_ctx_handle& ctxh = iter.second; + switch (ctxh.get_type()) { + case TWO_SIDED_CONTEXT: { + auto* ctx = static_cast(ctxh.m_ctx); + ctx->compute_expressions( + m_gstate->get_table(), + m_gstate->get_pkey_map(), + expression_vocab, + expression_regex_mapping + ); + ctx->get_expression_tables()->set_flattened( + m_gstate->get_pkeyed_table( + ctx->get_expression_tables()->m_master->get_schema(), + ctx->get_expression_tables()->m_master + ) + ); + } break; + case ONE_SIDED_CONTEXT: { + auto* ctx = static_cast(ctxh.m_ctx); + ctx->compute_expressions( + m_gstate->get_table(), + m_gstate->get_pkey_map(), + expression_vocab, + expression_regex_mapping + ); + ctx->get_expression_tables()->set_flattened( + m_gstate->get_pkeyed_table( + ctx->get_expression_tables()->m_master->get_schema(), + ctx->get_expression_tables()->m_master + ) + ); + } break; + case ZERO_SIDED_CONTEXT: { + auto* ctx = static_cast(ctxh.m_ctx); + ctx->compute_expressions( + m_gstate->get_table(), + m_gstate->get_pkey_map(), + expression_vocab, + expression_regex_mapping + ); + ctx->get_expression_tables()->set_flattened( + m_gstate->get_pkeyed_table( + ctx->get_expression_tables()->m_master->get_schema(), + ctx->get_expression_tables()->m_master + ) + ); + } break; + case GROUPED_PKEY_CONTEXT: { + auto* ctx = static_cast(ctxh.m_ctx); + ctx->compute_expressions( + m_gstate->get_table(), + m_gstate->get_pkey_map(), + expression_vocab, + expression_regex_mapping + ); + ctx->get_expression_tables()->set_flattened( + m_gstate->get_pkeyed_table( + ctx->get_expression_tables()->m_master->get_schema(), + ctx->get_expression_tables()->m_master + ) + ); + } break; + case UNIT_CONTEXT: + break; + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected context type"); + } break; + } + } +} + +void +t_gnode::_compute_expressions( + const std::shared_ptr& master, + const std::shared_ptr& flattened +) { + std::shared_ptr delta = m_oports[PSP_PORT_DELTA]->get_table(); + std::shared_ptr prev = m_oports[PSP_PORT_PREV]->get_table(); + std::shared_ptr current = + m_oports[PSP_PORT_CURRENT]->get_table(); + std::shared_ptr transitions = + m_oports[PSP_PORT_TRANSITIONS]->get_table(); + std::shared_ptr existed = + m_oports[PSP_PORT_EXISTED]->get_table(); + + t_expression_vocab& expression_vocab = *(m_expression_vocab); + t_regex_mapping& expression_regex_mapping = *(m_expression_regex_mapping); + + for (const auto& iter : m_contexts) { + const t_ctx_handle& ctxh = iter.second; + + switch (ctxh.get_type()) { + case TWO_SIDED_CONTEXT: { + auto* ctx = static_cast(ctxh.m_ctx); + ctx->compute_expressions( + master, + m_gstate->get_pkey_map(), + flattened, + delta, + prev, + current, + transitions, + existed, + expression_vocab, + expression_regex_mapping + ); + } break; + case ONE_SIDED_CONTEXT: { + auto* ctx = static_cast(ctxh.m_ctx); + ctx->compute_expressions( + master, + m_gstate->get_pkey_map(), + flattened, + delta, + prev, + current, + transitions, + existed, + expression_vocab, + expression_regex_mapping + ); + } break; + case ZERO_SIDED_CONTEXT: { + auto* ctx = static_cast(ctxh.m_ctx); + ctx->compute_expressions( + master, + m_gstate->get_pkey_map(), + flattened, + delta, + prev, + current, + transitions, + existed, + expression_vocab, + expression_regex_mapping + ); + } break; + case GROUPED_PKEY_CONTEXT: { + auto* ctx = static_cast(ctxh.m_ctx); + ctx->compute_expressions( + master, + m_gstate->get_pkey_map(), + flattened, + delta, + prev, + current, + transitions, + existed, + expression_vocab, + expression_regex_mapping + ); + } break; + case UNIT_CONTEXT: + break; + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected context type"); + } break; + } + } +} + +/****************************************************************************** + * + * Getters + */ + +const t_gstate::t_mapping& +t_gnode::get_pkey_map() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_gstate->get_pkey_map(); +} + +std::shared_ptr +t_gnode::get_expression_vocab() const { + return m_expression_vocab; +} + +std::shared_ptr +t_gnode::get_expression_regex_mapping() const { + return m_expression_regex_mapping; +} + +t_schema +t_gnode::get_output_schema() const { + return m_output_schema; +} + +std::vector +t_gnode::get_pivots() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + + std::vector rval; + + for (const auto& iter : m_contexts) { + auto ctxh = iter.second; + + switch (ctxh.m_ctx_type) { + case TWO_SIDED_CONTEXT: { + const auto* ctx = static_cast(ctxh.m_ctx); + auto pivots = ctx->get_pivots(); + rval.insert( + std::end(rval), std::begin(pivots), std::end(pivots) + ); + } break; + case ONE_SIDED_CONTEXT: { + const auto* ctx = static_cast(ctxh.m_ctx); + auto pivots = ctx->get_pivots(); + rval.insert( + std::end(rval), std::begin(pivots), std::end(pivots) + ); + } break; + case UNIT_CONTEXT: + case ZERO_SIDED_CONTEXT: + case GROUPED_PKEY_CONTEXT: { + // no pivots + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected context type"); + } break; + } + } + + return rval; +} + +std::vector +t_gnode::get_trees() { + + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + + std::vector rval; + + for (const auto& kv : m_contexts) { + const auto& ctxh = kv.second; + + switch (ctxh.m_ctx_type) { + // `get_trees()` not implemented, as unit contexts have no + // traversal of their own. + case UNIT_CONTEXT: + break; + case TWO_SIDED_CONTEXT: { + auto* ctx = reinterpret_cast(ctxh.m_ctx); + auto trees = ctx->get_trees(); + rval.insert(rval.end(), std::begin(trees), std::end(trees)); + } break; + case ONE_SIDED_CONTEXT: { + auto* ctx = reinterpret_cast(ctxh.m_ctx); + auto trees = ctx->get_trees(); + rval.insert(rval.end(), std::begin(trees), std::end(trees)); + } break; + case ZERO_SIDED_CONTEXT: { + auto* ctx = reinterpret_cast(ctxh.m_ctx); + auto trees = ctx->get_trees(); + rval.insert(rval.end(), std::begin(trees), std::end(trees)); + } break; + case GROUPED_PKEY_CONTEXT: { + auto* ctx = reinterpret_cast(ctxh.m_ctx); + auto trees = ctx->get_trees(); + rval.insert(rval.end(), std::begin(trees), std::end(trees)); + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected context type"); + } break; + } + } + return rval; +} + +void +t_gnode::set_id(t_uindex id) { + m_id = id; +} + +t_uindex +t_gnode::get_id() const { + return m_id; +} + +t_uindex +t_gnode::num_input_ports() const { + return m_input_ports.size(); +} + +t_uindex +t_gnode::num_output_ports() const { + return m_oports.size(); +} + +void +t_gnode::release_inputs() { + for (const auto& iter : m_input_ports) { + std::shared_ptr input_port = iter.second; + input_port->release(); + } +} + +void +t_gnode::release_outputs() { + for (const auto& p : m_oports) { + p->release(); + } +} + +std::vector +t_gnode::get_contexts_last_updated() const { + std::vector rval; + + for (const auto& kv : m_contexts) { + auto ctxh = kv.second; + switch (ctxh.m_ctx_type) { + case TWO_SIDED_CONTEXT: { + auto* ctx = reinterpret_cast(ctxh.m_ctx); + if (ctx->has_deltas()) { + rval.push_back(kv.first); + } + } break; + case ONE_SIDED_CONTEXT: { + auto* ctx = reinterpret_cast(ctxh.m_ctx); + if (ctx->has_deltas()) { + rval.push_back(kv.first); + } + } break; + case ZERO_SIDED_CONTEXT: { + auto* ctx = reinterpret_cast(ctxh.m_ctx); + if (ctx->has_deltas()) { + rval.push_back(kv.first); + } + } break; + case UNIT_CONTEXT: { + auto* ctx = reinterpret_cast(ctxh.m_ctx); + if (ctx->has_deltas()) { + rval.push_back(kv.first); + } + } break; + case GROUPED_PKEY_CONTEXT: { + auto* ctx = reinterpret_cast(ctxh.m_ctx); + if (ctx->has_deltas()) { + rval.push_back(kv.first); + } + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected context type"); + } break; + } + } + + if (t_env::log_progress()) { + std::cout << "get_contexts_last_updated<" << '\n'; + for (const auto& s : rval) { + std::cout << "\t" << s << '\n'; + } + std::cout << ">\n"; + } + return rval; +} + +std::vector +t_gnode::get_row_data_pkeys(const std::vector& pkeys) const { + return m_gstate->get_row_data_pkeys(pkeys); +} + +void +t_gnode::reset() { + std::vector rval; + + for (const auto& kv : m_contexts) { + auto ctxh = kv.second; + switch (ctxh.m_ctx_type) { + case TWO_SIDED_CONTEXT: { + auto* ctx = reinterpret_cast(ctxh.m_ctx); + ctx->reset(); + } break; + case ONE_SIDED_CONTEXT: { + auto* ctx = reinterpret_cast(ctxh.m_ctx); + ctx->reset(); + } break; + case ZERO_SIDED_CONTEXT: { + auto* ctx = reinterpret_cast(ctxh.m_ctx); + ctx->reset(); + } break; + case UNIT_CONTEXT: { + auto* ctx = reinterpret_cast(ctxh.m_ctx); + ctx->reset(); + } break; + case GROUPED_PKEY_CONTEXT: { + auto* ctx = reinterpret_cast(ctxh.m_ctx); + ctx->reset(); + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected context type"); + } break; + } + } + + m_gstate->reset(); + + // Clear expression-related state + m_expression_vocab->clear(); + m_expression_regex_mapping->clear(); +} + +void +t_gnode::clear_input_ports() { + for (const auto& iter : m_input_ports) { + std::shared_ptr input_port = iter.second; + input_port->get_table()->clear(); + } +} + +void +t_gnode::clear_output_ports() { + PSP_GIL_UNLOCK(); + PSP_WRITE_LOCK(*m_lock); + for (const auto& m_oport : m_oports) { + m_oport->get_table()->clear(); + } +} + +void +t_gnode::set_pool_cleanup(std::function cleanup) { + m_pool_cleanup = std::move(cleanup); +} + +const t_schema& +t_gnode::get_state_input_schema() const { + return m_gstate->get_input_schema(); +} + +bool +t_gnode::was_updated() const { + return m_was_updated; +} + +void +t_gnode::clear_updated() { + m_was_updated = false; +} + +std::string +t_gnode::repr() const { + std::stringstream ss; + ss << "t_gnode<" << this << ">"; + return ss.str(); +} + +#ifdef PSP_PARALLEL_FOR +void +t_gnode::set_lock(std::shared_mutex* lock) { + m_lock = lock; +} +#endif + +void +t_gnode::register_context( + const std::string& name, const std::shared_ptr& ctx +) { + _register_context( + name, UNIT_CONTEXT, reinterpret_cast(ctx.get()) + ); +} + +void +t_gnode::register_context( + const std::string& name, const std::shared_ptr& ctx +) { + _register_context( + name, ZERO_SIDED_CONTEXT, reinterpret_cast(ctx.get()) + ); +} + +void +t_gnode::register_context( + const std::string& name, const std::shared_ptr& ctx +) { + _register_context( + name, ONE_SIDED_CONTEXT, reinterpret_cast(ctx.get()) + ); +} + +void +t_gnode::register_context( + const std::string& name, const std::shared_ptr& ctx +) { + _register_context( + name, TWO_SIDED_CONTEXT, reinterpret_cast(ctx.get()) + ); +} +void +t_gnode::register_context( + const std::string& name, const std::shared_ptr& ctx +) { + _register_context( + name, GROUPED_PKEY_CONTEXT, reinterpret_cast(ctx.get()) + ); +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/gnode_state.cpp b/rust/perspective-server/cpp/perspective/src/cpp/gnode_state.cpp new file mode 100644 index 0000000..e2e8ad0 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/gnode_state.cpp @@ -0,0 +1,886 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace perspective { + +t_gstate::t_gstate( + t_schema input_schema, + t_schema output_schema, + t_backing_store backing_store +) : + m_input_schema(std::move(input_schema)), + m_output_schema(std::move(output_schema)), + m_backing_store(backing_store), + m_init(false) { + LOG_CONSTRUCTOR("t_gstate"); +} + +t_gstate::~t_gstate() { LOG_DESTRUCTOR("t_gstate"); } + +void +t_gstate::init() { + // The master `t_data_table` is the canonical copy of the dataset and is the + // only table eligible for on-disk backing. When `BACKING_STORE_DISK` is + // requested, the column files live in a unique per-table directory under + // the OS temp directory (native only; WASM/OPFS is handled separately). + std::string dirname; + if (m_backing_store == BACKING_STORE_DISK) { + dirname = create_backing_store_dir("perspective_"); + } + m_table = std::make_shared( + "", dirname, m_input_schema, DEFAULT_EMPTY_CAPACITY, m_backing_store + ); + m_table->init(); + m_pkcol = m_table->get_column("psp_pkey"); + m_opcol = m_table->get_column("psp_op"); + m_init = true; +} + +t_rlookup +t_gstate::lookup(t_tscalar pkey) const { + t_rlookup rval(0, false); + + t_mapping::const_iterator iter = m_mapping.find(pkey); + + if (iter == m_mapping.end()) { + return rval; + } + + rval.m_idx = iter->second; + rval.m_exists = true; + return rval; +} + +void +t_gstate::_mark_deleted(t_uindex idx) { + m_free.insert(idx); +} + +void +t_gstate::erase(const t_tscalar& pkey) { + t_mapping::const_iterator iter = m_mapping.find(pkey); + + if (iter == m_mapping.end()) { + return; + } + + auto columns = m_table->get_columns(); + + t_uindex idx = iter->second; + + for (auto* c : columns) { + c->clear(idx); + } + + m_mapping.erase(iter); + _mark_deleted(idx); +} + +t_uindex +t_gstate::lookup_or_create(const t_tscalar& pkey) { + auto pkey_ = m_symtable.get_interned_tscalar(pkey); + t_mapping::const_iterator iter = m_mapping.find(pkey_); + + if (iter != m_mapping.end()) { + return iter->second; + } + + if (!m_free.empty()) { + t_free_items::const_iterator iter = m_free.begin(); + t_uindex idx = *iter; + m_free.erase(iter); + m_mapping.emplace(pkey_, idx); + return idx; + } + + t_uindex nrows = m_table->num_rows(); + if (nrows >= m_table->get_capacity() - 1) { + m_table->reserve(std::max( + nrows + 1, + static_cast( + m_table->get_capacity() * PSP_TABLE_GROW_RATIO + ) + )); + } + + m_table->set_size(nrows + 1); + m_opcol->set_nth(nrows, OP_INSERT); + m_pkcol->set_scalar(nrows, pkey); + m_mapping.emplace(pkey_, nrows); + return nrows; +} + +void +t_gstate::fill_master_table(const std::shared_ptr& flattened) { + // insert into empty `m_table` + m_free.clear(); + m_mapping.clear(); + + const t_schema& master_table_schema = m_table->get_schema(); + + const t_column* flattened_pkey_col = + flattened->_get_const_column("psp_pkey"); + const t_column* flattened_op_col = + flattened->_get_const_column("psp_op"); + + t_uindex ncols = m_table->num_columns(); + auto* master_table = m_table.get(); + const bool page_to_disk = m_backing_store == BACKING_STORE_DISK; + + parallel_for( + int(ncols), + [&master_table, &master_table_schema, &flattened, page_to_disk](int idx) { + const std::string& column_name = master_table_schema.m_columns[idx]; + auto flattened_column = flattened->get_column_safe(column_name); + if (!flattened_column) { + return; + } + if (page_to_disk) { + // The master table is on-disk; preserve its disk-backed column + // and deep-copy the flattened (in-memory) column's data into + // it rather than aliasing the in-memory `shared_ptr`. + master_table->_get_column(column_name) + ->copy_from(*flattened_column); + } else { + // Alias each column `shared_ptr` from flattened into `m_table` + // rather than deep-cloning. + master_table->set_column(idx, std::move(flattened_column)); + } + } + ); + + m_pkcol = master_table->get_column("psp_pkey"); + m_opcol = master_table->get_column("psp_op"); + + master_table->set_capacity(flattened->get_capacity()); + master_table->set_size(flattened->size()); + + const std::uint8_t* flattened_op_base = + flattened_op_col->get_nth_base(); + for (t_uindex idx = 0, loop_end = flattened->num_rows(); idx < loop_end; + ++idx) { + t_tscalar pkey = flattened_pkey_col->get_scalar(idx); + t_op op = static_cast(flattened_op_base[idx]); + + switch (op) { + case OP_INSERT: { + // Write new primary keys into `m_mapping` + m_mapping.emplace( + m_symtable.get_interned_tscalar(pkey), idx + ); + m_opcol->set_nth(idx, OP_INSERT); + m_pkcol->set_scalar(idx, pkey); + } break; + case OP_DELETE: { + _mark_deleted(idx); + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected OP"); + } break; + } + } + +#ifdef PSP_TABLE_VERIFY + master_table->verify(); +#endif +} + +void +t_gstate::init_from_table(const std::shared_ptr& source) { + // Bulk-init the master table directly from `source`. Assumes: + // - master table is empty + // - all rows are `OP_INSERT` + // - `psp_pkey` has no duplicates + // + // Column `shared_ptr`s are aliased rather than deep-cloned. + m_free.clear(); + m_mapping.clear(); + + const t_schema& master_table_schema = m_table->get_schema(); + t_uindex ncols = m_table->num_columns(); + auto* master_table = m_table.get(); + const bool page_to_disk = m_backing_store == BACKING_STORE_DISK; + + parallel_for( + int(ncols), + [&master_table, &master_table_schema, &source, page_to_disk](int idx) { + const std::string& column_name = master_table_schema.m_columns[idx]; + auto src_col = source->get_column_safe(column_name); + if (!src_col) { + return; + } + if (page_to_disk) { + // Preserve the disk-backed master column; deep-copy rather than + // aliasing the in-memory source column. + master_table->_get_column(column_name)->copy_from(*src_col); + } else { + master_table->set_column(idx, std::move(src_col)); + } + } + ); + + m_pkcol = master_table->get_column("psp_pkey"); + m_opcol = master_table->get_column("psp_op"); + + master_table->set_capacity(source->get_capacity()); + master_table->set_size(source->size()); + + const t_column* pkey_col = master_table->_get_const_column("psp_pkey"); + for (t_uindex idx = 0, loop_end = source->num_rows(); idx < loop_end; + ++idx) { + t_tscalar pkey = pkey_col->get_scalar(idx); + m_mapping.emplace(m_symtable.get_interned_tscalar(pkey), idx); + } + +#ifdef PSP_TABLE_VERIFY + master_table->verify(); +#endif +} + +void +t_gstate::update_master_table( + const std::shared_ptr& flattened +) { + if (num_rows() == 0) { + fill_master_table(flattened); + return; + } + + // Update existing `m_table` + const t_column* flattened_pkey_col = + flattened->_get_const_column("psp_pkey"); + const t_column* flattened_op_col = + flattened->_get_const_column("psp_op"); + + t_data_table* master_table = m_table.get(); + std::vector master_table_indexes(flattened->num_rows()); + const std::uint8_t* flattened_op_base = + flattened_op_col->get_nth_base(); + + for (t_uindex idx = 0, loop_end = flattened->num_rows(); idx < loop_end; + ++idx) { + t_tscalar pkey = flattened_pkey_col->get_scalar(idx); + t_op op = static_cast(flattened_op_base[idx]); + + switch (op) { + case OP_INSERT: { + // Lookup/create the row index in `m_table` based on pkey + master_table_indexes[idx] = lookup_or_create(pkey); + + // Write the op and pkey to `m_table` + m_opcol->set_nth( + master_table_indexes[idx], OP_INSERT + ); + m_pkcol->set_scalar(master_table_indexes[idx], pkey); + } break; + case OP_DELETE: { + // Erase the pkey from the master table, but this does not + // change the size as the row isn't removed, just cleared out. + erase(pkey); + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected OP"); + } break; + } + } + + const t_schema& master_schema = m_table->get_schema(); + t_uindex ncols = master_table->num_columns(); + + parallel_for( + int(ncols), + [flattened, + flattened_op_col, + &master_schema, + &master_table, + &master_table_indexes, + this](int idx) { + const std::string& column_name = master_schema.m_columns[idx]; + t_column* master_column = + master_table->_get_column(column_name); + const t_column* flattened_column = + flattened->_get_const_column_safe(column_name); + if (!flattened_column) { + return; + } + update_master_column( + master_column, + flattened_column, + flattened_op_col, + master_table_indexes, + flattened->num_rows() + ); + } + ); +} + +void +t_gstate::update_master_column( + t_column* master_column, + const t_column* flattened_column, + const t_column* op_column, + const std::vector& master_table_indexes, + t_uindex num_rows +) { + const std::uint8_t* op_base = op_column->get_nth_base(); + for (t_uindex idx = 0, loop_end = num_rows; idx < loop_end; ++idx) { + bool is_valid = flattened_column->is_valid(idx); + t_uindex master_table_idx = master_table_indexes[idx]; + + if (!is_valid) { + bool is_cleared = flattened_column->is_cleared(idx); + if (is_cleared) { + master_column->clear(master_table_idx); + } + continue; + } + + t_op op = static_cast(op_base[idx]); + + if (op == OP_DELETE) { + continue; + } + + switch (flattened_column->get_dtype()) { + case DTYPE_NONE: { + } break; + case DTYPE_INT64: { + master_column->set_nth( + master_table_idx, + *(flattened_column->get_nth(idx)) + ); + } break; + case DTYPE_INT32: { + master_column->set_nth( + master_table_idx, + *(flattened_column->get_nth(idx)) + ); + } break; + case DTYPE_INT16: { + master_column->set_nth( + master_table_idx, + *(flattened_column->get_nth(idx)) + ); + } break; + case DTYPE_INT8: { + master_column->set_nth( + master_table_idx, + *(flattened_column->get_nth(idx)) + ); + } break; + case DTYPE_UINT64: { + master_column->set_nth( + master_table_idx, + *(flattened_column->get_nth(idx)) + ); + } break; + case DTYPE_UINT32: { + master_column->set_nth( + master_table_idx, + *(flattened_column->get_nth(idx)) + ); + } break; + case DTYPE_UINT16: { + master_column->set_nth( + master_table_idx, + *(flattened_column->get_nth(idx)) + ); + } break; + case DTYPE_UINT8: { + master_column->set_nth( + master_table_idx, + *(flattened_column->get_nth(idx)) + ); + } break; + case DTYPE_FLOAT64: { + master_column->set_nth( + master_table_idx, *(flattened_column->get_nth(idx)) + ); + } break; + case DTYPE_FLOAT32: { + master_column->set_nth( + master_table_idx, *(flattened_column->get_nth(idx)) + ); + } break; + case DTYPE_BOOL: { + master_column->set_nth( + master_table_idx, + *(flattened_column->get_nth(idx)) + ); + } break; + case DTYPE_TIME: { + master_column->set_nth( + master_table_idx, + *(flattened_column->get_nth(idx)) + ); + } break; + case DTYPE_DATE: { + master_column->set_nth( + master_table_idx, + *(flattened_column->get_nth(idx)) + ); + } break; + case DTYPE_STR: { + master_column->set_nth( + master_table_idx, flattened_column->get_nth(idx) + ); + } break; + case DTYPE_OBJECT: + default: { + PSP_COMPLAIN_AND_ABORT("Unexpected type"); + } + } + } +} + +void +t_gstate::pprint() const { + std::vector indices(m_mapping.size()); + t_uindex idx = 0; + for (const auto& iter : m_mapping) { + indices[idx] = iter.second; + ++idx; + } + m_table->pprint(indices); +} + +t_mask +t_gstate::get_cpp_mask() const { + t_uindex sz = m_table->size(); + t_mask msk(sz); + for (const auto& iter : m_mapping) { + msk.set(iter.second, true); + } + return msk; +} + +std::shared_ptr +t_gstate::get_table() const { + return m_table; +} + +t_tscalar +t_gstate::read_by_pkey( + const t_data_table& table, const std::string& colname, t_tscalar& pkey +) const { + const t_column* col_ = table._get_const_column(colname); + t_mapping::const_iterator iter = m_mapping.find(pkey); + if (iter != m_mapping.end()) { + return col_->get_scalar(iter->second); + } + PSP_COMPLAIN_AND_ABORT("Called without pkey"); +} + +void +t_gstate::resolve_pkeys( + const std::vector& pkeys, + std::vector& out_row_indices +) const { + t_uindex num_rows = pkeys.size(); + out_row_indices.resize(num_rows); + constexpr t_uindex INVALID_ROW = t_uindex(-1); + for (t_uindex idx = 0; idx < num_rows; ++idx) { + t_mapping::const_iterator iter = m_mapping.find(pkeys[idx]); + if (iter != m_mapping.end()) { + out_row_indices[idx] = iter->second; + } else { + out_row_indices[idx] = INVALID_ROW; + } + } +} + +void +t_gstate::read_column( + const t_data_table& table, + const std::string& colname, + const std::vector& pkeys, + std::vector& out_data +) const { + t_index num_rows = pkeys.size(); + const t_column* col_ = table._get_const_column(colname); + out_data.resize(num_rows); + + for (t_index idx = 0; idx < num_rows; ++idx) { + t_mapping::const_iterator iter = m_mapping.find(pkeys[idx]); + if (iter != m_mapping.end()) { + out_data[idx].set(col_->get_scalar(iter->second)); + } else { + out_data[idx] = t_tscalar(); + } + } +} + +void +t_gstate::read_column( + const t_data_table& table, + const std::string& colname, + const std::vector& pkeys, + std::vector& out_data +) const { + read_column(table, colname, pkeys, out_data, true); +} + +void +t_gstate::read_column( + const t_data_table& table, + const std::string& colname, + const std::vector& pkeys, + std::vector& out_data, + bool include_nones +) const { + t_index num_rows = pkeys.size(); + const t_column* col_ = table._get_const_column(colname); + + out_data.clear(); + out_data.reserve(num_rows); + for (t_index idx = 0; idx < num_rows; ++idx) { + t_mapping::const_iterator iter = m_mapping.find(pkeys[idx]); + if (iter != m_mapping.end()) { + auto tscalar = col_->get_scalar(iter->second); + if (include_nones || tscalar.is_valid()) { + out_data.push_back(tscalar.to_double()); + } + } + } +} + +void +t_gstate::read_column( + const t_data_table& table, + const std::string& colname, + t_uindex start_idx, + t_uindex end_idx, + std::vector& out_data +) const { + t_index num_rows = end_idx - start_idx; + + // Don't read invalid row indices. + if (num_rows <= 0) { + return; + } + + const t_column* col_ = table._get_const_column(colname); + + out_data.resize(num_rows); + + t_uindex i = 0; + for (t_uindex idx = start_idx; idx < end_idx; ++idx) { + out_data[i] = col_->get_scalar(idx); + i++; + } +} + +void +t_gstate::read_column( + const t_data_table& table, + const std::string& colname, + const std::vector& row_indices, + std::vector& out_data +) const { + const t_column* col_ = table._get_const_column(colname); + + t_index num_rows = row_indices.size(); + out_data.resize(num_rows); + + t_uindex i = 0; + for (auto idx : row_indices) { + out_data[i] = col_->get_scalar(idx); + i++; + } +} + +t_tscalar +t_gstate::get( + const t_data_table& table, const std::string& colname, t_tscalar pkey +) const { + t_mapping::const_iterator iter = m_mapping.find(pkey); + if (iter != m_mapping.end()) { + return table._get_const_column(colname)->get_scalar(iter->second); + } + + return {}; +} + +t_tscalar +t_gstate::get_value( + const t_data_table& table, const std::string& colname, const t_tscalar& pkey +) const { + const t_column* col_ = table._get_const_column(colname); + t_tscalar rval = mknone(); + + auto iter = m_mapping.find(pkey); + if (iter != m_mapping.end()) { + rval.set(col_->get_scalar(iter->second)); + } + + return rval; +} + +bool +t_gstate::is_unique( + const t_data_table& table, + const std::string& colname, + const std::vector& pkeys, + t_tscalar& value +) const { + const t_column* col_ = table._get_const_column(colname); + value = mknone(); + + for (const auto& pkey : pkeys) { + t_mapping::const_iterator iter = m_mapping.find(pkey); + if (iter != m_mapping.end()) { + auto tmp = col_->get_scalar(iter->second); + if (!value.is_none() && value != tmp) { + return false; + } + value = tmp; + } + } + + return true; +} + +bool +t_gstate::apply( + const t_data_table& table, + const std::string& colname, + const std::vector& pkeys, + t_tscalar& value, + const std::function& fn +) const { + const t_column* col_ = table._get_const_column(colname); + + value = mknone(); + + for (const auto& pkey : pkeys) { + t_mapping::const_iterator iter = m_mapping.find(pkey); + if (iter != m_mapping.end()) { + auto tmp = col_->get_scalar(iter->second); + bool done = fn(tmp, value); + if (done) { + value = tmp; + return done; + } + } + } + + return false; +} + +const t_schema& +t_gstate::get_output_schema() const { + return m_output_schema; +} + +const t_gstate::t_mapping& +t_gstate::get_pkey_map() const { + return m_mapping; +} + +t_dtype +t_gstate::get_pkey_dtype() const { + if (m_mapping.empty()) { + return DTYPE_STR; + } + auto iter = m_mapping.begin(); + return iter->first.get_dtype(); +} + +std::shared_ptr +t_gstate::get_pkeyed_table() const { + return get_pkeyed_table(m_input_schema, m_table); +} + +std::shared_ptr +t_gstate::get_pkeyed_table( + const t_schema& schema, const std::shared_ptr& table +) const { + // If there are no removes, just return the gstate table. Removes would + // cause m_mapping to be smaller than m_table. + if (m_mapping.size() == table->size()) { + return table; + } + + // Otherwise mask out the removed rows and return the table. + auto mask = get_cpp_mask(); + + // count = total number of rows - number of removed rows + t_uindex table_size = mask.count(); + + const auto& schema_columns = schema.m_columns; + t_uindex num_columns = schema_columns.size(); + + std::shared_ptr rval = + std::make_shared(schema, table_size); + rval->init(); + rval->set_size(table_size); + + parallel_for( + int(num_columns), + [&schema_columns, rval, table, &mask](int colidx) { + const std::string& colname = schema_columns[colidx]; + rval->set_column( + colname, table->_get_const_column(colname)->clone(mask) + ); + } + + ); + + return rval; +} + +t_uindex +t_gstate::num_rows() const { + return m_table->num_rows(); +} + +t_uindex +t_gstate::num_columns() const { + return m_table->num_columns(); +} + +std::vector +t_gstate::get_row_data_pkeys(const std::vector& pkeys) const { + t_uindex ncols = m_table->num_columns(); + const t_schema& schema = m_table->get_schema(); + std::vector rval; + rval.reserve(pkeys.size() * ncols); + + std::vector columns(ncols); + for (t_uindex idx = 0, loop_end = schema.size(); idx < loop_end; ++idx) { + const std::string& cname = schema.m_columns[idx]; + columns[idx] = m_table->_get_const_column(cname); + } + + auto none = mknone(); + + for (const auto& pkey : pkeys) { + t_mapping::const_iterator iter = m_mapping.find(pkey); + if (iter == m_mapping.end()) { + continue; + } + + for (t_uindex cidx = 0; cidx < ncols; ++cidx) { + auto v = columns[cidx]->get_scalar(iter->second); + if (v.is_valid()) { + rval.push_back(v); + } else { + rval.push_back(none); + } + } + } + return rval; +} + +bool +t_gstate::has_pkey(t_tscalar pkey) const { + return m_mapping.find(pkey) != m_mapping.end(); +} + +std::vector +t_gstate::has_pkeys(const std::vector& pkeys) const { + if (pkeys.empty()) { + return {}; + } + + std::vector rval(pkeys.size()); + t_uindex idx = 0; + + for (const auto& p : pkeys) { + t_tscalar tval; + tval.set(m_mapping.find(p) != m_mapping.end()); + rval[idx].set(tval); + ++idx; + } + + return rval; +} + +std::vector +t_gstate::get_pkeys() const { + std::vector rval(m_mapping.size()); + t_uindex idx = 0; + for (const auto& kv : m_mapping) { + rval[idx].set(kv.first); + ++idx; + } + return rval; +} + +std::pair +get_vec_min_max(const std::vector& vec) { + t_tscalar min = mknone(); + t_tscalar max = mknone(); + + for (const auto& v : vec) { + if (min.is_none()) { + min = v; + } else { + min = std::min(v, min); + } + + if (max.is_none()) { + max = v; + } else { + max = std::max(v, max); + } + } + + return std::pair(min, max); +} + +t_uindex +t_gstate::mapping_size() const { + return m_mapping.size(); +} + +void +t_gstate::reset() { + m_table->reset(); + m_mapping.clear(); + m_free.clear(); +} + +const t_schema& +t_gstate::get_input_schema() const { + return m_input_schema; +} + +std::vector +t_gstate::get_pkeys_idx(const std::vector& pkeys) const { + std::vector rv; + rv.reserve(pkeys.size()); + + for (const auto& p : pkeys) { + auto lk = lookup(p); + std::cout << "pkey " << p << " exists " << lk.m_exists << '\n'; + if (lk.m_exists) { + rv.push_back(lk.m_idx); + } + } + return rv; +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/heap_instruments.cpp b/rust/perspective-server/cpp/perspective/src/cpp/heap_instruments.cpp new file mode 100644 index 0000000..9c54ae9 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/heap_instruments.cpp @@ -0,0 +1,234 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include "perspective/base.h" +#include "perspective/heap_instruments.h" +#include +#include +#include +#include +#include +#include +#include +#include + +static std::uint64_t USED_MEMORY = 0; + +static constexpr std::uint64_t MIN_RELEVANT_SIZE = 5 * 1024 * 1024; + +extern "C" void +psp_print_used_memory() { + printf("Used memory: %llu\n", USED_MEMORY); +} + +struct Header { + std::uint64_t size; +}; + +using UnderlyingString = + std::basic_string, UnderlyingAllocator>; + +using UnderlyingIStringStream = std::basic_istringstream< + char, + std::char_traits, + UnderlyingAllocator>; + +struct AllocMeta { + Header header; + const UnderlyingString* trace; + std::uint64_t size; +}; + +static std::unordered_map< + UnderlyingString, + AllocMeta, + std::hash, + std::equal_to<>, + UnderlyingAllocator>> + stack_traces; + +static UnderlyingString IRRELEVANT = "irrelevant"; + +static inline void +record_stack_trace(Header* header, std::uint64_t size) { + if (size >= MIN_RELEVANT_SIZE) { + const char* stack_c_str = perspective::psp_stack_trace(); + UnderlyingIStringStream stack(stack_c_str); + UnderlyingString line; + UnderlyingString out; + + while (std::getline(stack, line)) { + line = line.substr(0, line.find_last_of(" (")); + out += line + "\n"; + } + + emscripten_builtin_free(const_cast(stack_c_str)); + + // stack_traces[ptr] = {.header = *header, .trace = out}; + if (stack_traces.find(out) == stack_traces.end()) { + stack_traces[out] = + AllocMeta{.header = *header, .trace = nullptr, .size = size}; + + stack_traces[out].trace = &stack_traces.find(out)->first; + } else { + stack_traces[out].size += size; + } + } else { + if (stack_traces.find(IRRELEVANT) == stack_traces.end()) { + stack_traces[IRRELEVANT] = AllocMeta{ + .header = *header, .trace = &IRRELEVANT, .size = size + }; + } else { + stack_traces[IRRELEVANT].size += size; + } + } +} + +extern "C" void +psp_dump_stack_traces() { + std::vector> metas; + metas.reserve(stack_traces.size()); + for (const auto& [_, meta] : stack_traces) { + metas.push_back(meta); + } + std::sort( + metas.begin(), + metas.end(), + [](const AllocMeta& a, const AllocMeta& b) { + return a.header.size > b.header.size; + } + ); + for (const auto& meta : metas) { + printf("Allocated %llu bytes\n", meta.header.size); + printf("Stacktrace:\n%s\n", meta.trace->c_str()); + } +} + +extern "C" void +psp_clear_stack_traces() { + stack_traces.clear(); +} + +void* +malloc(size_t size) { + if (size > MIN_RELEVANT_SIZE) { + printf("Allocating %zu bytes\n", size); + } + USED_MEMORY += size; + const size_t total_size = size + sizeof(Header); + auto* header = static_cast(emscripten_builtin_malloc(total_size)); + if (header == nullptr) { + fprintf(stderr, "Failed to allocate %zu bytes\n", size); + } + header->size = size; + record_stack_trace(header, size); + return header + 1; +} + +void* +calloc(size_t nmemb, size_t size) { + // printf("Allocating array: %zu elements of size %zu\n", nmemb, size); + USED_MEMORY += nmemb * size; + // return emscripten_builtin_calloc(nmemb, size); + const size_t total_size = (nmemb * size) + sizeof(Header); + auto* header = static_cast(emscripten_builtin_malloc(total_size)); + if (header == nullptr) { + fprintf(stderr, "Failed to allocate %zu bytes\n", size); + } + header->size = nmemb * size; + memset(header + 1, 0, nmemb * size); + record_stack_trace(header, size); + return header + 1; +} + +void +free(void* ptr) { + // printf("Freeing memory at %p\n", ptr); + + if (ptr == nullptr) { + emscripten_builtin_free(ptr); + } else { + auto* header = static_cast(ptr) - 1; + auto old_memory = USED_MEMORY; + USED_MEMORY -= header->size; + if (USED_MEMORY > old_memory) { + std::abort(); + } + emscripten_builtin_free(header); + } +} + +void* +memalign(size_t alignment, size_t size) { + const size_t total_size = size + sizeof(Header); + auto* header = + static_cast(emscripten_builtin_memalign(alignment, total_size) + ); + if (header == nullptr) { + fprintf(stderr, "Failed to allocate %zu bytes\n", size); + } + header->size = size; + record_stack_trace(header, size); + return header + 1; +} + +int +posix_memalign(void** memptr, size_t alignment, size_t size) { + auto* header = static_cast( + emscripten_builtin_memalign(alignment, size + sizeof(Header)) + ); + if (header == nullptr) { + fprintf(stderr, "Failed to allocate %zu bytes\n", size); + } + header->size = size; + USED_MEMORY += size; + record_stack_trace(header, size); + *memptr = header + 1; + return 0; +} + +void* +realloc(void* ptr, size_t new_size) { + if (ptr == nullptr) { + // If ptr is nullptr, realloc behaves like malloc + return malloc(new_size); + } + + if (new_size == 0) { + // If new_size is 0, realloc behaves like free + free(ptr); + return nullptr; + } + + auto* header = static_cast(ptr) - 1; + const size_t old_size = header->size; + + if (new_size <= old_size) { + USED_MEMORY -= old_size - new_size; + // If the new size is smaller or equal, we can potentially shrink the + // block in place. For simplicity, we don't actually shrink the block + // here. + header->size = new_size; // Update the size in the header + return ptr; // Return the same pointer + } + + // If the new size is larger, allocate a new block + void* new_ptr = malloc(new_size); + if (new_ptr == nullptr) { + return nullptr; + } + + memcpy(new_ptr, ptr, old_size); + free(ptr); + + return new_ptr; +} \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/cpp/init.cpp b/rust/perspective-server/cpp/perspective/src/cpp/init.cpp new file mode 100644 index 0000000..200e89d --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/init.cpp @@ -0,0 +1,24 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include + +namespace perspective { + +void +perspective_init() {} + +void +perspective_finalize() {} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/join_engine.cpp b/rust/perspective-server/cpp/perspective/src/cpp/join_engine.cpp new file mode 100644 index 0000000..86b94ff --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/join_engine.cpp @@ -0,0 +1,451 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include "perspective/join_engine.h" +#include "perspective/column.h" +#include "perspective/data_table.h" +#include "perspective/gnode.h" +#include "perspective/scalar.h" +#include +#include +#include +#include + +namespace perspective::server { + +namespace { + +// Typed column copy for fixed-size types. +template +void +copy_column_typed( + t_column* dst, + const t_column* src, + const std::vector>& matched_rows, + t_uindex num_matched, + bool use_first +) { + for (t_uindex i = 0; i < num_matched; ++i) { + auto src_idx = use_first ? matched_rows[i].first : matched_rows[i].second; + if (src_idx == static_cast(-1)) { + dst->clear(i); + } else { + const T* val = src->get_nth(src_idx); + const t_status* st = + src->is_status_enabled() ? src->get_nth_status(src_idx) + : nullptr; + dst->set_nth(i, *val, st ? *st : STATUS_VALID); + } + } +} + +void +copy_column_str( + t_column* dst, + const t_column* src, + const std::vector>& matched_rows, + t_uindex num_matched, + bool use_first +) { + for (t_uindex i = 0; i < num_matched; ++i) { + auto src_idx = use_first ? matched_rows[i].first : matched_rows[i].second; + if (src_idx == static_cast(-1)) { + dst->clear(i); + } else { + dst->set_scalar(i, src->get_scalar(src_idx)); + } + } +} + +void +copy_column_dispatch( + t_column* dst, + const t_column* src, + const std::vector>& matched_rows, + t_uindex num_matched, + bool use_first +) { + switch (dst->get_dtype()) { + case DTYPE_INT64: + copy_column_typed( + dst, src, matched_rows, num_matched, use_first + ); + break; + case DTYPE_INT32: + copy_column_typed( + dst, src, matched_rows, num_matched, use_first + ); + break; + case DTYPE_INT16: + copy_column_typed( + dst, src, matched_rows, num_matched, use_first + ); + break; + case DTYPE_INT8: + copy_column_typed( + dst, src, matched_rows, num_matched, use_first + ); + break; + case DTYPE_UINT64: + copy_column_typed( + dst, src, matched_rows, num_matched, use_first + ); + break; + case DTYPE_UINT32: + copy_column_typed( + dst, src, matched_rows, num_matched, use_first + ); + break; + case DTYPE_UINT16: + copy_column_typed( + dst, src, matched_rows, num_matched, use_first + ); + break; + case DTYPE_UINT8: + copy_column_typed( + dst, src, matched_rows, num_matched, use_first + ); + break; + case DTYPE_FLOAT64: + copy_column_typed( + dst, src, matched_rows, num_matched, use_first + ); + break; + case DTYPE_FLOAT32: + copy_column_typed( + dst, src, matched_rows, num_matched, use_first + ); + break; + case DTYPE_BOOL: + copy_column_typed( + dst, src, matched_rows, num_matched, use_first + ); + break; + case DTYPE_TIME: + copy_column_typed( + dst, src, matched_rows, num_matched, use_first + ); + break; + case DTYPE_DATE: + copy_column_typed( + dst, src, matched_rows, num_matched, use_first + ); + break; + case DTYPE_STR: + copy_column_str( + dst, src, matched_rows, num_matched, use_first + ); + break; + default: + // Fallback for any other types (OBJECT, F64PAIR, etc.) + for (t_uindex i = 0; i < num_matched; ++i) { + auto src_idx = use_first ? matched_rows[i].first + : matched_rows[i].second; + if (src_idx == static_cast(-1)) { + dst->clear(i); + } else { + dst->set_scalar(i, src->get_scalar(src_idx)); + } + } + break; + } +} + +// Copy the join key column for OUTER join rows where the left side has no +// match but the right side does. Uses the right key column as the source +// for just those rows; all other rows are already filled from the left. +void +copy_join_key_fallback( + t_column* dst, + const t_column* right_key_col, + const std::vector>& matched_rows, + t_uindex num_matched +) { + for (t_uindex i = 0; i < num_matched; ++i) { + if (matched_rows[i].first == static_cast(-1) + && matched_rows[i].second != static_cast(-1)) { + dst->set_scalar( + i, right_key_col->get_scalar(matched_rows[i].second) + ); + } + } +} + +} // anonymous namespace + +void +JoinEngine::register_join( + const t_id& join_table_id, + const t_id& left_table_id, + const t_id& right_table_id, + const std::string& on_column, + const std::string& right_on_column, + proto::JoinType join_type +) { + auto effective_right_on = right_on_column.empty() ? on_column : right_on_column; + JoinDef def{left_table_id, right_table_id, on_column, effective_right_on, join_type}; + m_join_defs.emplace(join_table_id, def); + m_table_to_join_tables.emplace(left_table_id, join_table_id); + m_table_to_join_tables.emplace(right_table_id, join_table_id); +} + +void +JoinEngine::unregister_join(const t_id& join_table_id) { + auto it = m_join_defs.find(join_table_id); + if (it == m_join_defs.end()) { + return; + } + + auto& def = it->second; + + for (const auto& source_id : {def.left_table_id, def.right_table_id}) { + auto range = m_table_to_join_tables.equal_range(source_id); + for (auto jt = range.first; jt != range.second;) { + if (jt->second == join_table_id) { + jt = m_table_to_join_tables.erase(jt); + } else { + ++jt; + } + } + } + + m_join_defs.erase(it); + m_caches.erase(join_table_id); +} + +bool +JoinEngine::is_join_table(const t_id& id) const { + return m_join_defs.contains(id); +} + +std::vector +JoinEngine::get_dependent_join_tables(const t_id& source_table_id) const { + std::vector result; + auto range = m_table_to_join_tables.equal_range(source_table_id); + for (auto it = range.first; it != range.second; ++it) { + result.push_back(it->second); + } + + return result; +} + +const JoinDef& +JoinEngine::get_join_def(const t_id& join_table_id) const { + return m_join_defs.at(join_table_id); +} + +MakeJoinResult +JoinEngine::make_join_table( + const std::string& on_column, + const std::string& right_on_column, + proto::JoinType join_type, + const std::shared_ptr
    & left_table, + const std::shared_ptr
    & right_table +) { + auto effective_right_on = right_on_column.empty() ? on_column : right_on_column; + auto left_schema = left_table->get_schema(); + auto right_schema = right_table->get_schema(); + if (!left_schema.has_column(on_column)) { + std::stringstream ss; + ss << "Column \"" << on_column << "\" not found in left table"; + return {nullptr, ss.str()}; + } + + if (!right_schema.has_column(effective_right_on)) { + std::stringstream ss; + ss << "Column \"" << effective_right_on << "\" not found in right table"; + return {nullptr, ss.str()}; + } + + if (left_schema.get_dtype(on_column) != right_schema.get_dtype(effective_right_on)) { + return {nullptr, "Join column type mismatch"}; + } + + for (const auto& rcol : right_schema.columns()) { + if (rcol == effective_right_on) { + continue; + } + + if (left_schema.has_column(rcol)) { + std::stringstream ss; + ss << "Column \"" << rcol << "\" exists in both tables"; + return {nullptr, ss.str()}; + } + } + + std::vector merged_columns; + std::vector merged_types; + for (t_uindex i = 0; i < left_schema.columns().size(); ++i) { + merged_columns.push_back(left_schema.columns()[i]); + merged_types.push_back(left_schema.types()[i]); + } + + for (t_uindex i = 0; i < right_schema.columns().size(); ++i) { + if (right_schema.columns()[i] == effective_right_on) { + continue; + } + + merged_columns.push_back(right_schema.columns()[i]); + merged_types.push_back(right_schema.types()[i]); + } + + t_schema merged_schema(merged_columns, merged_types); + auto join_table = Table::from_schema("", merged_schema); + return {join_table, ""}; +} + +void +JoinEngine::build_right_index( + JoinCache& cache, + const std::shared_ptr
    & right_table, + const std::string& on_column +) { + auto right_data = right_table->get_gnode()->get_table_sptr(); + const auto& right_pkey_map = right_table->get_gnode()->get_pkey_map(); + auto right_key_col = right_data->get_column(on_column); + cache.right_entries.assign(right_pkey_map.begin(), right_pkey_map.end()); + std::sort( + cache.right_entries.begin(), + cache.right_entries.end(), + [](const auto& a, const auto& b) { return a.first < b.first; } + ); + + cache.right_index.clear(); + cache.right_index.reserve(right_pkey_map.size()); + for (const auto& [pkey, row_idx] : cache.right_entries) { + auto join_key = right_key_col->get_scalar(row_idx); + if (!join_key.is_none()) { + cache.right_index[join_key].push_back(row_idx); + } + } + + cache.valid = true; +} + +void +JoinEngine::recompute( + const t_id& join_table_id, + const std::shared_ptr
    & left_table, + const std::shared_ptr
    & right_table, + const std::shared_ptr
    & join_table, + bool left_changed, + bool right_changed +) { + const auto& def = m_join_defs.at(join_table_id); + auto& cache = m_caches[join_table_id]; + auto left_data = left_table->get_gnode()->get_table_sptr(); + auto right_data = right_table->get_gnode()->get_table_sptr(); + const auto& left_pkey_map = left_table->get_gnode()->get_pkey_map(); + auto left_key_col = left_data->get_column(def.on_column); + auto right_key_col = right_data->get_column(def.right_on_column); + + // Rebuild right-side index only when the right table has changed, + // or on the first recompute when no cache exists yet. + if (right_changed || !cache.valid) { + build_right_index(cache, right_table, def.right_on_column); + } + + const auto& right_join_key_to_rows = cache.right_index; + const auto& right_entries = cache.right_entries; + + // Sort left pkey entries so the join result preserves left-table + // insertion order. + std::vector> left_entries( + left_pkey_map.begin(), left_pkey_map.end() + ); + + std::sort( + left_entries.begin(), + left_entries.end(), + [](const auto& a, const auto& b) { return a.first < b.first; } + ); + + const t_uindex NO_MATCH = static_cast(-1); + std::vector> matched_rows; + matched_rows.reserve(left_entries.size()); + tsl::hopscotch_set matched_right_rows; + for (const auto& [pkey, row_idx] : left_entries) { + auto join_key = left_key_col->get_scalar(row_idx); + if (join_key.is_none()) { + if (def.join_type == proto::LEFT + || def.join_type == proto::OUTER) { + matched_rows.emplace_back(row_idx, NO_MATCH); + } + + continue; + } + + auto it = right_join_key_to_rows.find(join_key); + if (it != right_join_key_to_rows.end()) { + for (auto right_row_idx : it->second) { + matched_rows.emplace_back(row_idx, right_row_idx); + if (def.join_type == proto::OUTER) { + matched_right_rows.insert(right_row_idx); + } + } + } else if (def.join_type == proto::LEFT + || def.join_type == proto::OUTER) { + matched_rows.emplace_back(row_idx, NO_MATCH); + } + } + + if (def.join_type == proto::OUTER) { + for (const auto& [pkey, row_idx] : right_entries) { + if (matched_right_rows.find(row_idx) + == matched_right_rows.end()) { + matched_rows.emplace_back(NO_MATCH, row_idx); + } + } + } + + t_uindex num_matched = matched_rows.size(); + auto join_schema = join_table->get_schema(); + t_data_table joined_data(join_schema); + joined_data.init(); + joined_data.extend(num_matched); + auto left_schema = left_table->get_schema(); + auto right_schema = right_table->get_schema(); + for (const auto& col_name : join_schema.columns()) { + auto dst_col = joined_data.get_column(col_name); + bool is_join_col = (col_name == def.on_column); + if (left_schema.has_column(col_name)) { + auto left_src_col = left_data->get_column(col_name); + copy_column_dispatch( + dst_col.get(), left_src_col.get(), matched_rows, num_matched, true + ); + + if (is_join_col) { + copy_join_key_fallback( + dst_col.get(), right_key_col.get(), matched_rows, num_matched + ); + } + } else if (right_schema.has_column(col_name)) { + auto src_col = right_data->get_column(col_name); + copy_column_dispatch( + dst_col.get(), src_col.get(), matched_rows, num_matched, false + ); + } + } + + joined_data.set_size(num_matched); + auto* pkey_col = joined_data.add_column("psp_pkey", DTYPE_INT32, true); + auto* okey_col = joined_data.add_column("psp_okey", DTYPE_INT32, true); + for (t_uindex i = 0; i < num_matched; ++i) { + pkey_col->set_nth(i, static_cast(i), STATUS_VALID); + okey_col->set_nth(i, static_cast(i), STATUS_VALID); + } + + join_table->clear(); + join_table->init(joined_data, num_matched, t_op::OP_INSERT, 0); +} + +} // namespace perspective::server diff --git a/rust/perspective-server/cpp/perspective/src/cpp/kernel_engine.cpp b/rust/perspective-server/cpp/perspective/src/cpp/kernel_engine.cpp new file mode 100644 index 0000000..9fd18be --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/kernel_engine.cpp @@ -0,0 +1,24 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include + +namespace perspective { +t_kernel_evaluator::t_kernel_evaluator() {} + +t_kernel_evaluator* +get_evaluator() { + static t_kernel_evaluator* evaluator = new t_kernel_evaluator(); + return evaluator; +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/main.cpp b/rust/perspective-server/cpp/perspective/src/cpp/main.cpp new file mode 100644 index 0000000..4e85179 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/main.cpp @@ -0,0 +1,20 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Main + */ +int +main(int argc, char** argv) { + std::cout << "Perspective initialized successfully" + << "\n"; +} \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/cpp/mask.cpp b/rust/perspective-server/cpp/perspective/src/cpp/mask.cpp new file mode 100644 index 0000000..a0526ec --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/mask.cpp @@ -0,0 +1,151 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include + +#include + +namespace perspective { + +t_mask::t_mask() { LOG_CONSTRUCTOR("t_mask"); } + +t_mask::t_mask(t_uindex size) : m_bitmap(t_msize(size)) { + LOG_CONSTRUCTOR("t_mask"); +} + +t_mask::t_mask(const t_simple_bitmask& m) { + m_bitmap = boost::dynamic_bitset<>(static_cast(m.size())); + + for (t_uindex idx = 0, loop_end = m.size(); idx < loop_end; ++idx) { + set(idx, m.is_set(idx)); + } +} + +t_mask::~t_mask() { LOG_DESTRUCTOR("t_mask"); } + +void +t_mask::clear() { + m_bitmap.clear(); +} + +t_uindex +t_mask::count() const { + return m_bitmap.count(); +} + +t_uindex +t_mask::size() const { + return m_bitmap.size(); +} + +bool +t_mask::get(t_uindex idx) const { + return m_bitmap.test(t_msize(idx)); +} + +void +t_mask::set(t_uindex idx, bool v) { + m_bitmap.set(t_msize(idx), v); +} + +void +t_mask::set(t_uindex idx) { + m_bitmap.set(t_msize(idx), true); +} + +t_mask& +t_mask::operator&=(const t_mask& b) { + m_bitmap &= b.m_bitmap; + return *this; +} + +t_mask& +t_mask::operator|=(const t_mask& b) { + m_bitmap |= b.m_bitmap; + return *this; +} + +t_mask& +t_mask::operator^=(const t_mask& b) { + m_bitmap ^= b.m_bitmap; + return *this; +} + +t_mask& +t_mask::operator-=(const t_mask& b) { + m_bitmap -= b.m_bitmap; + return *this; +} + +t_uindex +t_mask::find_first() const { + return m_bitmap.find_first(); +} + +t_uindex +t_mask::find_next(t_uindex pos) const { + return m_bitmap.find_next(t_msize(pos)); +} + +void +t_mask::pprint() const { + std::cout << *this << '\n'; +} + +t_uindex +t_mask_iterator::next() { + t_uindex rval = m_pos; + m_pos = m_mask->find_next(rval); + return rval; +} + +bool +t_mask_iterator::has_next() const { + return m_pos != m_end; +} + +t_mask_iterator::t_mask_iterator(t_maskcsptr m) : + m_mask(std::move(std::move(m))), + m_pos(m_mask->find_first()) { + LOG_CONSTRUCTOR("t_mask_iterator"); +} + +t_mask_iterator::t_mask_iterator() { LOG_CONSTRUCTOR("t_mask_iterator"); } + +t_uindex +t_mask_iterator::size() const { + return m_mask->size(); +} + +t_uindex +t_mask_iterator::count() const { + return m_mask->count(); +} + +t_mask_iterator::~t_mask_iterator() { LOG_DESTRUCTOR("t_mask_iterator"); } + +} // end namespace perspective + +namespace std { +std::ostream& +operator<<(std::ostream& os, const perspective::t_mask& mask) { + std::cout << "t_mask<\n"; + for (perspective::t_uindex idx = 0, loop_end = mask.size(); idx < loop_end; + ++idx) { + std::cout << "\t" << idx << ". " << mask.get(idx) << '\n'; + } + std::cout << ">\n"; + return os; +} +} // namespace std diff --git a/rust/perspective-server/cpp/perspective/src/cpp/multi_sort.cpp b/rust/perspective-server/cpp/perspective/src/cpp/multi_sort.cpp new file mode 100644 index 0000000..c414a36 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/multi_sort.cpp @@ -0,0 +1,204 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +t_mselem::t_mselem() : + m_pkey(mknone()), + m_order(0), + m_deleted(false), + m_updated(false) {} + +t_mselem::t_mselem(const std::vector& row) : + m_row(row), + m_pkey(mknone()), + m_order(0), + m_deleted(false), + m_updated(false) {} + +t_mselem::t_mselem(const std::vector& row, t_uindex order) : + m_row(row), + m_pkey(mknone()), + m_order(order), + m_deleted(false), + m_updated(false) {} + +t_mselem::t_mselem(const t_tscalar& pkey, const std::vector& row) : + m_row(row), + m_pkey(pkey), + m_order(0), + m_deleted(false), + m_updated(false) {} + +t_minmax_idx::t_minmax_idx(t_index mn, t_index mx) : m_min(mn), m_max(mx) {} + +// Given a vector return the indices of the +// minimum and maximum elements in it. +t_minmax_idx +get_minmax_idx(const std::vector& vec, t_sorttype stype) { + t_minmax_idx rval(-1, -1); + + if (vec.empty()) { + return rval; + } + + // min, max + std::pair min_max; + min_max.first = *(vec.begin()); + min_max.second = *(vec.begin()); + + switch (stype) { + case SORTTYPE_DESCENDING: + case SORTTYPE_ASCENDING: { + for (t_index idx = 0, loop_end = vec.size(); idx < loop_end; + ++idx) { + const t_tscalar& val = vec[idx]; + if (val <= min_max.first) { + min_max.first = val; + rval.m_min = idx; + } + + if (val >= min_max.second) { + min_max.second = val; + rval.m_max = idx; + } + } + return rval; + } break; + case SORTTYPE_ASCENDING_ABS: + case SORTTYPE_DESCENDING_ABS: { + double mindbl = std::abs(vec[0].to_double()); + double maxdbl = mindbl; + rval.m_min = 0; + rval.m_max = 0; + for (t_index idx = 1, loop_end = vec.size(); idx < loop_end; + ++idx) { + double val = std::abs(vec[idx].to_double()); + + if (val <= mindbl) { + mindbl = val; + rval.m_min = idx; + } + + if (val >= maxdbl) { + maxdbl = val; + rval.m_max = idx; + } + } + return rval; + } break; + case SORTTYPE_NONE: { + rval.m_min = 0; + rval.m_max = 0; + return rval; + } break; + default: { + return rval; + } + } + + return rval; +} + +double +to_double(const t_tscalar& c) { + return c.to_double(); +} + +t_nancmp::t_nancmp() : m_active(false), m_cmpval(CMP_OP_EQ) {} + +t_nancmp +nan_compare(t_sorttype order, const t_tscalar& a, const t_tscalar& b) { + t_nancmp rval; + bool a_fp = a.is_floating_point(); + bool b_fp = b.is_floating_point(); + + if (!a_fp && !b_fp) { + return rval; + } + + double a_dbl = a.to_double(); + double b_dbl = b.to_double(); + + bool a_nan = std::isnan(a_dbl); + bool b_nan = std::isnan(b_dbl); + + rval.m_active = a_nan || b_nan; + + if (!rval.m_active) { + return rval; + } + + if (a_nan && b_nan) { + rval.m_cmpval = CMP_OP_EQ; + return rval; + } + + if (a_nan) { + switch (order) { + case SORTTYPE_NONE: + case SORTTYPE_ASCENDING: + case SORTTYPE_ASCENDING_ABS: { + rval.m_cmpval = CMP_OP_LT; + } break; + case SORTTYPE_DESCENDING_ABS: + case SORTTYPE_DESCENDING: { + rval.m_cmpval = CMP_OP_GT; + } break; + } + return rval; + } + + switch (order) { + case SORTTYPE_NONE: + case SORTTYPE_ASCENDING: + case SORTTYPE_ASCENDING_ABS: { + rval.m_cmpval = CMP_OP_GT; + } break; + case SORTTYPE_DESCENDING_ABS: + case SORTTYPE_DESCENDING: { + rval.m_cmpval = CMP_OP_LT; + } break; + } + + return rval; +} + +t_multisorter::t_multisorter(const std::vector& order) : + m_sort_order(order) {} + +t_multisorter::t_multisorter( + std::shared_ptr> elems, + const std::vector& order +) : + m_sort_order(order), + m_elems(std::move(elems)) {} + +bool +t_multisorter::operator()(const t_mselem& a, const t_mselem& b) const { + return cmp_mselem(a, b, m_sort_order); +} + +bool +t_multisorter::operator()(t_index a, t_index b) const { + return this->operator()((*m_elems)[a], (*m_elems)[b]); +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/none.cpp b/rust/perspective-server/cpp/perspective/src/cpp/none.cpp new file mode 100644 index 0000000..cde5668 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/none.cpp @@ -0,0 +1,59 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include + +#include +namespace perspective { + +t_none::t_none() = default; + +bool +t_none::operator==(const t_none& /*unused*/) const { + return true; +} + +bool +t_none::operator<(const t_none& /*unused*/) const { + return false; +} + +bool +t_none::operator<=(const t_none& /*unused*/) const { + return true; +} + +bool +t_none::operator>(const t_none& /*unused*/) const { + return true; +} + +bool +t_none::operator>=(const t_none& /*unused*/) const { + return true; +} + +size_t +hash_value(const t_none& none) { + boost::hash hasher; + return hasher(static_cast(-1)); +} +} // namespace perspective + +namespace std { +std::ostream& +operator<<(std::ostream& os, const perspective::t_none& dt) { + os << ""; + return os; +} +} // namespace std diff --git a/rust/perspective-server/cpp/perspective/src/cpp/path.cpp b/rust/perspective-server/cpp/perspective/src/cpp/path.cpp new file mode 100644 index 0000000..acbf1cf --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/path.cpp @@ -0,0 +1,31 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include + +namespace perspective { +t_path::t_path() = default; + +t_path::t_path(const std::vector& path) : m_path(path) {} + +const std::vector& +t_path::path() const { + return m_path; +} + +std::vector& +t_path::path() { + return m_path; +} + +} // namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/pivot.cpp b/rust/perspective-server/cpp/perspective/src/cpp/pivot.cpp new file mode 100644 index 0000000..7ba5045 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/pivot.cpp @@ -0,0 +1,59 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include + +namespace perspective { + +t_pivot::t_pivot(const t_pivot_recipe& r) { + m_colname = r.m_colname; + m_name = r.m_name; + m_mode = r.m_mode; +} + +t_pivot::t_pivot(const std::string& colname) : + m_colname(colname), + m_name(colname), + m_mode(PIVOT_MODE_NORMAL) {} + +t_pivot::t_pivot(const std::string& colname, t_pivot_mode mode) : + m_colname(colname), + m_name(colname), + m_mode(mode) {} + +const std::string& +t_pivot::name() const { + return m_name; +} + +const std::string& +t_pivot::colname() const { + return m_colname; +} + +t_pivot_mode +t_pivot::mode() const { + return m_mode; +} + +t_pivot_recipe +t_pivot::get_recipe() const { + t_pivot_recipe rv; + rv.m_colname = m_colname; + rv.m_name = m_name; + rv.m_mode = m_mode; + return rv; +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/pool.cpp b/rust/perspective-server/cpp/perspective/src/cpp/pool.cpp new file mode 100644 index 0000000..914b1b3 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/pool.cpp @@ -0,0 +1,360 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace perspective { + +t_updctx::t_updctx() = default; + +t_updctx::t_updctx(t_uindex gnode_id, std::string ctx) : + m_gnode_id(gnode_id), + m_ctx(std::move(ctx)) {} + +#if defined PSP_ENABLE_WASM && !defined PSP_ENABLE_PYTHON + +t_pool::t_pool() : m_sleep(0) { m_run.clear(); } + +#elif defined PSP_ENABLE_PYTHON + +// t_val +// empty_callback() { +// return py::none(); +// } + +t_pool::t_pool() : +// : m_update_delegate(empty_callback()) +#ifdef PSP_PARALLEL_FOR + m_lock(new std::shared_mutex()), +#endif + m_sleep(0) { + m_run.clear(); +} + +#else + +t_pool::t_pool() : m_sleep(0) { m_run.clear(); } + +#endif + +t_pool::~t_pool() { +#ifdef PSP_PARALLEL_FOR + delete m_lock; +#endif +} + +void +t_pool::init() { + if (t_env::log_progress()) { + std::cout << "t_pool.init " << std::endl; + } + m_run.test_and_set(std::memory_order_acquire); + m_data_remaining.store(false); +} + +t_uindex +t_pool::register_gnode(t_gnode* node) { +#ifdef PSP_PARALLEL_FOR + PSP_WRITE_LOCK(*m_lock) +#endif + + m_gnodes.push_back(node); + t_uindex id = m_gnodes.size() - 1; + node->set_id(id); + node->set_pool_cleanup([this, id]() { this->m_gnodes[id] = nullptr; }); +#ifdef PSP_PARALLEL_FOR + node->set_lock(m_lock); +#endif + + if (t_env::log_progress()) { + std::cout << "t_pool.register_gnode node => " << node << " rv => " << id + << std::endl; + } + + return id; +} + +void +t_pool::unregister_gnode(t_uindex idx) { +#ifdef PSP_PARALLEL_FOR + PSP_WRITE_LOCK(*m_lock) +#endif + + if (t_env::log_progress()) { + std::cout << "t_pool.unregister_gnode idx => " << idx << std::endl; + } + + m_gnodes[idx] = nullptr; +} + +void +t_pool::send(t_uindex gnode_id, t_uindex port_id, const t_data_table& table) { + { +#ifdef PSP_PARALLEL_FOR + PSP_WRITE_LOCK(*m_lock); +#endif + // std::lock_guard lg(m_mtx); + m_data_remaining.store(true); + + if (m_gnodes[gnode_id] != nullptr) { + m_gnodes[gnode_id]->send(port_id, table); + } + + if (t_env::log_progress()) { + std::cout << "t_pool.send gnode_id => " << gnode_id + << " port_id => " << port_id << " tbl_size => " + << table.size() << std::endl; + } + + if (t_env::log_data_pool_send()) { + std::cout << "t_pool.send" << std::endl; + table.pprint(); + } + } +} + +#ifdef PSP_PARALLEL_FOR +std::shared_mutex* +t_pool::get_lock() const { + return m_lock; +} +#endif + +void +t_pool::_process(std::optional> callback) { + auto work_to_do = m_data_remaining.load(); + if (work_to_do) { + t_update_task task(*this); + task.run(callback); + } +} + +void +t_pool::stop() { + m_run.clear(std::memory_order_release); + _process(); + if (t_env::log_progress()) { + std::cout << "t_pool.stop" << std::endl; + } +} + +void +t_pool::set_sleep(t_uindex ms) { + m_sleep.store(ms); + if (t_env::log_progress()) { + std::cout << "t_pool.set_sleep ms => " << ms << std::endl; + } +} + +std::vector +t_pool::get_trees() { + std::vector rval; + for (auto& g : m_gnodes) { + if (g == nullptr) { + continue; + } + auto trees = g->get_trees(); + rval.insert(std::end(rval), std::begin(trees), std::end(trees)); + } + + if (t_env::log_progress()) { + std::cout << "t_pool.get_trees: " + << " rv => " << rval << std::endl; + } + + return rval; +} + +void +t_pool::register_context( + t_uindex gnode_id, + const std::string& name, + t_ctx_type type, + std::uintptr_t ptr +) { +#ifdef PSP_PARALLEL_FOR + PSP_WRITE_LOCK(*m_lock) +#endif + if (!validate_gnode_id(gnode_id)) { + return; + } + m_gnodes[gnode_id]->_register_context(name, type, ptr); +} + +void +t_pool::notify_userspace(t_uindex port_id) { + // #if defined PSP_ENABLE_WASM && !defined PSP_ENABLE_PYTHON + // m_update_delegate.call("_update_callback", port_id); + // #elif PSP_ENABLE_PYTHON + // if (!m_update_delegate.is_none()) { + // m_update_delegate.attr("_update_callback")(port_id); + // } + // #endif +} + +void +t_pool::unregister_context(t_uindex gnode_id, const std::string& name) { +#ifdef PSP_PARALLEL_FOR + PSP_WRITE_LOCK(*m_lock) +#endif + + if (t_env::log_progress()) { + std::cout << repr() << " << t_pool.unregister_context: " + << " gnode_id => " << gnode_id << " name => " << name + << std::endl; + } + + if (!validate_gnode_id(gnode_id)) { + return; + } + m_gnodes[gnode_id]->_unregister_context(name); +} + +bool +t_pool::get_data_remaining() const { + auto data = m_data_remaining.load(); + return data; +} + +std::vector +t_pool::get_row_data_pkeys( + t_uindex gnode_id, const std::vector& pkeys +) { +#ifdef PSP_PARALLEL_FOR + PSP_READ_LOCK(*m_lock) +#endif + + if (!validate_gnode_id(gnode_id)) { + return {}; + } + + auto rv = m_gnodes[gnode_id]->get_row_data_pkeys(pkeys); + + if (t_env::log_progress()) { + std::cout << "t_pool.get_row_data_pkeys: " + << " gnode_id => " << gnode_id << " pkeys => " << pkeys + << " rv => " << rv << std::endl; + } + + return rv; +} + +std::vector +t_pool::get_contexts_last_updated() { +#ifdef PSP_PARALLEL_FOR + PSP_READ_LOCK(*m_lock) +#endif + + std::vector rval; + + for (auto& m_gnode : m_gnodes) { + if (m_gnode == nullptr) { + continue; + } + + auto updated_contexts = m_gnode->get_contexts_last_updated(); + auto gnode_id = m_gnode->get_id(); + + for (const auto& ctx_name : updated_contexts) { + if (t_env::log_progress()) { + std::cout << "t_pool.get_contexts_last_updated: " + << " gnode_id => " << gnode_id << " ctx_name => " + << ctx_name << std::endl; + } + rval.emplace_back(gnode_id, ctx_name); + } + } + return rval; +} + +bool +t_pool::validate_gnode_id(t_uindex gnode_id) const { + return (m_gnodes[gnode_id] != nullptr) && gnode_id < m_gnodes.size(); +} + +std::string +t_pool::repr() const { + std::stringstream ss; + ss << "t_pool<" << this << ">"; + return ss.str(); +} + +void +t_pool::pprint_registered() const { + auto self = repr(); + + for (auto* m_gnode : m_gnodes) { + if (m_gnode == nullptr) { + continue; + } + auto gnode_id = m_gnode->get_id(); + auto ctxnames = m_gnode->get_registered_contexts(); + + for (const auto& cname : ctxnames) { + std::cout << self << " gnode_id => " << gnode_id << " ctxname => " + << cname << std::endl; + } + } +} + +t_uindex +t_pool::epoch() const { + return m_epoch.load(); +} + +void +t_pool::inc_epoch() { + ++m_epoch; +} + +std::vector +t_pool::get_gnodes_last_updated() { +#ifdef PSP_PARALLEL_FOR + PSP_READ_LOCK(*m_lock) +#endif + + std::vector rv; + + for (t_uindex idx = 0, loop_end = m_gnodes.size(); idx < loop_end; ++idx) { + if ((m_gnodes[idx] == nullptr) || !m_gnodes[idx]->was_updated()) { + continue; + } + + rv.push_back(idx); + m_gnodes[idx]->clear_updated(); + } + return rv; +} + +t_gnode* +t_pool::get_gnode(t_uindex idx) { +#ifdef PSP_PARALLEL_FOR + PSP_READ_LOCK(*m_lock) +#endif + + PSP_VERBOSE_ASSERT( + idx < m_gnodes.size() && m_gnodes[idx], "Bad gnode encountered" + ); + + return m_gnodes[idx]; +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/port.cpp b/rust/perspective-server/cpp/perspective/src/cpp/port.cpp new file mode 100644 index 0000000..afe2b13 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/port.cpp @@ -0,0 +1,113 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include + +#include + +namespace perspective { + +t_port::t_port(t_port_mode mode, t_schema schema) : + m_schema(std::move(schema)), + m_init(false), + m_table(nullptr), + m_prevsize(0) { + LOG_CONSTRUCTOR("t_port"); +} + +t_port::~t_port() { LOG_DESTRUCTOR("t_port"); } + +void +t_port::init() { + m_table = nullptr; + m_table = std::make_shared( + "", "", m_schema, DEFAULT_EMPTY_CAPACITY, BACKING_STORE_MEMORY + ); + m_table->init(); + m_init = true; +} + +std::shared_ptr +t_port::get_table() { + return m_table; +} + +void +t_port::set_table(std::shared_ptr table) { + m_table = nullptr; + m_table = std::move(table); +} + +void +t_port::send(const std::shared_ptr& table) { + m_table->append(*table); +} + +void +t_port::send(const t_data_table& table) { + m_table->append(table); +} + +t_schema +t_port::get_schema() const { + return m_schema; +} + +void +t_port::release() + +{ + if (m_table == nullptr) { + return; + } + + t_uindex size = m_table->size(); + + m_table = nullptr; + m_table = std::make_shared( + "", "", m_schema, DEFAULT_EMPTY_CAPACITY, BACKING_STORE_MEMORY + ); + m_table->init(); + + m_prevsize = size; +} + +void +t_port::release_or_clear() + +{ + if (m_table == nullptr) { + return; + } + + t_uindex size = m_table->size(); + + if (static_cast(size) < 0.4 * double(m_prevsize)) { + m_table->clear(); + } else { + release(); + } + + m_prevsize = size; +} + +void +t_port::clear() { + if (m_table == nullptr) { + return; + } + + m_table->clear(); +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/process_state.cpp b/rust/perspective-server/cpp/perspective/src/cpp/process_state.cpp new file mode 100644 index 0000000..5774269 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/process_state.cpp @@ -0,0 +1,46 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include + +namespace perspective { + +t_process_state::t_process_state() = default; + +void +t_process_state::clear_transitional_data_tables() const { + m_delta_data_table->clear(); + m_prev_data_table->clear(); + m_current_data_table->clear(); + m_transitions_data_table->clear(); + m_existed_data_table->clear(); +}; + +void +t_process_state::reserve_transitional_data_tables(t_uindex size) const { + m_delta_data_table->reserve(size); + m_prev_data_table->reserve(size); + m_current_data_table->reserve(size); + m_transitions_data_table->reserve(size); + m_existed_data_table->reserve(size); +}; + +void +t_process_state::set_size_transitional_data_tables(t_uindex size) const { + m_delta_data_table->set_size(size); + m_prev_data_table->set_size(size); + m_current_data_table->set_size(size); + m_transitions_data_table->set_size(size); + m_existed_data_table->set_size(size); +}; + +} // namespace perspective \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/cpp/pyutils.cpp b/rust/perspective-server/cpp/perspective/src/cpp/pyutils.cpp new file mode 100644 index 0000000..9420071 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/pyutils.cpp @@ -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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include + +namespace perspective { + +#ifdef PSP_ENABLE_PYTHON +PerspectiveGILUnlock::PerspectiveGILUnlock() {} +// m_thread_state(PyEval_SaveThread()) {} + +PerspectiveGILUnlock::~PerspectiveGILUnlock() { + // PyEval_RestoreThread(m_thread_state); +} +#endif + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/raii.cpp b/rust/perspective-server/cpp/perspective/src/cpp/raii.cpp new file mode 100644 index 0000000..2ccec99 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/raii.cpp @@ -0,0 +1,47 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#ifndef WIN32 +#ifdef PSP_DEBUG +#include +#endif +#endif + +namespace perspective { + +#ifndef PSP_ENABLE_WASM +t_file_handle::t_file_handle(t_handle value) : m_value(value) {} + +t_handle +t_file_handle::value() const { + return m_value; +} +#endif + +t_mmap_handle::t_mmap_handle(void* value, t_uindex len) : + m_value(value), + m_len(len) {} + +void* +t_mmap_handle::value() { + return m_value; +} + +t_uindex +t_mmap_handle::len() const { + return m_len; +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/raii_impl_linux.cpp b/rust/perspective-server/cpp/perspective/src/cpp/raii_impl_linux.cpp new file mode 100644 index 0000000..8ec56ab --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/raii_impl_linux.cpp @@ -0,0 +1,52 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#ifdef __linux__ +#include +#include +#include +#include + +namespace perspective { + +t_file_handle::~t_file_handle() { + if (valid()) { + t_index rcode = close(m_value); + PSP_VERBOSE_ASSERT(rcode == 0, "Error closing file."); + } +} + +bool +t_file_handle::valid() const { + return m_value >= 0; +} + +void +t_file_handle::release() { + m_value = -1; +} + +t_mmap_handle::~t_mmap_handle() { + if (valid()) { + t_index rcode = munmap(m_value, m_len); + PSP_VERBOSE_ASSERT(rcode == 0, "munmap failed."); + } +} + +bool +t_mmap_handle::valid() { + return m_value != MAP_FAILED; +} + +} // end namespace perspective + +#endif \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/cpp/raii_impl_osx.cpp b/rust/perspective-server/cpp/perspective/src/cpp/raii_impl_osx.cpp new file mode 100644 index 0000000..e009d93 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/raii_impl_osx.cpp @@ -0,0 +1,52 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#ifdef __APPLE__ +#include +#include +#include +#include + +namespace perspective { + +t_file_handle::~t_file_handle() { + if (valid()) { + t_index rcode = close(m_value); + PSP_VERBOSE_ASSERT(rcode, == 0, "Error closing file."); + } +} + +bool +t_file_handle::valid() const { + return m_value >= 0; +} + +void +t_file_handle::release() { + m_value = -1; +} + +t_mmap_handle::~t_mmap_handle() { + if (valid()) { + t_index rcode = munmap(m_value, m_len); + PSP_VERBOSE_ASSERT(rcode, == 0, "munmap failed."); + } +} + +bool +t_mmap_handle::valid() { + return m_value != MAP_FAILED; +} + +} // end namespace perspective + +#endif \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/cpp/raii_impl_win.cpp b/rust/perspective-server/cpp/perspective/src/cpp/raii_impl_win.cpp new file mode 100644 index 0000000..28026f0 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/raii_impl_win.cpp @@ -0,0 +1,51 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#ifdef WIN32 +#include +#include +#include + +namespace perspective { + +t_file_handle::~t_file_handle() { + if (valid()) { + auto rb = CloseHandle(m_value); + PSP_VERBOSE_ASSERT(rb, "Error closing file"); + } +} + +bool +t_file_handle::valid() const { + return m_value != INVALID_HANDLE_VALUE; +} + +void +t_file_handle::release() { + m_value = INVALID_HANDLE_VALUE; +} + +t_mmap_handle::~t_mmap_handle() { + if (valid()) { + auto rc = UnmapViewOfFile(m_value); + PSP_VERBOSE_ASSERT(rc, "Error unmapping view"); + } +} + +bool +t_mmap_handle::valid() { + return m_value != 0; +} + +} // end namespace perspective + +#endif \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/cpp/range.cpp b/rust/perspective-server/cpp/perspective/src/cpp/range.cpp new file mode 100644 index 0000000..b6fb64c --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/range.cpp @@ -0,0 +1,75 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include + +namespace perspective { + +t_range::t_range(t_uindex bridx, t_uindex eridx) : + m_bridx(bridx), + m_eridx(eridx), + m_mode(RANGE_ROW) {} + +t_range::t_range( + t_uindex bridx, t_uindex eridx, t_uindex bcidx, t_uindex ecidx +) : + m_bridx(bridx), + m_eridx(eridx), + m_bcidx(bcidx), + m_ecidx(ecidx), + m_mode(RANGE_ROW_COLUMN) {} + +t_range::t_range() : m_mode(RANGE_ALL) {} + +t_range::t_range( + const std::vector& brpath, const std::vector& erpath +) : + m_brpath(brpath), + m_erpath(erpath), + m_mode(RANGE_ROW_PATH) {} + +t_range::t_range( + const std::vector& brpath, + const std::vector& erpath, + const std::vector& bcpath, + const std::vector& ecpath +) {} + +t_range::t_range(const std::string& expr_name) {} + +t_uindex +t_range::bridx() const { + return m_bridx; +} + +t_uindex +t_range::eridx() const { + return m_eridx; +} + +t_uindex +t_range::bcidx() const { + return m_bcidx; +} + +t_uindex +t_range::ecidx() const { + return m_ecidx; +} + +t_range_mode +t_range::get_mode() const { + return m_mode; +} + +} // end namespace perspective \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/cpp/regex.cpp b/rust/perspective-server/cpp/perspective/src/cpp/regex.cpp new file mode 100644 index 0000000..66ac8f0 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/regex.cpp @@ -0,0 +1,40 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include + +namespace perspective { + +RE2* +t_regex_mapping::intern(const std::string& pattern) { + if (m_regex_map.count(pattern) == 1) { + return m_regex_map[pattern].get(); + } + + std::shared_ptr compiled_pattern = + std::make_shared(pattern, RE2::Quiet); + + if (!compiled_pattern->ok()) { + // TODO: provide a better error message when the regex can't compile. + return nullptr; + } + + m_regex_map[pattern] = compiled_pattern; + return m_regex_map[pattern].get(); +} + +void +t_regex_mapping::clear() { + m_regex_map.clear(); +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/residency.cpp b/rust/perspective-server/cpp/perspective/src/cpp/residency.cpp new file mode 100644 index 0000000..be593c6 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/residency.cpp @@ -0,0 +1,176 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +bool g_residency_active = false; +std::uint64_t g_residency_tick = 0; + +t_residency_manager& +t_residency_manager::inst() { + static t_residency_manager s_inst; + return s_inst; +} + +void +t_residency_manager::register_store(t_lstore* store) { + std::lock_guard lk(m_mutex); + m_stores.insert(store); +} + +void +t_residency_manager::unregister_store(t_lstore* store) { + std::lock_guard lk(m_mutex); + m_stores.erase(store); +} + +// Hard-coded residency budget (bytes) for WASM. A browser has no environment, +// so `PSP_MEMORY_BUDGET` is unreachable there — without a budget, residency is +// inert, on-disk columns never evict to OPFS, and the heap can grow past the +// 2GB signed-pointer ceiling. This caps resident disk-backed column buffers so +// the cold set is flushed to OPFS. Tunable via `-DPSP_WASM_MEMORY_BUDGET=...`. +#ifndef PSP_WASM_MEMORY_BUDGET +#define PSP_WASM_MEMORY_BUDGET (1024ull * 1024ull * 1024ull) // 1 GiB +#endif + +void +t_residency_manager::refresh_config() { + std::size_t budget = 0; +#ifdef PSP_ENABLE_WASM + budget = static_cast(PSP_WASM_MEMORY_BUDGET); +#else + const char* budget_env = std::getenv("PSP_MEMORY_BUDGET"); + if (budget_env != nullptr) { + budget = static_cast(std::strtoull(budget_env, nullptr, 10)); + } +#endif + + bool was_active = g_residency_active; + m_budget = budget; + g_residency_active = budget > 0; + + // If residency was just disabled, restore every evicted store so the lazy + // `ensure_resident()` hook ( + // now a no-op) never sees a null `m_base`. + if (was_active && !g_residency_active) { + for (auto* store : m_stores) { + store->restore(); + } + } +} + +std::size_t +t_residency_manager::resident_bytes() { + std::lock_guard lk(m_mutex); + std::size_t total = 0; + for (auto* store : m_stores) { + if (store->is_resident()) { + total += store->capacity(); + } + } + + return total; +} + +std::size_t +t_residency_manager::prepare() { + refresh_config(); + std::lock_guard lk(m_mutex); + m_pending.clear(); + m_pending_fnames.clear(); + + if (!g_residency_active) { + return 0; + } + + g_residency_tick = ++m_tick; + std::size_t resident = 0; + std::vector candidates; + candidates.reserve(m_stores.size()); + for (auto* store : m_stores) { + if (store->is_resident()) { + resident += store->capacity(); + candidates.push_back(store); + } + } + + if (resident <= m_budget) { + return 0; + } + + std::sort( + candidates.begin(), + candidates.end(), + [](const t_lstore* a, const t_lstore* b) { + return a->residency_tick() < b->residency_tick(); + } + ); + + // Select victims to bring under budget, but do NOT evict yet — the JS driver + // must open each victim's OPFS handle before `commit()` can flush it. + for (auto* store : candidates) { + if (resident <= m_budget) { + break; + } + + resident -= store->capacity(); + m_pending.push_back(store); + m_pending_fnames.push_back(store->get_fname()); + } + + return m_pending.size(); +} + +const char* +t_residency_manager::victim_fname(std::size_t i) const { + if (i >= m_pending_fnames.size()) { + return ""; + } + return m_pending_fnames[i].c_str(); +} + +void +t_residency_manager::commit() { + std::size_t n = 0; + { + std::lock_guard lk(m_mutex); + for (auto* store : m_pending) { + store->evict(); + ++m_evictions; + } + + n = m_pending.size(); + m_pending.clear(); + m_pending_fnames.clear(); + } + + if (n == 0) { + return; + } +} + +void +t_residency_manager::safepoint() { + std::lock_guard lk(m_safepoint_mutex); + prepare(); + commit(); +} + +} // namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/rlookup.cpp b/rust/perspective-server/cpp/perspective/src/cpp/rlookup.cpp new file mode 100644 index 0000000..23d7f21 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/rlookup.cpp @@ -0,0 +1,29 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include + +namespace perspective { +t_rlookup::t_rlookup(t_uindex idx, bool exists) : m_idx(idx), m_exists(exists) { + PSP_TRACE_SENTINEL(); + LOG_CONSTRUCTOR("t_rlookup"); +} + +t_rlookup::t_rlookup() = default; + +t_rlookup::~t_rlookup() { + PSP_TRACE_SENTINEL(); + LOG_DESTRUCTOR("t_rlookup"); +} +} // namespace perspective \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/cpp/scalar.cpp b/rust/perspective-server/cpp/perspective/src/cpp/scalar.cpp new file mode 100644 index 0000000..ba0eea7 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/scalar.cpp @@ -0,0 +1,1750 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +SUPPRESS_WARNINGS_VC(4800) + +namespace perspective { + +bool +operator>(const std::size_t& lhs, const t_tscalar& rhs) { + return rhs.operator<(lhs); +} + +#define BINARY_OPERATOR_BODY(OP) \ + t_tscalar rval; \ + rval.clear(); \ + rval.m_type = DTYPE_FLOAT64; \ + if (!is_numeric() || !other.is_numeric()) { \ + rval.m_status = STATUS_CLEAR; \ + } \ + if (!other.is_valid() || !is_valid()) { \ + return rval; \ + } \ + if (m_type == DTYPE_FLOAT64 && other.m_type == DTYPE_FLOAT64) { \ + rval.set(m_data.m_float64 OP other.m_data.m_float64); \ + return rval; \ + } \ + if (m_type == DTYPE_INT64 && other.m_type == DTYPE_INT64) { \ + rval.set(static_cast( \ + m_data.m_int64 OP other.m_data.m_int64)); \ + return rval; \ + } \ + if (m_type == DTYPE_INT32 && other.m_type == DTYPE_INT32) { \ + rval.set(static_cast( \ + m_data.m_int32 OP other.m_data.m_int32)); \ + return rval; \ + } \ + rval.set(to_double() OP other.to_double()); \ + return rval; + +/** + * @brief A function-style cast used only by ExprTk. Because ExprTk was + * designed to be used with numeric inputs or numeric-like inputs, it makes + * various assertions of its input type, which include calling this + * function-style cast through `T(0)`, `T(1)` etc. Because we can't guarantee + * what int this function is called with, we treat it as a double to + * prevent overflow. + * + * DO NOT USE THIS CONSTRUCTOR IN PERSPECTIVE - all `t_tscalar` objects + * should be constructed without initializer and then `set` should be called: + * + * t_tscalar x; + * x.set(1.2345); + * + * @param v + */ +t_tscalar::t_tscalar(int v) { this->set(static_cast(v)); } + +bool +t_tscalar::is_none() const { + return m_type == DTYPE_NONE; +} + +bool +t_tscalar::is_str() const { + return m_type == DTYPE_STR; +} + +bool +t_tscalar::is_of_type(unsigned char t) const { + return m_type == t; +} + +bool +t_tscalar::operator==(const t_tscalar& rhs) const { + if (m_type != rhs.m_type || m_status != rhs.m_status) { + return false; + } + + if ((m_status == STATUS_INVALID || m_status == STATUS_CLEAR) + && (rhs.m_status == STATUS_INVALID || rhs.m_status == STATUS_CLEAR)) { + return true; + } + + if (m_type == DTYPE_BOOL) { + return get() == rhs.get(); + } + + if (m_type != DTYPE_STR) { + return m_data.m_uint64 == rhs.m_data.m_uint64; + } + + return strcmp(get_char_ptr(), rhs.get_char_ptr()) == 0; +} + +bool +t_tscalar::operator!=(const t_tscalar& rhs) const { + return !operator==(rhs); +} + +bool +t_tscalar::operator<(const t_tscalar& rhs) const { + return compare_common(rhs); +} + +bool +t_tscalar::operator>(const t_tscalar& rhs) const { + return compare_common(rhs); +} + +bool +t_tscalar::operator>=(const t_tscalar& rhs) const { + return compare_common(rhs); +} + +bool +t_tscalar::operator<=(const t_tscalar& rhs) const { + return compare_common(rhs); +} + +t_tscalar +t_tscalar::operator+() const { + t_tscalar rval; + rval.clear(); + rval.m_type = m_type; + + if (!is_numeric()) { + rval.m_status = STATUS_CLEAR; + } + + if (!is_valid()) { + return rval; + } + + switch (m_type) { + case DTYPE_INT64: { + rval.set(+(m_data.m_int64)); + } break; + case DTYPE_INT32: { + rval.set(+(m_data.m_int32)); + } break; + case DTYPE_INT16: { + rval.set(+(m_data.m_int16)); + } break; + case DTYPE_INT8: { + rval.set(+(m_data.m_int8)); + } break; + case DTYPE_UINT64: { + rval.set(+(m_data.m_uint64)); + } break; + case DTYPE_UINT32: { + rval.set(+(m_data.m_uint32)); + } break; + case DTYPE_UINT16: { + rval.set(+(m_data.m_uint16)); + } break; + case DTYPE_UINT8: { + rval.set(+(m_data.m_uint8)); + } break; + case DTYPE_FLOAT64: { + rval.set(+(m_data.m_float64)); + } break; + case DTYPE_FLOAT32: { + rval.set(+(m_data.m_float32)); + } break; + default: + return mknone(); + } + + return rval; +} + +t_tscalar +t_tscalar::operator-() const { + t_tscalar rval; + rval.clear(); + rval.m_type = m_type; + + if (!is_numeric()) { + rval.m_status = STATUS_CLEAR; + } + + if (!is_valid()) { + return rval; + } + + switch (m_type) { + case DTYPE_INT64: { + rval.set(-m_data.m_int64); + } break; + case DTYPE_INT32: { + rval.set(-m_data.m_int32); + } break; + case DTYPE_INT16: { + rval.set(-m_data.m_int16); + } break; + case DTYPE_INT8: { + rval.set(-m_data.m_int8); + } break; + case DTYPE_UINT64: { + rval.set(-m_data.m_uint64); + } break; + case DTYPE_UINT32: { + rval.set(-m_data.m_uint32); + } break; + case DTYPE_UINT16: { + rval.set(-m_data.m_uint16); + } break; + case DTYPE_UINT8: { + rval.set(-m_data.m_uint8); + } break; + case DTYPE_FLOAT64: { + rval.set(-m_data.m_float64); + } break; + case DTYPE_FLOAT32: { + rval.set(-m_data.m_float32); + } break; + default: + return mknone(); + } + + return rval; +} + +t_tscalar +t_tscalar::operator+(const t_tscalar& other) const {BINARY_OPERATOR_BODY(+)} + +t_tscalar t_tscalar::operator-(const t_tscalar& other +) const {BINARY_OPERATOR_BODY(-)} + +t_tscalar t_tscalar::operator*(const t_tscalar& other +) const {BINARY_OPERATOR_BODY(*)} + +t_tscalar t_tscalar::operator/(const t_tscalar& other) const { + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_FLOAT64; + + if (!is_numeric() || !other.is_numeric()) { + rval.m_status = STATUS_CLEAR; + } + + if (!is_valid() || !other.is_valid()) { + return rval; + } + + if (other.to_double() == 0) { + return rval; + } + + rval.set(to_double() / other.to_double()); + + return rval; +} + +t_tscalar +t_tscalar::operator%(const t_tscalar& other) const { + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_FLOAT64; + + if (!is_numeric() || !other.is_numeric()) { + rval.m_status = STATUS_CLEAR; + } + + if (!is_valid() || !other.is_valid()) { + return rval; + } + + if (other.to_double() == 0) { + return rval; + } + + rval.set(fmod(to_double(), other.to_double())); + + return rval; +} + +template +t_tscalar +t_tscalar::operator+(T other) const { + t_tscalar other_scalar; + other_scalar.set(other); + return this->operator+(other_scalar); +} + +template <> +t_tscalar +t_tscalar::operator+(unsigned int other) const { + t_tscalar other_scalar; + other_scalar.set(other); + return this->operator+(other_scalar); +} + +template +t_tscalar +t_tscalar::operator-(T other) const { + t_tscalar other_scalar; + other_scalar.set(other); + return this->operator-(other_scalar); +} + +template +t_tscalar +t_tscalar::operator*(T other) const { + t_tscalar other_scalar; + other_scalar.set(other); + return this->operator*(other_scalar); +} + +template <> +t_tscalar +t_tscalar::operator*(int other) const { + t_tscalar other_scalar; + other_scalar.set(other); + return this->operator*(other_scalar); +} + +template <> +t_tscalar +t_tscalar::operator*(double other) const { + t_tscalar other_scalar; + other_scalar.set(other); + return this->operator*(other_scalar); +} + +template +t_tscalar +t_tscalar::operator/(T other) const { + t_tscalar other_scalar; + other_scalar.set(other); + return this->operator/(other_scalar); +} + +/** + * Win32 build complains if the std::uint64_t specialization is unset, and + * Linux complains if the std::uint64_t is set as it somehow conflicts with + * the unsigned long specialization below the ifdef. + */ +#ifdef WIN32 +template <> +t_tscalar +t_tscalar::operator/(std::uint64_t other) const { + t_tscalar other_scalar; + other_scalar.set(other); + return this->operator/(other_scalar); +} +#endif + +template <> +t_tscalar +t_tscalar::operator/(unsigned long other) const { + t_tscalar other_scalar; + other_scalar.set(static_cast(other)); + return this->operator/(other_scalar); +} + +template <> +t_tscalar +t_tscalar::operator/(double other) const { + t_tscalar other_scalar; + other_scalar.set(other); + return this->operator/(other_scalar); +} + +template +t_tscalar +t_tscalar::operator%(T other) const { + t_tscalar other_scalar; + other_scalar.set(other); + return this->operator%(other_scalar); +} + +t_tscalar& +t_tscalar::operator+=(const t_tscalar& rhs) { + t_tscalar tmp = this->operator+(rhs); + this->set(tmp); + return *this; +} + +t_tscalar& +t_tscalar::operator-=(const t_tscalar& rhs) { + t_tscalar tmp = this->operator-(rhs); + this->set(tmp); + return *this; +} + +t_tscalar& +t_tscalar::operator*=(const t_tscalar& rhs) { + t_tscalar tmp = this->operator*(rhs); + this->set(tmp); + return *this; +} + +t_tscalar& +t_tscalar::operator/=(const t_tscalar& rhs) { + t_tscalar tmp = this->operator/(rhs); + this->set(tmp); + return *this; +} + +t_tscalar& +t_tscalar::operator%=(const t_tscalar& rhs) { + t_tscalar tmp = this->operator%(rhs); + this->set(tmp); + return *this; +} + +t_tscalar::operator std::size_t() const { + switch (get_dtype()) { + case perspective::t_dtype::DTYPE_INT64: + return static_cast(get()); + case perspective::t_dtype::DTYPE_INT32: + return static_cast(get()); + case perspective::t_dtype::DTYPE_INT16: + return static_cast(get()); + case perspective::t_dtype::DTYPE_INT8: + return static_cast(get()); + case perspective::t_dtype::DTYPE_UINT64: + return static_cast(get()); + case perspective::t_dtype::DTYPE_UINT32: + return static_cast(get()); + case perspective::t_dtype::DTYPE_UINT16: + return static_cast(get()); + case perspective::t_dtype::DTYPE_UINT8: + return static_cast(get()); + case perspective::t_dtype::DTYPE_FLOAT64: + return static_cast(get()); + case perspective::t_dtype::DTYPE_FLOAT32: + return static_cast(get()); + default: + return std::numeric_limits::max(); + } + + return std::numeric_limits::max(); +} + +t_tscalar +t_tscalar::add_typesafe(const t_tscalar& rhs) const { + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_FLOAT64; + if (!is_numeric() || !rhs.is_numeric()) { + rval.m_status = STATUS_CLEAR; + return rval; + } + if (!rhs.is_valid() || !is_valid()) { + return rval; + } + if (is_floating_point() || rhs.is_floating_point()) { + rval.m_type = DTYPE_FLOAT64; + rval.set(to_double() + rhs.to_double()); + return rval; + } + rval.m_type = DTYPE_INT64; + rval.set(to_int64() + rhs.to_int64()); + return rval; +} + +t_tscalar +t_tscalar::sub_typesafe(const t_tscalar& rhs) const { + t_tscalar rval; + rval.clear(); + rval.m_type = DTYPE_FLOAT64; + if (!is_numeric() || !rhs.is_numeric()) { + rval.m_status = STATUS_CLEAR; + return rval; + } + if (!rhs.is_valid() || !is_valid()) { + return rval; + } + if (is_floating_point()) { + rval.m_type = DTYPE_FLOAT64; + rval.set(to_double() - rhs.to_double()); + return rval; + } + rval.m_type = DTYPE_INT32; + rval.set(to_int32() - rhs.to_int32()); + return rval; +} + +bool +t_tscalar::is_numeric() const { + return is_numeric_type(static_cast(m_type)); +} + +void +t_tscalar::clear() { + m_type = DTYPE_NONE; + m_data.m_uint64 = 0; + m_status = STATUS_INVALID; +} + +t_tscalar +t_tscalar::canonical(t_dtype dtype) { + t_tscalar rval; + rval.clear(); + rval.m_status = STATUS_VALID; + + switch (dtype) { + case DTYPE_INT64: { + rval.set(std::int64_t(0)); + } break; + case DTYPE_INT32: { + rval.set(std::int32_t(0)); + } break; + case DTYPE_INT16: { + rval.set(std::int16_t(0)); + } break; + case DTYPE_INT8: { + rval.set(std::int8_t(0)); + } break; + + case DTYPE_UINT64: { + rval.set(std::uint64_t(0)); + } break; + case DTYPE_UINT32: { + rval.set(std::uint32_t(0)); + } break; + case DTYPE_UINT16: { + rval.set(std::uint16_t(0)); + } break; + case DTYPE_UINT8: { + rval.set(std::uint8_t(0)); + } break; + + case DTYPE_FLOAT64: { + rval.set(double(0)); + } break; + case DTYPE_FLOAT32: { + rval.set(float(0)); + } break; + case DTYPE_DATE: { + rval.set(t_date(std::uint32_t(0))); + } break; + case DTYPE_TIME: { + rval.set(t_time(0)); + } break; + case DTYPE_BOOL: { + rval.set(false); + } break; + case DTYPE_NONE: { + // handled trivially + } break; + case DTYPE_STR: { + rval.m_type = DTYPE_STR; + } break; + case DTYPE_OBJECT: + default: { + PSP_COMPLAIN_AND_ABORT("Found unknown dtype."); + } + } + + return rval; +} + +void +t_tscalar::set(std::int64_t v) { + m_type = DTYPE_INT64; + m_data.m_int64 = v; + m_status = STATUS_VALID; +} + +void +t_tscalar::set(std::int32_t v) { + m_type = DTYPE_INT32; + m_data.m_uint64 = 0; + m_data.m_int32 = v; + m_status = STATUS_VALID; +} + +void +t_tscalar::set(std::int16_t v) { + m_type = DTYPE_INT16; + m_data.m_uint64 = 0; + m_data.m_int16 = v; + m_status = STATUS_VALID; +} + +void +t_tscalar::set(std::int8_t v) { + m_type = DTYPE_INT8; + m_data.m_uint64 = 0; + m_data.m_int8 = v; + m_status = STATUS_VALID; +} + +void +t_tscalar::set(std::uint64_t v) { + m_type = DTYPE_UINT64; + m_data.m_uint64 = v; + m_status = STATUS_VALID; +} + +void +t_tscalar::set(std::uint32_t v) { + m_type = DTYPE_UINT32; + m_data.m_uint64 = 0; + m_data.m_uint32 = v; + m_status = STATUS_VALID; +} + +void +t_tscalar::set(std::uint16_t v) { + m_type = DTYPE_UINT16; + m_data.m_uint64 = 0; + m_data.m_uint16 = v; + m_status = STATUS_VALID; +} + +void +t_tscalar::set(std::uint8_t v) { + m_type = DTYPE_UINT8; + m_data.m_uint64 = 0; + m_data.m_uint8 = v; + m_status = STATUS_VALID; +} + +void +t_tscalar::set(double v) { + m_type = DTYPE_FLOAT64; + m_data.m_float64 = v; + m_status = STATUS_VALID; +} + +void +t_tscalar::set(float v) { + m_type = DTYPE_FLOAT32; + m_data.m_uint64 = 0; + m_data.m_float32 = v; + m_status = STATUS_VALID; +} + +void +t_tscalar::set(bool v) { + m_type = DTYPE_BOOL; + m_data.m_uint64 = 0; + m_data.m_bool = v; + m_status = STATUS_VALID; +} + +void +t_tscalar::set(const char* v) { + m_type = DTYPE_STR; +#ifdef PSP_SSO_SCALAR + if (can_store_inplace(v)) { + strncpy(reinterpret_cast(&m_data), v, SCALAR_INPLACE_LEN); + m_inplace = true; + } else { + m_data.m_charptr = v; + m_inplace = false; + } +#else + m_data.m_charptr = v; +#endif + m_status = STATUS_VALID; +} + +void +t_tscalar::set(const t_date v) { + m_type = DTYPE_DATE; + m_data.m_uint64 = 0; + m_data.m_uint32 = v.raw_value(); + m_status = STATUS_VALID; +} + +void +t_tscalar::set(const t_time v) { + m_type = DTYPE_TIME; + m_data.m_int64 = v.raw_value(); + m_status = STATUS_VALID; +} + +void +t_tscalar::set(const t_none v) { + m_data.m_uint64 = 0; + m_type = DTYPE_NONE; + m_status = STATUS_VALID; +} + +void +t_tscalar::set(const t_tscalar v) { + m_type = v.m_type; + memcpy(&m_data, &(v.m_data), sizeof(t_scalar_u)); + m_status = v.m_status; +#ifdef PSP_SSO_SCALAR + m_inplace = v.m_inplace; +#endif +} + +void +t_tscalar::set_status(t_status s) { + m_status = s; +}; + +t_tscalar +t_tscalar::abs() const { + t_tscalar rval; + rval.clear(); + rval.m_type = m_type; + + if (!is_valid()) { + return rval; + } + + switch (m_type) { + case DTYPE_INT64: { + std::int64_t v = std::abs(to_double()); + rval.set(v); + } break; + case DTYPE_INT32: { + std::int32_t v = std::abs(to_double()); + rval.set(v); + } break; + case DTYPE_INT16: { + std::int16_t v = std::abs(to_double()); + rval.set(v); + } break; + case DTYPE_INT8: { + std::int8_t v = std::abs(to_double()); + rval.set(v); + } break; + case DTYPE_UINT64: + case DTYPE_UINT32: + case DTYPE_UINT16: + case DTYPE_UINT8: { + return *this; + } break; + + case DTYPE_FLOAT64: { + rval.set(std::abs(m_data.m_float64)); + } break; + case DTYPE_FLOAT32: { + rval.set(std::abs(m_data.m_float32)); + } break; + default: { + // no-op + } + } + return rval; +} + +t_tscalar +t_tscalar::negate() const { + t_tscalar rval; + rval.clear(); + rval.m_type = m_type; + + if (!is_valid()) { + return rval; + } + + switch (m_type) { + case DTYPE_INT64: { + rval.set(-(m_data.m_int64)); + } break; + case DTYPE_INT32: { + rval.set(-(m_data.m_int32)); + } break; + case DTYPE_INT16: { + rval.set(-(m_data.m_int16)); + } break; + case DTYPE_INT8: { + rval.set(-(m_data.m_int8)); + } break; + + case DTYPE_UINT64: { + rval.set(~m_data.m_uint64); + } break; + case DTYPE_UINT32: { + rval.set(~m_data.m_uint32); + } break; + case DTYPE_UINT16: { + rval.set(~m_data.m_uint16); + } break; + case DTYPE_UINT8: { + rval.set(~m_data.m_uint8); + } break; + + case DTYPE_FLOAT64: { + rval.set(-(m_data.m_float64)); + } break; + case DTYPE_FLOAT32: { + rval.set(-(m_data.m_float32)); + } break; + default: { + // no-op + } + } + return rval; +} + +t_tscalar +t_tscalar::add(const t_tscalar& other) const { + t_tscalar rval; + rval.clear(); + rval.m_type = m_type; + + if (!other.is_valid()) { + return *this; + } + + if (!is_valid()) { + return other; + } + + if (m_type != other.m_type) { + return rval; + } + + if (m_type == DTYPE_NONE) { + rval.set(other); + return rval; + } + + if (other.m_type == DTYPE_NONE) { + return *this; + } + + switch (m_type) { + case DTYPE_INT64: { + rval.set(m_data.m_int64 + other.m_data.m_int64); + } break; + case DTYPE_INT32: { + rval.set(m_data.m_int32 + other.m_data.m_int32); + } break; + case DTYPE_INT16: { + rval.set(m_data.m_int16 + other.m_data.m_int16); + } break; + case DTYPE_INT8: { + rval.set(m_data.m_int8 + other.m_data.m_int8); + } break; + + case DTYPE_UINT64: { + rval.set(m_data.m_uint64 + other.m_data.m_uint64); + } break; + case DTYPE_UINT32: { + rval.set(m_data.m_uint32 + other.m_data.m_uint32); + } break; + case DTYPE_UINT16: { + rval.set(m_data.m_uint16 + other.m_data.m_uint16); + } break; + case DTYPE_UINT8: { + rval.set(m_data.m_uint8 + other.m_data.m_uint8); + } break; + + case DTYPE_FLOAT64: { + rval.set(m_data.m_float64 + other.m_data.m_float64); + } break; + case DTYPE_FLOAT32: { + rval.set(m_data.m_float32 + other.m_data.m_float32); + } break; + default: { + // no-op + } + } + return rval; +} + +t_tscalar +t_tscalar::difference(const t_tscalar& other) const { + t_tscalar rval; + rval.clear(); + rval.m_type = m_type; + + if (!other.is_valid()) { + return *this; + } + + if (!is_valid()) { + return other.negate(); + } + + if (m_type != other.m_type) { + return rval; + } + + if (m_type == DTYPE_NONE) { + rval.set(other.negate()); + return rval; + } + + if (other.m_type == DTYPE_NONE) { + return *this; + } + + switch (m_type) { + case DTYPE_INT64: { + rval.set(m_data.m_int64 - other.m_data.m_int64); + } break; + case DTYPE_INT32: { + rval.set(m_data.m_int32 - other.m_data.m_int32); + } break; + case DTYPE_INT16: { + rval.set(m_data.m_int16 - other.m_data.m_int16); + } break; + case DTYPE_INT8: { + rval.set(m_data.m_int8 - other.m_data.m_int8); + } break; + + case DTYPE_UINT64: { + rval.set(m_data.m_uint64 - other.m_data.m_uint64); + } break; + case DTYPE_UINT32: { + rval.set(m_data.m_uint32 - other.m_data.m_uint32); + } break; + case DTYPE_UINT16: { + rval.set(m_data.m_uint16 - other.m_data.m_uint16); + } break; + case DTYPE_UINT8: { + rval.set(m_data.m_uint8 - other.m_data.m_uint8); + } break; + + case DTYPE_FLOAT64: { + rval.set(m_data.m_float64 - other.m_data.m_float64); + } break; + case DTYPE_FLOAT32: { + rval.set(m_data.m_float32 - other.m_data.m_float32); + } break; + default: { + // no-op + } + } + return rval; +} + +t_tscalar +t_tscalar::mul(const t_tscalar& other) const { + + bool fp = is_floating_point() || other.is_floating_point(); + t_tscalar rval; + + if (fp) { + rval.set(to_double() * other.to_double()); + return rval; + } + + bool is_s = is_signed() || other.is_signed(); + if (is_s) { + rval.set(to_int64() * other.to_int64()); + return rval; + } + + rval.set(to_uint64() * other.to_uint64()); + return rval; +} + +std::string +t_tscalar::repr() const { + std::stringstream ss; + ss << get_dtype_descr(static_cast(m_type)) << ":" + << get_status_descr(m_status) << ":" << to_string(); + return ss.str(); +} + +bool +t_tscalar::is_valid() const { + return m_status == STATUS_VALID; +} + +bool +t_tscalar::is_floating_point() const { + return (m_type == DTYPE_FLOAT32 || m_type == DTYPE_FLOAT64); +} + +bool +t_tscalar::is_signed() const { + return ( + m_type == DTYPE_INT64 || m_type == DTYPE_INT32 || m_type == DTYPE_INT16 + || m_type == DTYPE_INT8 + ); +} + +bool +t_tscalar::as_bool() const { + if (m_status != STATUS_VALID) { + return false; + } + + switch (m_type) { + case DTYPE_INT64: { + return bool(get()); + } break; + case DTYPE_INT32: { + return bool(get()); + } break; + case DTYPE_INT16: { + return bool(get()); + } break; + case DTYPE_INT8: { + return bool(get()); + } break; + case DTYPE_UINT64: { + return bool(get()); + } break; + case DTYPE_UINT32: { + return bool(get()); + } break; + case DTYPE_UINT16: { + return bool(get()); + } break; + case DTYPE_UINT8: { + return bool(get()); + } break; + case DTYPE_FLOAT64: { + return bool(get()); + } break; + case DTYPE_FLOAT32: { + return bool(get()); + } break; + case DTYPE_DATE: { + return bool(get()); + } break; + case DTYPE_TIME: { + return bool(get()); + } break; + case DTYPE_BOOL: { + return bool(get()); + } break; + case DTYPE_NONE: { + return bool(false); + } break; + case DTYPE_STR: { + return m_data.m_charptr != nullptr; + } break; + case DTYPE_OBJECT: + default: { +#ifdef PSP_DEBUG + std::cout << __FILE__ << ":" << __LINE__ << " Reached unknown type " + << m_type << "\n"; +#endif + } + } + return false; +} + +std::string +t_tscalar::to_string(bool for_expr) const { + if (m_status != STATUS_VALID) { + return {"null"}; + } + + std::stringstream ss; + + switch (m_type) { + case DTYPE_NONE: { + return {""}; + } break; + case DTYPE_INT64: { + ss << get(); + return ss.str(); + } break; + case DTYPE_INT32: { + ss << get(); + return ss.str(); + } break; + case DTYPE_INT16: { + ss << get(); + return ss.str(); + } break; + case DTYPE_UINT64: { + // treat object pointers as unsigned 64 bit ints + ss << get(); + return ss.str(); + } break; + case DTYPE_UINT32: { + ss << get(); + return ss.str(); + } break; + case DTYPE_UINT16: { + ss << get(); + return ss.str(); + } break; + case DTYPE_FLOAT64: { + ss << get(); + return ss.str(); + } break; + case DTYPE_FLOAT32: { + ss << get(); + return ss.str(); + } break; + case DTYPE_DATE: { + if (for_expr) { + auto d = get(); + ss << "date(" << d.year() << ", " << d.month() << ", " + << d.day() << ")"; + + return ss.str(); + } + + return get().str(); + } break; + case DTYPE_BOOL: { + ss << std::boolalpha << get(); + return ss.str(); + } break; + case DTYPE_INT8: { + ss << std::int32_t(get()); + return ss.str(); + } break; + case DTYPE_UINT8: { + ss << std::uint32_t(get()); + return ss.str(); + } break; + case DTYPE_TIME: { + // Format a millisecond UTC timestamp as a UTC datestring; + // localization is the display layer's job. + std::chrono::milliseconds timestamp(to_int64()); + date::sys_time ts(timestamp); + return date::format("%F %T", ts); + } break; + case DTYPE_STR: { + if (for_expr) { + ss << "'"; + } + + if (m_data.m_charptr == nullptr) { + if (for_expr) { + ss << "'"; + } + + return ss.str(); + } + + ss << get_char_ptr(); + if (for_expr) { + ss << "'"; + } + return ss.str(); + } break; + case DTYPE_OBJECT: + default: { + PSP_COMPLAIN_AND_ABORT("Unrecognized dtype"); + } + } + return {"null"}; +} + +double +t_tscalar::to_double() const { + switch (m_type) { + case DTYPE_INT64: { + return get(); + } break; + case DTYPE_INT32: { + return get(); + } break; + case DTYPE_INT16: { + return get(); + } break; + case DTYPE_INT8: { + return get(); + } break; + case DTYPE_UINT64: { + return get(); + } break; + case DTYPE_UINT32: { + return get(); + } break; + case DTYPE_UINT16: { + return get(); + } break; + case DTYPE_UINT8: { + return get(); + } break; + case DTYPE_FLOAT64: { + return get(); + } break; + case DTYPE_FLOAT32: { + return get(); + } break; + case DTYPE_DATE: { + return get(); + } break; + case DTYPE_TIME: { + return get(); + } break; + case DTYPE_BOOL: { + return static_cast(get()); + } break; + case DTYPE_NONE: + default: { + return 0; + } + } + + return 0; +} + +t_tscalar +t_tscalar::coerce_numeric_dtype(t_dtype dtype) const { + if (dtype == m_type) { + return *this; + } + + switch (dtype) { + case DTYPE_INT64: { + return coerce_numeric(); + } break; + case DTYPE_INT32: { + return coerce_numeric(); + } break; + case DTYPE_INT16: { + return coerce_numeric(); + } break; + case DTYPE_INT8: { + return coerce_numeric(); + } break; + case DTYPE_UINT64: { + return coerce_numeric(); + } break; + case DTYPE_UINT32: { + return coerce_numeric(); + } break; + case DTYPE_UINT16: { + return coerce_numeric(); + } break; + case DTYPE_UINT8: { + return coerce_numeric(); + } break; + case DTYPE_FLOAT64: { + return coerce_numeric(); + } break; + case DTYPE_FLOAT32: { + return coerce_numeric(); + } break; + case DTYPE_BOOL: { + return coerce_numeric(); + } break; + default: { + return *this; + } + } + + return mknone(); +} + +std::int64_t +t_tscalar::to_int64() const { + switch (m_type) { + case DTYPE_INT64: { + return get(); + } break; + case DTYPE_INT32: { + return get(); + } break; + case DTYPE_INT16: { + return get(); + } break; + case DTYPE_INT8: { + return get(); + } break; + case DTYPE_UINT64: { + return get(); + } break; + case DTYPE_UINT32: { + return get(); + } break; + case DTYPE_UINT16: { + return get(); + } break; + case DTYPE_UINT8: { + return get(); + } break; + case DTYPE_FLOAT64: { + return get(); + } break; + case DTYPE_FLOAT32: { + return get(); + } break; + case DTYPE_DATE: { + return get(); + } break; + case DTYPE_TIME: { + return get(); + } break; + case DTYPE_BOOL: { + return static_cast(get()); + } break; + case DTYPE_NONE: + default: { + return 0; + } + } + + return 0; +} + +std::int32_t +t_tscalar::to_int32() const { + switch (m_type) { + case DTYPE_INT64: { + return get(); + } break; + case DTYPE_INT32: { + return get(); + } break; + case DTYPE_INT16: { + return get(); + } break; + case DTYPE_INT8: { + return get(); + } break; + case DTYPE_UINT64: { + return get(); + } break; + case DTYPE_UINT32: { + return get(); + } break; + case DTYPE_UINT16: { + return get(); + } break; + case DTYPE_UINT8: { + return get(); + } break; + case DTYPE_FLOAT64: { + return get(); + } break; + case DTYPE_FLOAT32: { + return get(); + } break; + case DTYPE_DATE: { + return get(); + } break; + case DTYPE_TIME: { + return get(); + } break; + case DTYPE_BOOL: { + return static_cast(get()); + } break; + case DTYPE_NONE: + default: { + return 0; + } + } + + return 0; +} + +std::uint64_t +t_tscalar::to_uint64() const { + switch (m_type) { + case DTYPE_INT64: { + return get(); + } break; + case DTYPE_INT32: { + return get(); + } break; + case DTYPE_INT16: { + return get(); + } break; + case DTYPE_INT8: { + return get(); + } break; + case DTYPE_UINT64: { + return get(); + } break; + case DTYPE_UINT32: { + return get(); + } break; + case DTYPE_UINT16: { + return get(); + } break; + case DTYPE_UINT8: { + return get(); + } break; + case DTYPE_FLOAT64: { + return get(); + } break; + case DTYPE_FLOAT32: { + return get(); + } break; + case DTYPE_DATE: { + return get(); + } break; + case DTYPE_TIME: { + return get(); + } break; + case DTYPE_BOOL: { + return static_cast(get()); + } break; + case DTYPE_OBJECT: + case DTYPE_NONE: + default: { + return 0; + } + } + + return 0; +} + +bool +t_tscalar::begins_with(const t_tscalar& other) const { + if (m_status != STATUS_VALID || m_type != DTYPE_STR + || other.m_type != DTYPE_STR) { + return false; + } + std::string sstr = to_string(); + std::string ostr = other.to_string(); + std::string_to_lower(sstr); + std::string_to_lower(ostr); + return sstr.find(ostr) == 0; +} + +bool +t_tscalar::ends_with(const t_tscalar& other) const { + if (m_status != STATUS_VALID || m_type != DTYPE_STR + || other.m_type != DTYPE_STR) { + return false; + } + std::string sstr = to_string(); + std::string ostr = other.to_string(); + std::string_to_lower(sstr); + std::string_to_lower(ostr); + size_t idx = sstr.rfind(ostr); + return (idx != std::string::npos) && (idx + ostr.size() == sstr.size()); +} + +bool +t_tscalar::contains(const t_tscalar& other) const { + if (m_status != STATUS_VALID || m_type != DTYPE_STR + || other.m_type != DTYPE_STR) { + return false; + } + std::string sstr = to_string(); + std::string ostr = other.to_string(); + std::string_to_lower(sstr); + std::string_to_lower(ostr); + size_t idx = sstr.find(ostr); + return idx != std::string::npos; +} + +std::string +repr(const t_tscalar& s) { + return s.to_string(); +} + +size_t +hash_value(const t_tscalar& s) { + std::size_t seed = 0; + if (s.m_status == STATUS_VALID) { + boost::hash_combine(seed, s.m_status); + boost::hash_combine(seed, s.m_type); + if (s.m_type == DTYPE_STR) { + const char* c = s.get_char_ptr(); + boost::hash_combine(seed, boost::hash_range(c, c + std::strlen(c))); + } else { + boost::hash_combine(seed, s.m_data.m_uint64); + } + } else { + boost::hash_combine(seed, STATUS_INVALID); + boost::hash_combine(seed, s.m_type); + } + + return seed; +} + +t_tscalar +mktscalar() { + t_tscalar rval; + rval.set(t_none()); + return rval; +} + +template <> +std::int64_t +t_tscalar::get() const { + return m_data.m_int64; +} + +template <> +std::int32_t +t_tscalar::get() const { + return m_data.m_int32; +} + +template <> +std::int16_t +t_tscalar::get() const { + return m_data.m_int16; +} + +template <> +std::int8_t +t_tscalar::get() const { + return m_data.m_int8; +} + +template <> +std::uint64_t +t_tscalar::get() const { + return m_data.m_uint64; +} + +template <> +std::uint32_t +t_tscalar::get() const { + return m_data.m_uint32; +} + +template <> +std::uint16_t +t_tscalar::get() const { + return m_data.m_uint16; +} + +template <> +std::uint8_t +t_tscalar::get() const { + return m_data.m_uint8; +} + +template <> +double +t_tscalar::get() const { + return m_data.m_float64; +} + +template <> +float +t_tscalar::get() const { + return m_data.m_float32; +} + +template <> +bool +t_tscalar::get() const { + return m_data.m_bool; +} + +template <> +const char* +t_tscalar::get() const { + return get_char_ptr(); +} + +template <> +t_date +t_tscalar::get() const { + return t_date(m_data.m_uint32); +} + +template <> +t_time +t_tscalar::get() const { + return t_time(m_data.m_int64); +} + +template <> +t_none +t_tscalar::get() const { + return {}; +} + +t_dtype +t_tscalar::get_dtype() const { + return static_cast(m_type); +} + +bool +t_tscalar::cmp(t_filter_op op, const t_tscalar& other) const { + const t_tscalar& value = *this; + + switch (op) { + case FILTER_OP_LT: { + return m_status == STATUS_VALID && other.m_status == STATUS_VALID + && value < other; + } break; + case FILTER_OP_LTEQ: { + return (m_status == STATUS_VALID && other.m_status == STATUS_VALID + && value < other) + || other == value; + } break; + case FILTER_OP_GT: { + return m_status == STATUS_VALID && other.m_status == STATUS_VALID + && value > other; + } break; + case FILTER_OP_GTEQ: { + return (m_status == STATUS_VALID && other.m_status == STATUS_VALID + && value > other) + || other == value; + } break; + case FILTER_OP_EQ: { + return other == value; + } break; + case FILTER_OP_NE: { + return other != value; + } break; + case FILTER_OP_BEGINS_WITH: { + return value.begins_with(other); + } break; + case FILTER_OP_ENDS_WITH: { + return value.ends_with(other); + } break; + case FILTER_OP_CONTAINS: { + return value.contains(other); + } break; + case FILTER_OP_IS_NULL: { + return m_status != STATUS_VALID; + } break; + case FILTER_OP_IS_NOT_NULL: { + return m_status == STATUS_VALID; + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Invalid filter op"); + } break; + } + + return false; +} + +const char* +t_tscalar::get_char_ptr() const { +#ifdef PSP_SSO_SCALAR + if (is_inplace()) { + return m_data.m_inplace_char; + } +#endif + return m_data.m_charptr; +} + +#ifdef PSP_SSO_SCALAR +bool +t_tscalar::is_inplace() const { + return m_inplace; +} + +bool +t_tscalar::can_store_inplace(const char* s) { + return strlen(s) + 1 <= static_cast(SCALAR_INPLACE_LEN); +} + +bool +t_tscalar::can_store_inplace(const std::string& s) { + return s.size() + 1 <= static_cast(SCALAR_INPLACE_LEN); +} +#endif + +bool +t_tscalar::is_nan() const { + if (m_type == DTYPE_FLOAT64) { + return std::isnan(get()) || std::isinf(get()); + } + if (m_type == DTYPE_FLOAT32) { + return std::isnan(get()) || std::isinf(get()); + } + return false; +} + +t_tscalar +mknone() { + t_tscalar rval; + rval.set(t_none()); + return rval; +} + +t_tscalar +mknull(t_dtype dtype) { + t_tscalar rval; + rval.m_data.m_uint64 = 0; + rval.m_status = STATUS_INVALID; + rval.m_type = dtype; +#ifdef PSP_SSO_SCALAR + if (dtype == DTYPE_STR) { + rval.m_inplace = true; + } +#endif + return rval; +} + +t_tscalar +mkclear(t_dtype dtype) { + t_tscalar rval = mknull(dtype); + rval.m_status = STATUS_CLEAR; + return rval; +} + +template <> +t_tscalar +t_tscalar::coerce_numeric() const { + t_tscalar rv; + + if (m_type == DTYPE_STR) { + const auto* v = get(); + std::string s1("True"); + std::string s2("true"); + std::string s3("TRUE"); + + if (strcmp(v, s1.c_str()) == 0 || strcmp(v, s2.c_str()) == 0 + || strcmp(v, s3.c_str()) == 0) { + rv.set(true); + return rv; + } + rv.set(false); + return rv; + } + bool v = static_cast(m_data.m_uint64); + rv.set(v); + return rv; +} + +} // end namespace perspective + +namespace std { +std::ostream& +operator<<(std::ostream& os, const perspective::t_tscalar& t) { + os << repr(t); + return os; +} + +std::ostream& +operator<<(std::ostream& os, const std::vector& t) { + os << "["; + auto length = t.size(); + + for (auto i = 0; i < length; ++i) { + os << t[i]; + + if (i != length - 1) { + os << ","; + } + } + + os << "]"; + return os; +} + +} // end namespace std diff --git a/rust/perspective-server/cpp/perspective/src/cpp/schema.cpp b/rust/perspective-server/cpp/perspective/src/cpp/schema.cpp new file mode 100644 index 0000000..987d3aa --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/schema.cpp @@ -0,0 +1,214 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include + +namespace perspective { + +t_schema::t_schema() = default; + +t_schema::t_schema( + const std::vector& columns, const std::vector& types +) : + m_columns(columns), + m_types(types), + m_status_enabled(columns.size()), + m_pkeyidx(0), + m_opidx(0) { + PSP_VERBOSE_ASSERT(columns.size() == types.size(), "Size mismatch"); + + bool pkey_found = false; + bool op_found = false; + + std::string pkey_str("psp_pkey"); + std::string op_str("psp_op"); + for (std::vector::size_type idx = 0, loop_end = types.size(); + idx < loop_end; + ++idx) { + m_colidx_map[columns[idx]] = idx; + m_coldt_map[columns[idx]] = types[idx]; + m_status_enabled[idx] = true; + if (columns[idx] == pkey_str) { + pkey_found = true; + m_pkeyidx = idx; + } + + if (columns[idx] == op_str) { + op_found = true; + m_opidx = idx; + } + } + + m_is_pkey = pkey_found && op_found; +} + +t_uindex +t_schema::get_num_columns() const { + return m_columns.size(); +} + +t_uindex +t_schema::size() const { + return m_columns.size(); +} + +t_uindex +t_schema::get_colidx(const std::string& colname) const { + auto iter = m_colidx_map.find(colname); + if (iter == m_colidx_map.end()) { + std::stringstream ss; + ss << "Could not find column index for `" << colname + << "` as it does not exist in the schema." << '\n'; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + return iter->second; +} + +t_uindex +t_schema::get_colidx_safe(const std::string& colname) const { + auto iter = m_colidx_map.find(colname); + if (iter == m_colidx_map.end()) { + return -1; + } + return iter->second; +} + +t_dtype +t_schema::get_dtype(const std::string& colname) const { + auto iter = m_coldt_map.find(colname); + if (iter == m_coldt_map.end()) { + std::stringstream ss; + ss << "Could not get dtype for column `" << colname + << "` as it does not exist in the schema." << '\n'; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + return iter->second; +} + +bool +t_schema::is_pkey() const { + return m_is_pkey; +} + +bool +t_schema::operator==(const t_schema& rhs) const { + return m_columns == rhs.m_columns && m_types == rhs.m_types + && m_status_enabled == rhs.m_status_enabled; +} + +void +t_schema::add_column(const std::string& colname, t_dtype dtype) { + t_uindex idx = m_columns.size(); + m_columns.push_back(colname); + m_status_enabled.push_back(true); + m_types.push_back(dtype); + m_colidx_map[colname] = idx; + m_coldt_map[colname] = dtype; + + if (colname == "psp_pkey") { + m_pkeyidx = idx; + m_is_pkey = true; + } + + if (colname == "psp_op") { + m_opidx = idx; + m_is_pkey = true; + } +} + +void +t_schema::retype_column(const std::string& colname, t_dtype dtype) { + if (colname == "psp_pkey" + || colname == "psp_op") { + PSP_COMPLAIN_AND_ABORT("Cannot retype primary key or operation columns." + ); + } + if (!has_column(colname)) { + std::stringstream ss; + ss << "Cannot retype column `" << colname << "` as it does not exist." + << '\n'; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + + t_uindex idx = get_colidx(colname); + m_types[idx] = dtype; + m_colidx_map[colname] = idx; + m_coldt_map[colname] = dtype; +} + +bool +t_schema::has_column(std::string_view colname) const { + auto iter = m_colidx_map.find(colname.data()); + return iter != m_colidx_map.end(); +} + +const std::vector& +t_schema::columns() const { + return m_columns; +} + +std::vector +t_schema::types() const { + return m_types; +} + +std::string +t_schema::str() const { + std::stringstream ss; + ss << *this; + return ss.str(); +} + +t_schema +t_schema::drop(const std::set& columns) const { + std::vector cols; + std::vector types; + + for (t_uindex idx = 0, loop_end = m_columns.size(); idx < loop_end; ++idx) { + if (columns.find(m_columns[idx]) == columns.end()) { + cols.push_back(m_columns[idx]); + types.push_back(m_types[idx]); + } + } + return t_schema(cols, types); +} + +t_schema +t_schema::operator+(const t_schema& o) const { + t_schema rv(m_columns, m_types); + for (t_uindex idx = 0, loop_end = o.m_columns.size(); idx < loop_end; + ++idx) { + rv.add_column(o.m_columns[idx], o.m_types[idx]); + } + return rv; +} + +} // end namespace perspective + +namespace std { + +std::ostream& +operator<<(std::ostream& os, const perspective::t_schema& s) { + using namespace perspective; + const std::vector& cols = s.columns(); + const std::vector& types = s.types(); + + os << "t_schema<\n"; + for (size_t idx = 0, loop_end = cols.size(); idx < loop_end; ++idx) { + os << "\t" << idx << ". " << cols[idx] << ", " + << get_dtype_descr(types[idx]) << '\n'; + } + os << ">\n"; + return os; +} +} // namespace std diff --git a/rust/perspective-server/cpp/perspective/src/cpp/schema_column.cpp b/rust/perspective-server/cpp/perspective/src/cpp/schema_column.cpp new file mode 100644 index 0000000..55df142 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/schema_column.cpp @@ -0,0 +1,26 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include + +#include + +namespace perspective { + +t_schema_column::t_schema_column( + std::string tblname, std::string name, std::string altname, t_dtype dtype +) : + m_tblname(std::move(tblname)), + m_name(std::move(name)), + m_altname(std::move(altname)) {} +} // namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/server.cpp b/rust/perspective-server/cpp/perspective/src/cpp/server.cpp new file mode 100644 index 0000000..4955833 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/server.cpp @@ -0,0 +1,3255 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include "google/protobuf/repeated_ptr_field.h" +#include "google/protobuf/struct.pb.h" +#include "perspective.pb.h" +#include "perspective/base.h" +#include "perspective/computed_expression.h" +#include "perspective/exception.h" +#include "perspective/pyutils.h" +#include "perspective/raw_types.h" +#include "perspective/scalar.h" +#include "perspective/sparse_tree.h" +#include "perspective/table.h" +#include "perspective/time.h" +#include "perspective/view.h" +#include "perspective/view_config.h" +#include "re2/re2.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(PSP_ENABLE_WASM) || defined(__linux__) +#include +#endif + +#if !defined(WIN32) && !defined(PSP_ENABLE_WASM) +#include +#endif + +namespace perspective { + +static void +tscalar_to_proto(const t_tscalar& scalar, proto::Scalar* out) { + if (scalar.is_none() || !scalar.is_valid()) { + out->set_null(::google::protobuf::NullValue::NULL_VALUE); + } else if (scalar.is_str()) { + out->set_string(scalar.to_string()); + } else if (scalar.get_dtype() == DTYPE_BOOL) { + out->set_bool_(scalar.as_bool()); + } else { + out->set_float_(scalar.to_double()); + } +} + +std::uint32_t server::ProtoServer::m_client_id = 1; + +template <> +std::shared_ptr +make_context( + std::shared_ptr
    table, + std::shared_ptr schema, + std::shared_ptr view_config, + const std::string& name +) { + auto columns = view_config->get_columns(); + auto fterm = view_config->get_fterm(); + + auto cfg = t_config(columns); + auto ctx_unit = std::make_shared(*schema, cfg); + ctx_unit->init(); + + auto pool = table->get_pool(); + auto gnode = table->get_gnode(); + + pool->register_context( + gnode->get_id(), + name, + UNIT_CONTEXT, + reinterpret_cast(ctx_unit.get()) + ); + return ctx_unit; +} + +template <> +std::shared_ptr +make_context( + std::shared_ptr
    table, + std::shared_ptr schema, + std::shared_ptr view_config, + const std::string& name +) { + auto columns = view_config->get_columns(); + auto filter_op = view_config->get_filter_op(); + auto fterm = view_config->get_fterm(); + auto sortspec = view_config->get_sortspec(); + auto expressions = view_config->get_used_expressions(); + + auto cfg = t_config(columns, fterm, filter_op, expressions); + cfg.set_backing_store(table->get_backing_store()); + auto ctx0 = std::make_shared(*schema, cfg); + ctx0->init(); + ctx0->sort_by(sortspec); + + auto pool = table->get_pool(); + auto gnode = table->get_gnode(); + pool->register_context( + gnode->get_id(), + name, + ZERO_SIDED_CONTEXT, + reinterpret_cast(ctx0.get()) + ); + return ctx0; +} + +template <> +std::shared_ptr +make_context( + std::shared_ptr
    table, + std::shared_ptr schema, + std::shared_ptr view_config, + const std::string& name +) { + auto row_pivots = view_config->get_row_pivots(); + auto aggspecs = view_config->get_aggspecs(); + auto filter_op = view_config->get_filter_op(); + auto fterm = view_config->get_fterm(); + auto sortspec = view_config->get_sortspec(); + auto row_pivot_depth = view_config->get_row_pivot_depth(); + auto expressions = view_config->get_used_expressions(); + + auto cfg = t_config(row_pivots, aggspecs, fterm, filter_op, expressions); + cfg.set_backing_store(table->get_backing_store()); + auto ctx1 = std::make_shared(*schema, cfg); + + ctx1->init(); + ctx1->sort_by(sortspec); + + auto pool = table->get_pool(); + auto gnode = table->get_gnode(); + if (view_config->is_total_only()) { + ctx1->set_total_only(true); + } else if (view_config->is_leaves_only()) { + ctx1->set_leaves_only(true); + } else if (row_pivot_depth > -1) { + ctx1->set_depth(row_pivot_depth - 1); + } else { + ctx1->set_depth(row_pivots.size()); + } + + pool->register_context( + gnode->get_id(), + name, + ONE_SIDED_CONTEXT, + reinterpret_cast(ctx1.get()) + ); + + return ctx1; +} + +template <> +std::shared_ptr +make_context( + std::shared_ptr
    table, + std::shared_ptr schema, + std::shared_ptr view_config, + const std::string& name +) { + bool column_only = view_config->is_column_only(); + auto row_pivots = view_config->get_row_pivots(); + auto column_pivots = view_config->get_column_pivots(); + auto aggspecs = view_config->get_aggspecs(); + auto filter_op = view_config->get_filter_op(); + auto fterm = view_config->get_fterm(); + auto sortspec = view_config->get_sortspec(); + auto col_sortspec = view_config->get_col_sortspec(); + auto row_pivot_depth = view_config->get_row_pivot_depth(); + auto column_pivot_depth = view_config->get_column_pivot_depth(); + auto expressions = view_config->get_used_expressions(); + + t_totals total = !sortspec.empty() ? TOTALS_BEFORE : TOTALS_HIDDEN; + + auto cfg = t_config( + row_pivots, + column_pivots, + aggspecs, + total, + fterm, + filter_op, + expressions, + column_only + ); + cfg.set_backing_store(table->get_backing_store()); + auto ctx2 = std::make_shared(*schema, cfg); + + ctx2->init(); + + auto pool = table->get_pool(); + auto gnode = table->get_gnode(); + + if (!sortspec.empty()) { + ctx2->sort_by(sortspec); + } + + if (!col_sortspec.empty()) { + ctx2->column_sort_by(col_sortspec); + } + + if (view_config->is_total_only()) { + ctx2->set_total_only(true); + } else if (view_config->is_leaves_only() && !column_only) { + ctx2->set_leaves_only(true); + } else if (row_pivot_depth > -1) { + ctx2->set_depth(t_header::HEADER_ROW, row_pivot_depth - 1); + } else { + ctx2->set_depth(t_header::HEADER_ROW, row_pivots.size()); + } + + if (column_pivot_depth > -1) { + ctx2->set_depth(t_header::HEADER_COLUMN, column_pivot_depth - 1); + } else { + ctx2->set_depth(t_header::HEADER_COLUMN, column_pivots.size()); + } + + pool->register_context( + gnode->get_id(), + name, + TWO_SIDED_CONTEXT, + reinterpret_cast(ctx2.get()) + ); + + return ctx2; +} + +struct ValidatedExpr { + std::string expression_alias; + std::string expression; + std::string parse_expression_string; + tsl::hopscotch_map column_id_map; +}; + +static std::string +re_bool_to_str(std::string&& expression) { + static const RE2 true_("true"); + static const RE2 false_("false"); + RE2::GlobalReplace(&expression, true_, "True"); + RE2::GlobalReplace(&expression, false_, "False"); + return std::move(expression); +} + +template +static std::string +replace_with_re2( + const std::string& input, const re2::RE2& pattern, Lambda replacer +) { + std::string output; + re2::StringPiece input_piece(input); + + PSP_VERBOSE_ASSERT1( + pattern.NumberOfCapturingGroups() >= 2, + "Must have at least two capturing groups" + ); + + std::string outer_match; + std::string match; + const char* previous_end = input.data(); + + while (RE2::FindAndConsume(&input_piece, pattern, &outer_match, &match)) { + // With the pattern /"(dolor)"/ the input piece will jump _past_ the + // "dolor" match, so to maintain the text we skipped but didn't + // explicitly exclude from the group, we need to subtract the size of + // the match from the input piece to find the text before the match. + + // ↓↓↓↓↓↓↓↓↓↓↓↓ This would be lost otherwise + // Lorem ipsum "dolor" sit amet, consectetur adipiscing elit. + + // Calculate the start of the actual match. + // This unfortunately only works if nothing consumes + // input _after_ the outer match. + const char* match_start = input_piece.data() - outer_match.size(); + + // Append the text that was consumed but is not part of the match + output.append(previous_end, match_start - previous_end); + + // Apply the lambda function to the match and append the result + output += replacer(outer_match, match); + previous_end = input_piece.data(); + } + + output.append(previous_end, &*input.end() - previous_end); + return output; +} + +static auto +re_column_name_to_id(std::string&& expression, ValidatedExpr& validated_expr) { + static const RE2 column_name("(\"(.*?[^\\\\])\")"); + static const RE2 escaped_double_quote(R"(\")"); + static const RE2 escaped_backslash(R"(\\)"); + + std::string column_id; + tsl::hopscotch_map column_id_map; + tsl::hopscotch_map column_name_map; + std::uint32_t running_cidx = 0; + + auto parsed_expression_string = replace_with_re2( + expression, + column_name, + [&](const std::string& _, const std::string& cname) { + if (!column_name_map.contains(cname)) { + column_id = "COLUMN" + std::to_string(running_cidx); + column_name_map[cname] = column_id; + column_id_map[column_id] = cname; + } + + running_cidx++; + return column_name_map[cname]; + } + ); + return std::tuple(parsed_expression_string, column_id_map); +} + +static auto +re_intern_strings(std::string&& expression) { + static const RE2 intern_string("('.*?[^\\\\]')"); + RE2::GlobalReplace(&expression, intern_string, "intern(\\1)"); + return expression; +} + +static auto +re_unintern_some_exprs(std::string&& expression) { + static const RE2 interned_param( + "(?:match|match_all|search|indexof|replace|replace_all|contains)\\(" + "(?:.*?,\\s*(intern\\(('.*?')\\)))" + ); + static const RE2 intern_match("intern\\(('.*?')\\)"); + + auto parsed_expression_string = replace_with_re2( + expression, + interned_param, + [&](const std::string& outer_match, const std::string& inner_match) { + LOG_DEBUG("UNINTERNING: " << inner_match); + std::string out = inner_match; + return out; + } + ); + + return parsed_expression_string; +}; + +template +static std::vector +parse_expression_strings(const F& column_expr) { + // assert that F has an operator[] that takes a string and returns a string + static_assert(std::is_same_v< + decltype(std::declval()[std::declval()] + ), + std::string&>); + std::vector validated_exprs; + for (const auto& [colname, expr] : column_expr) { + // TODO: Remove + ValidatedExpr validated_expr; + validated_expr.expression = expr; + validated_expr.expression_alias = colname; + validated_expr.parse_expression_string = expr; + + LOG_DEBUG( + "Before preprocessing: " << validated_expr.parse_expression_string + ); + + validated_expr.parse_expression_string = + re_bool_to_str(std::move(validated_expr.parse_expression_string)); + + std::tie( + validated_expr.parse_expression_string, validated_expr.column_id_map + ) = + re_column_name_to_id( + std::move(validated_expr.parse_expression_string), + validated_expr + ); + + validated_expr.parse_expression_string = + re_intern_strings(std::move(validated_expr.parse_expression_string) + ); + + validated_expr.parse_expression_string = re_unintern_some_exprs( + std::move(validated_expr.parse_expression_string) + ); + + LOG_DEBUG( + "After preprocessing: " << validated_expr.parse_expression_string + ); + + validated_exprs.emplace_back(std::move(validated_expr)); + } + return validated_exprs; +} + +} // namespace perspective + +namespace perspective::server { +void +ServerResources::host_table(const t_id& id, std::shared_ptr
    table) { + PSP_WRITE_LOCK(m_write_lock); + m_tables.emplace(id, std::move(table)); +} + +void +ServerResources::host_view( + const std::uint32_t& client_id, + const t_id& id, + const t_id& table_id, + std::shared_ptr view +) { + PSP_WRITE_LOCK(m_write_lock); + m_view_to_table.emplace(id, table_id); + m_table_to_view.emplace(table_id, id); + m_views.emplace(id, std::move(view)); + if (!m_client_to_view.contains(client_id)) { + std::vector vec{id}; + m_client_to_view.emplace(client_id, vec); + } else { + m_client_to_view[client_id].emplace_back(id); + } +} + +std::shared_ptr
    +ServerResources::get_table(const t_id& id) { + PSP_READ_LOCK(m_write_lock); + return m_tables.at(id); +} + +std::vector +ServerResources::get_table_ids() { + PSP_READ_LOCK(m_write_lock); + std::vector vec; + for (auto const& imap : m_tables) { + if (!m_deleted_tables.contains(imap.first)) { + vec.push_back(imap.first); + } + } + + return vec; +} + +std::shared_ptr
    +ServerResources::get_table_for_view(const t_id& view_id) { + PSP_READ_LOCK(m_write_lock); + return m_tables.at(m_view_to_table.at(view_id)); +} + +ServerResources::t_id +ServerResources::get_table_id_for_view(const t_id& view_id) { + PSP_READ_LOCK(m_write_lock); + if (!m_view_to_table.contains(view_id)) { + throw PerspectiveViewNotFoundException(); + } + + return m_view_to_table.at(view_id); +} + +std::vector +ServerResources::get_view_ids(const t_id& table_id) { + PSP_READ_LOCK(m_write_lock); + std::vector out; + auto range = m_table_to_view.equal_range(table_id); + for (auto it = range.first; it != range.second; ++it) { + out.push_back(it->second); + } + return out; +} + +bool +ServerResources::has_view(const t_id& id) { + PSP_READ_LOCK(m_write_lock); + return m_views.contains(id); +} + +bool +ServerResources::has_table(const t_id& id) { + PSP_READ_LOCK(m_write_lock); + return m_tables.contains(id); +} + +std::shared_ptr +ServerResources::get_view(const t_id& id) { + PSP_READ_LOCK(m_write_lock); + if (!m_views.contains(id)) { + throw PerspectiveViewNotFoundException(); + } + + return m_views.at(id); +} + +void +ServerResources::delete_view(const std::uint32_t& client_id, const t_id& id) { + if (!m_view_to_table.contains(id)) { + throw PerspectiveViewNotFoundException(); + } + + { + PSP_WRITE_LOCK(m_write_lock); + auto table_id = m_view_to_table.at(id); + if (m_views.find(id) != m_views.end()) { + m_views.erase(id); + } + + if (m_view_to_table.find(id) != m_view_to_table.end()) { + m_view_to_table.erase(id); + } + + auto& vec = m_client_to_view[client_id]; + vec.erase(std::remove(vec.begin(), vec.end(), id), vec.end()); + auto range = m_table_to_view.equal_range(table_id); + for (auto it = range.first; it != range.second;) { + if (it->second == id) { + it = m_table_to_view.erase(it); + } else { + ++it; + } + } + } + + drop_view_on_update_sub(id); + drop_view_on_delete_sub(id); +} + +void +ServerResources::delete_table(const t_id& id) { + PSP_WRITE_LOCK(m_write_lock); + if (m_tables.find(id) != m_tables.end()) { + if (m_table_to_view.find(id) == m_table_to_view.end()) { + m_tables.erase(id); + m_dirty_tables.erase(id); + m_deleted_tables.erase(id); + } else { + PSP_COMPLAIN_AND_ABORT("Cannot delete table with views"); + } + } +} + +void +ServerResources::mark_table_dirty(const t_id& id) { + PSP_WRITE_LOCK(m_write_lock); + m_dirty_tables.insert(id); +} + +void +ServerResources::mark_table_clean(const t_id& id) { + PSP_WRITE_LOCK(m_write_lock); + m_dirty_tables.erase(id); +} + +void +ServerResources::mark_all_tables_clean() { + PSP_WRITE_LOCK(m_write_lock); + m_dirty_tables.clear(); +} + +void +ServerResources::create_table_on_delete_sub( + const t_id& table_id, Subscription sub_id +) { + PSP_WRITE_LOCK(m_write_lock); + if (!m_table_on_delete_subs.contains(table_id)) { + m_table_on_delete_subs[table_id] = {sub_id}; + } else { + m_table_on_delete_subs[table_id].push_back(sub_id); + } +} + +std::vector +ServerResources::get_table_on_delete_sub(const t_id& table_id) { + PSP_READ_LOCK(m_write_lock); + if (!m_table_on_delete_subs.contains(table_id)) { + return {}; + } + return m_table_on_delete_subs.at(table_id); +} + +void +ServerResources::remove_table_on_delete_sub( + const t_id& table_id, + const std::uint32_t sub_id, + const std::uint32_t client_id +) { + { + PSP_WRITE_LOCK(m_write_lock); + if (!m_table_on_delete_subs.contains(table_id)) { + m_table_on_delete_subs[table_id] = {}; + } + } + + PSP_READ_LOCK(m_write_lock); + auto& subs = m_table_on_delete_subs.at(table_id); + for (auto sub = subs.begin(); sub != subs.end();) { + if (sub->id == sub_id && sub->client_id == client_id) { + subs.erase(sub); + break; + } + + ++sub; + } +} + +void +ServerResources::create_view_on_delete_sub( + const t_id& view_id, Subscription sub +) { + PSP_WRITE_LOCK(m_write_lock); + if (!m_view_on_delete_subs.contains(view_id)) { + m_view_on_delete_subs[view_id] = {sub}; + } else { + m_view_on_delete_subs[view_id].push_back(sub); + } +} + +std::vector +ServerResources::get_view_on_delete_sub(const t_id& view_id) { + PSP_READ_LOCK(m_write_lock); + if (!m_view_on_delete_subs.contains(view_id)) { + return {}; + } + + return m_view_on_delete_subs.at(view_id); +} + +void +ServerResources::remove_view_on_delete_sub( + const t_id& view_id, + const std::uint32_t sub_id, + const std::uint32_t client_id +) { + PSP_WRITE_LOCK(m_write_lock); + if (!m_view_on_delete_subs.contains(view_id)) { + m_view_on_delete_subs[view_id] = {}; + } + + auto& subs = m_view_on_delete_subs.at(view_id); + for (auto sub = subs.begin(); sub != subs.end();) { + if (sub->id == sub_id && sub->client_id == client_id) { + subs.erase(sub); + break; + } + + ++sub; + } +} + +void +ServerResources::drop_view_on_delete_sub(const t_id& view_id) { + m_view_on_delete_subs.erase(view_id); +} + +void +ServerResources::create_view_on_update_sub( + const t_id& view_id, Subscription sub +) { + PSP_WRITE_LOCK(m_write_lock); + if (!m_view_on_update_subs.contains(view_id)) { + m_view_on_update_subs[view_id] = {sub}; + } else { + m_view_on_update_subs[view_id].push_back(sub); + } +} + +std::vector +ServerResources::get_view_on_update_sub(const t_id& view_id) { + PSP_READ_LOCK(m_write_lock); + if (!m_view_on_update_subs.contains(view_id)) { + return {}; + } + + return m_view_on_update_subs.at(view_id); +} + +void +ServerResources::drop_view_on_update_sub(const t_id& view_id) { + PSP_WRITE_LOCK(m_write_lock); + m_view_on_update_subs.erase(view_id); +} + +void +ServerResources::remove_view_on_update_sub( + const t_id& view_id, std::uint32_t sub_id, std::uint32_t client_id +) { + if (m_view_on_update_subs.find(view_id) != m_view_on_update_subs.end()) { + auto& subs = m_view_on_update_subs[view_id]; + subs.erase( + std::remove_if( + subs.begin(), + subs.end(), + [sub_id, client_id](const Subscription& sub) { + return sub.id == sub_id && sub.client_id == client_id; + } + ), + subs.end() + ); + } +} + +void +ServerResources::create_on_hosted_tables_update_sub(Subscription sub) { + PSP_WRITE_LOCK(m_write_lock); + m_on_hosted_tables_update_subs.push_back(sub); +} + +std::vector +ServerResources::get_on_hosted_tables_update_sub() { + PSP_READ_LOCK(m_write_lock); + return m_on_hosted_tables_update_subs; +} + +void +ServerResources::remove_on_hosted_tables_update_sub( + std::uint32_t sub_id, std::uint32_t client_id +) { + m_on_hosted_tables_update_subs.erase( + std::remove_if( + m_on_hosted_tables_update_subs.begin(), + m_on_hosted_tables_update_subs.end(), + [sub_id, client_id](const Subscription& sub) { + return sub.id == sub_id && sub.client_id == client_id; + } + ), + m_on_hosted_tables_update_subs.end() + ); +} + +std::vector, const ServerResources::t_id>> +ServerResources::get_dirty_tables() { + PSP_READ_LOCK(m_write_lock); + std::vector, const t_id>> out; + for (const auto& id : m_dirty_tables) { + out.emplace_back(m_tables[id], id); + } + return out; +} + +bool +ServerResources::is_table_dirty(const t_id& id) { + PSP_READ_LOCK(m_write_lock); + return m_dirty_tables.contains(id); +} + +void +ServerResources::drop_client(std::uint32_t client_id) { + if (m_client_to_view.contains(client_id)) { + + // Load-bearing copy + std::vector remove = m_client_to_view[client_id]; + for (const auto& view_id : remove) { + delete_view(client_id, view_id); + } + } + + std::vector subs; + std::remove_copy_if( + m_on_hosted_tables_update_subs.begin(), + m_on_hosted_tables_update_subs.end(), + std::back_inserter(subs), + [&client_id](const Subscription& item) { + return item.client_id == client_id; + } + ); + + m_on_hosted_tables_update_subs = subs; +} + +std::uint32_t +ServerResources::get_table_view_count(const t_id& table_id) { + const auto ret = m_table_to_view.find(table_id); + return std::distance(ret, m_table_to_view.end()); +} + +void +ServerResources::mark_table_deleted( + const t_id& table_id, std::uint32_t client_id, std::uint32_t msg_id +) { + m_deleted_tables[table_id] = Subscription{msg_id, client_id}; +} + +bool +ServerResources::is_table_deleted(const t_id& table_id) { + return get_table_view_count(table_id) == 0 + && m_deleted_tables.contains(table_id); +} + +Subscription +ServerResources::get_table_deleted_client(const t_id& table_id) { + return m_deleted_tables[table_id]; +} + +std::uint32_t +ProtoServer::new_session() { + if (m_cpu_time_start.load().time_since_epoch().count() == 0) { + m_cpu_time_start = std::chrono::high_resolution_clock::now(); + } + + return m_client_id++; +} + +void +ProtoServer::close_session(const std::uint32_t client_id) { + const auto start = std::chrono::high_resolution_clock::now(); + m_resources.drop_client(client_id); + const auto end = std::chrono::high_resolution_clock::now(); + m_cpu_time += + std::chrono::duration_cast(end - start) + .count(); +} + +std::vector> +ProtoServer::handle_request( + std::uint32_t client_id, const std::string_view& data +) { + const auto start = std::chrono::high_resolution_clock::now(); + proto::Request req_env; + req_env.ParseFromString(data); + std::vector> serialized_responses; + std::vector responses; + + auto msg_id = req_env.msg_id(); + auto entity_id = req_env.entity_id(); + try { + auto resp_msg = _handle_request(client_id, std::move(req_env)); + for (auto& resp : resp_msg) { + ProtoServerResp str_resp; + str_resp.data = resp.data.SerializeAsString(); + str_resp.client_id = resp.client_id; + serialized_responses.emplace_back(std::move(str_resp)); + } + } catch (const PerspectiveException& e) { + proto::Response resp; + auto* err = resp.mutable_server_error()->mutable_message(); + *err = std::string(e.what()); + responses.emplace_back(std::move(resp)); + } catch (const PerspectiveViewNotFoundException& e) { + proto::Response resp; + auto* err = resp.mutable_server_error(); + err->set_status_code(proto::StatusCode::VIEW_NOT_FOUND); + auto* msg = err->mutable_message(); + *msg = std::string(e.what()); + responses.emplace_back(std::move(resp)); + } catch (const std::exception& e) { + proto::Response resp; + auto* err = resp.mutable_server_error()->mutable_message(); + *err = std::string(e.what()); + responses.emplace_back(std::move(resp)); + } catch (...) { + proto::Response resp; + auto* err = resp.mutable_server_error()->mutable_message(); + std::exception_ptr p = std::current_exception(); + *err = "Unknown exception"; + responses.emplace_back(std::move(resp)); + } + + // proto::Response resp_env; + serialized_responses.reserve(responses.size()); + for (auto& resp : responses) { + resp.set_msg_id(msg_id); + resp.set_entity_id(entity_id); + + ProtoServerResp str_resp; + str_resp.data = resp.SerializeAsString(); + str_resp.client_id = client_id; + serialized_responses.emplace_back(std::move(str_resp)); + } + + const auto end = std::chrono::high_resolution_clock::now(); + m_cpu_time += + std::chrono::duration_cast(end - start) + .count(); + +#ifndef PSP_ENABLE_WASM + // Request safepoint: the response is fully serialized, so no raw column + // pointer is live. Trim resident disk-backed buffers to the memory budget. + // Native (mmap) evicts inline here. On WASM/OPFS eviction needs an async + // handle-open step, so the JS engine drives it (`prepare`/open/`commit`) + // after this synchronous call returns — see `engine.ts`/the poly. + t_residency_manager::inst().safepoint(); +#endif + + return serialized_responses; +} + +std::vector> +ProtoServer::poll() { + const auto start = std::chrono::high_resolution_clock::now(); + std::vector> out; + try { + const auto& responses = _poll(); + for (const auto& resp : responses) { + ProtoServerResp str_resp; + str_resp.data = resp.data.SerializeAsString(); + str_resp.client_id = resp.client_id; + out.emplace_back(str_resp); + } + + } catch (const PerspectiveException& e) { + proto::Response resp; + auto* err = resp.mutable_server_error()->mutable_message(); + *err = std::string(e.what()); + ProtoServerResp str_resp; + str_resp.data = resp.SerializeAsString(); + str_resp.client_id = 0; + out.emplace_back(std::move(str_resp)); + } catch (const PerspectiveViewNotFoundException& e) { + proto::Response resp; + auto* err = resp.mutable_server_error(); + err->set_status_code(proto::StatusCode::VIEW_NOT_FOUND); + auto* msg = err->mutable_message(); + *msg = std::string(e.what()); + ProtoServerResp str_resp; + str_resp.data = resp.SerializeAsString(); + str_resp.client_id = 0; + out.emplace_back(std::move(str_resp)); + } catch (const std::exception& e) { + proto::Response resp; + auto* err = resp.mutable_server_error()->mutable_message(); + *err = std::string(e.what()); + ProtoServerResp str_resp; + str_resp.data = resp.SerializeAsString(); + str_resp.client_id = 0; + out.emplace_back(std::move(str_resp)); + } catch (...) { + proto::Response resp; + auto* err = resp.mutable_server_error()->mutable_message(); + std::exception_ptr p = std::current_exception(); + *err = "Unknown exception"; + ProtoServerResp str_resp; + str_resp.data = resp.SerializeAsString(); + str_resp.client_id = 0; + out.emplace_back(std::move(str_resp)); + } + + const auto end = std::chrono::high_resolution_clock::now(); + m_cpu_time += + std::chrono::duration_cast(end - start) + .count(); + + // Request safepoint (see `handle_request`). +#ifndef PSP_ENABLE_WASM + t_residency_manager::inst().safepoint(); +#endif + + return out; +} + +// WASM/OPFS safepoint, driven from JS so the async OPFS handle-open step can run +// between the two synchronous phases. `prepare` selects victims, the JS driver +// opens each `residency_victim_fname(i)` handle, then `commit` flushes + frees. +std::size_t +ProtoServer::residency_prepare() { + return t_residency_manager::inst().prepare(); +} + +const char* +ProtoServer::residency_victim_fname(std::size_t i) { + return t_residency_manager::inst().victim_fname(i); +} + +void +ProtoServer::residency_commit() { + t_residency_manager::inst().commit(); +} + +proto::ColumnType +dtype_to_column_type(const t_dtype& t) { + switch (t) { + case t_dtype::DTYPE_BOOL: + return proto::ColumnType::BOOLEAN; + case t_dtype::DTYPE_UINT8: + case t_dtype::DTYPE_UINT16: + case t_dtype::DTYPE_UINT32: + case t_dtype::DTYPE_UINT64: + case t_dtype::DTYPE_INT8: + case t_dtype::DTYPE_INT16: + case t_dtype::DTYPE_INT32: + case t_dtype::DTYPE_INT64: + return proto::ColumnType::INTEGER; + case t_dtype::DTYPE_FLOAT32: + case t_dtype::DTYPE_FLOAT64: + return proto::ColumnType::FLOAT; + case t_dtype::DTYPE_STR: + return proto::ColumnType::STRING; + case t_dtype::DTYPE_DATE: + return proto::ColumnType::DATE; + case t_dtype::DTYPE_TIME: + return proto::ColumnType::DATETIME; + default: + PSP_COMPLAIN_AND_ABORT("Invalid type " + dtype_to_str(t)); + return proto::ColumnType::STRING; + } +} + +t_dtype +column_type_to_dtype(const proto::ColumnType& t) { + switch (t) { + case proto::ColumnType::BOOLEAN: + return t_dtype::DTYPE_BOOL; + case proto::ColumnType::FLOAT: + return t_dtype::DTYPE_FLOAT64; + case proto::ColumnType::INTEGER: + return t_dtype::DTYPE_INT32; + case proto::ColumnType::DATE: + return t_dtype::DTYPE_DATE; + case proto::ColumnType::DATETIME: + return t_dtype::DTYPE_TIME; + case proto::ColumnType::STRING: + return t_dtype::DTYPE_STR; + default: + PSP_COMPLAIN_AND_ABORT("Invalid column type"); + return t_dtype::DTYPE_STR; + } +} + +struct ValidViewPort { + std::uint32_t start_row; + std::uint32_t end_row; + std::uint32_t start_col; + std::uint32_t end_col; +}; + +static ValidViewPort +parse_format_options( + const proto::ViewPort& viewport, + std::uint32_t num_columns, + std::uint32_t num_rows, + std::uint32_t sides, + bool column_only, + std::uint32_t num_hidden, + std::uint32_t viewport_top = 0, + std::uint32_t viewport_left = 0, + std::uint32_t viewport_height = 0, + std::uint32_t viewport_width = 0 +) { + ValidViewPort out; + out.start_row = + viewport.has_start_row() ? viewport.start_row() : viewport_top; + out.start_col = + viewport.has_start_col() ? viewport.start_col() : viewport_left; + + std::uint32_t max_cols = num_columns + (sides == 0 ? 0 : 1); + std::uint32_t max_rows = num_rows; + std::uint32_t psp_offset = sides > 0 || column_only ? 1 : 0; + + out.end_row = std::min( + max_rows, + viewport.has_end_row() + ? viewport.end_row() + : (viewport_height != 0 ? out.start_row + viewport_height : max_rows + ) + ); + + out.end_col = std::min( + max_cols, + viewport.has_end_col() + ? viewport.end_col() + psp_offset + : (viewport_width != 0 ? out.start_col + viewport_width : max_cols) + ); + + return out; +} + +static constexpr bool +needs_poll(const proto::Request::ClientReqCase proto_case) { + using ReqCase = proto::Request::ClientReqCase; + + switch (proto_case) { + case ReqCase::kTableSizeReq: + case ReqCase::kTableSchemaReq: + case ReqCase::kTableMakePortReq: + case ReqCase::kTableValidateExprReq: + case ReqCase::kMakeTableReq: + case ReqCase::kViewDimensionsReq: + case ReqCase::kViewToColumnsStringReq: + case ReqCase::kViewToCsvReq: + case ReqCase::kViewToRowsStringReq: + case ReqCase::kViewToNdjsonStringReq: + case ReqCase::kViewToArrowReq: + case ReqCase::kViewSchemaReq: + case ReqCase::kViewGetMinMaxReq: + case ReqCase::kTableRemoveReq: + case ReqCase::kTableMakeViewReq: + case ReqCase::kViewOnUpdateReq: + case ReqCase::kViewCollapseReq: + case ReqCase::kViewExpandReq: + case ReqCase::kViewSetDepthReq: + return true; + case ReqCase::kTableOnDeleteReq: + case ReqCase::kViewOnDeleteReq: + case ReqCase::kViewRemoveDeleteReq: + case ReqCase::kTableUpdateReq: + case ReqCase::kTableRemoveDeleteReq: + case ReqCase::kGetHostedTablesReq: + case ReqCase::kRemoveHostedTablesUpdateReq: + case ReqCase::kTableReplaceReq: + case ReqCase::kTableDeleteReq: + case ReqCase::kViewGetConfigReq: + case ReqCase::kViewColumnPathsReq: + case ReqCase::kViewDeleteReq: + case ReqCase::kViewExpressionSchemaReq: + case ReqCase::kViewRemoveOnUpdateReq: + case ReqCase::kServerSystemInfoReq: + case ReqCase::kGetFeaturesReq: + case ReqCase::kMakeJoinTableReq: + return false; + case proto::Request::CLIENT_REQ_NOT_SET: + throw std::runtime_error("Unhandled request type 2"); + } + throw std::runtime_error("Unhandled request type"); +} + +static constexpr bool +entity_type_is_table(const proto::Request::ClientReqCase proto_case) { + using ReqCase = proto::Request::ClientReqCase; + + switch (proto_case) { + case ReqCase::kTableSizeReq: + case ReqCase::kTableSchemaReq: + case ReqCase::kTableMakePortReq: + case ReqCase::kTableValidateExprReq: + case ReqCase::kMakeTableReq: + case ReqCase::kTableOnDeleteReq: + case ReqCase::kTableRemoveReq: + case ReqCase::kTableUpdateReq: + case ReqCase::kTableRemoveDeleteReq: + case ReqCase::kGetHostedTablesReq: + case ReqCase::kServerSystemInfoReq: + case ReqCase::kGetFeaturesReq: + case ReqCase::kTableReplaceReq: + case ReqCase::kTableDeleteReq: + case ReqCase::kTableMakeViewReq: + case ReqCase::kMakeJoinTableReq: + return true; + case ReqCase::kViewOnDeleteReq: + case ReqCase::kViewRemoveDeleteReq: + case ReqCase::kViewDimensionsReq: + case ReqCase::kViewToColumnsStringReq: + case ReqCase::kViewToCsvReq: + case ReqCase::kViewToNdjsonStringReq: + case ReqCase::kViewToRowsStringReq: + case ReqCase::kViewToArrowReq: + case ReqCase::kViewSchemaReq: + case ReqCase::kViewGetMinMaxReq: + case ReqCase::kViewOnUpdateReq: + case ReqCase::kViewCollapseReq: + case ReqCase::kViewExpandReq: + case ReqCase::kViewSetDepthReq: + case ReqCase::kViewGetConfigReq: + case ReqCase::kViewColumnPathsReq: + case ReqCase::kViewDeleteReq: + case ReqCase::kViewExpressionSchemaReq: + case ReqCase::kViewRemoveOnUpdateReq: + case ReqCase::kRemoveHostedTablesUpdateReq: + return false; + case proto::Request::CLIENT_REQ_NOT_SET: + throw std::runtime_error("Unhandled request type 2"); + } + throw std::runtime_error("Unhandled request type"); +} + +void +ProtoServer::handle_process_table( + const Request& req, + std::vector>& proto_resp +) { + if (!m_realtime_mode && needs_poll(req.client_req_case())) { + if (entity_type_is_table(req.client_req_case())) { + if (m_resources.is_table_dirty(req.entity_id())) { + auto table = m_resources.get_table(req.entity_id()); + const auto& table_id = req.entity_id(); + _process_table(table, table_id, proto_resp); + } + } else { + auto table_id = m_resources.get_table_id_for_view(req.entity_id()); + if (m_resources.is_table_dirty(table_id)) { + auto table = m_resources.get_table(table_id); + _process_table(table, table_id, proto_resp); + } + } + } +} + +static std::string_view +view_sides_to_string(const ErasedView& view) { + switch (view.sides()) { + case 0: + return "zero"; + case 1: + return "one"; + case 2: + return "two"; + default: + PSP_COMPLAIN_AND_ABORT("Invalid number of sides"); + } +} + +static std::uint32_t +calculate_num_hidden(const ErasedView& view, const t_view_config& config) { + LOG_DEBUG("Calculating num hidden"); + auto num_hidden = 0; + auto sides = view.sides(); + LOG_DEBUG("View sides: " << sides); + switch (sides) { + case 0: + break; + case 1: + case 2: { + const auto& cols = config.get_columns(); + for (const auto& s : config.get_sortspec()) { + LOG_DEBUG("Checking sort spec: " << s.m_colname); + if (std::find(cols.begin(), cols.end(), s.m_colname) + == cols.end()) { + LOG_DEBUG("Found hidden column: " << s.m_colname); + num_hidden++; + } + } + for (const auto& s : config.get_col_sortspec()) { + if (std::find(cols.begin(), cols.end(), s.m_colname) + == cols.end()) { + LOG_DEBUG("Found hidden column: " << s.m_colname); + num_hidden++; + } + } + } break; + default: + PSP_COMPLAIN_AND_ABORT( + "Invalid number of sides: " + std::to_string(view.sides()) + ); + } + return num_hidden; +} + +template +static t_tscalar +coerce_to(const t_dtype dtype, const A& val) { + if constexpr (std::is_same_v) { + t_tscalar scalar; + scalar.clear(); + switch (dtype) { + case DTYPE_STR: + scalar.set(val); + return scalar; + case DTYPE_BOOL: + scalar.set(val == "true"); + return scalar; + case DTYPE_FLOAT32: + scalar.set(std::stof(val)); + return scalar; + case DTYPE_FLOAT64: + scalar.set(std::stod(val)); + return scalar; + case DTYPE_UINT8: + scalar.set((std::uint8_t)std::stoul(val)); + return scalar; + case DTYPE_UINT16: + scalar.set((std::uint16_t)std::stoul(val)); + return scalar; + case DTYPE_UINT32: + scalar.set((std::uint32_t)std::stoul(val)); + return scalar; + case DTYPE_UINT64: + scalar.set((std::uint64_t)std::stoull(val)); + return scalar; + case DTYPE_INT8: + scalar.set((std::int8_t)std::stoi(val)); + return scalar; + case DTYPE_INT16: + scalar.set((std::int16_t)std::stoi(val)); + return scalar; + case DTYPE_INT32: + scalar.set((std::int32_t)std::stoi(val)); + return scalar; + case DTYPE_INT64: + scalar.set((std::int64_t)std::stoll(val)); + return scalar; + case DTYPE_TIME: { + std::chrono::system_clock::time_point tp; + + if (!parse_all_date_time(tp, val)) { + PSP_COMPLAIN_AND_ABORT("Invalid date format"); + } + + // If parsing succeeded, create a time structure + t_time time{ + std::chrono::duration_cast( + tp.time_since_epoch() + ) + .count() + }; + + LOG_DEBUG("Parsed time: " << time.raw_value()); + scalar.set(time); + return scalar; + } + case DTYPE_DATE: { + std::tm tm = {}; + if (!parse_all_date_time(tm, val)) { + PSP_COMPLAIN_AND_ABORT("Invalid date format"); + } + t_date date{ + static_cast(tm.tm_year + 1900), + static_cast(tm.tm_mon), + static_cast(tm.tm_mday) + }; + + LOG_DEBUG("Parsed date: " << date.str()); + scalar.set(date); + return scalar; + } + default: + PSP_COMPLAIN_AND_ABORT( + "Column of type " + dtype_to_str(dtype) + + " cannot cast from string" + ); + } + } else if constexpr (std::is_same_v) { + t_tscalar scalar; + scalar.clear(); + switch (dtype) { + case DTYPE_BOOL: + scalar.set(val == 1); + return scalar; + case DTYPE_UINT8: + case DTYPE_UINT16: + case DTYPE_INT8: + case DTYPE_INT16: + case DTYPE_INT32: + scalar.set(static_cast(val)); + return scalar; + case DTYPE_INT64: + case DTYPE_UINT32: + case DTYPE_UINT64: + case DTYPE_FLOAT32: + case DTYPE_FLOAT64: + scalar.set(static_cast(val)); + return scalar; + case DTYPE_DATE: { + const auto time = static_cast(val / 1000); + std::tm* tm = std::gmtime(&time); + t_date date{ + static_cast(tm->tm_year + 1900), + static_cast(tm->tm_mon), + static_cast(tm->tm_mday) + }; + + scalar.set(date); + return scalar; + } + case DTYPE_TIME: { + t_time time{std::chrono::milliseconds((long long)val).count()}; + scalar.set(time); + return scalar; + } + default: + std::stringstream ss; + ss << "Unsupported double type: " << dtype; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + } else if constexpr (std::is_same_v) { + return t_tscalar(val); + } else if constexpr (std::is_same_v) { + return t_tscalar(val); + } else if constexpr (std::is_same_v) { + return t_tscalar(val); + } else { + static_assert(!std::is_same_v, "Unsupported type"); + } +} + +std::vector> +ProtoServer::_handle_request(std::uint32_t client_id, Request&& req) { + std::vector> proto_resp; + // proto::Response resp_env; + + auto msg_id = req.msg_id(); + auto entity_id = req.entity_id(); + + auto push_resp = [&](Response&& resp) { + resp.set_msg_id(msg_id); + resp.set_entity_id(entity_id); + ProtoServerResp resp2; + resp2.data = std::move(resp); + resp2.client_id = client_id; + proto_resp.emplace_back(std::move(resp2)); + }; + + if (!m_realtime_mode) { + handle_process_table(req, proto_resp); + } + + switch (req.client_req_case()) { + case proto::Request::kGetFeaturesReq: { + proto::Response resp; + const auto& features = resp.mutable_get_features_resp(); + features->set_group_by(true); + features->set_split_by(true); + features->set_sort(true); + features->set_on_update(true); + features->set_expressions(true); + features->add_group_rollup_mode(proto::GroupRollupMode::ROLLUP); + features->add_group_rollup_mode(proto::GroupRollupMode::FLAT); + features->add_group_rollup_mode(proto::GroupRollupMode::TOTAL); + proto::GetFeaturesResp_ColumnTypeOptions opts; + opts.add_options("=="); + opts.add_options("!="); + opts.add_options(">"); + opts.add_options(">="); + opts.add_options("<"); + opts.add_options("<="); + opts.add_options("begins with"); + opts.add_options("contains"); + opts.add_options("ends with"); + opts.add_options("in"); + opts.add_options("not in"); + opts.add_options("is not null"); + opts.add_options("is null"); + (*features->mutable_filter_ops())[proto::ColumnType::STRING] = + std::move(opts); + + proto::GetFeaturesResp_ColumnTypeOptions opts2; + opts2.add_options("=="); + opts2.add_options("!="); + opts2.add_options(">"); + opts2.add_options(">="); + opts2.add_options("<"); + opts2.add_options("<="); + opts2.add_options("is not null"); + opts2.add_options("is null"); + (*features->mutable_filter_ops())[proto::ColumnType::INTEGER] = + opts2; + (*features->mutable_filter_ops())[proto::ColumnType::FLOAT] = opts2; + (*features->mutable_filter_ops())[proto::ColumnType::DATE] = opts2; + (*features->mutable_filter_ops())[proto::ColumnType::DATETIME] = + opts2; + (*features->mutable_filter_ops())[proto::ColumnType::INTEGER] = + std::move(opts2); + + proto::GetFeaturesResp_ColumnTypeOptions opts3; + opts3.add_options("=="); + // opts3.add_options("!="); + opts.add_options("is not null"); + opts.add_options("is null"); + (*features->mutable_filter_ops())[proto::ColumnType::BOOLEAN] = + opts2; + + proto::GetFeaturesResp_AggregateOptions string_opts; + string_opts.add_aggregates()->set_name("count"); + string_opts.add_aggregates()->set_name("any"); + string_opts.add_aggregates()->set_name("distinct count"); + string_opts.add_aggregates()->set_name("dominant"); + string_opts.add_aggregates()->set_name("first"); + string_opts.add_aggregates()->set_name("join"); + string_opts.add_aggregates()->set_name("last"); + string_opts.add_aggregates()->set_name("last by index"); + string_opts.add_aggregates()->set_name("median"); + string_opts.add_aggregates()->set_name("q1"); + string_opts.add_aggregates()->set_name("q3"); + string_opts.add_aggregates()->set_name("unique"); + auto args1 = string_opts.add_aggregates(); + args1->set_name("min by"); + args1->add_args(proto::ColumnType::FLOAT); + auto args2 = string_opts.add_aggregates(); + args2->set_name("max by"); + args2->add_args(proto::ColumnType::FLOAT); + (*features->mutable_aggregates())[proto::ColumnType::STRING] = + string_opts; + + (*features->mutable_aggregates())[proto::ColumnType::BOOLEAN] = + string_opts; + + proto::GetFeaturesResp_AggregateOptions number_opts; + number_opts.add_aggregates()->set_name("sum"); + number_opts.add_aggregates()->set_name("abs sum"); + number_opts.add_aggregates()->set_name("any"); + number_opts.add_aggregates()->set_name("avg"); + number_opts.add_aggregates()->set_name("count"); + number_opts.add_aggregates()->set_name("distinct count"); + number_opts.add_aggregates()->set_name("dominant"); + number_opts.add_aggregates()->set_name("first"); + number_opts.add_aggregates()->set_name("gmv"); + number_opts.add_aggregates()->set_name("high"); + number_opts.add_aggregates()->set_name("low"); + number_opts.add_aggregates()->set_name("max"); + number_opts.add_aggregates()->set_name("min"); + number_opts.add_aggregates()->set_name("high minus low"); + number_opts.add_aggregates()->set_name("last by index"); + number_opts.add_aggregates()->set_name("last minus first"); + number_opts.add_aggregates()->set_name("last"); + number_opts.add_aggregates()->set_name("mean"); + number_opts.add_aggregates()->set_name("median"); + number_opts.add_aggregates()->set_name("q1"); + number_opts.add_aggregates()->set_name("q3"); + number_opts.add_aggregates()->set_name("pct sum parent"); + number_opts.add_aggregates()->set_name("pct sum total"); + number_opts.add_aggregates()->set_name("stddev"); + number_opts.add_aggregates()->set_name("sum abs"); + number_opts.add_aggregates()->set_name("sum not null"); + number_opts.add_aggregates()->set_name("unique"); + number_opts.add_aggregates()->set_name("var"); + auto args3 = number_opts.add_aggregates(); + args3->set_name("weighted mean"); + args3->add_args(proto::ColumnType::FLOAT); + (*features->mutable_aggregates())[proto::ColumnType::INTEGER] = + number_opts; + + (*features->mutable_aggregates())[proto::ColumnType::FLOAT] = + number_opts; + + proto::GetFeaturesResp_AggregateOptions datetime_opts; + datetime_opts.add_aggregates()->set_name("count"); + datetime_opts.add_aggregates()->set_name("any"); + datetime_opts.add_aggregates()->set_name("avg"); + datetime_opts.add_aggregates()->set_name("distinct count"); + datetime_opts.add_aggregates()->set_name("dominant"); + datetime_opts.add_aggregates()->set_name("first"); + datetime_opts.add_aggregates()->set_name("high"); + datetime_opts.add_aggregates()->set_name("low"); + datetime_opts.add_aggregates()->set_name("min"); + datetime_opts.add_aggregates()->set_name("max"); + datetime_opts.add_aggregates()->set_name("last by index"); + datetime_opts.add_aggregates()->set_name("last"); + datetime_opts.add_aggregates()->set_name("median"); + datetime_opts.add_aggregates()->set_name("q1"); + datetime_opts.add_aggregates()->set_name("q3"); + datetime_opts.add_aggregates()->set_name("unique"); + (*features->mutable_aggregates())[proto::ColumnType::DATE] = + datetime_opts; + + (*features->mutable_aggregates())[proto::ColumnType::DATETIME] = + datetime_opts; + + push_resp(std::move(resp)); + break; + } + case proto::Request::kGetHostedTablesReq: { + const auto& r = req.get_hosted_tables_req(); + if (!r.subscribe()) { + proto::Response resp; + const auto& tables = resp.mutable_get_hosted_tables_resp(); + const auto& infos = tables->mutable_table_infos(); + for (const auto& name : m_resources.get_table_ids()) { + const auto& v = infos->Add(); + + v->set_entity_id(name); + const auto tbl = m_resources.get_table(name); + + if (!tbl->get_index().empty()) { + v->set_index(tbl->get_index()); + } + + if (tbl->get_limit() != std::numeric_limits::max()) { + v->set_limit(tbl->get_limit()); + } + } + + push_resp(std::move(resp)); + } else { + Subscription sub_info; + sub_info.id = req.msg_id(); + sub_info.client_id = client_id; + m_resources.create_on_hosted_tables_update_sub(sub_info); + } + + break; + } + case proto::Request::kRemoveHostedTablesUpdateReq: { + auto sub_id = req.remove_hosted_tables_update_req().id(); + m_resources.remove_on_hosted_tables_update_sub(sub_id, client_id); + proto::Response resp; + resp.mutable_remove_hosted_tables_update_resp(); + push_resp(std::move(resp)); + break; + } + case proto::Request::kMakeTableReq: { + const auto& r = req.make_table_req(); + if (m_resources.has_table(entity_id)) { + proto::Response resp; + auto* err = resp.mutable_server_error()->mutable_message(); + std::stringstream ss; + ss << "Table \"" << entity_id << "\" already exists"; + *err = ss.str(); + push_resp(std::move(resp)); + break; + } + + std::string index; + std::uint32_t limit = std::numeric_limits::max(); + std::shared_ptr
    table; + switch (r.options().make_table_type_case()) { + case proto::MakeTableReq_MakeTableOptions::kMakeLimitTable: { + limit = r.options().make_limit_table(); + break; + } + case proto::MakeTableReq_MakeTableOptions::kMakeIndexTable: { + index = r.options().make_index_table(); + break; + } + case proto::MakeTableReq_MakeTableOptions:: + MAKE_TABLE_TYPE_NOT_SET: + break; + } + + // On-disk backing: native uses a memory-mapped file; WASM uses the + // WasmFS/OPFS backend (`storage_impl_wasm.cpp`). The browser JS + // bootstrap mounts OPFS at `/perspective` before any table is built. + t_backing_store backing_store = r.options().page_to_disk() + ? BACKING_STORE_DISK + : BACKING_STORE_MEMORY; + + switch (r.data().data_case()) { + case proto::MakeTableData::kFromView: { + auto view = m_resources.get_view(r.data().from_view()); + proto::ViewPort viewport; + auto dims = parse_format_options( + viewport, + view->num_columns(), + view->num_rows(), + view->sides(), + view->get_view_config()->is_column_only(), + 0 + ); + auto arrow = view->to_arrow( + dims.start_row, + dims.end_row, + dims.start_col, + dims.end_col + ); + + table = Table::from_arrow( + index, std::move(*arrow), limit, backing_store + ); + break; + } + case proto::MakeTableData::kFromArrow: { + std::string data = r.data().from_arrow(); + { auto _ = std::move(req); } + + table = Table::from_arrow( + index, std::move(data), limit, backing_store + ); + break; + } + case proto::MakeTableData::kFromCsv: { + std::string data = r.data().from_csv(); + { auto _ = std::move(req); } + + table = Table::from_csv( + index, std::move(data), limit, backing_store + ); + break; + } + case proto::MakeTableData::kFromCols: { + std::string data = r.data().from_cols(); + { auto _ = std::move(req); } + + table = Table::from_cols( + index, std::move(data), limit, backing_store + ); + break; + } + case proto::MakeTableData::kFromRows: { + std::string data = r.data().from_rows(); + { auto _ = std::move(req); } + + table = Table::from_rows( + index, std::move(data), limit, backing_store + ); + break; + } + case proto::MakeTableData::kFromNdjson: { + std::string data = r.data().from_ndjson(); + { auto _ = std::move(req); } + + table = Table::from_ndjson( + index, std::move(data), limit, backing_store + ); + break; + } + case proto::MakeTableData::kFromSchema: { + std::vector columns; + std::vector types; + const auto& schema = r.data().from_schema().schema(); + for (const auto& it : schema) { + columns.push_back(it.name()); + types.push_back(column_type_to_dtype(it.type())); + } + + t_schema table_schema(columns, types); + table = Table::from_schema( + index, table_schema, limit, backing_store + ); + break; + } + case proto::MakeTableData::DATA_NOT_SET: { + PSP_COMPLAIN_AND_ABORT("MakeTableReq malformed"); + break; + } + } + + m_resources.host_table(entity_id, table); + proto::Response resp; + resp.mutable_make_table_resp(); + push_resp(std::move(resp)); + + // Notify `on_thsoted_tables_update` listeners + auto subscriptions = m_resources.get_on_hosted_tables_update_sub(); + for (auto& subscription : subscriptions) { + Response out; + out.set_msg_id(subscription.id); + ProtoServerResp resp2; + resp2.data = std::move(out); + resp2.client_id = subscription.client_id; + proto_resp.emplace_back(std::move(resp2)); + } + + break; + } + case proto::Request::kMakeJoinTableReq: { + const auto& r = req.make_join_table_req(); + if (m_resources.has_table(entity_id)) { + proto::Response resp; + auto* err = resp.mutable_server_error()->mutable_message(); + std::stringstream ss; + ss << "Table \"" << entity_id << "\" already exists"; + *err = ss.str(); + push_resp(std::move(resp)); + break; + } + + if (!m_resources.has_table(r.left_table_id())) { + proto::Response resp; + auto* err = resp.mutable_server_error()->mutable_message(); + std::stringstream ss; + ss << "Table \"" << r.left_table_id() << "\" not found"; + *err = ss.str(); + push_resp(std::move(resp)); + break; + } + + if (!m_resources.has_table(r.right_table_id())) { + proto::Response resp; + auto* err = resp.mutable_server_error()->mutable_message(); + std::stringstream ss; + ss << "Table \"" << r.right_table_id() << "\" not found"; + *err = ss.str(); + push_resp(std::move(resp)); + break; + } + + auto left_table = m_resources.get_table(r.left_table_id()); + auto right_table = m_resources.get_table(r.right_table_id()); + + auto result = m_join_engine.make_join_table( + r.on_column(), r.right_on_column(), r.join_type(), left_table, right_table + ); + + if (!result.ok()) { + proto::Response resp; + *resp.mutable_server_error()->mutable_message() = result.error; + push_resp(std::move(resp)); + break; + } + + m_resources.host_table(entity_id, result.table); + m_join_engine.register_join( + entity_id, + r.left_table_id(), + r.right_table_id(), + r.on_column(), + r.right_on_column(), + r.join_type() + ); + + // Compute initial join + m_join_engine.recompute( + entity_id, left_table, right_table, result.table + ); + + // Process the join table so its gnode state is up to date + auto jt = m_resources.get_table(entity_id); + jt->get_pool()->_process(); + m_resources.mark_table_dirty(entity_id); + m_resources.mark_table_clean(entity_id); + + proto::Response resp; + resp.mutable_make_join_table_resp(); + push_resp(std::move(resp)); + + // Notify on_hosted_tables_update listeners + auto subscriptions = m_resources.get_on_hosted_tables_update_sub(); + for (auto& subscription : subscriptions) { + Response out; + out.set_msg_id(subscription.id); + ProtoServerResp resp2; + resp2.data = std::move(out); + resp2.client_id = subscription.client_id; + proto_resp.emplace_back(std::move(resp2)); + } + + break; + } + case proto::Request::kTableSizeReq: { + auto table = m_resources.get_table(req.entity_id()); + proto::Response resp; + auto* tbl_size = resp.mutable_table_size_resp(); + tbl_size->set_size(table->size()); + push_resp(std::move(resp)); + break; + } + case proto::Request::kTableSchemaReq: { + auto table = m_resources.get_table(req.entity_id()); + + proto::Response resp; + auto* output_schema = + resp.mutable_table_schema_resp()->mutable_schema(); + auto table_schema = table->get_schema(); + auto columns = table_schema.columns(); + auto types = table_schema.types(); + for (std::size_t i = 0; i < table_schema.size(); ++i) { + auto* ktp = output_schema->add_schema(); + ktp->set_name(columns[i]); + ktp->set_type(dtype_to_column_type(types[i])); + } + + push_resp(std::move(resp)); + break; + } + case proto::Request::kTableMakePortReq: { + auto table = m_resources.get_table(req.entity_id()); + proto::Response resp; + auto* make_port = resp.mutable_table_make_port_resp(); + make_port->set_port_id(table->make_port()); + + push_resp(std::move(resp)); + break; + } + case proto::Request::kTableValidateExprReq: { + auto table = m_resources.get_table(req.entity_id()); + const auto& r = req.table_validate_expr_req(); + + const auto& col_with_expr = r.column_to_expr(); + const auto& exprs = parse_expression_strings(col_with_expr); + + // TODO: validate the expression, mocked out for now + proto::Response resp; + auto* validate_expr = resp.mutable_table_validate_expr_resp(); + + std::vector>>> + legacy_exprs; + + legacy_exprs.reserve(exprs.size()); + for (const auto& expr : exprs) { + legacy_exprs.emplace_back( + expr.expression_alias, + expr.expression, + expr.parse_expression_string, + std::vector>{ + expr.column_id_map.begin(), expr.column_id_map.end() + } + ); + } + + const auto& res = table->validate_expressions(legacy_exprs); + + std::vector> schema; + for (const auto& [col, val] : res.get_expression_schema()) { + schema.emplace_back( + col, dtype_to_column_type(str_to_dtype(val)) + ); + } + + for (auto&& [col_name, col_type] : schema) { + (*validate_expr->mutable_expression_schema())[col_name] = + col_type; + } + + std::vector> + errors; + for (const auto& [col_name, err] : res.get_expression_errors()) { + proto::TableValidateExprResp_ExprValidationError proto_err; + *proto_err.mutable_error_message() = err.m_error_message; + proto_err.set_column(err.m_column); + proto_err.set_line(err.m_line); + (*validate_expr->mutable_errors())[col_name] = + std::move(proto_err); + } + + for (const auto& [col_name, col_expr] : r.column_to_expr()) { + (*validate_expr->mutable_expression_alias())[col_name] = + col_expr; + } + + push_resp(std::move(resp)); + break; + } + case proto::Request::kTableReplaceReq: { + if (m_join_engine.is_join_table(req.entity_id())) { + proto::Response resp; + *resp.mutable_server_error()->mutable_message() = + "Cannot update a read-only join table"; + push_resp(std::move(resp)); + break; + } + auto table = m_resources.get_table(req.entity_id()); + table->clear(); + const auto& r = req.table_replace_req(); + switch (r.data().data_case()) { + case proto::MakeTableData::kFromArrow: { + table->update_arrow(r.data().from_arrow(), 0); + break; + } + case proto::MakeTableData::kFromCsv: { + table->update_csv(r.data().from_csv(), 0); + break; + } + case proto::MakeTableData::kFromRows: { + table->update_rows(r.data().from_rows(), 0); + break; + } + case proto::MakeTableData::kFromCols: + table->update_cols(r.data().from_cols(), 0); + break; + case proto::MakeTableData::kFromSchema: + case proto::MakeTableData::DATA_NOT_SET: + default: { + PSP_COMPLAIN_AND_ABORT("TableReplaceReq malformed"); + break; + } + } + + m_resources.mark_table_dirty(req.entity_id()); + proto::Response resp; + resp.mutable_table_replace_resp(); + push_resp(std::move(resp)); + break; + } + case proto::Request::kTableRemoveReq: { + if (m_join_engine.is_join_table(req.entity_id())) { + proto::Response resp; + *resp.mutable_server_error()->mutable_message() = + "Cannot update a read-only join table"; + push_resp(std::move(resp)); + break; + } + const auto& r = req.table_remove_req(); + auto table = m_resources.get_table(req.entity_id()); + switch (r.data().data_case()) { + case proto::MakeTableData::kFromCols: { + table->remove_cols(r.data().from_cols()); + break; + } + case proto::MakeTableData::kFromRows: { + table->remove_rows(r.data().from_rows()); + break; + } + case proto::MakeTableData::kFromArrow: + case proto::MakeTableData::kFromCsv: + case proto::MakeTableData::kFromSchema: + case proto::MakeTableData::DATA_NOT_SET: + default: { + PSP_COMPLAIN_AND_ABORT("remove malformed"); + break; + } + } + + // proto_resp.should_poll = true; + m_resources.mark_table_dirty(req.entity_id()); + proto::Response resp; + resp.mutable_table_remove_resp(); + push_resp(std::move(resp)); + break; + } + case proto::Request::kTableUpdateReq: { + if (m_join_engine.is_join_table(req.entity_id())) { + proto::Response resp; + *resp.mutable_server_error()->mutable_message() = + "Cannot update a read-only join table"; + push_resp(std::move(resp)); + break; + } + const auto& r = req.table_update_req(); + auto table = m_resources.get_table(req.entity_id()); + switch (r.data().data_case()) { + case proto::MakeTableData::kFromArrow: { + table->update_arrow(r.data().from_arrow(), r.port_id()); + break; + } + case proto::MakeTableData::kFromCsv: { + table->update_csv(r.data().from_csv(), r.port_id()); + break; + } + case proto::MakeTableData::kFromRows: { + table->update_rows(r.data().from_rows(), r.port_id()); + break; + } + case proto::MakeTableData::kFromCols: { + table->update_cols(r.data().from_cols(), r.port_id()); + break; + } + case proto::MakeTableData::kFromNdjson: { + table->update_ndjson(r.data().from_ndjson(), r.port_id()); + break; + } + case proto::MakeTableData::kFromSchema: + case proto::MakeTableData::DATA_NOT_SET: + default: { + PSP_COMPLAIN_AND_ABORT("MakeTableReq malformed"); + break; + } + } + + m_resources.mark_table_dirty(req.entity_id()); + proto::Response resp; + resp.mutable_table_update_resp(); + push_resp(std::move(resp)); + break; + } + case proto::Request::kTableMakeViewReq: { + auto table = m_resources.get_table(req.entity_id()); + auto schema = std::make_shared( + table->get_gnode()->get_output_schema() + ); + const auto& r = req.table_make_view_req(); + const auto& cfg = r.config(); + + const auto& group_by = cfg.group_by(); + std::vector row_pivots{ + group_by.begin(), group_by.end() + }; + + const auto& split_by = cfg.split_by(); + std::vector column_pivots{ + split_by.begin(), split_by.end() + }; + + const auto& aggs = cfg.aggregates(); + tsl::ordered_map> aggregates; + for (const auto& [col_name, agg_list] : aggs) { + aggregates[col_name] = std::vector(); + for (const auto& agg : agg_list.aggregations()) { + aggregates[col_name].push_back(agg); + } + } + + const auto& sorts = cfg.sort(); + std::vector sortby; + std::vector> sort_str; + for (const auto& sort : sorts) { + const char* column_sort = sort_op_str_from_proto(sort.op()); + sort_str.push_back({sort.column(), column_sort}); + } + + bool column_only = false; + bool is_total = + cfg.has_group_rollup_mode() ? cfg.group_rollup_mode() == 2 : false; + + // make sure that primary keys are created for column-only views + if (row_pivots.empty() && !column_pivots.empty()) { + row_pivots.emplace_back("psp_okey"); + column_only = true; + } + + std::vector> expressions; + auto exprs = parse_expression_strings(cfg.expressions()); + + std::vector>>> + legacy_exprs; + + legacy_exprs.resize(1); + for (const auto& expr : exprs) { + legacy_exprs[0] = { + expr.expression_alias, + expr.expression, + expr.parse_expression_string, + std::vector>{ + expr.column_id_map.begin(), expr.column_id_map.end() + } + }; + + // Validate these expression, creating is not the same thing! + const auto& res = table->validate_expressions(legacy_exprs); + if (!res.get_expression_errors().empty()) { + // TODO unify error reporting - this works differently than + // `validate_expressions()`. In this case there is + // guaranteed to only be one ... + PSP_COMPLAIN_AND_ABORT(res.get_expression_errors() + .at(expr.expression_alias) + .m_error_message); + } + + const auto& gnode = table->get_gnode(); + auto column_id_map = + std::vector>( + expr.column_id_map.begin(), expr.column_id_map.end() + ); + + auto expr_vocab = gnode->get_expression_vocab(); + t_expression_vocab& expression_vocab = *expr_vocab; + auto expression_regex_mapping = + gnode->get_expression_regex_mapping(); + t_regex_mapping& regex_mapping = *expression_regex_mapping; + + std::shared_ptr computed_expression = + m_computed_expression_parser.precompute( + expr.expression_alias, + expr.expression, + expr.parse_expression_string, + column_id_map, + gnode->get_table_sptr(), + gnode->get_pkey_map(), + schema, + expression_vocab, + regex_mapping + ); + + auto dtype = computed_expression->get_dtype(); + + schema->add_column(expr.expression_alias, dtype); + expressions.push_back(std::make_shared( + expr.expression_alias, + expr.expression, + expr.parse_expression_string, + column_id_map, + dtype + )); + } + + t_vocab vocab; + vocab.init(false); + std::vector< + std::tuple>> + filter; + filter.reserve(cfg.filter().size()); + + for (const auto& f : cfg.filter()) { + for (const auto& arg : f.value()) { + switch (arg.scalar_case()) { + case proto::Scalar::kString: { +#ifdef PSP_SSO_SCALAR + if (!t_tscalar::can_store_inplace(arg.string())) { + vocab.get_interned(arg.string()); + } +#else + vocab.get_interned(arg.string()); +#endif + break; + } + case proto::Scalar::kBool: + case proto::Scalar::kFloat: + case proto::Scalar::kNull: + case proto::Scalar::SCALAR_NOT_SET: + break; + } + } + } + + for (const auto& f : cfg.filter()) { + std::vector args; + args.reserve(f.value().size()); + for (const auto& arg : f.value()) { + t_tscalar a; + a.clear(); + switch (arg.scalar_case()) { + case proto::Scalar::kBool: { + a.set(arg.bool_()); + args.push_back(a); + break; + } + case proto::Scalar::kFloat: { + a = coerce_to( + schema->get_dtype(f.column()), arg.float_() + ); + + args.push_back(a); + break; + } + case proto::Scalar::kString: { + if (!schema->has_column(f.column())) { + PSP_COMPLAIN_AND_ABORT( + "Filter column not in schema: " + f.column() + ); + } + +#ifdef PSP_SSO_SCALAR + if (!t_tscalar::can_store_inplace(arg.string())) { +#endif + a = coerce_to( + schema->get_dtype(f.column()), + vocab.unintern_c( + vocab.get_interned(arg.string()) + ) + ); +#ifdef PSP_SSO_SCALAR + } else { + + a = coerce_to( + schema->get_dtype(f.column()), + arg.string().c_str() + ); + } +#endif + args.push_back(a); + break; + } + case proto::Scalar::kNull: + a.set(t_none()); + args.push_back(a); + break; + case proto::Scalar::SCALAR_NOT_SET: + PSP_COMPLAIN_AND_ABORT( + "Filter scalar type not implemented: " + + std::to_string(arg.scalar_case()) + ) + break; + } + } + + filter.emplace_back(f.column(), f.op(), args); + } + + const auto& cols = cfg.columns(); + std::vector columns; + if (cols.has_columns()) { + columns = { + cols.columns().columns().begin(), + cols.columns().columns().end() + }; + } else { + columns = table->get_column_names(); + for (const auto& f : expressions) { + columns.push_back(f->get_expression_alias()); + } + } + + LOG_DEBUG( + "Creating view config with \n" + << "row_pivots: " << row_pivots << '\n' + << "column_pivots: " << column_pivots + << '\n' + // << "aggregates: " << aggregates << '\n' + << "columns: " << columns + << '\n' + // << "filter: " << filter << '\n' + << "sort_str: " << sort_str << '\n' + << "expressions: " << expressions << '\n' + << "column_only: " << column_only << '\n' + ); + + std::string filter_op; + switch (cfg.filter_op()) { + case proto::ViewConfig_FilterReducer:: + ViewConfig_FilterReducer_OR: + filter_op = "or"; + break; + case proto::ViewConfig_FilterReducer:: + ViewConfig_FilterReducer_AND: + default: + filter_op = "and"; + break; + } + + LOG_DEBUG("FILTER_OP: " << filter_op); + + bool leaves_only = + cfg.has_group_rollup_mode() ? cfg.group_rollup_mode() == 1 : false; + bool total_only = + cfg.has_group_rollup_mode() ? cfg.group_rollup_mode() == 2 : false; + + auto config = std::make_shared( + vocab, + row_pivots, + column_pivots, + aggregates, + columns, + filter, + sort_str, + expressions, + filter_op, + column_only, + leaves_only, + total_only + ); + config->init(schema); + + if (cfg.has_group_by_depth()) { + config->set_row_pivot_depth(cfg.group_by_depth()); + } + + std::uint32_t sides; + + if (!group_by.empty() || !split_by.empty()) { + if (!split_by.empty()) { + sides = 2; + } else { + sides = 1; + } + } else if (total_only) { + sides = 1; + } else { + sides = 0; + } + + bool is_unit_context = table->get_index().empty() && sides == 0 + && row_pivots.empty() && column_pivots.empty() + && aggregates.empty() && columns.empty() && sort_str.empty() + && cfg.expressions().empty(); + + std::shared_ptr erased_view; + + if (is_unit_context) { + auto ctx = + make_context(table, schema, config, r.view_id()); + auto view = std::make_shared>( + table, ctx, r.view_id(), "|", config + ); + erased_view = std::make_shared(std::move(view)); + } else if (sides == 0) { + auto ctx = + make_context(table, schema, config, r.view_id()); + auto view = std::make_shared>( + table, ctx, r.view_id(), "|", config + ); + erased_view = std::make_shared(view); + } else if (sides == 1) { + auto ctx = + make_context(table, schema, config, r.view_id()); + auto view = std::make_shared>( + table, ctx, r.view_id(), "|", config + ); + erased_view = std::make_shared(std::move(view)); + } else if (sides == 2) { + auto ctx = + make_context(table, schema, config, r.view_id()); + auto view = std::make_shared>( + table, ctx, r.view_id(), "|", config + ); + erased_view = std::make_shared(std::move(view)); + } else { + PSP_COMPLAIN_AND_ABORT("Invalid number of sides"); + } + + m_resources.host_view( + client_id, r.view_id(), req.entity_id(), erased_view + ); + + proto::Response resp; + auto* make_view = resp.mutable_table_make_view_resp(); + make_view->set_view_id(r.view_id()); + push_resp(std::move(resp)); + break; + } + case proto::Request::kTableOnDeleteReq: { + Subscription sub_info; + sub_info.id = req.msg_id(); + sub_info.client_id = client_id; + m_resources.create_table_on_delete_sub(req.entity_id(), sub_info); + break; + } + case proto::Request::kTableRemoveDeleteReq: { + auto sub_id = req.table_remove_delete_req().id(); + m_resources.remove_table_on_delete_sub( + req.entity_id(), sub_id, client_id + ); + proto::Response resp; + resp.mutable_table_remove_delete_resp(); + push_resp(std::move(resp)); + break; + } + + case proto::Request::kViewOnDeleteReq: { + Subscription sub_info; + sub_info.id = req.msg_id(); + sub_info.client_id = client_id; + m_resources.create_view_on_delete_sub(req.entity_id(), sub_info); + break; + } + case proto::Request::kViewRemoveDeleteReq: { + auto sub_id = req.view_remove_delete_req().id(); + m_resources.remove_view_on_delete_sub( + req.entity_id(), sub_id, client_id + ); + proto::Response resp; + resp.mutable_view_remove_delete_resp(); + push_resp(std::move(resp)); + break; + } + + case proto::Request::kViewSchemaReq: { + auto view = m_resources.get_view(req.entity_id()); + proto::Response resp; + auto* view_schema = + resp.mutable_view_schema_resp()->mutable_schema(); + auto schema = view->schema(); + for (const auto& [k, v] : schema) { + (*view_schema)[k] = dtype_to_column_type(str_to_dtype(v)); + } + + push_resp(std::move(resp)); + break; + } + case proto::Request::kViewDimensionsReq: { + auto view = m_resources.get_view(req.entity_id()); + auto table = m_resources.get_table_for_view(req.entity_id()); + + proto::Response resp; + auto* view_dims = resp.mutable_view_dimensions_resp(); + auto ncols = view->num_columns(); + auto config = view->get_view_config(); + auto num_hidden = calculate_num_hidden(*view, *config); + + auto num_view_columns = 0; + const auto real_size = config->get_columns().size(); + if (view->sides() == 2) { + // ctx2's `num_columns()` already excludes hidden sort + // columns (option B): visible-only is the value we want + // to report. + num_view_columns = ncols; + } else if (ncols > 0 && real_size > 0) { + num_view_columns = ncols + - (ncols / (config->get_columns().size() + num_hidden)) + * num_hidden; + } + + view_dims->set_num_view_columns(num_view_columns); + view_dims->set_num_view_rows(view->num_rows()); + view_dims->set_num_table_columns(table->get_schema().size()); + view_dims->set_num_table_rows(table->size()); + + push_resp(std::move(resp)); + break; + } + case proto::Request::kViewGetConfigReq: { + auto view = m_resources.get_view(req.entity_id()); + auto view_config = view->get_view_config(); + + proto::Response resp; + auto* view_config_proto = + resp.mutable_view_get_config_resp()->mutable_config(); + + for (const auto& col : view_config->get_columns()) { + view_config_proto->mutable_columns() + ->mutable_columns() + ->add_columns(col); + } + + if (!view_config->get_row_pivots().empty()) { + for (const auto& aggspec : view_config->get_aggspecs()) { + auto* proto_exprs = view_config_proto->mutable_aggregates(); + const auto agg = aggspec; + if (aggspec.agg() == AGGTYPE_WEIGHTED_MEAN + || aggspec.agg() == AGGTYPE_MAX_BY + || aggspec.agg() == AGGTYPE_MIN_BY) { + + proto::ViewConfig_AggList agglist; + agglist.add_aggregations(agg.agg_str()); + agglist.add_aggregations(agg.get_input_depnames()[1]); + (*proto_exprs)[aggspec.name()] = agglist; + } else { + proto::ViewConfig_AggList agglist; + agglist.add_aggregations(agg.agg_str()); + (*proto_exprs)[aggspec.name()] = agglist; + } + } + } + + for (const auto& agg : view_config->get_row_pivots()) { + if (agg == "psp_pkey" || agg == "psp_okey") { + continue; + } + view_config_proto->add_group_by(agg); + } + + for (const auto& agg : view_config->get_column_pivots()) { + view_config_proto->add_split_by(agg); + } + + // TODO: Sort, Expressions, and Aggregations + + for (const auto& sort : view_config->get_sortspec()) { + auto* proto_sort = view_config_proto->mutable_sort(); + auto* s = proto_sort->Add(); + s->set_column(sort.m_colname); + s->set_op(sort_op_to_proto(sort.m_sort_type)); + } + + for (const auto& filter : view_config->get_fterm()) { + auto* proto_filter = view_config_proto->mutable_filter(); + auto* f = proto_filter->Add(); + f->set_column(filter.m_colname); + f->set_op(filter_op_to_str(filter.m_op)); + auto vals = std::vector(filter.m_bag.size()); + if (filter.m_op != FILTER_OP_NOT_IN + && filter.m_op != FILTER_OP_IN) { + vals.push_back(filter.m_threshold); + } else { + for (const auto& scalar : filter.m_bag) { + vals.push_back(scalar); + } + } + + for (const auto& scalar : vals) { + auto* s = f->mutable_value()->Add(); + switch (scalar.get_dtype()) { + case DTYPE_BOOL: + s->set_bool_(scalar.get()); + break; + case DTYPE_FLOAT32: + s->set_float_(scalar.get()); + break; + case DTYPE_FLOAT64: + s->set_float_(scalar.get()); + break; + case DTYPE_INT8: + s->set_float_((double)scalar.get()); + break; + case DTYPE_INT16: + s->set_float_((double)scalar.get()); + break; + case DTYPE_INT32: + s->set_float_((double)scalar.get()); + break; + case DTYPE_INT64: + s->set_float_((double)scalar.get()); + break; + case DTYPE_UINT8: + s->set_float_((double)scalar.get()); + break; + case DTYPE_UINT16: + s->set_float_((double)scalar.get()); + break; + case DTYPE_UINT32: + s->set_float_((double)scalar.get()); + break; + case DTYPE_UINT64: + s->set_float_((double)scalar.get()); + break; + case DTYPE_STR: + s->set_string(scalar.get()); + break; + case DTYPE_DATE: { + auto tm = scalar.get(); + std::stringstream ss; + ss << std::setfill('0') << std::setw(4) << tm.year() + << "-" << std::setfill('0') + << std::setw(2) + // Increment month by 1, as date::month is [1-12] + // but t_date::month() is [0-11] + << tm.month() + 1 << "-" << std::setfill('0') + << std::setw(2) << tm.day(); + s->set_string(ss.str()); + break; + } + case DTYPE_TIME: + s->set_float_( + (double)scalar.get().raw_value() + ); + break; + case DTYPE_NONE: + s->set_null( + ::google::protobuf::NullValue::NULL_VALUE + ); + break; + default: + PSP_COMPLAIN_AND_ABORT( + "Invalid scalar type: " + scalar.to_string() + ); + } + } + } + + switch (view_config->get_filter_op()) { + case FILTER_OP_OR: + view_config_proto->set_filter_op( + proto::ViewConfig_FilterReducer:: + ViewConfig_FilterReducer_OR + ); + break; + case FILTER_OP_AND: + default: + view_config_proto->set_filter_op( + proto::ViewConfig_FilterReducer:: + ViewConfig_FilterReducer_AND + ); + break; + } + + if (view_config->get_row_pivot_depth() != -1) { + view_config_proto->set_group_by_depth( + view_config->get_row_pivot_depth() + ); + } + + if (view_config->is_total_only()) { + const auto mode = proto::GroupRollupMode::TOTAL; + view_config_proto->set_group_rollup_mode(mode); + } else if (view_config->is_leaves_only()) { + const auto mode = proto::GroupRollupMode::FLAT; + view_config_proto->set_group_rollup_mode(mode); + } else { + const auto mode = proto::GroupRollupMode::ROLLUP; + view_config_proto->set_group_rollup_mode(mode); + } + + for (const auto& expr : view_config->get_expressions()) { + auto* proto_exprs = view_config_proto->mutable_expressions(); + (*proto_exprs)[expr->get_expression_alias()] = + expr->get_expression_string(); + } + + push_resp(std::move(resp)); + break; + } + case proto::Request::kViewColumnPathsReq: { + auto view = m_resources.get_view(req.entity_id()); + + const auto& r = req.view_column_paths_req(); + + proto::Response resp; + auto* view_col_paths = + resp.mutable_view_column_paths_resp()->mutable_paths(); + + // // TODO this is a better representation of column_paths but + // // it is not legacy compat. + // for (const auto& col_paths : view->column_paths()) { + // auto* col_path = view_col_paths->Add(); + // auto* paths = col_path->mutable_path(); + // for (const auto& path : col_paths) { + // *paths->Add() = path; + // } + // } + + std::string col; + const auto column_paths = r.has_start_col() ? r.has_end_col() + ? view->column_paths_range(r.start_col(), r.end_col()) + : view->column_paths_range( + r.start_col(), view->num_columns() + ) + : r.has_end_col() ? view->column_paths_range(0, r.end_col()) + : view->column_paths(); + + for (auto& col_paths : column_paths) { + col = ""; + if (!col_paths.empty()) { + auto it = col_paths.begin(); + col += *it; + + for (++it; it != col_paths.end(); ++it) { + col += "|"; + col += *it; + } + } + + *view_col_paths->Add() = col; + } + + push_resp(std::move(resp)); + break; + } + case proto::Request::kViewToNdjsonStringReq: { + auto view = m_resources.get_view(req.entity_id()); + const auto& r = req.view_to_rows_string_req(); + auto config = view->get_view_config(); + std::string nidx{view_sides_to_string(*view)}; + auto num_hidden = calculate_num_hidden(*view, *config); + + auto dims = parse_format_options( + r.viewport(), + view->num_columns(), + view->num_rows(), + view->sides(), + config->is_column_only(), + num_hidden + ); + + auto json_str = view->to_ndjson( + dims.start_row, + dims.end_row, + dims.start_col, + dims.end_col, + num_hidden, + r.formatted(), + r.index(), + r.id(), + view->sides(), + view->sides() > 0 && !config->is_column_only(), + nidx, + config->get_columns().size(), + view->get_view_config()->get_row_pivots().size() + ); + + proto::Response resp; + auto* view_cols_str = resp.mutable_view_to_ndjson_string_resp(); + view_cols_str->set_ndjson_string(json_str); + push_resp(std::move(resp)); + break; + } + case proto::Request::kViewToRowsStringReq: { + auto view = m_resources.get_view(req.entity_id()); + const auto& r = req.view_to_rows_string_req(); + auto config = view->get_view_config(); + std::string nidx{view_sides_to_string(*view)}; + auto num_hidden = calculate_num_hidden(*view, *config); + + auto dims = parse_format_options( + r.viewport(), + view->num_columns(), + view->num_rows(), + view->sides(), + config->is_column_only(), + num_hidden + ); + + auto json_str = view->to_rows( + dims.start_row, + dims.end_row, + dims.start_col, + dims.end_col, + num_hidden, + r.formatted(), + r.index(), + r.id(), + view->sides(), + view->sides() > 0 && !config->is_column_only(), + nidx, + config->get_columns().size(), + view->get_view_config()->get_row_pivots().size() + ); + + proto::Response resp; + auto* view_cols_str = resp.mutable_view_to_rows_string_resp(); + view_cols_str->set_json_string(json_str); + push_resp(std::move(resp)); + break; + } + case proto::Request::kViewToColumnsStringReq: { + auto view = m_resources.get_view(req.entity_id()); + const auto& r = req.view_to_columns_string_req(); + auto config = view->get_view_config(); + std::string nidx{view_sides_to_string(*view)}; + auto num_hidden = calculate_num_hidden(*view, *config); + LOG_DEBUG("num_hidden: " << num_hidden); + + auto dims = parse_format_options( + r.viewport(), + view->num_columns(), + view->num_rows(), + view->sides(), + config->is_column_only(), + num_hidden + ); + + auto json_str = view->to_columns( + dims.start_row, + dims.end_row, + dims.start_col, + dims.end_col, + num_hidden, + r.formatted(), + r.index(), + r.id(), + view->sides(), + view->sides() > 0 && !config->is_column_only() && !config->get_row_pivots().empty(), + nidx, + config->get_columns().size(), + config->get_row_pivots().size() + ); + + proto::Response resp; + auto* view_cols_str = resp.mutable_view_to_columns_string_resp(); + view_cols_str->set_json_string(json_str); + push_resp(std::move(resp)); + break; + } + case proto::Request::kTableDeleteReq: { + // Prevent deleting a source table that feeds a join table + auto dependents = + m_join_engine.get_dependent_join_tables(req.entity_id()); + if (!dependents.empty() + && !m_join_engine.is_join_table(req.entity_id())) { + proto::Response resp; + std::stringstream ss; + ss << "Cannot delete table: it is a source for join table \"" + << dependents[0] << "\""; + *resp.mutable_server_error()->mutable_message() = ss.str(); + push_resp(std::move(resp)); + break; + } + + // If this is a join table being deleted, clean up join metadata + if (m_join_engine.is_join_table(req.entity_id())) { + m_join_engine.unregister_join(req.entity_id()); + } + + const auto is_immediate = req.table_delete_req().is_immediate(); + if (is_immediate + || m_resources.get_table_view_count(req.entity_id()) == 0) { + m_resources.delete_table(req.entity_id()); + + for (const auto& sub : + m_resources.get_table_on_delete_sub(req.entity_id())) { + proto::Response resp; + resp.mutable_table_on_delete_resp(); + resp.set_msg_id(sub.id); + resp.set_entity_id(req.entity_id()); + ProtoServerResp resp2; + resp2.data = std::move(resp); + resp2.client_id = sub.client_id; + proto_resp.emplace_back(std::move(resp2)); + } + + proto::Response resp; + resp.mutable_table_delete_resp(); + push_resp(std::move(resp)); + + // notify `on_hosted_tables_update` listeners + auto subscriptions = + m_resources.get_on_hosted_tables_update_sub(); + for (auto& subscription : subscriptions) { + Response out; + out.set_msg_id(subscription.id); + ProtoServerResp resp2; + resp2.data = std::move(out); + resp2.client_id = subscription.client_id; + proto_resp.emplace_back(std::move(resp2)); + } + } else { + m_resources.mark_table_deleted( + req.entity_id(), client_id, req.msg_id() + ); + } + + break; + } + case proto::Request::kViewDeleteReq: { + for (const auto& sub : + m_resources.get_view_on_delete_sub(req.entity_id())) { + proto::Response resp; + resp.mutable_view_on_delete_resp(); + resp.set_msg_id(sub.id); + resp.set_entity_id(req.entity_id()); + ProtoServerResp resp2; + resp2.data = std::move(resp); + resp2.client_id = sub.client_id; + proto_resp.emplace_back(std::move(resp2)); + } + + const auto table_id = + m_resources.get_table_id_for_view(req.entity_id()); + m_resources.delete_view(client_id, req.entity_id()); + proto::Response resp; + resp.mutable_view_delete_resp(); + push_resp(std::move(resp)); + if (m_resources.is_table_deleted(table_id)) { + Subscription deleted_id = + m_resources.get_table_deleted_client(table_id); + + m_resources.delete_table(table_id); + for (const auto& sub : + m_resources.get_table_on_delete_sub(table_id)) { + proto::Response resp; + resp.mutable_table_on_delete_resp(); + resp.set_msg_id(sub.id); + resp.set_entity_id(table_id); + ProtoServerResp resp2; + resp2.data = std::move(resp); + resp2.client_id = sub.client_id; + proto_resp.emplace_back(std::move(resp2)); + } + + // Notify the client which initially called delete + proto::Response resp; + resp.mutable_table_delete_resp(); + resp.set_msg_id(deleted_id.id); + resp.set_entity_id(table_id); + ProtoServerResp resp2; + resp2.data = std::move(resp); + resp2.client_id = deleted_id.client_id; + proto_resp.emplace_back(std::move(resp2)); + + // notify `on_hosted_tables_update` listeners + auto subscriptions = + m_resources.get_on_hosted_tables_update_sub(); + for (auto& subscription : subscriptions) { + Response out; + out.set_msg_id(subscription.id); + ProtoServerResp resp2; + resp2.data = std::move(out); + resp2.client_id = subscription.client_id; + proto_resp.emplace_back(std::move(resp2)); + } + } + // const auto table_id = + break; + } + case proto::Request::kViewRemoveOnUpdateReq: { + auto sub_id = req.view_remove_on_update_req().id(); + m_resources.remove_view_on_update_sub( + req.entity_id(), sub_id, client_id + ); + + proto::Response resp; + resp.mutable_view_remove_on_update_resp(); + push_resp(std::move(resp)); + break; + } + case proto::Request::kViewExpressionSchemaReq: { + auto view = m_resources.get_view(req.entity_id()); + + proto::Response resp; + auto* view_expr_schema = + resp.mutable_view_expression_schema_resp()->mutable_schema(); + auto schema = view->expression_schema(); + for (const auto& [k, v] : schema) { + (*view_expr_schema)[k] = dtype_to_column_type(str_to_dtype(v)); + } + + push_resp(std::move(resp)); + break; + } + case proto::Request::kViewToArrowReq: { + auto view = m_resources.get_view(req.entity_id()); + const auto& r = req.view_to_arrow_req(); + auto config = view->get_view_config(); + auto num_hidden = calculate_num_hidden(*view, *config); + auto dims = parse_format_options( + r.viewport(), + view->num_columns(), + view->num_rows(), + view->sides(), + view->get_view_config()->is_column_only(), + num_hidden + ); + + proto::Response resp; + auto* arrow = resp.mutable_view_to_arrow_resp()->mutable_arrow(); + bool legacy_names = r.viewport().has_emit_legacy_row_path_names() + ? r.viewport().emit_legacy_row_path_names() + : true; + *arrow = *view->to_arrow( + dims.start_row, + dims.end_row, + dims.start_col, + dims.end_col, + true, + r.compression() == "lz4", + legacy_names + ); + + push_resp(std::move(resp)); + break; + } + case proto::Request::kViewToCsvReq: { + LOG_DEBUG("Handling ViewToCsvReq"); + auto view = m_resources.get_view(req.entity_id()); + const auto& r = req.view_to_csv_req(); + auto config = view->get_view_config(); + auto num_hidden = calculate_num_hidden(*view, *config); + auto dims = parse_format_options( + r.viewport(), + view->num_columns(), + view->num_rows(), + view->sides(), + view->get_view_config()->is_column_only(), + num_hidden + ); + + proto::Response resp; + auto* csv = resp.mutable_view_to_csv_resp()->mutable_csv(); + *csv = *view->to_csv( + dims.start_row, dims.end_row, dims.start_col, dims.end_col + ); + + push_resp(std::move(resp)); + break; + } + case proto::Request::kViewOnUpdateReq: { + Subscription sub_info; + sub_info.id = req.msg_id(); + sub_info.client_id = client_id; + m_resources.create_view_on_update_sub(req.entity_id(), sub_info); + if (req.view_on_update_req().has_mode() + && req.view_on_update_req().mode() + == proto::ViewOnUpdateReq_Mode::ViewOnUpdateReq_Mode_ROW) { + auto view = m_resources.get_view(req.entity_id()); + view->set_deltas_enabled(true); + } + break; + } + case proto::Request::kViewGetMinMaxReq: { + const auto& col = req.view_get_min_max_req().column_name(); + auto view = m_resources.get_view(req.entity_id()); + const auto min_max = view->get_min_max(col); + proto::Response resp; + auto* pair = resp.mutable_view_get_min_max_resp(); + tscalar_to_proto(min_max.first, pair->mutable_min()); + tscalar_to_proto(min_max.second, pair->mutable_max()); + push_resp(std::move(resp)); + break; + } + case proto::Request::kViewCollapseReq: { + const auto& r = req.view_collapse_req(); + auto view = m_resources.get_view(req.entity_id()); + auto num_changed = view->collapse(r.row_index()); + proto::Response resp; + auto* collapse_resp = resp.mutable_view_collapse_resp(); + collapse_resp->set_num_changed(num_changed); + push_resp(std::move(resp)); + break; + } + case proto::Request::kViewExpandReq: { + const auto& r = req.view_expand_req(); + auto view = m_resources.get_view(req.entity_id()); + auto num_changed = view->expand(r.row_index()); + proto::Response resp; + auto* expand_resp = resp.mutable_view_expand_resp(); + expand_resp->set_num_changed(num_changed); + push_resp(std::move(resp)); + break; + } + case proto::Request::kViewSetDepthReq: { + const auto& r = req.view_set_depth_req(); + auto view = m_resources.get_view(req.entity_id()); + view->set_depth(r.depth()); + proto::Response resp; + resp.mutable_view_set_depth_resp(); + push_resp(std::move(resp)); + break; + } + case proto::Request::kServerSystemInfoReq: { + proto::Response resp; + auto* sys_info = resp.mutable_server_system_info_resp(); +#if defined(PSP_ENABLE_WASM) && !defined(PSP_ENABLE_PYTHON) + const auto heap_size = psp_heap_size(); + sys_info->set_heap_size(heap_size); + const auto res = mallinfo(); + sys_info->set_used_size(res.uordblks); +#elif defined(__linux__) && !defined(PSP_ENABLE_WASM) + auto res = mallinfo(); + sys_info->set_heap_size(res.usmblks); + sys_info->set_used_size(res.uordblks); +#elif defined(__APPLE__) && !defined(PSP_ENABLE_WASM) + rusage out; + getrusage(RUSAGE_SELF, &out); + sys_info->set_heap_size(out.ru_maxrss); +#endif + sys_info->set_cpu_time(m_cpu_time); + sys_info->set_cpu_time_epoch( + std::chrono::duration_cast( + std::chrono::high_resolution_clock::now() + - m_cpu_time_start.load() + ) + .count() + ); + + m_cpu_time_start = std::chrono::high_resolution_clock::now(); + m_cpu_time = 0; + push_resp(std::move(resp)); + break; + } + case proto::Request::CLIENT_REQ_NOT_SET: { + PSP_COMPLAIN_AND_ABORT("Client request unknown variant") + break; + } + } + + return proto_resp; +} + +std::vector> +ProtoServer::_poll() { + std::vector> resp_envs; + auto tables = m_resources.get_dirty_tables(); + for (auto& [table, table_id] : tables) { + _process_table_unchecked(table, table_id, resp_envs); + } + + m_resources.mark_all_tables_clean(); + + // Build the set of dirty source table IDs so we can tell the join + // engine which side(s) changed, allowing it to skip rebuilding the + // right-side index when only the left table was updated. + tsl::hopscotch_set dirty_ids; + for (auto& [_, table_id] : tables) { + dirty_ids.insert(table_id); + } + + // Recompute join tables whose sources were dirty, using a worklist + // to handle chained joins (join of join) in dependency order. + tsl::hopscotch_set processed_joins; + std::vector worklist; + for (auto& [_, table_id] : tables) { + auto dependents = m_join_engine.get_dependent_join_tables(table_id); + for (auto& join_id : dependents) { + if (processed_joins.find(join_id) == processed_joins.end()) { + worklist.push_back(join_id); + } + } + } + + while (!worklist.empty()) { + auto join_id = worklist.back(); + worklist.pop_back(); + if (!processed_joins.insert(join_id).second) { + continue; + } + + const auto& def = m_join_engine.get_join_def(join_id); + bool left_changed = dirty_ids.contains(def.left_table_id); + bool right_changed = dirty_ids.contains(def.right_table_id); + auto left_table = m_resources.get_table(def.left_table_id); + auto right_table = m_resources.get_table(def.right_table_id); + auto join_table = m_resources.get_table(join_id); + m_join_engine.recompute( + join_id, + left_table, + right_table, + join_table, + left_changed, + right_changed + ); + + _process_table_unchecked(join_table, join_id, resp_envs); + m_resources.mark_table_clean(join_id); + + // The recomputed join table is itself "dirty" for chained joins. + dirty_ids.insert(join_id); + + // Check for chained joins (join tables that depend on this join) + auto chained = m_join_engine.get_dependent_join_tables(join_id); + for (auto& chained_id : chained) { + if (processed_joins.find(chained_id) == processed_joins.end()) { + worklist.push_back(chained_id); + } + } + } + + return resp_envs; +} + +void +ProtoServer::_process_table_unchecked( + std::shared_ptr
    & table, + const ServerResources::t_id& table_id, + std::vector>& outs +) { + table->get_pool()->_process([this, table_id, &outs](auto port_id) { + // record changes per port. + auto view_ids = m_resources.get_view_ids(table_id); + for (const auto& view_id : view_ids) { + if (!m_resources.has_view(view_id)) { + continue; + } + + auto view = m_resources.get_view(view_id); + auto subscriptions = m_resources.get_view_on_update_sub(view_id); + for (auto& subscription : subscriptions) { + Response out; + out.set_msg_id(subscription.id); + out.set_entity_id(view_id); + auto* r = out.mutable_view_on_update_resp(); + r->set_port_id(port_id); + if (view->get_deltas_enabled()) { + *r->mutable_delta() = *view->get_row_delta_as_arrow(); + } + + ProtoServerResp resp2; + resp2.data = std::move(out); + resp2.client_id = subscription.client_id; + outs.emplace_back(std::move(resp2)); + } + } + }); +} + +void +ProtoServer::_process_table( + std::shared_ptr
    & table, + const ServerResources::t_id& table_id, + std::vector>& outs +) { + if (!m_resources.is_table_dirty(table_id)) { + return; + } + + _process_table_unchecked(table, table_id, outs); + m_resources.mark_table_clean(table_id); +} + +} // namespace perspective::server + +const char* +perspective::sort_op_str_from_proto(proto::SortOp sort_op) { + switch (sort_op) { + case proto::SORT_NONE: + return "none"; + case proto::SORT_ASC: + return "asc"; + case proto::SORT_DESC: + return "desc"; + case proto::SORT_ASC_ABS: + return "asc abs"; + case proto::SORT_DESC_ABS: + return "desc abs"; + case proto::SORT_COL_ASC: + return "col asc"; + case proto::SORT_COL_DESC: + return "col desc"; + case proto::SORT_COL_ASC_ABS: + return "col asc abs"; + case proto::SORT_COL_DESC_ABS: + return "col desc abs"; + default: + PSP_COMPLAIN_AND_ABORT("Invalid sort op"); + return "none"; + } +} + +perspective::proto::SortOp +perspective::sort_op_to_proto(perspective::t_sorttype sort_op) { + switch (sort_op) { + case perspective::t_sorttype::SORTTYPE_ASCENDING: + return perspective::proto::SORT_ASC; + case perspective::t_sorttype::SORTTYPE_DESCENDING: + return perspective::proto::SORT_DESC; + case perspective::t_sorttype::SORTTYPE_NONE: + return perspective::proto::SORT_NONE; + case perspective::t_sorttype::SORTTYPE_ASCENDING_ABS: + return perspective::proto::SORT_ASC_ABS; + case perspective::t_sorttype::SORTTYPE_DESCENDING_ABS: + return perspective::proto::SORT_DESC_ABS; + } + PSP_COMPLAIN_AND_ABORT("Invalid sort op"); +} diff --git a/rust/perspective-server/cpp/perspective/src/cpp/simple_bitmask.cpp b/rust/perspective-server/cpp/perspective/src/cpp/simple_bitmask.cpp new file mode 100644 index 0000000..609c627 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/simple_bitmask.cpp @@ -0,0 +1,20 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include + +namespace perspective { + +t_simple_bitmask::t_simple_bitmask(t_uindex idx) +} \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/cpp/slice.cpp b/rust/perspective-server/cpp/perspective/src/cpp/slice.cpp new file mode 100644 index 0000000..a34db48 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/slice.cpp @@ -0,0 +1,132 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include + +namespace perspective { + +const t_range& +t_slice::range() const { + return m_range; +} + +const std::vector& +t_slice::row_paths() const { + return m_row_paths; +} + +const std::vector& +t_slice::column_paths() const { + return m_column_paths; +} + +const std::vector& +t_slice::row_indices() const { + return m_row_indices; +} + +const std::vector& +t_slice::column_indices() const { + return m_column_indices; +} + +const std::vector& +t_slice::row_data() const { + return m_row_data; +} + +const std::vector& +t_slice::column_data() const { + return m_column_data; +} + +const std::vector& +t_slice::row_depth() const { + + return m_row_depth; +} + +const std::vector& +t_slice::column_depth() const { + return m_column_depth; +} + +const std::vector& +t_slice::is_row_expanded() const { + return m_is_row_expanded; +} + +const std::vector& +t_slice::is_column_expanded() const { + return m_is_column_expanded; +} + +t_range& +t_slice::range() { + return m_range; +} + +std::vector& +t_slice::row_paths() { + return m_row_paths; +} + +std::vector& +t_slice::column_paths() { + return m_column_paths; +} + +std::vector& +t_slice::row_indices() { + return m_row_indices; +} + +std::vector& +t_slice::column_indices() { + return m_column_indices; +} + +std::vector& +t_slice::row_data() { + return m_row_data; +} + +std::vector& +t_slice::column_data() { + return m_column_data; +} + +std::vector& +t_slice::row_depth() { + + return m_row_depth; +} + +std::vector& +t_slice::column_depth() { + return m_column_depth; +} + +std::vector& +t_slice::is_row_expanded() { + return m_is_row_expanded; +} + +std::vector& +t_slice::is_column_expanded() { + return m_is_column_expanded; +} + +} // namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/sort_specification.cpp b/rust/perspective-server/cpp/perspective/src/cpp/sort_specification.cpp new file mode 100644 index 0000000..1b7ea5c --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/sort_specification.cpp @@ -0,0 +1,82 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include + +#include + +namespace perspective { + +t_sortspec::t_sortspec( + std::string column_name, t_index agg_index, t_sorttype sort_type +) : + m_colname(std::move(column_name)), + m_agg_index(agg_index), + m_sort_type(sort_type), + m_sortspec_type(SORTSPEC_TYPE_IDX) {} + +t_sortspec::t_sortspec(t_index agg_index, t_sorttype sort_type) : + m_agg_index(agg_index), + m_sort_type(sort_type), + m_sortspec_type(SORTSPEC_TYPE_IDX) {} + +t_sortspec::t_sortspec( + const std::vector& path, t_index agg_index, t_sorttype sort_type +) : + m_agg_index(agg_index), + m_sort_type(sort_type), + m_sortspec_type(SORTSPEC_TYPE_PATH), + m_path(path) {} + +t_sortspec::t_sortspec() : + m_agg_index(INVALID_INDEX), + m_sort_type(SORTTYPE_NONE), + m_sortspec_type(SORTSPEC_TYPE_IDX) {} + +bool +t_sortspec::operator==(const t_sortspec& s2) const { + return (m_agg_index == s2.m_agg_index) && (m_sort_type == s2.m_sort_type); +} + +bool +t_sortspec::operator!=(const t_sortspec& s2) const { + return !(*this == s2); +} + +std::vector +get_sort_orders(const std::vector& vec) { + if (vec.empty()) { + return {}; + } + auto num = vec.size(); + std::vector sort_orders(num); + + for (std::vector::size_type idx = 0; idx < num; ++idx) { + sort_orders[idx] = vec[idx].m_sort_type; + } + return sort_orders; +} + +} // end namespace perspective + +namespace std { +PERSPECTIVE_EXPORT std::ostream& +operator<<(std::ostream& os, const perspective::t_sortspec& t) { + using namespace perspective; + os << "t_sortspec"; + return os; +} + +} // end namespace std diff --git a/rust/perspective-server/cpp/perspective/src/cpp/sparse_tree.cpp b/rust/perspective-server/cpp/perspective/src/cpp/sparse_tree.cpp new file mode 100644 index 0000000..7b36914 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/sparse_tree.cpp @@ -0,0 +1,2920 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +// Tweet length +const t_uindex MAX_JOIN_SIZE = 280; + +t_tscalar +get_dominant(std::vector& values) { + if (values.empty()) { + return mknone(); + } + + // Single-pass O(n) frequency count using hash map instead of O(n log n) + // sort. + tsl::hopscotch_map freq; + freq.reserve(values.size()); + + t_tscalar delem = values[0]; + t_index dcount = 0; + + for (const auto& val : values) { + if (!val.is_valid()) { + continue; + } + t_index count = ++freq[val]; + if (count > dcount) { + dcount = count; + delem = val; + } + } + + return delem; +} + +t_tree_unify_rec::t_tree_unify_rec( + t_uindex sptidx, t_uindex daggidx, t_uindex saggidx, t_uindex nstrands +) : + m_sptidx(sptidx), + m_daggidx(daggidx), + m_saggidx(saggidx), + m_nstrands(nstrands) {} + +t_stree::t_stree( + const std::vector& pivots, + const std::vector& aggspecs, + t_schema schema, + const t_config& cfg +) : + m_pivots(pivots), + m_init(false), + m_curidx(1), + m_aggspecs(aggspecs), + m_schema(std::move(schema)), + m_cur_aggidx(1), + m_has_delta(false), + m_num_row_pivots_in_tree(pivots.size()) { + const auto& g_agg_str = cfg.get_grand_agg_str(); + m_grand_agg_str = g_agg_str.empty() ? "Grand Aggregate" : g_agg_str; +} + +void +t_stree::set_gmv_row_pivot_meta( + t_uindex num_row_pivots_in_tree, + const std::string& next_row_pivot_name +) { + m_num_row_pivots_in_tree = num_row_pivots_in_tree; + m_next_row_pivot_name = next_row_pivot_name; +} + +t_uindex +t_stree::get_num_row_pivots_in_tree() const { + return m_num_row_pivots_in_tree; +} + +const std::string& +t_stree::get_next_row_pivot_name() const { + return m_next_row_pivot_name; +} + +t_stree::~t_stree() { + for (auto& iter : m_smap) { + free(const_cast(iter.first)); + } +} + +t_uindex +t_stree::get_num_aggcols() const { + return m_aggspecs.size(); +} + +std::string +t_stree::repr() const { + std::stringstream ss; + ss << "t_stree<" << this << ">"; + return ss.str(); +} + +void +t_stree::init() { + m_nodes = std::make_shared(); + m_idxpkey = std::make_shared(); + m_idxleaf = std::make_shared(); + + t_tscalar value = m_symtable.get_interned_tscalar(m_grand_agg_str.c_str()); + t_tnode node(0, root_pidx(), value, 0, value, 1, 0); + m_nodes->insert(node); + + std::vector columns; + std::vector dtypes; + + for (const auto& spec : m_aggspecs) { + auto cinfo = spec.get_output_specs(m_schema); + + for (const auto& ci : cinfo) { + columns.push_back(ci.m_name); + dtypes.push_back(ci.m_type); + } + } + + t_schema schema(columns, dtypes); + + t_uindex capacity = DEFAULT_EMPTY_CAPACITY; + m_aggregates = std::make_shared(schema, capacity); + m_aggregates->init(); + m_aggregates->set_size(capacity); + + m_aggcols = std::vector(columns.size()); + + for (t_uindex idx = 0, loop_end = columns.size(); idx < loop_end; ++idx) { + m_aggcols[idx] = m_aggregates->_get_const_column(columns[idx]); + } + + m_deltas = std::make_shared(); + m_features = std::vector(CTX_FEAT_LAST_FEATURE); + m_init = true; +} + +t_tscalar +t_stree::get_value(t_index idx) const { + iter_by_idx iter = m_nodes->get().find(idx); + PSP_VERBOSE_ASSERT( + iter != m_nodes->get().end(), "Reached end iterator" + ); + return iter->m_value; +} + +t_tscalar +t_stree::get_sortby_value(t_index idx) const { + iter_by_idx iter = m_nodes->get().find(idx); + PSP_VERBOSE_ASSERT( + iter != m_nodes->get().end(), "Reached end iterator" + ); + return iter->m_sort_value; +} + +void +t_stree::build_strand_table_phase_1( + t_tscalar pkey, + t_op op, + t_uindex idx, + t_uindex npivots, + t_uindex strand_count_idx, + t_uindex aggcolsize, + bool force_current_row, + const std::vector& piv_ccols, + const std::vector& piv_tcols, + const std::vector& agg_ccols, + const std::vector& agg_dcols, + std::vector& piv_scols, + std::vector& agg_acols, + t_column* agg_scount, + t_column* spkey, + t_uindex& insert_count, + bool& pivots_neq, + const std::vector& pivot_like +) const { + pivots_neq = false; + tsl::hopscotch_set pivmap; + + // if a row has been changed (value change, validity change, removed, etc.), + // will be false. + bool no_new_rows = true; + + // for each strand col, take out the value at row `idx` and use the + // transition to calculate whether the pivot has changed. + for (t_uindex pidx = 0, ploop_end = pivot_like.size(); pidx < ploop_end; + ++pidx) { + const std::string& colname = pivot_like.at(pidx); + if (pivmap.find(colname) != pivmap.end()) { + continue; + } + pivmap.insert(colname); + piv_scols[pidx]->push_back(piv_ccols[pidx]->get_scalar(idx)); + const auto* trans_ = piv_tcols[pidx]->get_nth(idx); + auto trans = static_cast(*trans_); + + // `no_new_rows` is used to calculate the strand count for the + // "count" aggregate. Previously we only checked if the column's + // value and validity did not change, which led to a long-running bug + // where the "last" aggregate would increase the "count" of another + // column even when no new rows were added. Thus, we only need + // to check if new rows were added, not whether the row's value + // has changed. + if (trans != VALUE_TRANSITION_EQ_TT + && trans != VALUE_TRANSITION_NEQ_TT) { + no_new_rows = false; + } + + if (pidx < npivots) { + pivots_neq = pivots_neq || pivots_changed(trans); + } + } + + // For each aggregate col, except "psp_strand_count" as marked by + // "strand_count_idx" (the number of strands in the table), if the pivot + // has changed OR force_current_row is true, then use the aggregate + // from `current`, else use the `delta`. + for (t_uindex aggidx = 0; aggidx < aggcolsize; ++aggidx) { + if (aggidx != strand_count_idx) { + if (pivots_neq || force_current_row) { + agg_acols[aggidx]->push_back(agg_ccols[aggidx]->get_scalar(idx) + ); + } else { + agg_acols[aggidx]->push_back(agg_dcols[aggidx]->get_scalar(idx) + ); + } + } + } + + std::int8_t strand_count; + + if (op == OP_DELETE) { + // A row has been removed + strand_count = -1; + } else { + // Strand count is 1 if there are no pivots, if new rows have been + // added, if pivots have been changed, or force_current_row is true. + strand_count = + npivots == 0 || !no_new_rows || pivots_neq || force_current_row ? 1 + : 0; + } + + agg_scount->push_back(strand_count); + spkey->push_back(pkey); + + ++insert_count; +} + +void +t_stree::build_strand_table_phase_2( + t_tscalar pkey, + t_uindex idx, + t_uindex npivots, + t_uindex strand_count_idx, + t_uindex aggcolsize, + const std::vector& piv_pcols, + const std::vector& agg_pcols, + std::vector& piv_scols, + std::vector& agg_acols, + t_column* agg_scount, + t_column* spkey, + t_uindex& insert_count, + const std::vector& pivot_like +) const { + tsl::hopscotch_set pivmap; + + // For each column, insert the prev value to the strand + for (t_uindex pidx = 0, ploop_end = pivot_like.size(); pidx < ploop_end; + ++pidx) { + const std::string& colname = pivot_like.at(pidx); + if (pivmap.find(colname) != pivmap.end()) { + continue; + } + pivmap.insert(colname); + piv_scols[pidx]->push_back(piv_pcols[pidx]->get_scalar(idx)); + } + + for (t_uindex aggidx = 0; aggidx < aggcolsize; ++aggidx) { + if (aggidx != strand_count_idx) { + agg_acols[aggidx]->push_back( + agg_pcols[aggidx]->get_scalar(idx).negate() + ); + } + } + + agg_scount->push_back(std::int8_t(-1)); + spkey->push_back(pkey); + ++insert_count; +} + +t_build_strand_table_metadata +t_stree::build_strand_table_metadata( + const t_data_table& flattened, + const std::vector& aggspecs, + const t_config& config +) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + + t_build_strand_table_metadata metadata; + + metadata.m_flattened_schema = flattened.get_schema(); + std::set sschema_colset; + + for (const auto& piv : m_pivots) { + const std::string& colname = piv.colname(); + std::string sortby_colname = config.get_sort_by(colname); + auto add_col = [&sschema_colset, &metadata](const std::string& cname) { + if (sschema_colset.find(cname) == sschema_colset.end()) { + metadata.m_pivot_like_columns.push_back(cname); + metadata.m_strand_schema.add_column( + cname, metadata.m_flattened_schema.get_dtype(cname) + ); + sschema_colset.insert(cname); + } + }; + + add_col(colname); + add_col(sortby_colname); + } + + metadata.m_pivsize = sschema_colset.size(); + + std::set aggcolset; + for (const auto& aggspec : aggspecs) { + for (const auto& dep : aggspec.get_dependencies()) { + if (dep.type() == DEPTYPE_COLUMN) { + const std::string& depname = dep.name(); + aggcolset.insert(depname); + + if (aggspec.is_non_delta() + && sschema_colset.find(depname) == sschema_colset.end()) { + metadata.m_pivot_like_columns.push_back(depname); + metadata.m_strand_schema.add_column( + depname, metadata.m_flattened_schema.get_dtype(depname) + ); + sschema_colset.insert(depname); + } + } + } + } + + metadata.m_npivotlike = sschema_colset.size(); + metadata.m_strand_schema.add_column( + "psp_pkey", flattened.get_const_column("psp_pkey")->get_dtype() + ); + + for (const auto& aggcol : aggcolset) { + metadata.m_aggschema.add_column( + aggcol, metadata.m_flattened_schema.get_dtype(aggcol) + ); + } + + metadata.m_aggschema.add_column("psp_strand_count", DTYPE_INT8); + return metadata; +} + +/** + * @brief Builds the strand table for all subsequent updates after the first + * update to the table. + * + * @param flattened + * @param delta + * @param prev + * @param current + * @param transitions + * @param aggspecs + * @param config + * @return std::pair, + * std::shared_ptr> + */ +std::pair, std::shared_ptr> +t_stree::build_strand_table( + const t_data_table& flattened, + const t_data_table& delta, + const t_data_table& prev, + const t_data_table& current, + const t_data_table& transitions, + const std::vector& aggspecs, + const t_config& config +) const { + + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + + auto metadata = build_strand_table_metadata(flattened, aggspecs, config); + + // strand table + std::shared_ptr strands = + std::make_shared(metadata.m_strand_schema); + strands->init(); + + // strand table + std::shared_ptr aggs = + std::make_shared(metadata.m_aggschema); + aggs->init(); + + std::shared_ptr pkey_col = + flattened.get_const_column("psp_pkey"); + std::shared_ptr op_col = + flattened.get_const_column("psp_op"); + + t_uindex npivotlike = metadata.m_npivotlike; + std::vector piv_pcols(npivotlike); + std::vector piv_ccols(npivotlike); + std::vector piv_tcols(npivotlike); + std::vector piv_scols(npivotlike); + + t_uindex insert_count = 0; + + // Get each intermediate column, including columns aggregated by + // last, high, and low as they were added to m_strand_schema in + // the construction method. + for (t_uindex pidx = 0; pidx < npivotlike; ++pidx) { + const std::string& piv = metadata.m_strand_schema.m_columns[pidx]; + piv_pcols[pidx] = prev._get_const_column(piv); + piv_ccols[pidx] = current._get_const_column(piv); + piv_tcols[pidx] = transitions._get_const_column(piv); + piv_scols[pidx] = strands->_get_column(piv); + } + + t_uindex aggcolsize = metadata.m_aggschema.m_columns.size(); + std::vector agg_ccols(aggcolsize); + std::vector agg_pcols(aggcolsize); + std::vector agg_dcols(aggcolsize); + std::vector agg_acols(aggcolsize); + + t_uindex strand_count_idx = 0; + + for (t_uindex aggidx = 0; aggidx < aggcolsize; ++aggidx) { + const std::string& aggcol = metadata.m_aggschema.m_columns[aggidx]; + if (aggcol == "psp_strand_count") { + agg_dcols[aggidx] = nullptr; + agg_ccols[aggidx] = nullptr; + agg_pcols[aggidx] = nullptr; + strand_count_idx = aggidx; + } else { + agg_dcols[aggidx] = delta._get_const_column(aggcol); + agg_ccols[aggidx] = current._get_const_column(aggcol); + agg_pcols[aggidx] = prev._get_const_column(aggcol); + } + + agg_acols[aggidx] = aggs->_get_column(aggcol); + } + + t_column* agg_scount = aggs->_get_column("psp_strand_count"); + + t_column* spkey = strands->_get_column("psp_pkey"); + + t_mask msk_prev; + t_mask msk_curr; + + if (config.has_filters()) { + msk_prev = filter_table_for_config(prev, config); + msk_curr = filter_table_for_config(current, config); + } + + bool has_filters = config.has_filters(); + const std::uint8_t* op_base = op_col->get_nth_base(); + + if (has_filters) { + for (t_uindex idx = 0, loop_end = flattened.size(); idx < loop_end; + ++idx) { + bool filter_prev = msk_prev.get(idx); + bool filter_curr = msk_curr.get(idx); + + t_tscalar pkey = pkey_col->get_scalar(idx); + std::uint8_t op_ = op_base[idx]; + t_op op = static_cast(op_); + bool pivots_neq; + + if (!filter_prev && !filter_curr) { + // nothing to do + continue; + } + if (!filter_prev && filter_curr) { + // apply current row + build_strand_table_phase_1( + pkey, + op, + idx, + metadata.m_pivsize, + strand_count_idx, + aggcolsize, + true, + piv_ccols, + piv_tcols, + agg_ccols, + agg_dcols, + piv_scols, + agg_acols, + agg_scount, + spkey, + insert_count, + pivots_neq, + metadata.m_pivot_like_columns + ); + } else if (filter_prev && !filter_curr) { + // reverse prev row + build_strand_table_phase_2( + pkey, + idx, + metadata.m_pivsize, + strand_count_idx, + aggcolsize, + piv_pcols, + agg_pcols, + piv_scols, + agg_acols, + agg_scount, + spkey, + insert_count, + metadata.m_pivot_like_columns + ); + } else if (filter_prev && filter_curr) { + // should be handled as normal + build_strand_table_phase_1( + pkey, + op, + idx, + metadata.m_pivsize, + strand_count_idx, + aggcolsize, + false, + piv_ccols, + piv_tcols, + agg_ccols, + agg_dcols, + piv_scols, + agg_acols, + agg_scount, + spkey, + insert_count, + pivots_neq, + metadata.m_pivot_like_columns + ); + + if (op == OP_DELETE || !pivots_neq) { + continue; + } + + build_strand_table_phase_2( + pkey, + idx, + metadata.m_pivsize, + strand_count_idx, + aggcolsize, + piv_pcols, + agg_pcols, + piv_scols, + agg_acols, + agg_scount, + spkey, + insert_count, + metadata.m_pivot_like_columns + ); + } + } + } else { + for (t_uindex idx = 0, loop_end = flattened.size(); idx < loop_end; + ++idx) { + + t_tscalar pkey = pkey_col->get_scalar(idx); + std::uint8_t op_ = op_base[idx]; + t_op op = static_cast(op_); + bool pivots_neq; + + // FOR EVERY ROW, + // piv_ccols: current strand col, piv_tcols: current transition + // col for strand + build_strand_table_phase_1( + pkey, + op, + idx, + metadata.m_pivsize, + strand_count_idx, + aggcolsize, + false, + piv_ccols, + piv_tcols, + agg_ccols, + agg_dcols, + piv_scols, + agg_acols, + agg_scount, + spkey, + insert_count, + pivots_neq, + metadata.m_pivot_like_columns + ); + + if (op == OP_DELETE || !pivots_neq) { + continue; + } + + // FOR EVERY ROW, + // piv_pcols: prev, piv_scols: strands? final data? + build_strand_table_phase_2( + pkey, + idx, + metadata.m_pivsize, + strand_count_idx, + aggcolsize, + piv_pcols, + agg_pcols, + piv_scols, + agg_acols, + agg_scount, + spkey, + insert_count, + metadata.m_pivot_like_columns + ); + } + } + + strands->reserve(insert_count); + strands->set_size(insert_count); + aggs->reserve(insert_count); + aggs->set_size(insert_count); + agg_scount->valid_raw_fill(); + return std::pair< + std::shared_ptr, + std::shared_ptr>(strands, aggs); +} + +/** + * @brief Builds the strand table immediately after a view's creation, where + * the only table that matters is `flattened` which contains the dataset + * from the gnode_state. + * + * @param flattened + * @param aggspecs + * @param config + * @return std::pair, + * std::shared_ptr> + */ +std::pair, std::shared_ptr> +t_stree::build_strand_table( + const t_data_table& flattened, + const std::vector& aggspecs, + const t_config& config +) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + + auto metadata = build_strand_table_metadata(flattened, aggspecs, config); + + // strand table + std::shared_ptr strands = + std::make_shared(metadata.m_strand_schema); + strands->init(); + + // strand table + std::shared_ptr aggs = + std::make_shared(metadata.m_aggschema); + aggs->init(); + + std::shared_ptr pkey_col = + flattened.get_const_column("psp_pkey"); + + std::shared_ptr op_col = + flattened.get_const_column("psp_op"); + + t_uindex npivotlike = metadata.m_npivotlike; + std::vector piv_fcols(npivotlike); + std::vector piv_scols(npivotlike); + t_uindex insert_count = 0; + for (t_uindex pidx = 0; pidx < npivotlike; ++pidx) { + const std::string& piv = metadata.m_strand_schema.m_columns[pidx]; + piv_fcols[pidx] = flattened._get_const_column(piv); + piv_scols[pidx] = strands->_get_column(piv); + } + + t_uindex aggcolsize = metadata.m_aggschema.m_columns.size(); + std::vector agg_fcols(aggcolsize); + std::vector agg_acols(aggcolsize); + t_uindex strand_count_idx = 0; + for (t_uindex aggidx = 0; aggidx < aggcolsize; ++aggidx) { + const std::string& aggcol = metadata.m_aggschema.m_columns[aggidx]; + if (aggcol == "psp_strand_count") { + agg_fcols[aggidx] = nullptr; + strand_count_idx = aggidx; + } else { + agg_fcols[aggidx] = flattened._get_const_column(aggcol); + } + + agg_acols[aggidx] = aggs->_get_column(aggcol); + } + + t_column* agg_scount = aggs->_get_column("psp_strand_count"); + t_column* spkey = strands->_get_column("psp_pkey"); + t_mask msk; + if (config.has_filters()) { + msk = filter_table_for_config(flattened, config); + } + + bool has_filters = config.has_filters(); + const t_uindex loop_end = flattened.size(); + const t_uindex size = loop_end - msk.count(); + const t_uindex ploop_end = metadata.m_pivot_like_columns.size(); + const std::uint8_t* op_base = op_col->get_nth_base(); + parallel_for(int(aggcolsize + 1), [&](int aggidx) { + // This over-allocates for `OP_DELETE`, as it only accounts for + // filtered count. + if (aggidx > 0) { + if (aggidx - 1 != strand_count_idx) { + agg_acols[aggidx - 1]->reserve(size); + } + } else { + spkey->reserve(size); + for (t_uindex pidx = 0; pidx < ploop_end; ++pidx) { + piv_scols[pidx]->reserve(size); + } + } + + for (t_uindex idx = 0; idx < loop_end; ++idx) { + bool filter = !has_filters || msk.get(idx); + if (!filter) { + continue; + } + + std::uint8_t op_ = op_base[idx]; + t_op op = static_cast(op_); + if (op == OP_DELETE) { + continue; + } + + if (aggidx == 0) { + t_tscalar pkey = pkey_col->get_scalar(idx); + for (t_uindex pidx = 0; pidx < ploop_end; ++pidx) { + piv_scols[pidx]->push_back(piv_fcols[pidx]->get_scalar(idx) + ); + } + + agg_scount->push_back(1); + spkey->push_back(pkey); + ++insert_count; + } else if (aggidx - 1 != strand_count_idx) { + agg_acols[aggidx - 1]->push_back( + agg_fcols[aggidx - 1]->get_scalar(idx) + ); + } + } + }); + + strands->reserve(insert_count); + strands->set_size(insert_count); + aggs->reserve(insert_count); + aggs->set_size(insert_count); + agg_scount->valid_raw_fill(); + return std::pair< + std::shared_ptr, + std::shared_ptr>(strands, aggs); +} + +bool +t_stree::pivots_changed(t_value_transition t) const { + + return t == VALUE_TRANSITION_NEQ_TF || t == VALUE_TRANSITION_NVEQ_FT + || t == VALUE_TRANSITION_NEQ_TT; +} + +void +t_stree::populate_pkey_idx( + const t_dtree_ctx& ctx, + const t_dtree& dtree, + t_uindex dptidx, + t_uindex sptidx, + t_uindex ndepth, + t_idxpkey& new_idx_pkey +) { + if (ndepth == dtree.last_level()) { + auto pkey_col = ctx.get_pkey_col(); + auto strand_count_col = ctx.get_strand_count_col(); + auto liters = ctx.get_leaf_iterators(dptidx); + + for (const auto* lfiter = liters.first; lfiter != liters.second; + ++lfiter) { + + auto lfidx = *lfiter; + auto pkey = + m_symtable.get_interned_tscalar(pkey_col->get_scalar(lfidx)); + auto strand_count = + strand_count_col->get_nth_base()[lfidx]; + + // Checks the strand count and adds a new primary key if it's + // increased. + if (strand_count > 0) { + t_stpkey s(sptidx, pkey); + new_idx_pkey.insert(s); + } + + if (strand_count < 0) { + remove_pkey(sptidx, pkey); + } + } + } +} + +void +t_stree::update_shape_from_static(const t_dtree_ctx& ctx) { + + m_newids.clear(); + m_newleaves.clear(); + m_tree_unification_records.clear(); + + const std::shared_ptr scount = + ctx.get_aggtable().get_const_column("psp_strand_count_sum"); + + const t_dtree& dtree = ctx.get_tree(); + + // map dptidx to sptidx + std::map nmap; + nmap[0] = 0; + + t_filter filter; + + // update root + auto root_iter = m_nodes->get().find(0); + auto root_node = *root_iter; + + // scount = summed strand count + t_index root_nstrands = + *(scount->get_nth(0)) + root_node.m_nstrands; + root_node.set_nstrands(std::max(root_nstrands, (t_index)1)); + m_nodes->get().replace(root_iter, root_node); + + t_tree_unify_rec unif_rec(0, 0, 0, root_nstrands); + m_tree_unification_records.push_back(unif_rec); + + t_idxpkey new_idx_pkey; + + for (auto dptidx : dtree.dfs()) { + t_uindex sptidx = 0; + t_depth ndepth = dtree.get_depth(dptidx); + + if (dptidx == 0) { + populate_pkey_idx(ctx, dtree, dptidx, sptidx, ndepth, new_idx_pkey); + continue; + } + + t_uindex p_dptidx = dtree.get_parent(dptidx); + t_uindex p_sptidx = nmap[p_dptidx]; + + t_tscalar value = + m_symtable.get_interned_tscalar(dtree.get_value(filter, dptidx)); + + t_tscalar sortby_value = m_symtable.get_interned_tscalar( + dtree.get_sortby_value(filter, dptidx) + ); + + t_uindex src_ridx = dptidx; + + auto iter = + m_nodes->get().find(std::make_tuple(p_sptidx, value)); + + auto nstrands = *(scount->get_nth(dptidx)); + + if (iter == m_nodes->get().end() && nstrands < 0) { + continue; + } + + if (iter == m_nodes->get().end()) { + // create node and enqueue + sptidx = genidx(); + + t_uindex dst_ridx = gen_aggidx(); + + t_tnode node( + sptidx, + p_sptidx, + value, + ndepth, + sortby_value, + nstrands, + dst_ridx + ); + + m_newids.insert(sptidx); + + if (ndepth == dtree.last_level()) { + m_newleaves.insert(sptidx); + } + + auto insert_pair = m_nodes->insert(node); + if (!insert_pair.second) { + auto failed_because = *(insert_pair.first); + std::cout << "failed because of " << failed_because << '\n'; + } + PSP_VERBOSE_ASSERT(insert_pair.second, "Failed to insert node"); + t_tree_unify_rec unif_rec(sptidx, src_ridx, dst_ridx, nstrands); + m_tree_unification_records.push_back(unif_rec); + } else { + sptidx = iter->m_idx; + + // update node + t_tnode node = *iter; + node.set_sort_value(sortby_value); + + t_uindex dst_ridx = node.m_aggidx; + + nstrands = node.m_nstrands + nstrands; + + t_tree_unify_rec unif_rec(sptidx, src_ridx, dst_ridx, nstrands); + m_tree_unification_records.push_back(unif_rec); + + sptidx = iter->m_idx; + node.set_nstrands(nstrands); + PSP_VERBOSE_ASSERT( + m_nodes->get().replace(iter, node), + , + "Failed to replace" + ); // middle argument ignored + } + + populate_pkey_idx(ctx, dtree, dptidx, sptidx, ndepth, new_idx_pkey); + nmap[dptidx] = sptidx; + } + + auto biter = new_idx_pkey.get().begin(); + auto eiter = new_idx_pkey.get().end(); + + for (auto iter = biter; iter != eiter; ++iter) { + t_stpkey s(iter->m_idx, iter->m_pkey); + m_idxpkey->insert(s); + } + + mark_zero_desc(); +} + +void +t_stree::mark_zero_desc() { + auto zeros = zero_strands(); + std::set z_desc; + + for (auto z : zeros) { + auto d = get_descendents(z); + std::copy(d.begin(), d.end(), std::inserter(z_desc, z_desc.end())); + } + + for (auto n : z_desc) { + auto iter = m_nodes->get().find(n); + auto node = *iter; + node.set_nstrands(0); + m_nodes->get().replace(iter, node); + } +} + +void +t_stree::update_aggs_from_static( + const t_dtree_ctx& ctx, + const t_gstate& gstate, + const t_data_table& expression_master_table +) { + const t_data_table& src_aggtable = ctx.get_aggtable(); + + t_agg_update_info agg_update_info; + t_schema aggschema = m_aggregates->get_schema(); + + for (const auto& colname : aggschema.m_columns) { + agg_update_info.m_src.push_back( + src_aggtable._get_const_column(colname) + ); + agg_update_info.m_dst.push_back(m_aggregates->_get_column(colname) + ); + agg_update_info.m_aggspecs.push_back(ctx.get_aggspec(colname)); + } + + auto is_col_scaled_aggregate = [&](int col_idx) -> bool { + int agg_type = agg_update_info.m_aggspecs[col_idx].agg(); + + return agg_type == AGGTYPE_SCALED_DIV || agg_type == AGGTYPE_SCALED_ADD + || agg_type == AGGTYPE_SCALED_MUL; + }; + + size_t col_cnt = aggschema.m_columns.size(); + auto& cols_topo_sorted = agg_update_info.m_dst_topo_sorted; + cols_topo_sorted.clear(); + cols_topo_sorted.reserve(col_cnt); + + static bool const enable_aggregate_reordering = true; + static bool const enable_fix_double_calculation = true; + + tsl::hopscotch_set dst_visited; + auto push_column = [&](size_t idx) { + if (enable_fix_double_calculation) { + t_column* dst = agg_update_info.m_dst[idx]; + if (dst_visited.find(dst) != dst_visited.end()) { + return; + } + dst_visited.insert(dst); + } + cols_topo_sorted.push_back(idx); + }; + + if (enable_aggregate_reordering) { + // Move scaled agg columns to the end + // This does not handle case where scaled aggregate depends on other + // scaled aggregate ( not sure if that is possible ) + for (size_t i = 0; i < col_cnt; ++i) { + if (!is_col_scaled_aggregate(i)) { + push_column(i); + } + } + for (size_t i = 0; i < col_cnt; ++i) { + if (is_col_scaled_aggregate(i)) { + push_column(i); + } + } + } else { + // If backed out, use same column order as before ( not topo sorted ) + for (size_t i = 0; i < col_cnt; ++i) { + push_column(i); + } + } + + for (const auto& r : m_tree_unification_records) { + if (!node_exists(r.m_sptidx)) { + continue; + } + + update_agg_table( + r.m_sptidx, + agg_update_info, + r.m_daggidx, + r.m_saggidx, + r.m_nstrands, + gstate, + expression_master_table + ); + } +} + +t_uindex +t_stree::genidx() { + return m_curidx++; +} + +std::vector +t_stree::get_children(t_uindex idx) const { + t_by_pidx_ipair iterators = m_nodes->get().equal_range(idx); + + t_uindex nchild = std::distance(iterators.first, iterators.second); + + std::vector temp(nchild); + + t_index count = 0; + for (iter_by_pidx iter = iterators.first; iter != iterators.second; + ++iter) { + temp[count] = iter->m_idx; + ++count; + } + return temp; +} + +t_uindex +t_stree::size() const { + return m_nodes->size(); +} + +void +t_stree::get_child_nodes(t_uindex idx, t_tnodevec& nodes) const { + t_index num_children = get_num_children(idx); + t_tnodevec temp(num_children); + auto iterators = m_nodes->get().equal_range(idx); + std::copy(iterators.first, iterators.second, temp.begin()); + std::swap(nodes, temp); +} + +t_uindex +t_stree::get_num_children(t_uindex ptidx) const { + auto iterators = m_nodes->get().equal_range(ptidx); + return std::distance(iterators.first, iterators.second); +} + +t_uindex +t_stree::gen_aggidx() { + if (!m_agg_freelist.empty()) { + t_uindex rval = m_agg_freelist.back(); + m_agg_freelist.pop_back(); + return rval; + } + + t_uindex cur_cap = m_aggregates->size(); + t_uindex rval = m_cur_aggidx; + ++m_cur_aggidx; + if (rval >= cur_cap) { + double nrows = ceil(1.3 * double(rval)); + m_aggregates->extend(static_cast(nrows)); + } + + return rval; +} + +void +t_stree::update_agg_table( + t_uindex nidx, + t_agg_update_info& info, + t_uindex src_ridx, + t_uindex dst_ridx, + t_index nstrands, + const t_gstate& gstate, + const t_data_table& expression_master_table +) { + const t_schema& expression_schema = expression_master_table.get_schema(); + + for (t_uindex idx : info.m_dst_topo_sorted) { + const t_column* src = info.m_src[idx]; + t_column* dst = info.m_dst[idx]; + const t_aggspec& spec = info.m_aggspecs[idx]; + t_tscalar new_value = mknone(); + t_tscalar old_value = mknone(); + auto is_expr = + expression_schema.has_column(spec.get_dependencies()[0].name()); + + switch (spec.agg()) { + case AGGTYPE_PCT_SUM_PARENT: + case AGGTYPE_PCT_SUM_GRAND_TOTAL: + case AGGTYPE_SUM: { + t_tscalar src_scalar = src->get_scalar(src_ridx); + t_tscalar dst_scalar = dst->get_scalar(dst_ridx); + old_value.set(dst_scalar); + + // is_nan returns false for non-float types + if (is_expr || old_value.is_nan()) { + + // if we previously had a NaN, add can't make it finite + // again; recalculate entire sum in case it is now finite + auto pkeys = get_pkeys(nidx); + new_value.set( + reduce_from_gstate< + std::function&)>>( + gstate, + expression_master_table, + spec.get_dependencies()[0].name(), + pkeys, + [&](std::vector& values) { + if (values.empty()) { + return mknone(); + } + + t_tscalar rval; + rval.set(std::uint64_t(0)); + rval.m_type = dst->get_dtype(); + for (const auto& v : values) { + if (v.is_nan()) { + continue; + } + + rval = rval.add( + v.coerce_numeric_dtype(dst->get_dtype()) + ); + } + + return rval; + } + ) + ); + } else { + new_value.set(dst_scalar.add(src_scalar)); + } + + dst->set_scalar(dst_ridx, new_value); + } break; + case AGGTYPE_COUNT: { + if (nidx == 0) { + new_value.set(nstrands - 1); + } else { + new_value.set(nstrands); + } + + dst->set_scalar(dst_ridx, new_value); + } break; + case AGGTYPE_MEAN: { + auto pkeys = get_pkeys(nidx); + std::vector values; + + read_column_from_gstate( + gstate, + expression_master_table, + spec.get_dependencies()[0].name(), + pkeys, + values, + false + ); + + auto nr = + std::accumulate(values.begin(), values.end(), double(0)); + double dr = values.size(); + + auto* dst_pair = + dst->get_nth>(dst_ridx); + + old_value.set(dst_pair->first / dst_pair->second); + + dst_pair->first = nr; + dst_pair->second = dr; + + dst->set_valid(dst_ridx, true); + + new_value.set(nr / dr); + } break; + case AGGTYPE_WEIGHTED_MEAN: { + auto pkeys = get_pkeys(nidx); + + if (spec.get_dependencies().size() < 2) { + dst->set_valid(dst_ridx, false); + break; + } + + double nr = 0; + double dr = 0; + std::vector values; + std::vector weights; + + read_column_from_gstate( + gstate, + expression_master_table, + spec.get_dependencies()[0].name(), + pkeys, + values + ); + + read_column_from_gstate( + gstate, + expression_master_table, + spec.get_dependencies()[1].name(), + pkeys, + weights + ); + + auto weights_it = weights.begin(); + auto values_it = values.begin(); + + for (; weights_it != weights.end() && values_it != values.end(); + ++weights_it, ++values_it) { + if (weights_it->is_valid() && values_it->is_valid() + && !weights_it->is_nan() && !values_it->is_nan()) { + nr += weights_it->to_double() * values_it->to_double(); + dr += weights_it->to_double(); + } + } + + auto* dst_pair = + dst->get_nth>(dst_ridx); + old_value.set(dst_pair->first / dst_pair->second); + + dst_pair->first = nr; + dst_pair->second = dr; + + bool valid = (dr != 0); + dst->set_valid(dst_ridx, valid); + new_value.set(nr / dr); + } break; + case AGGTYPE_UNIQUE: { + auto pkeys = get_pkeys(nidx); + old_value.set(dst->get_scalar(dst_ridx)); + + bool is_unique = is_unique_from_gstate( + gstate, + expression_master_table, + spec.get_dependencies()[0].name(), + pkeys, + new_value + ); + + if (new_value.m_type == DTYPE_STR) { + if (is_unique) { + new_value = m_symtable.get_interned_tscalar(new_value); + dst->set_scalar(dst_ridx, new_value); + } else { + // set the row to invalid but don't set new = old + // because we need to unintern strings over and over + // again if we set new = old. + dst->set_valid(dst_ridx, false); + } + } else { + if (is_unique) { + dst->set_scalar(dst_ridx, new_value); + } else { + dst->set_valid(dst_ridx, false); + new_value = old_value; + } + } + } break; + case AGGTYPE_OR: + case AGGTYPE_ANY: { + old_value.set(dst->get_scalar(dst_ridx)); + auto pkeys = get_pkeys(nidx); + + apply_from_gstate( + gstate, + expression_master_table, + spec.get_dependencies()[0].name(), + pkeys, + new_value, + [](const t_tscalar& row_value, t_tscalar& output) { + if (row_value.as_bool()) { + output.set(row_value); + return true; + } + return false; + } + ); + + dst->set_scalar(dst_ridx, new_value); + } break; + case AGGTYPE_Q1: { + old_value.set(dst->get_scalar(dst_ridx)); + auto pkeys = get_pkeys(nidx); + new_value.set( + reduce_from_gstate< + std::function&)>>( + gstate, + expression_master_table, + spec.get_dependencies()[0].name(), + pkeys, + [&](std::vector& values) { + return get_aggregate_median(values, 0.25); + } + ) + ); + + dst->set_scalar(dst_ridx, new_value); + } break; + case AGGTYPE_Q3: { + old_value.set(dst->get_scalar(dst_ridx)); + auto pkeys = get_pkeys(nidx); + new_value.set( + reduce_from_gstate< + std::function&)>>( + gstate, + expression_master_table, + spec.get_dependencies()[0].name(), + pkeys, + [&](std::vector& values) { + return get_aggregate_median(values, 0.75); + } + ) + ); + + dst->set_scalar(dst_ridx, new_value); + } break; + case AGGTYPE_MEDIAN: { + old_value.set(dst->get_scalar(dst_ridx)); + auto pkeys = get_pkeys(nidx); + + new_value.set( + reduce_from_gstate< + std::function&)>>( + gstate, + expression_master_table, + spec.get_dependencies()[0].name(), + pkeys, + [&](std::vector& values) { + return get_aggregate_median(values, 0.5); + } + ) + ); + + dst->set_scalar(dst_ridx, new_value); + } break; + case AGGTYPE_JOIN: { + old_value.set(dst->get_scalar(dst_ridx)); + auto pkeys = get_pkeys(nidx); + + new_value.set( + reduce_from_gstate< + std::function&)>>( + gstate, + expression_master_table, + spec.get_dependencies()[0].name(), + pkeys, + [this](std::vector& values) { + std::set vset; + for (const auto& v : values) { + vset.insert(v); + } + + std::stringstream ss; + t_uindex str_size = 0; + for (auto iter = vset.begin(); iter != vset.end(); + ++iter) { + + auto st = iter->to_string(); + auto next_len = st.size(); + if (next_len + str_size > MAX_JOIN_SIZE) { + break; + } + + if (iter != vset.begin()) { + str_size += 2; + ss << ", "; + } + + str_size += next_len; + ss << st; + } + return m_symtable.get_interned_tscalar( + ss.str().c_str() + ); + } + ) + ); + + dst->set_scalar(dst_ridx, new_value); + } break; + case AGGTYPE_SCALED_DIV: { + const t_column* src_1 = info.m_dst[spec.get_agg_one_idx()]; + const t_column* src_2 = info.m_dst[spec.get_agg_two_idx()]; + + t_column* dst = info.m_dst[idx]; + old_value.set(dst->get_scalar(dst_ridx)); + + double agg1 = src_1->get_scalar(dst_ridx).to_double(); + double agg2 = src_2->get_scalar(dst_ridx).to_double(); + + double w1 = spec.get_agg_one_weight(); + double w2 = spec.get_agg_two_weight(); + + double v = (agg1 * w1) / (agg2 * w2); + + new_value.set(v); + dst->set_scalar(dst_ridx, new_value); + } break; + case AGGTYPE_SCALED_ADD: { + + const t_column* src_1 = info.m_dst[spec.get_agg_one_idx()]; + const t_column* src_2 = info.m_dst[spec.get_agg_two_idx()]; + + t_column* dst = info.m_dst[idx]; + old_value.set(dst->get_scalar(dst_ridx)); + + double v = (src_1->get_scalar(dst_ridx).to_double() + * spec.get_agg_one_weight()) + + (src_2->get_scalar(dst_ridx).to_double() + * spec.get_agg_two_weight()); + + new_value.set(v); + dst->set_scalar(dst_ridx, new_value); + } break; + case AGGTYPE_SCALED_MUL: { + const t_column* src_1 = info.m_dst[spec.get_agg_one_idx()]; + const t_column* src_2 = info.m_dst[spec.get_agg_two_idx()]; + + t_column* dst = info.m_dst[idx]; + old_value.set(dst->get_scalar(dst_ridx)); + + double v = (src_1->get_scalar(dst_ridx).to_double() + * spec.get_agg_one_weight()) + * (src_2->get_scalar(dst_ridx).to_double() + * spec.get_agg_two_weight()); + + new_value.set(v); + dst->set_scalar(dst_ridx, new_value); + } break; + case AGGTYPE_DOMINANT: { + old_value.set(dst->get_scalar(dst_ridx)); + auto pkeys = get_pkeys(nidx); + + new_value.set( + reduce_from_gstate< + std::function&)>>( + gstate, + expression_master_table, + spec.get_dependencies()[0].name(), + pkeys, + [](std::vector& values) { + return get_dominant(values); + } + ) + ); + + dst->set_scalar(dst_ridx, new_value); + } break; + case AGGTYPE_FIRST: { + old_value.set(dst->get_scalar(dst_ridx)); + auto pair = first_last_helper( + nidx, spec, gstate, expression_master_table + ); + new_value.set(pair.first); + dst->set_scalar(dst_ridx, new_value); + } break; + case AGGTYPE_LAST_BY_INDEX: { + old_value.set(dst->get_scalar(dst_ridx)); + auto pair = first_last_helper( + nidx, spec, gstate, expression_master_table + ); + new_value.set(pair.second); + dst->set_scalar(dst_ridx, new_value); + } break; + case AGGTYPE_LAST_MINUS_FIRST: { + old_value.set(dst->get_scalar(dst_ridx)); + auto pair = (first_last_helper( + nidx, spec, gstate, expression_master_table + )); + new_value.set(pair.second.sub_typesafe(pair.first)); + dst->set_scalar(dst_ridx, new_value); + } break; + case AGGTYPE_AND: { + old_value.set(dst->get_scalar(dst_ridx)); + auto pkeys = get_pkeys(nidx); + + new_value.set( + reduce_from_gstate< + std::function&)>>( + gstate, + expression_master_table, + spec.get_dependencies()[0].name(), + pkeys, + [](std::vector& values) { + t_tscalar rval; + rval.set(true); + + for (const auto& v : values) { + if (!v.as_bool()) { + rval.set(false); + break; + } + } + return rval; + } + ) + ); + dst->set_scalar(dst_ridx, new_value); + } break; + case AGGTYPE_LAST_VALUE: { + t_tscalar dst_scalar = dst->get_scalar(dst_ridx); + old_value.set(dst_scalar); + t_uindex leaf; + if (is_leaf(nidx)) { + leaf = nidx; + } else { + auto iters = + m_idxleaf->get().equal_range(nidx); + if (iters.first != iters.second) { + leaf = (--iters.second)->m_lfidx; + } else { + dst->set_scalar(dst_ridx, mknone()); + break; + } + } + + auto iters = m_idxpkey->get().equal_range(leaf); + if (iters.first != iters.second) { + t_tscalar pkey = (--iters.second)->m_pkey; + + dst->set_scalar( + dst_ridx, + read_by_pkey_from_gstate( + gstate, + expression_master_table, + spec.get_dependencies()[0].name(), + pkey + ) + ); + } else { + dst->set_scalar(dst_ridx, mknone()); + } + } break; + case AGGTYPE_MAX: { + t_tscalar dst_scalar = dst->get_scalar(dst_ridx); + old_value.set(dst_scalar); + auto pkeys = get_pkeys(nidx); + if (pkeys.empty()) { + dst->set_scalar(dst_ridx, new_value); + break; + } + + std::vector values; + read_column_from_gstate( + gstate, + expression_master_table, + spec.get_dependencies()[0].name(), + pkeys, + values + ); + + new_value.set(*std::max_element(values.begin(), values.end())); + dst->set_scalar(dst_ridx, new_value); + } break; + case AGGTYPE_MAX_BY: { + t_tscalar dst_scalar = dst->get_scalar(dst_ridx); + old_value.set(dst_scalar); + auto pkeys = get_pkeys(nidx); + if (pkeys.empty()) { + dst->set_scalar(dst_ridx, new_value); + break; + } + + if (spec.get_dependencies().size() < 2) { + dst->set_scalar(dst_ridx, new_value); + break; + } + + std::vector values; + read_column_from_gstate( + gstate, + expression_master_table, + spec.get_dependencies()[1].name(), + pkeys, + values + ); + + const auto max_row_idx = std::distance( + values.begin(), + std::max_element(values.begin(), values.end()) + ); + + read_column_from_gstate( + gstate, + expression_master_table, + spec.get_dependencies()[0].name(), + pkeys, + values + ); + + new_value.set(values.at(max_row_idx)); + dst->set_scalar(dst_ridx, new_value); + } break; + case AGGTYPE_MIN: { + t_tscalar dst_scalar = dst->get_scalar(dst_ridx); + old_value.set(dst_scalar); + auto pkeys = get_pkeys(nidx); + if (pkeys.empty()) { + dst->set_scalar(dst_ridx, new_value); + break; + } + + std::vector values; + read_column_from_gstate( + gstate, + expression_master_table, + spec.get_dependencies()[0].name(), + pkeys, + values + ); + + new_value.set(*std::min_element(values.begin(), values.end())); + dst->set_scalar(dst_ridx, new_value); + } break; + case AGGTYPE_MIN_BY: { + t_tscalar dst_scalar = dst->get_scalar(dst_ridx); + old_value.set(dst_scalar); + auto pkeys = get_pkeys(nidx); + if (pkeys.empty()) { + dst->set_scalar(dst_ridx, new_value); + break; + } + + if (spec.get_dependencies().size() < 2) { + dst->set_scalar(dst_ridx, new_value); + break; + } + + std::vector values; + read_column_from_gstate( + gstate, + expression_master_table, + spec.get_dependencies()[1].name(), + pkeys, + values + ); + + const auto min_row_idx = std::distance( + values.begin(), + std::min_element(values.begin(), values.end()) + ); + + read_column_from_gstate( + gstate, + expression_master_table, + spec.get_dependencies()[0].name(), + pkeys, + values + ); + + new_value.set(values.at(min_row_idx)); + dst->set_scalar(dst_ridx, new_value); + } break; + case AGGTYPE_HIGH_WATER_MARK: { + t_tscalar src_scalar = src->get_scalar(src_ridx); + t_tscalar dst_scalar = dst->get_scalar(dst_ridx); + + old_value.set(dst_scalar); + new_value.set(src_scalar); + + if (dst_scalar.is_valid()) { + new_value.set(std::max(dst_scalar, src_scalar)); + } + + dst->set_scalar(dst_ridx, new_value); + } break; + case AGGTYPE_LOW_WATER_MARK: { + t_tscalar src_scalar = src->get_scalar(src_ridx); + t_tscalar dst_scalar = dst->get_scalar(dst_ridx); + + old_value.set(dst_scalar); + new_value.set(src_scalar); + + if (dst_scalar.is_valid()) { + new_value.set(std::min(dst_scalar, src_scalar)); + } + dst->set_scalar(dst_ridx, new_value); + } break; + case AGGTYPE_HIGH_MINUS_LOW: { + t_tscalar dst_scalar = dst->get_scalar(dst_ridx); + old_value.set(dst_scalar); + auto pkeys = get_pkeys(nidx); + std::vector values; + read_column_from_gstate( + gstate, + expression_master_table, + spec.get_dependencies()[0].name(), + pkeys, + values + ); + auto low_high = + std::minmax_element(values.begin(), values.end()); + t_tscalar first; + first.set(*(low_high.first)); + t_tscalar second; + second.set(*(low_high.second)); + new_value.set(second.sub_typesafe(first)); + dst->set_scalar(dst_ridx, new_value); + } break; + case AGGTYPE_UDF_COMBINER: + case AGGTYPE_UDF_REDUCER: { + // these will be filled in later + } break; + case AGGTYPE_SUM_NOT_NULL: { + old_value.set(dst->get_scalar(dst_ridx)); + auto pkeys = get_pkeys(nidx); + + new_value.set( + reduce_from_gstate< + std::function&)>>( + gstate, + expression_master_table, + spec.get_dependencies()[0].name(), + pkeys, + [](std::vector& values) { + if (values.empty()) { + return mknone(); + } + + t_tscalar rval; + rval.set(std::uint64_t(0)); + rval.m_type = values[0].m_type; + + for (const auto& v : values) { + if (v.is_nan()) { + continue; + } + rval = rval.add(v); + } + + return rval; + } + ) + ); + dst->set_scalar(dst_ridx, new_value); + } break; + case AGGTYPE_SUM_ABS: { + old_value.set(dst->get_scalar(dst_ridx)); + auto pkeys = get_pkeys(nidx); + + new_value.set( + reduce_from_gstate< + std::function&)>>( + gstate, + expression_master_table, + spec.get_dependencies()[0].name(), + pkeys, + [](std::vector& values) { + if (values.empty()) { + return mknone(); + } + + t_tscalar rval; + rval.set(std::uint64_t(0)); + rval.m_type = values[0].m_type; + for (const auto& v : values) { + rval = rval.add(v.abs()); + } + return rval; + } + ) + ); + + dst->set_scalar(dst_ridx, new_value); + } break; + case AGGTYPE_ABS_SUM: { + old_value.set(dst->get_scalar(dst_ridx)); + auto pkeys = get_pkeys(nidx); + new_value.set( + reduce_from_gstate< + std::function&)>>( + gstate, + expression_master_table, + spec.get_dependencies()[0].name(), + pkeys, + [](std::vector& values) { + if (values.empty()) { + return mknone(); + } + t_tscalar rval; + rval.set(std::uint64_t(0)); + rval.m_type = values[0].m_type; + for (const auto& v : values) { + rval = rval.add(v); + } + return rval.abs(); + } + ) + ); + dst->set_scalar(dst_ridx, new_value); + } break; + case AGGTYPE_GMV: { + // The user-facing rule is "leaf = sum, parent = sum over + // immediate row children of |raw sum of child subtree|". + // Under split_by this is complicated by t_ctx2 sharding + // the data across several t_stree instances — see + // [t_stree::set_gmv_row_pivot_meta] for the metadata that + // distinguishes "the next pivot in this tree is a row + // pivot" from "the next missing row pivot lives in the + // gstate". + old_value.set(dst->get_scalar(dst_ridx)); + const auto& col_name = spec.get_dependencies()[0].name(); + const auto dst_dtype = dst->get_dtype(); + const auto k_tree = m_num_row_pivots_in_tree; + const auto& next_row_pivot = m_next_row_pivot_name; + const bool has_missing_row_pivot = !next_row_pivot.empty(); + const auto depth = get_depth(nidx); + + auto sum_reducer = + [dst_dtype](std::vector& values) -> t_tscalar { + if (values.empty()) { + return mknone(); + } + t_tscalar v; + v.set(std::uint64_t(0)); + v.m_type = dst_dtype; + for (const auto& x : values) { + if (x.is_nan()) { + continue; + } + v = v.add(x.coerce_numeric_dtype(dst_dtype)); + } + return v; + }; + + // row_path_len = min(depth, k_tree). A node is a "row-leaf" + // iff there are no row pivots missing from this tree AND + // all row pivots are consumed at this node's depth — i.e. + // depth >= k_tree. + const bool is_row_leaf = + !has_missing_row_pivot && depth >= k_tree; + + if (is_row_leaf) { + auto pkeys = get_pkeys(nidx); + new_value.set( + reduce_from_gstate&)>>( + gstate, + expression_master_table, + col_name, + pkeys, + sum_reducer + ) + ); + } else if (depth < k_tree) { + // Row-parent and this tree's next pivot is the next + // row pivot. Tree children at depth+1 are the + // immediate row children — use them directly. + t_tscalar rval; + rval.set(std::uint64_t(0)); + rval.m_type = dst_dtype; + for (auto cidx : get_child_idx(nidx)) { + auto cpkeys = get_pkeys(cidx); + t_tscalar csum = reduce_from_gstate&)>>( + gstate, + expression_master_table, + col_name, + cpkeys, + sum_reducer + ); + if (csum.is_valid() && !csum.is_nan()) { + rval = rval.add(csum.abs()); + } + } + new_value.set(rval); + } else { + // Row-parent but this tree's pivots beyond `depth` + // (if any) are column pivots, so the immediate row + // children aren't tree children. Read the missing + // row pivot column for this node's pkeys, partition + // by it, sum each partition, sum |partition_sum|. + auto pkeys = get_pkeys(nidx); + std::vector pivot_vals; + std::vector data_vals; + read_column_from_gstate( + gstate, + expression_master_table, + next_row_pivot, + pkeys, + pivot_vals + ); + read_column_from_gstate( + gstate, + expression_master_table, + col_name, + pkeys, + data_vals + ); + + std::unordered_map partials; + for (std::size_t i = 0; + i < pivot_vals.size() && i < data_vals.size(); + ++i) { + const auto& x = data_vals[i]; + if (!x.is_valid() || x.is_nan()) { + continue; + } + auto& acc = partials[pivot_vals[i]]; + if (!acc.is_valid()) { + acc.set(std::uint64_t(0)); + acc.m_type = dst_dtype; + } + acc = acc.add(x.coerce_numeric_dtype(dst_dtype)); + } + + t_tscalar rval; + rval.set(std::uint64_t(0)); + rval.m_type = dst_dtype; + for (const auto& kv : partials) { + rval = rval.add(kv.second.abs()); + } + new_value.set(rval); + } + dst->set_scalar(dst_ridx, new_value); + } break; + case AGGTYPE_MUL: { + old_value.set(dst->get_scalar(dst_ridx)); + auto pkeys = get_pkeys(nidx); + new_value.set( + reduce_from_gstate< + std::function&)>>( + gstate, + expression_master_table, + spec.get_dependencies()[0].name(), + pkeys, + [](std::vector& values) { + if (values.empty()) { + return t_tscalar(); + } + if (values.size() == 1) { + return values[0]; + } + t_tscalar v = values[0]; + for (t_uindex vidx = 1, vloop_end = values.size(); + vidx < vloop_end; + ++vidx) { + v = v.mul(values[vidx]); + } + return v; + } + ) + ); + + dst->set_scalar(dst_ridx, new_value); + } break; + case AGGTYPE_DISTINCT_COUNT: { + old_value.set(dst->get_scalar(dst_ridx)); + auto pkeys = get_pkeys(nidx); + + new_value.set( + reduce_from_gstate< + std::function&)>>( + gstate, + expression_master_table, + spec.get_dependencies()[0].name(), + pkeys, + [](std::vector& values) { + tsl::hopscotch_set vset; + for (const auto& v : values) { + vset.insert(v); + } + std::uint32_t rv = vset.size(); + return rv; + } + ) + ); + + dst->set_scalar(dst_ridx, new_value); + } break; + case AGGTYPE_DISTINCT_LEAF: { + auto pkeys = get_pkeys(nidx); + old_value.set(dst->get_scalar(dst_ridx)); + bool skip = false; + bool is_unique = is_unique_from_gstate( + gstate, + expression_master_table, + spec.get_dependencies()[0].name(), + pkeys, + new_value + ); + + if (is_leaf(nidx) && is_unique) { + if (new_value.m_type == DTYPE_STR) { + new_value = m_symtable.get_interned_tscalar(new_value); + } + } else { + if (new_value.m_type == DTYPE_STR) { + new_value = m_symtable.get_interned_tscalar(""); + } else { + dst->set_valid(dst_ridx, false); + new_value = old_value; + skip = true; + } + } + if (!skip) { + dst->set_scalar(dst_ridx, new_value); + } + } break; + case AGGTYPE_VARIANCE: + case AGGTYPE_STANDARD_DEVIATION: { + old_value.set(dst->get_scalar(dst_ridx)); + + auto pkeys = get_pkeys(nidx); + std::vector values; + + read_column_from_gstate( + gstate, + expression_master_table, + spec.get_dependencies()[0].name(), + pkeys, + values, + false + ); + + // Calculate the count, rolling mean, and sum of squares of + // differences from the current mean at each iteration. + double count = 0; + double mean = 0; + double m2 = 0; + + for (double num : values) { + count++; + double next_mean = mean + (num - mean) / count; + m2 += (num - mean) * (num - next_mean); + mean = next_mean; + } + + // Only calculate stddev for more than 1 element in the group. + if (count >= 2) { + double value = m2 / count; + + if (spec.agg() == AGGTYPE_STANDARD_DEVIATION) { + value = std::sqrt(value); + } + + new_value.set(value); + dst->set_scalar(dst_ridx, new_value); + dst->set_valid(dst_ridx, true); + } else { + dst->set_valid(dst_ridx, false); + } + + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Not implemented"); + } + } // end switch + + bool val_neq = old_value != new_value; + + m_has_delta = m_has_delta || val_neq; + bool deltas_enabled = m_features.at(CTX_FEAT_DELTA); + if (deltas_enabled && val_neq) { + m_deltas->insert(t_tcdelta(nidx, idx, old_value, new_value)); + } + + } // end for +} + +std::vector +t_stree::zero_strands() const { + auto iterators = m_nodes->get().equal_range(0); + std::vector rval; + + for (auto& iter = iterators.first; iter != iterators.second; ++iter) { + rval.push_back(iter->m_idx); + } + return rval; +} + +std::set +t_stree::non_zero_leaves(const std::vector& zero_strands) const { + return non_zero_ids(m_newleaves, zero_strands); +} + +std::set +t_stree::non_zero_ids(const std::vector& zero_strands) const { + return non_zero_ids(m_newids, zero_strands); +} + +std::set +t_stree::non_zero_ids( + const std::set& ptiset, const std::vector& zero_strands +) const { + std::set zeroset; + for (auto idx : zero_strands) { + zeroset.insert(idx); + } + + std::set rval; + + for (const auto& newid : ptiset) { + if (zeroset.find(newid) == zeroset.end()) { + rval.insert(newid); + } + } + + return rval; +} + +t_uindex +t_stree::get_parent_idx(t_uindex ptidx) const { + iter_by_idx iter = m_nodes->get().find(ptidx); + if (iter == m_nodes->get().end()) { + std::cout << "Failed in tree => " << repr() << '\n'; + PSP_VERBOSE_ASSERT(false, "Did not find node"); + } + return iter->m_pidx; +} + +std::vector +t_stree::get_ancestry(t_uindex idx) const { + t_uindex rpidx = root_pidx(); + std::vector rval; + + while (idx != rpidx) { + rval.push_back(idx); + idx = get_parent_idx(idx); + } + + std::reverse(rval.begin(), rval.end()); + return rval; +} + +t_index +t_stree::get_sibling_idx(t_index p_ptidx, t_index p_nchild, t_uindex c_ptidx) + const { + t_by_pidx_ipair iterators = m_nodes->get().equal_range(p_ptidx); + iter_by_pidx c_iter = + m_nodes->project(m_nodes->get().find(c_ptidx)); + return std::distance(iterators.first, c_iter); +} + +t_uindex +t_stree::get_aggidx(t_uindex idx) const { + iter_by_idx iter = m_nodes->get().find(idx); + PSP_VERBOSE_ASSERT( + iter != m_nodes->get().end(), "Failed in get_aggidx" + ); + return iter->m_aggidx; +} + +std::shared_ptr +t_stree::get_aggtable() const { + return m_aggregates; +} + +t_data_table* +t_stree::_get_aggtable() { + return m_aggregates.get(); +} + +t_stree::t_tnode +t_stree::get_node(t_uindex idx) const { + iter_by_idx iter = m_nodes->get().find(idx); + PSP_VERBOSE_ASSERT( + iter != m_nodes->get().end(), "Failed in get_node" + ); + return *iter; +} + +void +t_stree::get_path(t_uindex idx, std::vector& rval) const { + t_uindex curidx = idx; + + if (curidx == 0) { + return; + } + + while (1) { + iter_by_idx iter = m_nodes->get().find(curidx); + rval.push_back(iter->m_value); + curidx = iter->m_pidx; + if (curidx == 0) { + break; + } + } +} + +t_uindex +t_stree::resolve_child(t_uindex root, const t_tscalar& datum) const { + auto iter = m_nodes->get().find(std::make_tuple(root, datum)); + + if (iter == m_nodes->get().end()) { + return INVALID_INDEX; + } + + return iter->m_idx; +} + +void +t_stree::clear_aggregates(const std::vector& indices) { + auto cols = m_aggregates->get_columns(); + for (auto* c : cols) { + for (auto aggidx : indices) { + c->set_valid(aggidx, false); + } + } + + m_agg_freelist.insert( + std::end(m_agg_freelist), std::begin(indices), std::end(indices) + ); +} + +void +t_stree::drop_zero_strands() { + auto iterators = m_nodes->get().equal_range(0); + + std::vector leaves; + + auto lst = last_level(); + + std::vector node_ids; + + for (auto iter = iterators.first; iter != iterators.second; ++iter) { + if (iter->m_depth == lst) { + leaves.push_back(iter->m_idx); + } + node_ids.push_back(iter->m_aggidx); + } + + clear_aggregates(node_ids); + + for (auto nidx : leaves) { + auto ancestry = get_ancestry(nidx); + + for (auto ancidx : ancestry) { + if (ancidx == nidx) { + continue; + } + remove_leaf(ancidx, nidx); + } + } + + auto iterators2 = m_nodes->get().equal_range(0); + + m_nodes->get().erase(iterators2.first, iterators2.second); +} + +void +t_stree::add_pkey(t_uindex idx, t_tscalar pkey) { + t_stpkey s(idx, pkey); + m_idxpkey->insert(s); +} + +void +t_stree::remove_pkey(t_uindex idx, t_tscalar pkey) { + auto iter = m_idxpkey->get().find(std::make_tuple(idx, pkey)); + + if (iter == m_idxpkey->get().end()) { + return; + } + + m_idxpkey->get().erase(iter); +} + +void +t_stree::add_leaf(t_uindex nidx, t_uindex lfidx) { + t_stleaves s(nidx, lfidx); + m_idxleaf->insert(s); +} + +void +t_stree::remove_leaf(t_uindex nidx, t_uindex lfidx) { + auto iter = + m_idxleaf->get().find(std::make_tuple(nidx, lfidx)); + + if (iter == m_idxleaf->get().end()) { + return; + } + + m_idxleaf->get().erase(iter); +} + +t_by_idx_pkey_ipair +t_stree::get_pkeys_for_leaf(t_uindex idx) const { + return m_idxpkey->get().equal_range(idx); +} + +std::vector +t_stree::get_pkeys(t_uindex idx) const { + std::vector rval; + std::vector leaves = get_leaves(idx); + + for (auto leaf : leaves) { + auto iters = get_pkeys_for_leaf(leaf); + for (auto iter = iters.first; iter != iters.second; ++iter) { + rval.push_back(iter->m_pkey); + } + } + return rval; +} + +std::vector +t_stree::get_leaves(t_uindex idx) const { + std::vector rval; + + if (is_leaf(idx)) { + rval.push_back(idx); + return rval; + } + + auto iters = m_idxleaf->get().equal_range(idx); + for (auto iter = iters.first; iter != iters.second; ++iter) { + rval.push_back(iter->m_lfidx); + } + return rval; +} + +t_depth +t_stree::get_depth(t_uindex ptidx) const { + auto iter = m_nodes->get().find(ptidx); + return iter->m_depth; +} + +void +t_stree::get_drd_indices( + t_uindex ridx, t_depth rel_depth, std::vector& leaves +) const { + std::vector> pending; + + if (rel_depth == 0) { + leaves.push_back(ridx); + return; + } + + t_depth rdepth = get_depth(ridx); + t_depth edepth = rdepth + rel_depth; + + pending.emplace_back(ridx, rdepth); + + while (!pending.empty()) { + std::pair head = pending.back(); + pending.pop_back(); + + if (head.second == edepth - 1) { + auto children = get_child_idx(head.first); + std::copy( + children.begin(), children.end(), std::back_inserter(leaves) + ); + } else { + auto children = get_child_idx_depth(head.first); + std::copy( + children.begin(), children.end(), std::back_inserter(pending) + ); + } + } +} + +std::vector +t_stree::get_child_idx(t_uindex idx) const { + t_index num_children = get_num_children(idx); + std::vector children(num_children); + auto iterators = m_nodes->get().equal_range(idx); + t_index count = 0; + while (iterators.first != iterators.second) { + children[count] = iterators.first->m_idx; + ++count; + ++iterators.first; + } + return children; +} + +std::vector> +t_stree::get_child_idx_depth(t_uindex idx) const { + t_index num_children = get_num_children(idx); + std::vector> children(num_children); + auto iterators = m_nodes->get().equal_range(idx); + t_index count = 0; + while (iterators.first != iterators.second) { + children[count] = std::pair( + iterators.first->m_idx, iterators.first->m_depth + ); + ++count; + ++iterators.first; + } + return children; +} + +void +t_stree::populate_leaf_index(const std::set& leaves) { + for (auto nidx : leaves) { + std::vector ancestry = get_ancestry(nidx); + + for (auto ancidx : ancestry) { + if (ancidx == nidx) { + continue; + } + + add_leaf(ancidx, nidx); + } + } +} + +t_uindex +t_stree::last_level() const { + return m_pivots.size(); +} + +bool +t_stree::is_leaf(t_uindex nidx) const { + auto iter = m_nodes->get().find(nidx); + PSP_VERBOSE_ASSERT( + iter != m_nodes->get().end(), "Did not find node" + ); + return iter->m_depth == last_level(); +} + +std::vector +t_stree::get_descendents(t_uindex nidx) const { + std::vector rval; + + std::vector queue; + queue.push_back(nidx); + + while (!queue.empty()) { + auto h = queue.back(); + queue.pop_back(); + auto children = get_children(h); + queue.insert(std::end(queue), std::begin(children), std::end(children)); + rval.insert(std::end(rval), std::begin(children), std::end(children)); + } + + return rval; +} + +const std::vector& +t_stree::get_pivots() const { + return m_pivots; +} + +t_index +t_stree::resolve_path(t_uindex root, const std::vector& path) const { + t_index curidx = root; + + if (path.empty()) { + return curidx; + } + + for (t_index i = path.size() - 1; i >= 0; i--) { + iter_by_pidx_hash iter = + m_nodes->get().find(std::make_tuple(curidx, path[i])); + if (iter == m_nodes->get().end()) { + return INVALID_INDEX; + } + curidx = iter->m_idx; + } + + return curidx; +} + +// aggregates should be presized to be same size +// as agg_indices +void +t_stree::get_aggregates_for_sorting( + t_uindex nidx, + const std::vector& agg_indices, + std::vector& aggregates, + t_ctx2* ctx2 +) const { + t_uindex aggidx = get_aggidx(nidx); + for (t_uindex idx = 0, loop_end = agg_indices.size(); idx < loop_end; + ++idx) { + auto which_agg = agg_indices[idx]; + if (which_agg < 0) { + aggregates[idx] = get_sortby_value(nidx); + } else if ((ctx2 != nullptr) || (size_t(which_agg) >= m_aggcols.size())) { + aggregates[idx].set(t_none()); + if (ctx2 != nullptr) { + if ((ctx2->get_config().get_totals() == TOTALS_BEFORE) + && (size_t(which_agg) < m_aggcols.size())) { + aggregates[idx] = m_aggcols[which_agg]->get_scalar(aggidx); + continue; + } + + // two sided pivoted column + // we don't have enough information here to work out the shape + // of the data so we have to use ctx2 to resolve + + // fetch the row/column path from ctx2 + std::vector col_path = + ctx2->get_column_path_userspace(which_agg + 1); + if (col_path.empty()) { + if (ctx2->get_config().get_totals() == TOTALS_AFTER) { + aggregates[idx] = + m_aggcols[which_agg % m_aggcols.size()]->get_scalar( + aggidx + ); + } + continue; + } + + std::vector row_path; + get_path(nidx, row_path); + + auto* target_tree = ctx2->get_trees()[get_node(nidx).m_depth]; + t_index target = target_tree->resolve_path(0, row_path); + if (target != INVALID_INDEX) { + target = target_tree->resolve_path(target, col_path); + } + if (target != INVALID_INDEX) { + aggregates[idx] = target_tree->get_aggregate( + target, which_agg % m_aggcols.size() + ); + } + } + } else { + aggregates[idx] = m_aggcols[which_agg]->get_scalar(aggidx); + } + } +} + +t_tscalar +t_stree::get_aggregate(t_index idx, t_index aggnum) const { + if (aggnum < 0) { + return get_value(idx); + } + + auto aggtable = get_aggtable(); + const auto* c = aggtable->_get_const_column(aggnum); + auto agg_ridx = get_aggidx(idx); + + t_index pidx = get_parent_idx(idx); + + t_index agg_pridx = + pidx == INVALID_INDEX ? INVALID_INDEX : get_aggidx(pidx); + + return extract_aggregate(m_aggspecs[aggnum], c, agg_ridx, agg_pridx); +} + +t_tscalar +t_stree::get_aggregate_median(std::vector& values, float n) const { + const auto size = values.size(); + const float raw_size = static_cast(size) * n; + bool is_even_size = fabsf(roundf(raw_size) - raw_size) <= 0.00001; + if (size == 0) { + return {}; + } + + if (size == 1) { + return values[0]; + } + + const auto split = static_cast(raw_size); + if (is_even_size && values[0].is_floating_point()) { + t_tscalar median_average; + auto middle = values.begin() + split; + nth_element(values.begin(), middle, values.end()); + median_average.set( + (*middle + *(middle - 1)) / static_cast(2) + ); + + return median_average; + } + + auto middle = values.begin() + split; + std::nth_element(values.begin(), middle, values.end()); + return *middle; +} + +void +t_stree::get_child_indices(t_index idx, std::vector& out_data) const { + t_index num_children = get_num_children(idx); + std::vector temp(num_children); + t_by_pidx_ipair iterators = m_nodes->get().equal_range(idx); + t_index count = 0; + for (iter_by_pidx iter = iterators.first; iter != iterators.second; + ++iter) { + temp[count] = iter->m_idx; + ++count; + } + std::swap(out_data, temp); +} + +void +t_stree::clear_deltas() { + m_deltas->clear(); + m_has_delta = false; +} + +void +t_stree::clear() { + m_nodes->clear(); + clear_deltas(); +} + +void +t_stree::set_alerts_enabled(bool enabled_state) { + m_features[CTX_FEAT_ALERT] = enabled_state; +} + +void +t_stree::set_deltas_enabled(bool enabled_state) { + m_features[CTX_FEAT_DELTA] = enabled_state; +} + +void +t_stree::set_feature_state(t_ctx_feature feature, bool state) { + m_features[feature] = state; +} + +const std::shared_ptr& +t_stree::get_deltas() const { + return m_deltas; +} + +std::pair +t_stree::first_last_helper( + t_uindex nidx, + const t_aggspec& spec, + const t_gstate& gstate, + const t_data_table& expression_master_table +) const { + auto pkeys = get_pkeys(nidx); + + if (pkeys.empty()) { + return std::make_pair(mknone(), mknone()); + } + + const auto& deps = spec.get_dependencies(); + if (deps.size() < 2) { + return std::make_pair(mknone(), mknone()); + } + + std::vector values; + std::vector sort_values; + + read_column_from_gstate( + gstate, expression_master_table, deps[0].name(), pkeys, values + ); + + read_column_from_gstate( + gstate, expression_master_table, deps[1].name(), pkeys, sort_values + ); + + auto minmax_idx = get_minmax_idx(sort_values, spec.get_sort_type()); + + std::pair ret; + switch (spec.get_sort_type()) { + case SORTTYPE_ASCENDING: + case SORTTYPE_ASCENDING_ABS: { + if (minmax_idx.m_min >= 0) { + ret.first = values[minmax_idx.m_min]; + } else { + ret.first = mknone(); + } + + if (minmax_idx.m_max >= 0) { + ret.second = values[minmax_idx.m_max]; + } else { + ret.second = mknone(); + } + } break; + case SORTTYPE_DESCENDING: + case SORTTYPE_DESCENDING_ABS: { + if (minmax_idx.m_max >= 0) { + ret.first = values[minmax_idx.m_max]; + } else { + ret.first = mknone(); + } + + if (minmax_idx.m_min >= 0) { + ret.second = values[minmax_idx.m_min]; + } else { + ret.second = mknone(); + } + } break; + default: { + ret.first = mknone(); + ret.second = mknone(); + } + } + + return ret; +} + +bool +t_stree::node_exists(t_uindex idx) { + iter_by_idx iter = m_nodes->get().find(idx); + return iter != m_nodes->get().end(); +} + +t_data_table* +t_stree::get_aggtable() { + return m_aggregates.get(); +} + +std::pair +t_stree::insert_node(const t_tnode& node) { + return m_nodes->insert(node); +} + +bool +t_stree::has_deltas() const { + return m_has_delta; +} + +void +t_stree::get_sortby_path(t_uindex idx, std::vector& rval) const { + t_uindex curidx = idx; + + if (curidx == 0) { + return; + } + + while (1) { + iter_by_idx iter = m_nodes->get().find(curidx); + rval.push_back(iter->m_sort_value); + curidx = iter->m_pidx; + if (curidx == 0) { + break; + } + } +} + +void +t_stree::set_has_deltas(bool v) { + m_has_delta = v; +} + +t_bfs_iter +t_stree::bfs() const { + return {this}; +} + +t_dfs_iter +t_stree::dfs() const { + return {this}; +} + +void +t_stree::pprint() const { + for (auto idx : dfs()) { + std::vector path; + get_path(idx, path); + for (t_uindex space_idx = 0; space_idx < path.size(); ++space_idx) { + std::cout << " "; + } + std::cout << idx << " <" << path << ">"; + for (t_uindex aidx = 0; aidx < get_num_aggcols(); ++aidx) { + std::cout << get_aggregate(idx, aidx) << ", "; + } + std::cout << '\n'; + } +} + +void +t_stree::read_column_from_gstate( + const t_gstate& gstate, + const t_data_table& expression_master_table, + const std::string& colname, + const std::vector& pkeys, + std::vector& out_data +) const { + const t_schema& expression_schema = expression_master_table.get_schema(); + + if (expression_schema.has_column(colname)) { + gstate.read_column(expression_master_table, colname, pkeys, out_data); + } else { + std::shared_ptr gstate_master_table = gstate.get_table(); + gstate.read_column(*gstate_master_table, colname, pkeys, out_data); + } +} + +void +t_stree::read_column_from_gstate( + const t_gstate& gstate, + const t_data_table& expression_master_table, + const std::string& colname, + const std::vector& pkeys, + std::vector& out_data, + bool include_none +) const { + const t_schema& expression_schema = expression_master_table.get_schema(); + + if (expression_schema.has_column(colname)) { + gstate.read_column( + expression_master_table, colname, pkeys, out_data, include_none + ); + } else { + std::shared_ptr gstate_master_table = gstate.get_table(); + gstate.read_column( + *gstate_master_table, colname, pkeys, out_data, include_none + ); + } +} + +t_tscalar +t_stree::read_by_pkey_from_gstate( + const t_gstate& gstate, + const t_data_table& expression_master_table, + const std::string& colname, + t_tscalar& pkey +) const { + const t_schema& expression_schema = expression_master_table.get_schema(); + + if (expression_schema.has_column(colname)) { + return gstate.read_by_pkey(expression_master_table, colname, pkey); + } + std::shared_ptr gstate_master_table = gstate.get_table(); + return gstate.read_by_pkey(*gstate_master_table, colname, pkey); +} + +bool +t_stree::is_unique_from_gstate( + const t_gstate& gstate, + const t_data_table& expression_master_table, + const std::string& colname, + const std::vector& pkeys, + t_tscalar& value +) const { + const t_schema& expression_schema = expression_master_table.get_schema(); + + if (expression_schema.has_column(colname)) { + return gstate.is_unique(expression_master_table, colname, pkeys, value); + } + std::shared_ptr gstate_master_table = gstate.get_table(); + return gstate.is_unique(*gstate_master_table, colname, pkeys, value); +} + +bool +t_stree::apply_from_gstate( + const t_gstate& gstate, + const t_data_table& expression_master_table, + const std::string& colname, + const std::vector& pkeys, + t_tscalar& value, + const std::function& fn +) const { + const t_schema& expression_schema = expression_master_table.get_schema(); + + if (expression_schema.has_column(colname)) { + return gstate.apply(expression_master_table, colname, pkeys, value, fn); + } + std::shared_ptr gstate_master_table = gstate.get_table(); + return gstate.apply(*gstate_master_table, colname, pkeys, value, fn); +} + +template +typename FN_T::result_type +t_stree::reduce_from_gstate( + const t_gstate& gstate, + const t_data_table& expression_master_table, + const std::string& colname, + const std::vector& pkeys, + FN_T fn +) const { + const t_schema& expression_schema = expression_master_table.get_schema(); + + if (expression_schema.has_column(colname)) { + return gstate.reduce(expression_master_table, colname, pkeys, fn); + } + std::shared_ptr gstate_master_table = gstate.get_table(); + return gstate.reduce(*gstate_master_table, colname, pkeys, fn); +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/sparse_tree_node.cpp b/rust/perspective-server/cpp/perspective/src/cpp/sparse_tree_node.cpp new file mode 100644 index 0000000..a3d16cb --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/sparse_tree_node.cpp @@ -0,0 +1,92 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include + +namespace perspective { + +t_stnode::t_stnode( + t_uindex idx, + t_uindex pidx, + const t_tscalar& value, + std::uint8_t depth, + const t_tscalar& sort_value, + t_uindex nstrands, + t_uindex aggidx +) : + m_idx(idx), + m_pidx(pidx), + m_depth(depth), + m_nstrands(nstrands), + m_aggidx(aggidx) { + m_value.set(value); + m_sort_value.set(sort_value); +} + +t_stnode::t_stnode() = default; + +void +t_stnode::set_nstrands(t_index nstrands) { + m_nstrands = nstrands; +} + +void +t_stnode::set_sort_value(t_tscalar sv) { + m_sort_value.set(sv); +} + +t_stpkey::t_stpkey(t_uindex idx, t_tscalar pkey) : m_idx(idx), m_pkey(pkey) {} + +t_stpkey::t_stpkey() = default; + +t_stleaves::t_stleaves(t_uindex idx, t_uindex lfidx) : + m_idx(idx), + m_lfidx(lfidx) {} + +t_stleaves::t_stleaves() = default; + +t_cellinfo::t_cellinfo() = default; + +t_cellinfo::t_cellinfo( + t_index idx, + t_depth treenum, + t_index agg_index, + t_uindex ridx, + t_uindex cidx +) : + m_idx(idx), + m_treenum(treenum), + m_agg_index(agg_index), + m_ridx(ridx), + m_cidx(cidx) {} + +} // end namespace perspective + +namespace std { +std::ostream& +operator<<(std::ostream& os, const perspective::t_stnode& node) { + os << "t_stnode<" + << "idx: " << node.m_idx << " pidx: " << node.m_pidx + << " value: " << node.m_value << " sort_value: " << node.m_sort_value + << " aggidx: " << node.m_aggidx << " nstrands: " << node.m_nstrands + << " depth: " << static_cast(node.m_depth) << ">"; + return os; +} + +std::ostream& +operator<<(std::ostream& os, const perspective::t_cellinfo& node) { + os << "t_cellinfo"; + return os; +} +} // namespace std diff --git a/rust/perspective-server/cpp/perspective/src/cpp/step_delta.cpp b/rust/perspective-server/cpp/perspective/src/cpp/step_delta.cpp new file mode 100644 index 0000000..5b7bebf --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/step_delta.cpp @@ -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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include + +namespace perspective { + +// Deltas for various contexts + +t_zcdelta::t_zcdelta( + t_tscalar pkey, t_index colidx, t_tscalar old_value, t_tscalar new_value +) : + m_pkey(pkey), + m_colidx(colidx), + m_old_value(old_value), + m_new_value(new_value) {} + +t_tcdelta::t_tcdelta( + t_uindex nidx, t_uindex aggidx, t_tscalar old_value, t_tscalar new_value +) : + m_nidx(nidx), + m_aggidx(aggidx), + m_old_value(old_value), + m_new_value(new_value) {} + +t_cellupd::t_cellupd( + t_index row, + t_index column, + const t_tscalar& old_value, + const t_tscalar& new_value +) : + row(row), + column(column), + old_value(old_value), + new_value(new_value) {} + +t_cellupd::t_cellupd() = default; + +// t_stepdelta contains a vector of t_cellupd objects showing the cells that +// have been changed +t_stepdelta::t_stepdelta() = default; + +t_stepdelta::t_stepdelta( + bool rows_changed, bool columns_changed, const std::vector& cells +) : + rows_changed(rows_changed), + columns_changed(columns_changed), + cells(cells) {} + +// t_rowdelta contains a vector of row indices that have been changed +t_rowdelta::t_rowdelta() = default; + +t_rowdelta::t_rowdelta( + bool rows_changed, + t_uindex num_rows_changed, + const std::vector& data +) : + rows_changed(rows_changed), + num_rows_changed(num_rows_changed), + data(data) {} +} // end namespace perspective + +namespace std { + +std::ostream& +operator<<(std::ostream& os, const perspective::t_cellupd& cell) { + os << "t_cellupd \n{" + << "\n\trow => " << cell.row << "\n\tcolumn => " << cell.column + << "\n\told_value => " << cell.old_value << "\n\tnew_value => " + << cell.new_value << "\n}" << '\n'; + + return os; +} + +} // end namespace std diff --git a/rust/perspective-server/cpp/perspective/src/cpp/storage.cpp b/rust/perspective-server/cpp/perspective/src/cpp/storage.cpp new file mode 100644 index 0000000..ac5856e --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/storage.cpp @@ -0,0 +1,726 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +SUPPRESS_WARNINGS_VC(4505) +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifndef WIN32 +#include +#include +#include +#include +#endif + +namespace perspective { + +t_lstore_recipe::t_lstore_recipe() : m_alignment(0), m_from_recipe(false) {} + +t_lstore_recipe::t_lstore_recipe(t_uindex capacity) : + m_capacity(capacity), + m_size(0), + m_alignment(0), + m_fflags(PSP_DEFAULT_FFLAGS), + m_fmode(PSP_DEFAULT_FMODE), + m_creation_disposition(PSP_DEFAULT_CREATION_DISPOSITION), + m_mprot(PSP_DEFAULT_MPROT), + m_mflags(PSP_DEFAULT_MFLAGS), + m_backing_store(BACKING_STORE_MEMORY), + m_from_recipe(false) + +{ + PSP_TRACE_SENTINEL(); + LOG_CONSTRUCTOR("t_lstore_recipe"); +} + +t_lstore_recipe::t_lstore_recipe( + std::string dirname, + std::string colname, + t_uindex capacity, + t_backing_store backing_store +) : + m_dirname(std::move(dirname)), + m_colname(std::move(colname)), + m_capacity(capacity), + m_size(0), + m_alignment(0), + m_fflags(PSP_DEFAULT_FFLAGS), + m_fmode(PSP_DEFAULT_FMODE), + m_creation_disposition(PSP_DEFAULT_CREATION_DISPOSITION), + m_mprot(PSP_DEFAULT_MPROT), + m_mflags(PSP_DEFAULT_MFLAGS), + m_backing_store(backing_store), + m_from_recipe(false) { + PSP_TRACE_SENTINEL(); + LOG_CONSTRUCTOR("t_lstore_recipe"); +} + +t_lstore_recipe::t_lstore_recipe( + std::string dirname, + std::string colname, + t_uindex capacity, + t_fflag fflags, + t_fflag fmode, + t_fflag creation_disposition, + t_fflag mprot, + t_fflag mflags, + t_backing_store backing_store +) : + m_dirname(std::move(dirname)), + m_colname(std::move(colname)), + m_capacity(capacity), + m_size(0), + m_alignment(0), + m_fflags(fflags), + m_fmode(fmode), + m_creation_disposition(creation_disposition), + m_mprot(mprot), + m_mflags(mflags), + m_backing_store(backing_store), + m_from_recipe(false) { + PSP_TRACE_SENTINEL(); + LOG_CONSTRUCTOR("t_lstore_recipe"); +} + +t_lstore_recipe::t_lstore_recipe( + std::string colname, + t_uindex capacity, + t_fflag mprot, + t_fflag mflags, + t_backing_store backing_store +) : + m_colname(std::move(colname)), + m_capacity(capacity), + m_size(0), + m_alignment(0), + m_fflags(PSP_DEFAULT_FFLAGS), + m_fmode(PSP_DEFAULT_FMODE), + m_creation_disposition(PSP_DEFAULT_CREATION_DISPOSITION), + m_mprot(mprot), + m_mflags(mflags), + m_backing_store(backing_store), + m_from_recipe(false) { + PSP_TRACE_SENTINEL(); + LOG_CONSTRUCTOR("t_lstore_recipe"); +} + +t_lstore::t_lstore() : + m_base(nullptr), + m_fd(0), + m_capacity(0), + m_size(0), + m_alignment(0), + m_backing_store(BACKING_STORE_MEMORY), + m_init(false), + m_resize_factor(1.2), + m_version(0) { + + PSP_TRACE_SENTINEL(); + LOG_CONSTRUCTOR("t_lstore"); +#ifdef PSP_MPROTECT + PSP_VERBOSE_ASSERT( + sizeof(t_lstore) == get_page_size(), "Bad lstore sizeof" + ); +#endif +} + +t_lstore::t_lstore(const t_lstore& s, t_lstore_tmp_init_tag t) { + PSP_VERBOSE_ASSERT(this != &s, "Initializing from self"); + PSP_TRACE_SENTINEL(); + LOG_CONSTRUCTOR("t_lstore"); + copy_helper(s); + m_version = 0; + m_base = nullptr; + m_size = 0; + m_alignment = 0; + m_fd = 0; + m_init = false; + if (s.m_backing_store == BACKING_STORE_DISK) { + m_fname = s.get_desc_fname(); + } + init(); + set_size(s.size()); +#ifdef PSP_MPROTECT + PSP_VERBOSE_ASSERT( + sizeof(t_lstore) == get_page_size(), "Bad lstore sizeof" + ); + freeze_impl(); +#endif +} + +std::string +t_lstore::repr() const { + std::stringstream ss; + ss << "t_lstore<" << this << ">"; + return ss.str(); +} + +void +t_lstore::copy_helper(t_lstore& other) { + PSP_TRACE_SENTINEL(); + copy_helper_(other); +} + +void +t_lstore::copy_helper(const t_lstore& other) { + PSP_TRACE_SENTINEL(); + copy_helper_(other); +} + +void +t_lstore::copy_helper_(const t_lstore& other) { + PSP_TRACE_SENTINEL(); + m_dirname = other.m_dirname; + m_fname = other.m_fname; + m_colname = other.m_colname; + m_base = nullptr; + m_fd = other.m_fd; + m_capacity = other.m_capacity; + m_size = other.m_size; + m_alignment = other.m_alignment; + m_fflags = other.m_fflags; + m_fmode = other.m_fmode; + m_creation_disposition = other.m_creation_disposition; + m_mprot = other.m_mprot; + m_mflags = other.m_mflags; + m_backing_store = other.m_backing_store; + m_init = false; + m_resize_factor = other.m_resize_factor; + m_version = other.m_version; + m_from_recipe = other.m_from_recipe; + PSP_CHECK_CAPACITY(); +} + +t_lstore::t_lstore(t_lstore&& other) noexcept { + PSP_VERBOSE_ASSERT(this != &other, "Constructing from self"); + PSP_TRACE_SENTINEL(); + LOG_CONSTRUCTOR("t_lstore"); + copy_helper(other); + m_init = false; +} + +t_lstore& +t_lstore::operator=(t_lstore&& other) noexcept { + PSP_VERBOSE_ASSERT(this != &other, "Assigning self"); + PSP_TRACE_SENTINEL(); + LOG_CONSTRUCTOR("t_lstore"); + copy_helper(other); + m_init = false; + return *this; +} + +t_lstore::~t_lstore() { + PSP_TRACE_SENTINEL(); + if (!m_init) { + return; + } + + switch (m_backing_store) { + case BACKING_STORE_DISK: { + t_residency_manager::inst().unregister_store(this); + destroy_mapping(); + close_file(m_fd); + + bool dont_delete = + std::getenv("PSP_DO_NOT_DELETE_TABLES") != nullptr; + + if (!dont_delete) { + rmfile(m_fname); + } + } break; + case BACKING_STORE_MEMORY: { +#ifdef _MSC_VER + if (m_alignment >= 2) { + _aligned_free(m_base); // seriously + } else +#endif // _MSC_VER + { + free(m_base); + } + +#ifdef PSP_MPROTECT + unfreeze_impl(); +#endif + } break; + default: { + PSP_VERBOSE_ASSERT(false, "Unknown backing store"); + } break; + } +} + +void +t_lstore::set_size(t_uindex idx) // in bytes +{ + PSP_TRACE_SENTINEL(); + t_unlock_store tmp(this); +#ifdef PSP_VERIFY + PSP_VERBOSE_ASSERT(m_size <= m_capacity, "Setting bad size"); +#endif + m_size = idx; +} + +void +t_lstore::init() { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(!m_init, "Already inited column"); + LOG_INIT("t_lstore"); + + t_unlock_store tmp(this); + switch (m_backing_store) { + case BACKING_STORE_DISK: { + PSP_VERBOSE_ASSERT( + m_alignment < 2, + "nontrivial alignments currently " + "unsupported for BACKING_STORE_DISK" + ); + m_fd = create_file(); + m_base = create_mapping(); + } break; + case BACKING_STORE_MEMORY: { + size_t const alloc_size = std::max( + std::max(size_t(m_alignment), size_t(8U)), size_t(capacity()) + ); + + if (m_alignment < 2) { + m_base = calloc(alloc_size, 1); + } else { + // nontrivial alignment + PSP_VERBOSE_ASSERT( + !(m_alignment & (m_alignment - 1)), + "store alignment must be a power of two!" + ); + +#ifdef _MSC_VER + m_base = _aligned_malloc(alloc_size, size_t(m_alignment)); +#else + int result = posix_memalign( + &m_base, + std::max(sizeof(void*), size_t(m_alignment)), + alloc_size + ); + if (result != 0) { + m_base = nullptr; + } +#endif + + PSP_VERBOSE_ASSERT( + m_base, + "MALLOC_FAILED" + ); // ensure we check before trying to + // memset() in this case + memset(m_base, 0, alloc_size); + } + + PSP_VERBOSE_ASSERT(m_base, "MALLOC_FAILED"); + } break; + default: { + PSP_VERBOSE_ASSERT(false, "Unknown backing store"); + } break; + } + + m_init = true; + + if (m_backing_store == BACKING_STORE_DISK) { + m_residency_tick = g_residency_tick; + t_residency_manager::inst().register_store(this); + } +} + +void +t_lstore::reserve(t_uindex capacity) { + reserve_impl(capacity, false); +} + +void +t_lstore::shrink(t_uindex capacity) { + reserve_impl(capacity, true); +} + +void +t_lstore::reserve_impl(t_uindex capacity, bool allow_shrink) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + // ensure_resident(); + if ((capacity < m_capacity) && !allow_shrink) { + return; + } + + PSP_VERBOSE_ASSERT( + capacity >= m_size, "reduce size before reducing capacity!" + ); + capacity = std::max(capacity, m_size); + + capacity = 4 * std::uint64_t(ceil(double(capacity * m_resize_factor) / 4)); + capacity = std::max(capacity, static_cast(8)); + if (m_alignment > 1) { + capacity = (capacity + m_alignment - 1) & ~(m_alignment - 1); + } + t_uindex ocapacity = m_capacity; + + if (t_env::log_storage_resize()) { + std::cout << repr() << " ocap => " << ocapacity << " ncap => " + << capacity << std::endl; + } + + switch (m_backing_store) { + case BACKING_STORE_MEMORY: { + void* base = nullptr; + + if (m_alignment < 2) { + base = realloc(m_base, size_t(capacity)); + } else { +// nontrivial alignment +#if _MSC_VER + base = _aligned_realloc( + m_base, size_t(capacity), size_t(m_alignment) + ); +#else + base = realloc(m_base, size_t(capacity)); + + if ((uintptr_t(base) & (m_alignment - 1)) != 0) { + // realloc() hasn't given us the correct alignment + // so we need to fix it up + PSP_VERBOSE_ASSERT( + !(m_alignment & (m_alignment - 1)), + "store alignment must be a power of two!" + ); + + void* aligned_base = nullptr; + int result = posix_memalign( + &aligned_base, + std::max(sizeof(void*), size_t(m_alignment)), + size_t(capacity) + ); + PSP_VERBOSE_ASSERT(result, == 0, "posix_memalign failed"); + + memcpy(aligned_base, base, ocapacity); + free(base); + base = aligned_base; + } +#endif + } + + PSP_VERBOSE_ASSERT(base != nullptr, "realloc failed"); + { + t_unlock_store tmp(this); + m_base = base; + m_capacity = capacity; + ++m_version; + } + } break; + case BACKING_STORE_DISK: { + PSP_VERBOSE_ASSERT( + m_alignment < 2, + "nontrivial alignments currently " + "unsupported for BACKING_STORE_DISK" + ); + resize_mapping(capacity); + ++m_version; + } break; + default: { + PSP_COMPLAIN_AND_ABORT("unknown backing medium"); + } + } + + if (capacity > ocapacity) { + memset( + static_cast(m_base) + ocapacity, + 0, + size_t(capacity - ocapacity) + ); + } +} + +// Assumes store has been initted +void +t_lstore::load(const std::string& fname) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + + t_rfmapping imap; + map_file_read(fname, imap); + reserve(imap.m_size); + memcpy(m_base, imap.m_base, size_t(imap.m_size)); + m_size = imap.m_size; + PSP_CHECK_CAPACITY(); +} + +void +t_lstore::save(const std::string& fname) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + PSP_VERBOSE_ASSERT(m_init, "Store not inited."); + + t_rfmapping omap; + map_file_write(fname, capacity(), omap); + memcpy(omap.m_base, m_base, size_t(capacity())); +} + +void +t_lstore::copy(t_lstore& out) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + PSP_COMPLAIN_AND_ABORT("copy is unimplemented!"); +} + +void +t_lstore::warmup() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); +} + +t_uindex +t_lstore::size() const { + PSP_TRACE_SENTINEL(); + return m_size; +} + +t_uindex +t_lstore::capacity() const { + PSP_TRACE_SENTINEL(); + return m_capacity; +} + +std::pair +t_lstore::capacity_pair() const { + PSP_TRACE_SENTINEL(); + t_uindex cap = capacity(); + return std::pair(upper32(cap), lower32(cap)); +} + +std::string +t_lstore::get_fname() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_fname; +} + +void +t_lstore::push_back(const void* ptr, t_uindex len) { + PSP_TRACE_SENTINEL(); + // ensure_resident(); + if (m_size + len >= m_capacity) { + reserve(static_cast(m_size + len) + ); // reserve() will multiply by m_resize_factor internally + } + + PSP_VERBOSE_ASSERT(m_size + len < m_capacity, "Insufficient capacity."); + + memcpy(static_cast(m_base) + m_size, ptr, size_t(len)); + + { + t_unlock_store tmp(this); + m_size += len; + } + PSP_CHECK_CAPACITY(); +} + +void* +t_lstore::get_ptr(t_uindex offset) { + return static_cast(static_cast(m_base) + offset); +} + +const void* +t_lstore::get_ptr(t_uindex offset) const { + return static_cast(static_cast(m_base) + offset); +} + +std::string +t_lstore::get_desc_fname() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return unique_path(m_fname); +} + +t_uindex +t_lstore::get_version() const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_version; +} + +void +t_lstore::append(const t_lstore& other) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + // const_cast(other).ensure_resident(); + push_back(other.m_base, other.size()); +} + +void +t_lstore::clear() { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); +#ifndef PSP_ENABLE_WASM + ensure_resident(); + memset(m_base, 0, size_t(capacity())); +#endif + { + t_unlock_store tmp(this); + m_size = 0; + } +} + +t_lstore_recipe +t_lstore::get_recipe() const { + t_lstore_recipe rval( + m_dirname, + m_colname, + m_capacity, + PSP_DEFAULT_SHARED_RO_FFLAGS, + PSP_DEFAULT_SHARED_RO_FMODE, + PSP_DEFAULT_SHARED_RO_CREATION_DISPOSITION, + PSP_DEFAULT_SHARED_RO_MPROT, + PSP_DEFAULT_SHARED_RO_MFLAGS, + m_backing_store + ); + rval.m_fname = m_fname; + rval.m_from_recipe = true; + rval.m_size = m_size; + rval.m_alignment = m_alignment; + return rval; +} + +t_lstore_recipe +t_lstore::get_clone_recipe() const { + t_lstore_recipe rval = get_recipe(); + if (m_backing_store == BACKING_STORE_DISK) { + rval.m_from_recipe = false; + } + return rval; +} + +void +t_lstore::fill(const t_lstore& other) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + // const_cast(other).ensure_resident(); + reserve(other.size()); + memcpy(m_base, const_cast(other.m_base), size_t(other.size())); + set_size(other.size()); +} + +void +t_lstore::fill(const t_lstore& other, const t_mask& mask, t_uindex elem_size) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + reserve(mask.size() * elem_size); + + PSP_VERBOSE_ASSERT( + mask.size() * elem_size <= m_size, "Not enough space to fill" + ); + + t_uindex offset = 0; + + const auto* src_base = reinterpret_cast(other.get_ptr(0)); + auto* dst_base = reinterpret_cast(m_base); + + for (t_uindex idx = 0, loop_end = mask.size(); idx < loop_end; ++idx) { + if (mask.get(idx)) { + memcpy( + dst_base + offset, src_base + idx * elem_size, size_t(elem_size) + ); + offset += elem_size; + } + } + + set_size(mask.count() * elem_size); +} + +void +t_lstore::pprint() const { + std::cout << repr() << std::endl; + t_uindex nelems = size() / sizeof(std::uint8_t); + for (t_uindex idx = 0; idx < size() / nelems; ++idx) { + + std::cout << idx << " => " + << static_cast(*(get_nth(idx))) + << std::endl; + } +} + +std::shared_ptr +t_lstore::clone() const { + auto recipe = get_clone_recipe(); + std::shared_ptr rval(new t_lstore(recipe)); + rval->init(); + rval->set_size(m_size); + rval->fill(*this); + return rval; +} + +void +t_lstore::evict() { + if (m_backing_store != BACKING_STORE_DISK || !m_init || m_base == nullptr) { + return; + } +#ifdef PSP_HAS_OPFS_BRIDGE + // Flush the resident buffer to OPFS (suspending), then free it. Only valid + // from a `promising` safepoint — never mid-request. + psp_opfs_store( + m_fname.c_str(), m_base, static_cast(capacity()) + ); + free(m_base); + m_base = nullptr; +#else + // Native: `destroy_mapping()` (`munmap`) writes back the `MAP_SHARED` pages; + // the file `m_fd` stays open for a later `restore()`. + destroy_mapping(); + m_base = nullptr; +#endif + ++m_version; +} + +void +t_lstore::restore() { + if (m_backing_store != BACKING_STORE_DISK || !m_init || m_base != nullptr) { + return; + } + // May be called from parallel-for workers within a request (native); the + // OPFS path is only entered from the single-threaded promising safepoint. + std::lock_guard lk(t_residency_manager::inst().mutex()); + if (m_base != nullptr) { + return; + } +#ifdef PSP_HAS_OPFS_BRIDGE + auto cap = static_cast(capacity()); + m_base = calloc(std::max(cap, static_cast(1)), 1); + psp_opfs_load(m_fname.c_str(), m_base, static_cast(capacity())); +#else + m_base = create_mapping(); +#endif + // Stamp LRU recency at bring-in time. Moved here from `ensure_resident()` so + // the per-access hot path reads no residency global (see `storage.h`). + m_residency_tick = g_residency_tick; + t_residency_manager::inst().note_restore(); + ++m_version; +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/storage_impl_linux.cpp b/rust/perspective-server/cpp/perspective/src/cpp/storage_impl_linux.cpp new file mode 100644 index 0000000..ce14773 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/storage_impl_linux.cpp @@ -0,0 +1,132 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include + +// NB: `first.h` force-defines `__linux__` on any non-mac/non-win platform, +// *including emscripten*. Exclude WASM here so `storage_impl_wasm.cpp` (and not +// this `open`/`mmap` implementation) provides the storage backend on WASM. +#if defined(__linux__) && !defined(PSP_ENABLE_WASM) +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +t_lstore::t_lstore(const t_lstore_recipe& a) : + m_base(0), + m_dirname(a.m_dirname), + m_colname(a.m_colname), + m_fd(-1), + m_capacity(a.m_capacity), + m_size(0), + m_alignment(a.m_alignment), + m_fflags(a.m_fflags), + m_fmode(a.m_fmode), + m_creation_disposition(a.m_creation_disposition), + m_mprot(a.m_mprot), + m_mflags(a.m_mflags), + m_backing_store(a.m_backing_store), + m_init(false), + m_resize_factor(1.3), + m_version(0), + m_from_recipe(a.m_from_recipe) { + if (m_from_recipe) { + m_fname = a.m_fname; + return; + } + + if (m_backing_store == BACKING_STORE_DISK) { + std::stringstream ss; + ss << a.m_dirname << "/" + << "_col_" << a.m_colname << "_" << this; + m_fname = unique_path(ss.str()); + } +} + +t_handle +t_lstore::create_file() { + t_handle fd = open(m_fname.c_str(), m_fflags, m_fmode); + PSP_VERBOSE_ASSERT(fd != -1, "Error opening file"); + + if (m_from_recipe) { + return fd; + } + + t_index truncate_bytes = static_cast(capacity()); + + t_index rcode = ftruncate(fd, truncate_bytes); + + PSP_VERBOSE_ASSERT(rcode >= 0, "Ftruncate failed"); + return fd; +} + +void* +// NOLINTNEXTLINE +t_lstore::create_mapping() { + void* rval = mmap(0, capacity(), m_mprot, m_mflags, m_fd, 0); + PSP_VERBOSE_ASSERT(rval != MAP_FAILED, "mmap failed"); + return rval; +} + +void +t_lstore::resize_mapping(t_uindex cap_new) { + t_index rcode = ftruncate(m_fd, cap_new); + PSP_VERBOSE_ASSERT(rcode == 0, "ftruncate failed"); + void* base = mremap(m_base, capacity(), cap_new, MREMAP_MAYMOVE); + + if (base == MAP_FAILED) { + PSP_COMPLAIN_AND_ABORT("mremap failed!"); + } + + m_base = base; + m_capacity = cap_new; +} + +void +t_lstore::destroy_mapping() { + if (m_base == nullptr) { + return; // already evicted + } + t_index rc = munmap(m_base, capacity()); + PSP_VERBOSE_ASSERT(!rc, "Failed to destroy mapping"); +} + +void +t_lstore::freeze_impl() { + PSP_COMPLAIN_AND_ABORT("Not implemented"); +} + +void +t_lstore::unfreeze_impl() { + PSP_COMPLAIN_AND_ABORT("Not implemented"); +} + +} // end namespace perspective + +#endif diff --git a/rust/perspective-server/cpp/perspective/src/cpp/storage_impl_osx.cpp b/rust/perspective-server/cpp/perspective/src/cpp/storage_impl_osx.cpp new file mode 100644 index 0000000..082af9a --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/storage_impl_osx.cpp @@ -0,0 +1,135 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#ifdef __APPLE__ + +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +t_lstore::t_lstore(const t_lstore_recipe& a) : + m_base(nullptr), + m_dirname(a.m_dirname), + m_colname(a.m_colname), + m_fd(-1), + m_capacity(a.m_capacity), + m_size(0), + m_alignment(a.m_alignment), + m_fflags(a.m_fflags), + m_fmode(a.m_fmode), + m_creation_disposition(a.m_creation_disposition), + m_mprot(a.m_mprot), + m_mflags(a.m_mflags), + m_backing_store(a.m_backing_store), + m_init(false), + m_resize_factor(1.3), + m_version(0), + m_from_recipe(a.m_from_recipe) { + if (m_from_recipe) { + m_fname = a.m_fname; + return; + } + + if (m_backing_store == BACKING_STORE_DISK) { + std::stringstream ss; + ss << a.m_dirname << "/" + << "_col_" << a.m_colname << "_" << this; + m_fname = unique_path(ss.str()); + } +} + +t_handle +t_lstore::create_file() { + t_handle fd = open(m_fname.c_str(), m_fflags, m_fmode); + PSP_VERBOSE_ASSERT(fd != -1, "Error opening file"); + + if (m_from_recipe) { + return fd; + } + + auto truncate_bytes = static_cast(capacity()); + + t_index rcode = ftruncate(fd, truncate_bytes); + + PSP_VERBOSE_ASSERT(rcode, >= 0, "Ftruncate failed"); + return fd; +} + +void* +// NOLINTNEXTLINE +t_lstore::create_mapping() { + void* rval = mmap(nullptr, capacity(), m_mprot, m_mflags, m_fd, 0); + PSP_VERBOSE_ASSERT(rval, != MAP_FAILED, "mmap failed"); + return rval; +} + +void +t_lstore::resize_mapping(t_uindex cap_new) { + t_index rcode = ftruncate(m_fd, cap_new); + PSP_VERBOSE_ASSERT(rcode, == 0, "ftruncate failed"); + + if (munmap(m_base, capacity()) == -1) { + throw; + } + + void* base = + mmap(nullptr, cap_new, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, 0); + + if (base == MAP_FAILED) { + PSP_COMPLAIN_AND_ABORT("mremap failed!"); + } + + m_base = base; + m_capacity = cap_new; +} + +void +t_lstore::destroy_mapping() { + if (m_base == nullptr) { + return; // already evicted + } + t_index rc = munmap(m_base, capacity()); + PSP_VERBOSE_ASSERT(rc, == 0, "Failed to destroy mapping"); +} + +void +t_lstore::freeze_impl() { + PSP_COMPLAIN_AND_ABORT("Not implemented"); +} + +void +t_lstore::unfreeze_impl() { + PSP_COMPLAIN_AND_ABORT("Not implemented"); +} + +} // end namespace perspective + +#endif diff --git a/rust/perspective-server/cpp/perspective/src/cpp/storage_impl_wasm.cpp b/rust/perspective-server/cpp/perspective/src/cpp/storage_impl_wasm.cpp new file mode 100644 index 0000000..8c81b36 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/storage_impl_wasm.cpp @@ -0,0 +1,127 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include + +#ifdef PSP_ENABLE_WASM + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +// ┌─────────────────────────────────────────────────────────────────────────┐ +// │ Emulated mmap over WasmFS/OPFS │ +// │ │ +// │ WasmFS has no usable file-backed `mmap` (and even where one exists it │ +// │ copies the whole file into linear memory), so `BACKING_STORE_DISK` on │ +// │ WASM keeps a resident `malloc` buffer (`m_base`) that mirrors the backing │ +// │ file. Reads/writes go through `m_base`; the file is the durable copy that │ +// │ is (re)read via `pread` on mapping and written via `pwrite` on flush. │ +// │ │ +// │ Within a session `m_base` is the source of truth — the file is sized to │ +// │ match (so a later read/restore is correct) but is only written when the │ +// │ buffer is flushed. This is the substrate the residency manager (Phase 4) │ +// │ evicts: `pwrite` the buffer, `free` it, keep the file; restore by │ +// │ re-`pread`ing into a fresh buffer. │ +// └─────────────────────────────────────────────────────────────────────────┘ + +t_lstore::t_lstore(const t_lstore_recipe& a) : + m_base(nullptr), + m_dirname(a.m_dirname), + m_colname(a.m_colname), + m_fd(-1), + m_capacity(a.m_capacity), + m_size(0), + m_alignment(a.m_alignment), + m_fflags(a.m_fflags), + m_fmode(a.m_fmode), + m_creation_disposition(a.m_creation_disposition), + m_mprot(a.m_mprot), + m_mflags(a.m_mflags), + m_backing_store(a.m_backing_store), + m_init(false), + m_resize_factor(1.3), + m_version(0), + m_from_recipe(a.m_from_recipe) { + if (m_from_recipe) { + m_fname = a.m_fname; + return; + } + + if (m_backing_store == BACKING_STORE_DISK) { + std::stringstream ss; + ss << a.m_dirname << "/" + << "_col_" << a.m_colname << "_" << this; + m_fname = unique_path(ss.str()); + } +} + +// A disk-backed column on WASM is just a resident `malloc` buffer; there is no +// OS file (the build is `NO_FILESYSTEM`). Persistence to OPFS is performed by +// the residency manager (`t_lstore::evict`/`restore` in storage.cpp) via the +// `psp_opfs_*` bridge, at promising safepoints. `m_fname` is the OPFS key. + +t_handle +t_lstore::create_file() { + // No OS file; return a sentinel handle. + return 1; +} + +void* +t_lstore::create_mapping() { + auto cap = static_cast(capacity()); + void* base = calloc(std::max(cap, static_cast(1)), 1); + PSP_VERBOSE_ASSERT(base != nullptr, "calloc failed"); + return base; +} + +void +t_lstore::resize_mapping(t_uindex cap_new) { + void* base = realloc(m_base, static_cast(cap_new)); + PSP_VERBOSE_ASSERT(base != nullptr, "realloc failed"); + m_base = base; + m_capacity = cap_new; +} + +void +t_lstore::destroy_mapping() { + if (m_base != nullptr) { + free(m_base); + m_base = nullptr; + } +} + +void +t_lstore::freeze_impl() { + PSP_COMPLAIN_AND_ABORT("Not implemented"); +} + +void +t_lstore::unfreeze_impl() { + PSP_COMPLAIN_AND_ABORT("Not implemented"); +} + +} // end namespace perspective + +#endif diff --git a/rust/perspective-server/cpp/perspective/src/cpp/storage_impl_win.cpp b/rust/perspective-server/cpp/perspective/src/cpp/storage_impl_win.cpp new file mode 100644 index 0000000..59a4572 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/storage_impl_win.cpp @@ -0,0 +1,185 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#ifdef WIN32 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +t_lstore::t_lstore(const t_lstore_recipe& a) : + m_dirname(a.m_dirname), + m_colname(a.m_colname), + m_base(0), + m_fd(0), + m_capacity(a.m_capacity), + m_size(0), + m_alignment(a.m_alignment), + m_fflags(a.m_fflags), + m_fmode(a.m_fmode), + m_creation_disposition(a.m_creation_disposition), + m_mprot(a.m_mprot), + m_mflags(a.m_mflags), + m_backing_store(a.m_backing_store), + m_init(false), + m_resize_factor(1.3), + m_version(0), + m_from_recipe(a.m_from_recipe) { + if (m_from_recipe) { + m_fname = a.m_fname; + return; + } + + if (m_backing_store == BACKING_STORE_DISK) { + std::stringstream ss; + ss << a.m_dirname << "\\" + << "_col_" << a.m_colname << "_" << this; + m_fname = unique_path(ss.str()); + } +} + +t_handle +t_lstore::create_file() { + t_handle rval = CreateFile( + m_fname.c_str(), + m_fflags, + m_fmode, + 0, // security + m_creation_disposition, + FILE_ATTRIBUTE_NORMAL, + 0 // template file + ); + + PSP_VERBOSE_ASSERT(rval != INVALID_HANDLE_VALUE, "Error opening file"); + + if (m_from_recipe) { + return rval; + } + + LARGE_INTEGER sz; + sz.QuadPart = capacity(); + + auto rb = SetFilePointerEx(rval, sz, 0, FILE_BEGIN); + PSP_VERBOSE_ASSERT(rb, "Error setting fpointer"); + + rb = SetEndOfFile(rval); + PSP_VERBOSE_ASSERT(rb, "Error setting eof"); + + return rval; +} + +void* +t_lstore::create_mapping() { + std::pair capacity = capacity_pair(); + + m_winmapping_handle = CreateFileMapping( + m_fd, + 0, // default security + m_mprot, + capacity.first, + capacity.second, + 0 // anonymous mapping + ); + + PSP_VERBOSE_ASSERT(m_winmapping_handle != 0, "Error creating filemapping"); + + void* ptr = MapViewOfFile( + m_winmapping_handle, + m_mflags, + 0, // 0 offset + 0, // 0 offset + 0 // entire file + ); + + PSP_VERBOSE_ASSERT(ptr != 0, "Error mapping file"); + + return ptr; +} + +void +t_lstore::resize_mapping(t_uindex cap_new) { + flush_mapping(m_base, capacity()); + destroy_mapping(); + + // TODO error handling + CloseHandle(m_winmapping_handle); + + LARGE_INTEGER sz; + sz.QuadPart = cap_new; + + auto rb = SetFilePointerEx(m_fd, sz, 0, FILE_BEGIN); + PSP_VERBOSE_ASSERT(rb, "Error setting fpointer"); + + rb = SetEndOfFile(m_fd); + PSP_VERBOSE_ASSERT(rb, "Error setting eof"); + + m_capacity = cap_new; + m_base = create_mapping(); +} + +void +t_lstore::destroy_mapping() { + auto rc = UnmapViewOfFile(m_base); + PSP_VERBOSE_ASSERT(rc, "Error unmapping view"); + m_base = 0; +} + +void +t_lstore::freeze_impl() { + DWORD dwOld; + if (VirtualProtect( + (LPVOID)this, + static_cast(get_page_size()), + PAGE_READONLY, + &dwOld + ) + == 0) { + std::cout << "Virtual protect failed addr => " << this + << " error code => " << GetLastError() << "\n"; + PSP_VERBOSE_ASSERT(false, "virtual protect failed"); + } +} + +void +t_lstore::unfreeze_impl() { + DWORD dwOld; + if (VirtualProtect( + (LPVOID)this, + static_cast(get_page_size()), + PAGE_READWRITE, + &dwOld + ) + == 0) { + std::cout << "Virtual protect failed addr => " << this + << " error code => " << GetLastError() << "\n"; + PSP_VERBOSE_ASSERT(false, "virtual protect failed"); + } +} + +} // end namespace perspective + +#endif diff --git a/rust/perspective-server/cpp/perspective/src/cpp/sym_table.cpp b/rust/perspective-server/cpp/perspective/src/cpp/sym_table.cpp new file mode 100644 index 0000000..016f1c9 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/sym_table.cpp @@ -0,0 +1,128 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include +#include +#include + +namespace perspective { + +std::mutex sym_table_mutex; + +t_symtable::t_symtable() = default; + +t_symtable::~t_symtable() { + for (const auto& kv : m_mapping) { + free(const_cast(kv.second)); + } +} + +const char* +t_symtable::get_interned_cstr(const char* s) { + auto iter = m_mapping.find(s); + + if (iter != m_mapping.end()) { + return iter->second; + } + + auto* scopy = strdup(s); + m_mapping[scopy] = scopy; + return scopy; +} + +t_tscalar +t_symtable::get_interned_tscalar(const char* s) { +#ifdef PSP_SSO_SCALAR + if (t_tscalar::can_store_inplace(s)) { + t_tscalar rval; + rval.set(s); + return rval; + } +#endif + t_tscalar rval; + rval.set(get_interned_cstr(s)); + return rval; +} + +t_tscalar +t_symtable::get_interned_tscalar(const t_tscalar& s) { +#ifdef PSP_SSO_SCALAR + if (!s.is_str() || s.is_inplace()) { + return s; + } +#else + if (!s.is_str()) { + return s; + } +#endif + t_tscalar rval; + rval.set(get_interned_cstr(s.get_char_ptr())); + rval.m_status = s.m_status; + return rval; +} + +t_uindex +t_symtable::size() const { + return m_mapping.size(); +} + +static t_symtable* +get_symtable() { + static t_symtable* sym = nullptr; + + if (sym == nullptr) { + sym = new t_symtable; + } + return sym; +} + +const char* +get_interned_cstr(const char* s) { + std::lock_guard guard(sym_table_mutex); + auto* sym = get_symtable(); + return sym->get_interned_cstr(s); +} + +t_tscalar +get_interned_tscalar(const char* s) { +#ifdef PSP_SSO_SCALAR + if (t_tscalar::can_store_inplace(s)) { + t_tscalar rval; + rval.set(s); + return rval; + } +#endif + t_tscalar rval; + rval.set(get_interned_cstr(s)); + return rval; +} + +t_tscalar +get_interned_tscalar(const t_tscalar& s) { +#ifdef PSP_SSO_SCALAR + if (!s.is_str() || s.is_inplace()) { + return s; + } +#else + if (!s.is_str()) { + return s; + } +#endif + t_tscalar rval; + rval.set(get_interned_cstr(s.get_char_ptr())); + return rval; +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/table.cpp b/rust/perspective-server/cpp/perspective/src/cpp/table.cpp new file mode 100644 index 0000000..d6684d0 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/table.cpp @@ -0,0 +1,1863 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include "perspective/arrow_loader.h" +#include "perspective/base.h" +#include "perspective/column.h" +#include "perspective/computed_expression.h" +#include "perspective/data_table.h" +#include "perspective/raw_types.h" +#include "perspective/schema.h" +#include "rapidjson/document.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Give each Table a unique ID so that operations on it map back correctly +static perspective::t_uindex GLOBAL_TABLE_ID = 0; + +namespace perspective { +Table::Table( + std::shared_ptr pool, + std::vector column_names, + std::vector data_types, + std::uint32_t limit, + std::string index, + t_backing_store backing_store +) : + m_init(false), + m_id(GLOBAL_TABLE_ID++), + m_pool(std::move(pool)), + m_column_names(std::move(column_names)), + m_data_types(std::move(data_types)), + m_offset(0), + m_limit(limit), + m_index(std::move(index)), + m_gnode_set(false), + m_backing_store(backing_store) { + + validate_columns(m_column_names); +} + +void +Table::init( + t_data_table& data_table, + std::uint32_t row_count, + const t_op op, + const t_uindex port_id +) { + /** + * For the Table to be initialized correctly, make sure that the operation + * and index columns are processed before the new offset is calculated. + * Calculating the offset before the `process_op_column` and + * `process_index_column` causes primary keys to be misaligned. + */ + process_op_column(data_table, op); + calculate_offset(row_count); + + if (!m_gnode_set) { + // create a new gnode, send it to the table + auto new_gnode = make_gnode(data_table.get_schema()); + set_gnode(new_gnode); + m_pool->register_gnode(m_gnode.get()); + } + + PSP_VERBOSE_ASSERT(m_gnode_set, "gnode is not set!"); + m_pool->send(m_gnode->get_id(), port_id, data_table); + + m_init = true; +} + +void +Table::init_bulk( + const std::shared_ptr& data_table, + std::uint32_t row_count +) { + PSP_VERBOSE_ASSERT( + !m_gnode_set, + "`init_bulk` can only be used for the initial load of a `Table`." + ); + + process_op_column(*data_table, t_op::OP_INSERT); + calculate_offset(row_count); + + auto new_gnode = make_gnode(data_table->get_schema()); + set_gnode(new_gnode); + m_pool->register_gnode(m_gnode.get()); + + m_gnode->init_bulk(data_table); + + m_init = true; +} + +t_uindex +Table::size() const { + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + // the gstate master table has all rows including removed ones; the mapping + // contains only the current rows in the table. + return m_gnode->mapping_size(); +} + +t_schema +Table::get_schema() const { + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + auto schema = m_gnode->get_output_schema(); + std::vector names = schema.columns(); + std::vector types = schema.types(); + auto implicit_index_it = std::find(names.begin(), names.end(), "psp_okey"); + if (implicit_index_it != names.end()) { + auto idx = std::distance(names.begin(), implicit_index_it); + names.erase(names.begin() + idx); + types.erase(types.begin() + idx); + } + + return {names, types}; +} + +t_validated_expression_map +Table::validate_expressions( + const std::vector>>>& expressions +) const { + t_validated_expression_map rval = t_validated_expression_map(); + + // Expression columns live on the `t_gstate` master table, so this + // schema will always contain ALL expressions columns created by ALL views + // on this table instance. + auto master_table_schema = m_gnode->get_table_sptr()->get_schema(); + + // However, we need to keep track of the "real" columns at the time the + // table was instantiated, which exists on the output schema. This means + // that we cannot create an expression column that references another + // expression column - expressions can only reference "real" columns. + auto gnode_schema = get_schema(); + + // Use the gnode's expression vocab to validate expressions so we never + // have string-typed scalars with nullptr. + t_expression_vocab& expression_vocab = *(m_gnode->get_expression_vocab()); + t_regex_mapping& regex_mapping = *(m_gnode->get_expression_regex_mapping()); + + for (const auto& expr : expressions) { + const std::string& expression_alias = std::get<0>(expr); + const std::string& expression_string = std::get<1>(expr); + const std::string& parsed_expression_string = std::get<2>(expr); + + t_expression_error error; + error.m_line = -1; + error.m_column = -1; + + // Cannot overwrite a "real" column with an expression column + if (gnode_schema.has_column(expression_alias)) { + error.m_error_message = "Value Error - expression \"" + + expression_alias + "\" cannot overwrite an existing column."; + error.m_line = 0; + error.m_column = 0; + rval.add_error(expression_alias, error); + continue; + } + + const auto& column_ids = std::get<3>(expr); + + t_dtype expression_dtype = m_computed_expression_parser.get_dtype( + expression_alias, + expression_string, + parsed_expression_string, + column_ids, + m_gnode->get_table_sptr(), + m_gnode->get_pkey_map(), + gnode_schema, + error, + expression_vocab, + regex_mapping + ); + + // FIXME: none == bad type? what about clear + if (expression_dtype == DTYPE_NONE) { + // extract the error from the stream and set it in the returned map + rval.add_error(expression_alias, error); + } else { + rval.add_expression( + expression_alias, dtype_to_str(expression_dtype) + ); + } + } + + return rval; +} + +std::shared_ptr +Table::make_gnode(const t_schema& in_schema) { + t_schema out_schema = in_schema.drop({"psp_pkey", "psp_op"}); + auto gnode = + std::make_shared(in_schema, out_schema, m_backing_store); + gnode->init(); + return gnode; +} + +void +Table::set_gnode(std::shared_ptr gnode) { + m_gnode = std::move(gnode); + m_gnode_set = true; +} + +void +Table::unregister_gnode(t_uindex id) const { + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + m_pool->unregister_gnode(id); +} + +void +Table::reset_gnode(t_uindex id) const { + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + t_gnode* gnode = m_pool->get_gnode(id); + gnode->reset(); +} + +t_uindex +Table::make_port() const { + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + PSP_VERBOSE_ASSERT( + m_gnode_set, "Cannot make input port on a gnode that does not exist." + ); + return m_gnode->make_input_port(); +} + +void +Table::remove_port(t_uindex port_id) const { + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + PSP_VERBOSE_ASSERT( + m_gnode_set, "Cannot remove input port on a gnode that does not exist." + ); + m_gnode->remove_input_port(port_id); +} + +void +Table::calculate_offset(std::uint32_t row_count) { + m_offset = m_offset + row_count; +} + +t_uindex +Table::get_id() const { + return m_id; +} + +std::shared_ptr +Table::get_pool() const { + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_pool; +} + +std::shared_ptr +Table::get_gnode() const { + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_gnode; +} + +const std::vector& +Table::get_column_names() const { + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_column_names; +} + +const std::vector& +Table::get_data_types() const { + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_data_types; +} + +const std::string& +Table::get_index() const { + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_index; +} + +std::uint32_t +Table::get_offset() const { + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_offset; +} + +std::uint32_t +Table::get_limit() const { + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_limit; +} + +t_backing_store +Table::get_backing_store() const { + return m_backing_store; +} + +void +Table::set_column_names(const std::vector& column_names) { + validate_columns(column_names); + m_column_names = column_names; +} + +void +Table::set_data_types(const std::vector& data_types) { + m_data_types = data_types; +} + +std::unordered_map> +schema_to_arrow_map(const t_schema& gnode_output_schema) { + auto map = + std::unordered_map>(); + + auto schema = gnode_output_schema.drop({"psp_okey"}); + auto column_names = schema.columns(); + auto data_types = schema.types(); + for (auto idx = 0; idx < column_names.size(); ++idx) { + const std::string& name = column_names[idx]; + const t_dtype& type = data_types[idx]; + switch (type) { + case DTYPE_FLOAT32: + map[name] = std::make_shared(); + break; + case DTYPE_FLOAT64: + map[name] = std::make_shared(); + break; + case DTYPE_STR: + map[name] = std::make_shared(); + break; + case DTYPE_BOOL: + map[name] = std::make_shared(); + break; + case DTYPE_UINT32: + map[name] = std::make_shared(); + break; + case DTYPE_UINT64: + map[name] = std::make_shared(); + break; + case DTYPE_INT32: + map[name] = std::make_shared(); + break; + case DTYPE_INT64: + map[name] = std::make_shared(); + break; + case DTYPE_TIME: + map[name] = std::make_shared(); + break; + case DTYPE_DATE: + map[name] = std::make_shared(); + break; + default: + std::stringstream ss; + ss << "Error loading arrow type " << dtype_to_str(type) + << " for column " << name << "\n"; + PSP_COMPLAIN_AND_ABORT(ss.str()) + break; + } + } + return map; +} + +void +Table::update_csv(const std::string_view& data, std::uint32_t port_id) { + auto type_map = schema_to_arrow_map(get_gnode()->get_output_schema()); + apachearrow::ArrowLoader arrow_loader; + arrow_loader.init_csv(data, true, type_map); + std::uint32_t row_count = 0; + row_count = arrow_loader.row_count(); + t_data_table data_table(get_schema()); + data_table.init(); + data_table.extend(row_count); + arrow_loader.fill_table(data_table, get_schema(), m_index, m_offset, true); + process_op_column(data_table, t_op::OP_INSERT); + calculate_offset(row_count); + m_pool->send(get_gnode()->get_id(), port_id, data_table); +} + +std::shared_ptr
    +Table::from_csv( + const std::string& index, + std::string&& data, + std::uint32_t limit, + t_backing_store backing_store +) { + auto map = + std::unordered_map>(); + + apachearrow::ArrowLoader arrow_loader; + arrow_loader.init_csv(data, false, map); + + // Arrow has materialized the CSV into its own buffers at this point; drop + // the raw CSV string so it does not live concurrently with the Arrow table + // and the `t_data_table`. + { auto _ = std::move(data); } + + std::vector column_names = arrow_loader.names(); + std::vector data_types = arrow_loader.types(); + t_schema input_schema(column_names, data_types); + auto implicit_index_it = + std::find(column_names.begin(), column_names.end(), "__INDEX__"); + const bool has_index_column = implicit_index_it != column_names.end(); + + if (has_index_column) { + auto idx = std::distance(column_names.begin(), implicit_index_it); + // position of the column is at the same index in both vectors + column_names.erase(column_names.begin() + idx); + data_types.erase(data_types.begin() + idx); + } + + t_schema output_schema(column_names, data_types); + std::uint32_t row_count = arrow_loader.row_count(); + + auto data_table = std::make_shared(output_schema); + data_table->init(); + + { + auto loader = std::move(arrow_loader); + data_table->extend(row_count); + loader.fill_table(*data_table, input_schema, index, 0, false); + } + + auto pool = std::make_shared(); + pool->init(); + auto tbl = std::make_shared
    ( + pool, column_names, data_types, limit, index, backing_store + ); + + // `psp_pkey` is guaranteed unique only when the index is implicit (a + // generated row-number). Explicit indexes or `__INDEX__` columns can + // contain duplicates, which must be deduplicated via the `flatten()` + // path. + const bool can_bulk_init = index.empty() && !has_index_column; + if (can_bulk_init) { + tbl->init_bulk(data_table, row_count); + } else { + tbl->init(*data_table, row_count, t_op::OP_INSERT, 0); + data_table.reset(); + pool->_process(); + } + return tbl; +} + +static bool +ichar_equals(char a, char b) { + return std::tolower(static_cast(a)) + == std::tolower(static_cast(b)); +} + +static bool +istrequals(std::string_view a, std::string_view b) { + return a.size() == b.size() + && std::equal(a.begin(), a.end(), b.begin(), ichar_equals); +} + +t_dtype +rapidjson_type_to_dtype(const rapidjson::Value& value) { + switch (value.GetType()) { + case rapidjson::Type::kStringType: { + const auto& str = value.GetString(); + if (str[0] == '\0') { + return t_dtype::DTYPE_STR; + } + + if (istrequals(str, "true") || istrequals(str, "false")) { + return t_dtype::DTYPE_BOOL; + } + + // TODO JSON will no longer support date/datetime inference. The + // only way to load JSON data with these types will be with a + // Schema! + + char* endptr; + strtol(str, &endptr, 10); + if (*endptr == '\0') { + return t_dtype::DTYPE_INT32; + } + + strtof(str, &endptr); + if (*endptr == '\0') { + return t_dtype::DTYPE_FLOAT64; + } + + std::tm tm; + std::memset(&tm, 0, sizeof(tm)); + std::chrono::system_clock::time_point tp; + + if (parse_all_date_time(tm, tp, str)) { + if (tm.tm_hour == 0 && tm.tm_min == 0 && tm.tm_sec == 0) { + return t_dtype::DTYPE_DATE; + } + return t_dtype::DTYPE_TIME; + } + + auto datetime = apachearrow::parseAsArrowTimestamp(str); + if (datetime != std::nullopt) { + return t_dtype::DTYPE_TIME; + } + + return t_dtype::DTYPE_STR; + } + case rapidjson::Type::kNumberType: { + if (value.IsInt64()) { + if (value.GetInt64() + > std::numeric_limits::max()) { + return t_dtype::DTYPE_FLOAT64; + } + return t_dtype::DTYPE_INT32; + } + if (value.IsInt()) { + return t_dtype::DTYPE_INT32; + } + + return t_dtype::DTYPE_FLOAT64; + } + case rapidjson::Type::kTrueType: + case rapidjson::Type::kFalseType: + return t_dtype::DTYPE_BOOL; + case rapidjson::kNullType: + return t_dtype::DTYPE_NONE; + case rapidjson::kObjectType: + case rapidjson::kArrayType: + PSP_COMPLAIN_AND_ABORT("Unknown JSON type"); + return t_dtype::DTYPE_NONE; + default: + PSP_COMPLAIN_AND_ABORT("Unknown JSON type"); + return t_dtype::DTYPE_NONE; + } +} + +void +Table::clear() { + reset_gnode(m_gnode->get_id()); +} + +template +struct promote { + constexpr static t_dtype dtype = DTYPE_NONE; +}; + +#define PROMOTE_IMPL(A, B, C) \ + template <> \ + struct promote { \ + constexpr static t_dtype dtype = C; \ + }; + +PROMOTE_IMPL(DTYPE_INT32, DTYPE_INT64, DTYPE_INT64) +// PROMOTE_IMPL(std::int32_t, std::float_t, DTYPE_FLOAT32) +// PROMOTE_IMPL(std::int32_t, std::double_t, DTYPE_FLOAT64) + +template +static A +json_into(const rapidjson::Value& value) { + if constexpr (std::is_same_v || std::is_same_v || std::is_same_v) { + if (value.IsInt()) { + return value.GetInt(); + } + if (value.IsInt64()) { + return value.GetInt64(); + } + if (value.IsDouble()) { + return value.GetDouble(); + } + if (value.IsFloat()) { + return value.GetFloat(); + } + if (value.IsString()) { + if constexpr (std::is_same_v) { + return std::atoi(value.GetString()); + } else if constexpr (std::is_same_v) { + return std::atoll(value.GetString()); + } else if constexpr (std::is_same_v || std::is_same_v) { + return std::atof(value.GetString()); + } else { + static_assert(!std::is_same_v, "No coercion for type"); + } + } + if (value.IsNull()) { + return 0; + } + + std::stringstream ss; + ss << "Could not coerce " << value.GetType() << " to " + << "a number"; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } else if constexpr (std::is_same_v) { + switch (value.GetType()) { + case rapidjson::kNullType: + return ""; + case rapidjson::kFalseType: + return "false"; + case rapidjson::kTrueType: + return "true"; + case rapidjson::kObjectType: + PSP_COMPLAIN_AND_ABORT("Cannot coerce object to string"); + case rapidjson::kArrayType: + PSP_COMPLAIN_AND_ABORT("Cannot coerce array to string"); + case rapidjson::kStringType: + return value.GetString(); + case rapidjson::kNumberType: + if (value.IsInt()) { + return std::to_string(value.GetInt()); + } + if (value.IsInt64()) { + return std::to_string(value.GetInt64()); + } + if (value.IsDouble()) { + return std::to_string(value.GetDouble()); + } + if (value.IsFloat()) { + return std::to_string(value.GetFloat()); + } + } + + std::stringstream ss; + ss << "Could not coerce " << value.GetType() << " to " + << "a string"; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } else if constexpr (std::is_same_v) { + std::tm tm; + if (value.IsString()) { + if (!parse_all_date_time(tm, value.GetString())) { + PSP_COMPLAIN_AND_ABORT("Could not coerce to date"); + } + } else if (value.IsInt64()) { + return t_date::from_epoch_ms(value.GetInt64()); + } else { + PSP_COMPLAIN_AND_ABORT("Could not coerce to date"); + } + + return t_date(tm.tm_year + 1900, tm.tm_mon, tm.tm_mday); + } else if constexpr (std::is_same_v) { + if (value.IsString()) { + std::chrono::system_clock::time_point tp; + if (!parse_all_date_time(tp, value.GetString())) { + PSP_COMPLAIN_AND_ABORT("Could not coerce to time"); + } + + return t_time(std::chrono::duration_cast( + tp.time_since_epoch() + ) + .count()); + } + if (value.IsDouble()) { + return t_time(value.GetDouble()); + } + if (value.IsInt64()) { + return t_time(value.GetInt64()); + } + if (value.IsInt()) { + return t_time(value.GetInt()); + } + PSP_COMPLAIN_AND_ABORT( + "Could not coerce " + std::to_string(value.GetType()) + + " to a time." + ); + } else { + static_assert(!std::is_same_v, "No coercion for type"); + } +} + +std::optional +fill_column_json( + const std::shared_ptr& col, + const t_uindex i, + const rapidjson::Value& value, + const bool is_update +) { + if (value.IsNull()) { + if (is_update) { + col->unset(i); + } else { + col->clear(i); + } + return std::nullopt; + } + + switch (col->get_dtype()) { + case t_dtype::DTYPE_STR: { + if (!value.IsString()) { + auto v = json_into(value); + col->set_nth(i, v); + } else { + col->set_nth(i, value.GetString()); + } + return std::nullopt; + } + case t_dtype::DTYPE_INT32: { + if (value.IsInt()) { + col->set_nth(i, value.GetInt()); + return std::nullopt; + } + + if (value.IsInt64()) { + if (value.GetInt64() > std::numeric_limits::max()) + [[likely]] { + if (!is_update) { + LOG_DEBUG("Promoting due to int32 overflow"); + return {DTYPE_FLOAT64}; + } + } + + // Coerce in update mode + col->set_nth( + i, static_cast(value.GetInt64()) + ); + + return std::nullopt; + } + + if (value.IsDouble()) { + if (is_update) { + col->set_nth( + i, static_cast(value.GetDouble()) + ); + return std::nullopt; + } + + return {DTYPE_FLOAT64}; + } + + if (value.IsString()) { + const auto& str = value.GetString(); + if (str[0] == '\0') { + if (is_update) { + col->set_valid(i, false); + return std::nullopt; + } + + return {t_dtype::DTYPE_STR}; + } + + char* endptr; + std::int32_t result = strtol(str, &endptr, 10); + if (*endptr == '\0') { + col->set_nth(i, result); + return std::nullopt; + } + + float result2 = strtof(str, &endptr); + if (*endptr == '\0') { + if (is_update) { + col->set_nth( + i, static_cast(result2) + ); + return std::nullopt; + } + + return {t_dtype::DTYPE_FLOAT64}; + } + + return {t_dtype::DTYPE_STR}; + } + + std::stringstream ss; + ss << "Expected int, found " << value.GetType(); + PSP_COMPLAIN_AND_ABORT(ss.str()); + return std::nullopt; + } + case t_dtype::DTYPE_INT64: { + if (value.IsInt64()) [[likely]] { + col->set_nth(i, value.GetInt()); + } else if (value.IsDouble()) { + return {DTYPE_FLOAT64}; + } else if (value.IsString()) { + col->set_nth(i, std::atoll(value.GetString())); + } else { + std::stringstream ss; + ss << "Expected int64, found " << value.GetType(); + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + return std::nullopt; + } + case t_dtype::DTYPE_FLOAT64: { + if (value.IsDouble()) [[likely]] { + col->set_nth(i, value.GetDouble()); + } else if (value.IsInt64()) { + col->set_nth(i, static_cast(value.GetInt64())); + } else if (value.IsInt()) { + col->set_nth(i, value.GetInt()); + } else if (value.IsString()) { + col->set_nth(i, std::atof(value.GetString())); + } else { + std::stringstream ss; + ss << "Expected double, found " << value.GetType(); + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + return std::nullopt; + } + case t_dtype::DTYPE_BOOL: { + if (value.IsBool()) [[likely]] { + col->set_nth(i, value.GetBool()); + } else if (value.IsString() && istrequals(value.GetString(), "true")) { + col->set_nth(i, true); + } else if (value.IsString() && istrequals(value.GetString(), "false")) { + col->set_nth(i, false); + } else if (value.IsInt()) { + col->set_nth(i, value.GetInt() != 0); + } else { + std::stringstream ss; + ss << "Expected bool, found " << value.GetType(); + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + return std::nullopt; + } + case t_dtype::DTYPE_TIME: { + col->set_nth(i, json_into(value)); + return std::nullopt; + } + case t_dtype::DTYPE_DATE: { + col->set_nth(i, json_into(value)); + return std::nullopt; + } + default: + PSP_COMPLAIN_AND_ABORT("JSON field not yet implemented"); + return std::nullopt; + } +} + +void +Table::remove_rows(const std::string_view& data) { + // 1.) Infer schema + rapidjson::Document document; + document.Parse(data.data()); + if (!document.IsArray()) { + PSP_COMPLAIN_AND_ABORT("Cannot remove fish!\n") + } + + if (m_index.empty()) { + PSP_COMPLAIN_AND_ABORT("Cannot remove from unindexed Table\n") + } + + const t_schema& output_schema = get_gnode()->get_output_schema(); + + std::vector column_names{m_index}; + std::vector data_types{output_schema.get_dtype(m_index)}; + + t_schema schema(column_names, data_types); + + // 2.) Create table + t_data_table data_table(schema); + data_table.init(); + data_table.extend(document.Size()); + + data_table.add_column("psp_pkey", schema.get_dtype(m_index), true); + + const auto& psp_pkey_col = data_table.get_column("psp_pkey"); + + // 3.) Fill table + t_uindex ii = 0; + auto col = data_table.get_column(m_index); + for (const auto& cell : document.GetArray()) { + auto promote = fill_column_json(col, ii, cell, true); + if (promote) { + std::stringstream ss; + ss << "Cannot append value of type " << dtype_to_str(*promote) + << " to column \"" << m_index << "\" of type " << dtype_to_str(col->get_dtype()) + << " at index " << ii + << std::endl; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + + // if (!is_implicit && m_index == col_name) { + fill_column_json(psp_pkey_col, ii, cell, true); + // } + + ii++; + } + + data_table.clone_column("psp_pkey", "psp_okey"); + // calculate_offset(data_table.size()); + process_op_column(data_table, OP_DELETE); + m_pool->send(get_gnode()->get_id(), 0, data_table); +} + +void +Table::remove_cols(const std::string_view& data) { + // 1.) Infer schema + rapidjson::Document document; + document.Parse(data.data()); + if (!document.IsArray()) { + PSP_COMPLAIN_AND_ABORT("Cannot remove fish!\n") + } + + if (m_index.empty()) { + PSP_COMPLAIN_AND_ABORT("Cannot remove from unindexed Table\n") + } + + const t_schema& output_schema = get_gnode()->get_output_schema(); + + std::vector column_names{m_index}; + std::vector data_types{output_schema.get_dtype(m_index)}; + + t_schema schema(column_names, data_types); + + // 2.) Create table + t_data_table data_table(schema); + data_table.init(); + data_table.extend(document.Size()); + + data_table.add_column("psp_pkey", schema.get_dtype(m_index), true); + data_table.add_column("psp_okey", schema.get_dtype(m_index), true); + + const auto& psp_pkey_col = data_table.get_column("psp_pkey"); + const auto& psp_okey_col = data_table.get_column("psp_okey"); + + // 3.) Fill table + t_uindex ii = 0; + auto col = data_table.get_column(m_index); + for (const auto& cell : document.GetArray()) { + auto promote = fill_column_json(col, ii, cell, true); + if (promote) { + std::stringstream ss; + ss << "Cannot append value of type " << dtype_to_str(*promote) + << " to column \"" << m_index << "\" of type " << dtype_to_str(col->get_dtype()) + << " at index " << ii + << std::endl; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + + // if (!is_implicit && m_index == col_name) { + fill_column_json(psp_pkey_col, ii, cell, true); + fill_column_json(psp_okey_col, ii, cell, true); + // } + + ii++; + } + + // calculate_offset(nrows); + calculate_offset(data_table.size()); + process_op_column(data_table, OP_DELETE); + m_pool->send(get_gnode()->get_id(), 0, data_table); +} + +void +Table::update_cols(const std::string_view& data, std::uint32_t port_id) { + // 1.) Infer schema + rapidjson::Document document; + document.Parse(data.data()); + if (!document.IsObject()) { + // TODO Legacy error message + PSP_COMPLAIN_AND_ABORT( + "Cannot determine data types without column names!\n" + ) + } + + t_uindex nrows = 0; + for (const auto& it : document.GetObj()) { + if (!it.value.IsArray()) { + PSP_COMPLAIN_AND_ABORT("Malformed column") + } + + if (it.value.Empty()) { + PSP_COMPLAIN_AND_ABORT("Can't create table from empty columns") + } + + nrows = std::max(nrows, static_cast(it.value.Size())); + } + + bool is_implicit = m_index.empty(); + t_schema table_schema = get_schema(); + + // 2.) Create table + t_data_table data_table(table_schema); + data_table.init(); + data_table.extend(nrows); + + LOG_DEBUG("Updating table with schema " << table_schema); + LOG_DEBUG("Implicit index? " << is_implicit); + if (is_implicit) { + data_table.add_column("psp_pkey", DTYPE_INT32, true); + } else { + data_table.add_column( + "psp_pkey", table_schema.get_dtype(m_index), true + ); + } + + const auto& psp_pkey_col = data_table.get_column("psp_pkey"); + + auto schema = data_table.get_schema(); + + if (is_implicit && !document.GetObj().HasMember("__INDEX__")) { + for (std::uint32_t ii = 0; ii < nrows; ii++) { + psp_pkey_col->set_nth(ii, (m_offset + ii)); + } + } + + // 3.) Fill table + for (const auto& column : document.GetObj()) { + t_uindex ii = 0; + std::string_view col_name = column.name.GetString(); + if (std::string_view{column.name.GetString()} == "__INDEX__") { + col_name = "psp_pkey"; + } + + if (!schema.has_column(col_name)) { + LOG_DEBUG("Ignoring column " << col_name); + continue; + } + + for (const auto& cell : column.value.GetArray()) { + auto col = data_table.get_column(col_name); + auto promote = fill_column_json(col, ii, cell, true); + if (promote) { + std::stringstream ss; + ss << "Cannot append value of type " << dtype_to_str(*promote) + << " to column \"" << col_name << "\" of type " << dtype_to_str(col->get_dtype()) + << " at index " << ii + << std::endl; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + + if (!is_implicit && m_index == column.name.GetString()) { + fill_column_json(psp_pkey_col, ii, cell, true); + } + + ii++; + } + } + + data_table.clone_column("psp_pkey", "psp_okey"); + + process_op_column(data_table, t_op::OP_INSERT); + calculate_offset(nrows); + m_pool->send(get_gnode()->get_id(), port_id, data_table); +} + +std::shared_ptr
    +Table::from_cols( + const std::string& index, + std::string&& data, + std::uint32_t limit, + t_backing_store backing_store +) { + // 1.) Infer schema + rapidjson::Document document; + document.Parse(data.data()); + + std::vector column_names; + std::vector data_types; + bool is_implicit = true; + t_uindex nrows = 0; + + // https://github.com/Tencent/rapidjson/issues/1994 + for (const auto& it : document.GetObj()) { + if (!it.value.IsArray()) { + PSP_COMPLAIN_AND_ABORT("Malformed column") + } + + if (it.value.Empty()) { + PSP_COMPLAIN_AND_ABORT("Can't create table from empty columns") + } + + if (it.name.GetString() == index) { + is_implicit = false; + } + + nrows = std::max(nrows, static_cast(it.value.Size())); + bool found = false; + for (const auto& column_value : it.value.GetArray()) { + auto dtype = rapidjson_type_to_dtype(column_value); + if (dtype != DTYPE_NONE) { + data_types.push_back(dtype); + found = true; + break; + } + } + + if (!found) { + data_types.push_back(DTYPE_STR); + } + + column_names.emplace_back(it.name.GetString()); + } + + t_schema schema(column_names, data_types); + + // 2.) Create table + auto data_table = std::make_unique(schema); + data_table->init(); + data_table->extend(nrows); + + if (is_implicit) { + // TODO should this be t_uindex? + data_table->add_column("psp_pkey", DTYPE_INT32, true); + data_table->add_column("psp_okey", DTYPE_INT32, true); + } else { + data_table->add_column("psp_pkey", schema.get_dtype(index), true); + data_table->add_column("psp_okey", schema.get_dtype(index), true); + } + + const auto& psp_pkey_col = data_table->get_column("psp_pkey"); + const auto& psp_okey_col = data_table->get_column("psp_okey"); + + // 3.) Fill table + for (const auto& col : document.GetObj()) { + t_uindex ii = 0; + const auto& col_name = col.name.GetString(); + LOG_DEBUG( + "Filling column " + << col_name << " dtype " + << dtype_to_str(data_table->get_column(col_name)->get_dtype()) + ); + for (const auto& cell : col.value.GetArray()) { + auto col = data_table->get_column(col_name); + auto promote = fill_column_json(col, ii, cell, false); + if (promote) { + LOG_DEBUG( + "Promoting column " << col_name << " from " + << dtype_to_str(col->get_dtype()) + << " to " << dtype_to_str(*promote) + ); + data_table->promote_column(col_name, *promote, ii, true); + col = data_table->get_column(col_name); + fill_column_json(col, ii, cell, false); + } + + if (!is_implicit && index == col_name) { + fill_column_json(psp_pkey_col, ii, cell, false); + fill_column_json(psp_okey_col, ii, cell, false); + } + + ii++; + } + } + + if (is_implicit) { + for (t_uindex ii = 0; ii < nrows; ii++) { + psp_pkey_col->set_nth(ii, ii); + psp_okey_col->set_nth(ii, ii); + } + } + + { auto _ = std::move(document); } + { auto _ = std::move(data); } + + auto pool = std::make_shared(); + pool->init(); + auto tbl = std::make_shared
    ( + pool, schema.columns(), schema.types(), limit, index, backing_store + ); + + tbl->init(*data_table, nrows, t_op::OP_INSERT, 0); + data_table.reset(); + pool->_process(); + return tbl; +} + +// rapidjson::StringBuffer buffer; +// buffer.Clear(); +// rapidjson::Writer writer(buffer); +// document.Accept(writer); +// std::cout << buffer.GetString() << std::endl; + +void +Table::update_rows(const std::string_view& data, std::uint32_t port_id) { + // 1.) Infer schema + rapidjson::Document document; + document.Parse(data.data()); + if (document.Size() == 0) { + return; + } + + if (!document[0].IsObject()) { + // TODO Legacy error message + PSP_COMPLAIN_AND_ABORT( + "Cannot determine data types without column names!\n" + ) + } + + bool is_implicit = m_index.empty(); + t_schema table_schema = get_schema(); + + // 2.) Create table + t_uindex size = document.Size(); + t_data_table data_table(table_schema); + data_table.init(); + data_table.extend(size); + if (is_implicit) { + data_table.add_column("psp_pkey", DTYPE_INT32, true); + } else { + data_table.add_column( + "psp_pkey", table_schema.get_dtype(m_index), true + ); + } + + t_uindex ii = 0; + const auto& psp_pkey_col = data_table.get_column("psp_pkey"); + auto schema = data_table.get_schema(); + // t_uindex col_count; + // bool supports_partial = + // m_limit == std::numeric_limits::max() && !m_index.empty(); + bool is_first_row = true; + std::vector missing_columns = m_column_names; + + // 3.) Fill table + for (const auto& row : document.GetArray()) { + if (is_implicit) { + psp_pkey_col->set_nth(ii, (ii + m_offset)); + } + + // col_count = m_column_names.size(); + for (const auto& it : row.GetObj()) { + std::shared_ptr col; + std::string_view col_name = it.name.GetString(); + if (std::string_view{it.name.GetString()} == "__INDEX__") { + col_name = "psp_pkey"; + } + + if (!schema.has_column(col_name)) { + LOG_DEBUG("Ignoring column " << col_name); + LOG_DEBUG("Schema:\n" << schema); + continue; + } + + if (is_first_row) { + missing_columns.erase( + std ::remove( + missing_columns.begin(), missing_columns.end(), col_name + ), + missing_columns.end() + ); + } + + col = data_table.get_column(col_name); + auto promote = fill_column_json(col, ii, it.value, true); + if (promote) { + std::stringstream ss; + ss << "Cannot append value of type " << dtype_to_str(*promote) + << " to column \"" << col_name << "\" of type " << dtype_to_str(col->get_dtype()) + << " at index " << ii + << std::endl; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + + if (!is_implicit && m_index == it.name.GetString()) { + fill_column_json(psp_pkey_col, ii, it.value, true); + } + } + + is_first_row = false; + ii++; + } + + data_table.clone_column("psp_pkey", "psp_okey"); + process_op_column(data_table, t_op::OP_INSERT); + calculate_offset(size); + m_pool->send(get_gnode()->get_id(), port_id, data_table); +} + +std::shared_ptr
    +Table::from_rows( + const std::string& index, + std::string&& data, + std::uint32_t limit, + t_backing_store backing_store +) { + // 1.) Infer schema + rapidjson::Document document; + document.Parse(data.data()); + // if (document.Size() == 0) { + // PSP_COMPLAIN_AND_ABORT("Can't create table from empty rows") + // } + + if (document.Size() > 0 && !document[0].IsObject()) { + LOG_DEBUG("Received non-object " << document[0].GetType()); + // TODO Legacy error message + PSP_COMPLAIN_AND_ABORT( + "Cannot determine data types without column names!\n" + ) + } + + std::vector column_names; + std::vector data_types; + bool is_implicit = true; + std::set columns_known_type; + std::set columns_seen; + + [&]() { + for (const auto& row : document.GetArray()) { + for (const auto& col : row.GetObj()) { + columns_seen.insert(col.name.GetString()); + } + + // https://github.com/Tencent/rapidjson/issues/1994 + for (const auto& col : row.GetObj()) { + if (col.name.GetString() == index) { + is_implicit = false; + } + + if (columns_known_type.count(col.name.GetString()) > 0) { + continue; + } + + auto dtype = rapidjson_type_to_dtype(col.value); + if (dtype != DTYPE_NONE) { + columns_known_type.insert(col.name.GetString()); + data_types.push_back(rapidjson_type_to_dtype(col.value)); + column_names.emplace_back(col.name.GetString()); + } + + // Theoretically there can end too early if the first + // few rows are missing columns that are present in later rows. + if (columns_known_type.size() == columns_seen.size()) { + return; + } + } + } + }(); + + auto untyped_columns = columns_seen; + for (const auto& col : columns_seen) { + if (columns_known_type.count(col) == 0) { + // Default all null columns to string + data_types.push_back(DTYPE_STR); + column_names.emplace_back(col); + } + } + + t_schema schema(column_names, data_types); + + // 2.) Create table + auto data_table = std::make_unique(schema); + data_table->init(); + data_table->extend(document.Size()); + + if (is_implicit) { + data_table->add_column("psp_pkey", DTYPE_INT32, true); + data_table->add_column("psp_okey", DTYPE_INT32, true); + } else { + data_table->add_column("psp_pkey", schema.get_dtype(index), true); + data_table->add_column("psp_okey", schema.get_dtype(index), true); + } + + std::int32_t ii = 0; + + const auto& psp_pkey_col = data_table->get_column("psp_pkey"); + const auto& psp_okey_col = data_table->get_column("psp_okey"); + + // 3.) Fill table + for (const auto& row : document.GetArray()) { + for (const auto& it : row.GetObj()) { + auto col = data_table->get_column(it.name.GetString()); + const auto* col_name = it.name.GetString(); + const auto& cell = it.value; + auto promote = fill_column_json(col, ii, cell, false); + if (promote) { + LOG_DEBUG( + "Promoting column " << col_name << " from " + << dtype_to_str(col->get_dtype()) + << " to " << dtype_to_str(*promote) + ); + data_table->promote_column(col_name, *promote, ii, true); + col = data_table->get_column(col_name); + fill_column_json(col, ii, cell, false); + } + + if (!is_implicit && index == it.name.GetString()) { + fill_column_json(psp_pkey_col, ii, it.value, false); + fill_column_json(psp_okey_col, ii, it.value, false); + } + } + + if (is_implicit) { + psp_pkey_col->set_nth(ii, ii); + psp_okey_col->set_nth(ii, ii); + } + + ii++; + } + + { auto _ = std::move(document); } + { auto _ = std::move(data); } + + auto pool = std::make_shared(); + pool->init(); + auto tbl = std::make_shared
    ( + pool, schema.columns(), schema.types(), limit, index, backing_store + ); + + tbl->init(*data_table, document.Size(), t_op::OP_INSERT, 0); + data_table.reset(); + pool->_process(); + return tbl; +} + +void +Table::update_ndjson(const std::string_view& data, std::uint32_t port_id) { + rapidjson::Document document; + rapidjson::StringStream s(data.data()); + document.ParseStream(s); + if (document.Size() == 0) { + return; + } + + if (!document.IsObject()) { + // TODO Legacy error message + PSP_COMPLAIN_AND_ABORT( + "Cannot determine data types without column names!\n" + ) + } + + bool is_implicit = m_index.empty(); + t_schema table_schema = get_schema(); + + // 2.) Create table + t_data_table data_table(table_schema); + data_table.init(); + + // 2a.) Estimate row size to reduce malloc pressure. + auto newlines = 0; + for (char i : data) { + if (i == '\n') { + newlines++; + } + } + + data_table.reserve(newlines + 1); + if (is_implicit) { + data_table.add_column("psp_pkey", DTYPE_INT32, true); + } else { + data_table.add_column( + "psp_pkey", table_schema.get_dtype(m_index), true + ); + } + + t_uindex ii = 0; + const auto& psp_pkey_col = data_table.get_column("psp_pkey"); + auto schema = data_table.get_schema(); + bool is_first_row = true; + std::vector missing_columns = m_column_names; + + // 3.) Fill table + bool is_finished = false; + while (!is_finished) { + if (is_implicit) { + psp_pkey_col->set_nth(ii, (ii + m_offset)); + } + + for (const auto& it : document.GetObj()) { + std::shared_ptr col; + std::string_view col_name = it.name.GetString(); + if (std::string_view{it.name.GetString()} == "__INDEX__") { + col_name = "psp_pkey"; + } + + if (!schema.has_column(col_name)) { + LOG_DEBUG("Ignoring column " << col_name); + LOG_DEBUG("Schema:\n" << schema); + continue; + } + + if (is_first_row) { + missing_columns.erase( + std ::remove( + missing_columns.begin(), missing_columns.end(), col_name + ), + missing_columns.end() + ); + } + + col = data_table.get_column(col_name); + auto promote = fill_column_json(col, ii, it.value, true); + if (promote) { + std::stringstream ss; + ss << "Cannot append value of type " << dtype_to_str(*promote) + << " to column \"" << col_name << "\" of type " << dtype_to_str(col->get_dtype()) + << " at index " << ii + << std::endl; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + + if (!is_implicit && m_index == it.name.GetString()) { + fill_column_json(psp_pkey_col, ii, it.value, true); + } + } + + is_first_row = false; + + ii++; + + document.ParseStream(s); + if (document.HasParseError()) { + is_finished = true; + } + } + + data_table.extend(ii); + data_table.clone_column("psp_pkey", "psp_okey"); + process_op_column(data_table, t_op::OP_INSERT); + calculate_offset(ii); + m_pool->send(get_gnode()->get_id(), port_id, data_table); +} + +std::shared_ptr
    +Table::from_ndjson( + const std::string& index, + std::string&& data, + std::uint32_t limit, + t_backing_store backing_store +) { + // 1.) Infer schema + rapidjson::Document document; + rapidjson::StringStream s(data.data()); + document.ParseStream(s); + + if (document.Size() > 0 && !document.IsObject()) { + std::stringstream ss; + ss << "Received non-object " << document[0].GetType(); + PSP_COMPLAIN_AND_ABORT(ss.str()) + } + + std::vector column_names; + std::vector data_types; + bool is_implicit = true; + std::set columns_known_type; + std::set columns_seen; + + // TODO I don't think it makes sense to do the same incremental-schema + // enhancement we do for regular JSON. For now this only checks the first + // row. + [&]() { + for (const auto& col : document.GetObj()) { + columns_seen.insert(col.name.GetString()); + } + + // https://github.com/Tencent/rapidjson/issues/1994 + for (const auto& col : document.GetObj()) { + if (col.name.GetString() == index) { + is_implicit = false; + } + + if (columns_known_type.count(col.name.GetString()) > 0) { + continue; + } + + auto dtype = rapidjson_type_to_dtype(col.value); + if (dtype != DTYPE_NONE) { + columns_known_type.insert(col.name.GetString()); + data_types.push_back(rapidjson_type_to_dtype(col.value)); + column_names.emplace_back(col.name.GetString()); + } + + // Theoretically there can end too early if the first + // few rows are missing columns that are present in later rows. + if (columns_known_type.size() == columns_seen.size()) { + return; + } + } + }(); + + auto untyped_columns = columns_seen; + for (const auto& col : columns_seen) { + if (columns_known_type.count(col) == 0) { + // Default all null columns to string + data_types.push_back(DTYPE_STR); + column_names.emplace_back(col); + } + } + + t_schema schema(column_names, data_types); + + // 2.) Create table + auto data_table = std::make_unique(schema); + data_table->init(); + + if (is_implicit) { + data_table->add_column("psp_pkey", DTYPE_INT32, true); + data_table->add_column("psp_okey", DTYPE_INT32, true); + } else { + data_table->add_column("psp_pkey", schema.get_dtype(index), true); + data_table->add_column("psp_okey", schema.get_dtype(index), true); + } + + std::int32_t ii = 0; + const auto& psp_pkey_col = data_table->get_column("psp_pkey"); + const auto& psp_okey_col = data_table->get_column("psp_okey"); + + // 2a.) Estimate row size to reduce malloc pressure. + auto newlines = 0; + for (char i : data) { + if (i == '\n') { + newlines++; + } + } + + data_table->reserve(newlines + 1); + + // 3.) Fill table + bool is_finished = false; + while (!is_finished) { + data_table->extend(ii + 1); + for (const auto& it : document.GetObj()) { + auto col = data_table->get_column(it.name.GetString()); + const auto* col_name = it.name.GetString(); + const auto& cell = it.value; + auto promote = fill_column_json(col, ii, cell, false); + if (promote) { + LOG_DEBUG( + "Promoting column " << col_name << " from " + << dtype_to_str(col->get_dtype()) + << " to " << dtype_to_str(*promote) + ); + + data_table->promote_column(col_name, *promote, ii, true); + col = data_table->get_column(col_name); + fill_column_json(col, ii, cell, false); + } + + if (!is_implicit && index == it.name.GetString()) { + fill_column_json(psp_pkey_col, ii, it.value, false); + fill_column_json(psp_okey_col, ii, it.value, false); + } + } + + if (is_implicit) { + psp_pkey_col->set_nth(ii, ii); + psp_okey_col->set_nth(ii, ii); + } + + ii++; + document.ParseStream(s); + if (document.HasParseError()) { + is_finished = true; + } + } + + data_table->extend(ii); + + { auto _ = std::move(document); } + { auto _ = std::move(data); } + + auto pool = std::make_shared(); + pool->init(); + auto tbl = std::make_shared
    ( + pool, schema.columns(), schema.types(), limit, index, backing_store + ); + + tbl->init(*data_table, ii, t_op::OP_INSERT, 0); + data_table.reset(); + pool->_process(); + return tbl; +} + +std::shared_ptr
    +Table::from_schema( + const std::string& index, + const t_schema& schema, + std::uint32_t limit, + t_backing_store backing_store +) { + auto pool = std::make_shared(); + pool->init(); + + t_data_table data_table(schema); + data_table.init(); + + // TODO check for implicit index; + if (index.empty()) { + data_table.add_column("psp_pkey", DTYPE_INT32, true); + data_table.add_column("psp_okey", DTYPE_INT32, true); + } else { + if (!schema.has_column(index)) { + std::stringstream ss; + ss << "Specified index `" << index + << "` does not appear in the Table." << '\n'; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + + data_table.clone_column(index, "psp_pkey"); + data_table.clone_column(index, "psp_okey"); + } + + auto tbl = std::make_shared
    ( + pool, schema.columns(), schema.types(), limit, index, backing_store + ); + + tbl->init(data_table, 0, t_op::OP_INSERT, 0); + pool->_process(); + return tbl; +} + +void +Table::update_arrow(const std::string_view& data, std::uint32_t port_id) { + apachearrow::ArrowLoader arrow_loader; + arrow_loader.initialize( + reinterpret_cast(data.data()), data.size() + ); + + t_data_table data_table{this->get_schema()}; + data_table.init(); + auto row_count = arrow_loader.row_count(); + data_table.extend(row_count); + auto input_schema = this->get_schema(); + + auto arrow_names = arrow_loader.names(); + if (std::find(arrow_names.begin(), arrow_names.end(), "__INDEX__") + != arrow_names.end()) { + if (m_index.empty()) { + input_schema.add_column("__INDEX__", DTYPE_INT32); + } else { + input_schema.add_column( + "__INDEX__", input_schema.get_dtype(m_index) + ); + } + } + + arrow_loader.fill_table(data_table, input_schema, m_index, m_offset, true); + + process_op_column(data_table, t_op::OP_INSERT); + calculate_offset(row_count); + m_pool->send(get_gnode()->get_id(), port_id, data_table); +} + +std::shared_ptr
    +Table::from_arrow( + const std::string& index, + std::string&& data, + std::uint32_t limit, + t_backing_store backing_store +) { + apachearrow::ArrowLoader arrow_loader; + + // Parse the arrow and get its metadata + arrow_loader.initialize( + reinterpret_cast(data.data()), data.size() + ); + + // Infer schema + auto columns = arrow_loader.names(); + auto types = arrow_loader.types(); + + t_schema input_schema{columns, types}; + + auto implicit_index_it = + std::find(columns.begin(), columns.end(), "__INDEX__"); + + if (implicit_index_it != columns.end()) { + auto idx = std::distance(columns.begin(), implicit_index_it); + // position of the column is at the same index in both + // vectors + columns.erase(columns.begin() + idx); + types.erase(types.begin() + idx); + } + + t_schema output_schema{columns, types}; + auto data_table = std::make_unique(output_schema); + data_table->init(); + + { + auto _ = std::move(data); + auto loader = std::move(arrow_loader); + auto row_count = loader.row_count(); + data_table->extend(row_count); + loader.fill_table(*data_table, input_schema, index, 0, false); + } + + // Make Table + auto pool = std::make_shared(); + pool->init(); + auto table = + std::make_shared
    (pool, columns, types, limit, index, backing_store); + table->init(*data_table, data_table->num_rows(), t_op::OP_INSERT, 0); + data_table.reset(); + pool->_process(); + return table; +} + +std::shared_ptr
    +Table::make_table( + const std::vector& column_names, + const std::vector& data_types, + std::uint32_t limit, + const std::string& index, + const std::string_view& data, + t_backing_store backing_store +) { + auto pool = std::make_shared(); + pool->init(); + t_schema schema(column_names, data_types); + t_data_table data_table{schema}; + data_table.init(); + // data_table.extend(10); + std::shared_ptr pkey; + if (schema.has_column("psp_pkey")) { + pkey = data_table.get_column("psp_pkey"); + } else { + pkey = data_table.add_column_sptr( + "psp_pkey", perspective::DTYPE_UINT64, true + ); + } + for (std::uint64_t i = 0; i < data_table.size(); ++i) { + pkey->set_nth(i, i); + } + if (!schema.has_column("psp_okey")) { + data_table.clone_column("psp_pkey", "psp_okey"); + } + auto columns = data_table.get_schema().columns(); + auto dtypes = data_table.get_schema().types(); + auto table = std::make_shared
    ( + pool, columns, dtypes, limit, index, backing_store + ); + table->init(data_table, data_table.num_rows(), t_op::OP_INSERT, 0); + pool->_process(); + return table; +} + +void +Table::validate_columns(const std::vector& column_names) { + if (!m_index.empty()) { + // Check if index is valid after getting column names + bool explicit_index = + std::find(column_names.begin(), column_names.end(), m_index) + != column_names.end(); + if (!explicit_index) { + PSP_COMPLAIN_AND_ABORT( + "Specified index `" + m_index + "` does not exist in dataset." + ); + } + } +} + +void +Table::process_op_column(t_data_table& data_table, const t_op op) { + auto* op_col = data_table.add_column("psp_op", DTYPE_UINT8, false); + switch (op) { + case OP_DELETE: { + op_col->raw_fill(OP_DELETE); + } break; + default: { + op_col->raw_fill(OP_INSERT); + const auto size = data_table.size(); + if (m_offset + size >= m_limit) { + const auto& psp_pkey_col = data_table.get_column("psp_pkey"); + const auto d_rows = + size - std::max(0, m_limit - m_offset); + data_table.extend(size + d_rows); + auto* op_col = + data_table.add_column("psp_op", DTYPE_UINT8, false); + auto old_key = std::max(0, m_offset - m_limit); + for (auto i = 0; i < d_rows; i++) { + psp_pkey_col->set_nth(size + i, old_key); + op_col->set_nth(size + i, OP_DELETE); + old_key += 1; + } + } + } + } +} + +} // namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/time.cpp b/rust/perspective-server/cpp/perspective/src/cpp/time.cpp new file mode 100644 index 0000000..bcaf3f1 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/time.cpp @@ -0,0 +1,289 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include + +namespace perspective { + +t_tdelta::t_tdelta() : v(0) {} + +t_tdelta::t_tdelta(std::int64_t v) : v(v) {} + +t_tdelta& +t_tdelta::operator*=(std::int64_t multiplier) { + v *= multiplier; + return *this; +} + +t_time::t_time() : m_storage(0) {} + +t_time::t_time(std::int64_t raw_val) : m_storage(raw_val) {} + +t_time::t_time(int year, int month, int day, int hour, int min, int sec) : + m_storage(static_cast( + to_gmtime(year, month, day, hour, min, sec) * 1000 + )) {} + +std::int64_t +t_time::raw_value() const { + return m_storage; +} + +bool +operator<(const t_time& a, const t_time& b) { + return a.m_storage < b.m_storage; +} + +bool +operator<=(const t_time& a, const t_time& b) { + return a.m_storage <= b.m_storage; +} + +bool +operator>(const t_time& a, const t_time& b) { + return a.m_storage > b.m_storage; +} + +bool +operator>=(const t_time& a, const t_time& b) { + return a.m_storage >= b.m_storage; +} + +bool +operator==(const t_time& a, const t_time& b) { + return a.m_storage == b.m_storage; +} + +bool +operator!=(const t_time& a, const t_time& b) { + return a.m_storage != b.m_storage; +} + +bool +t_time::as_tm(struct tm& out) const { + return gmtime(out, as_seconds(), 0) == 1; +} + +std::tm +t_time::get_tm() const { + std::tm rval; + gmtime(rval, microseconds(), 0); + return rval; +} + +std::int32_t +isleap(long int year) { + return (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) ? 1 : 0; +} + +std::int32_t +t_time::gmtime(struct tm& out, std::int64_t secs, std::int32_t offset) { + std::int64_t days; + std::int64_t rem; + std::int64_t y; + const unsigned short int* ip; + + days = secs / SECS_PER_DAY; + rem = secs % SECS_PER_DAY; + rem += offset; + while (rem < 0) { + rem += SECS_PER_DAY; + --days; + } + while (rem >= SECS_PER_DAY) { + rem -= SECS_PER_DAY; + ++days; + } + out.tm_hour = rem / SECS_PER_HOUR; + rem %= SECS_PER_HOUR; + out.tm_min = rem / 60; + out.tm_sec = rem % 60; + /* January 1, 1970 was a Thursday. */ + out.tm_wday = (4 + days) % 7; + if (out.tm_wday < 0) { + out.tm_wday += 7; + } + y = 1970; + +#define DIV(a, b) ((a) / (b) - ((a) % (b) < 0)) +#define LEAPS_THRU_END_OF(y) (DIV(y, 4) - DIV(y, 100) + DIV(y, 400)) + + while (days < 0 || days >= (isleap(y) != 0 ? 366 : 365)) { + /* Guess a corrected year, assuming 365 days per + * year. */ + long int yg = + y + days / 365 - static_cast(days % 365 < 0); + + /* Adjust DAYS and Y to match the guessed year. + */ + days -= + ((yg - y) * 365 + LEAPS_THRU_END_OF(yg - 1) + - LEAPS_THRU_END_OF(y - 1)); + y = yg; + } + out.tm_year = y - 1900; + if (out.tm_year != y - 1900) { + /* The year cannot be represented due to + * overflow. */ + return 0; + } + out.tm_yday = days; + ip = __mon_yday[isleap(y)]; + for (y = 11; days < (long int)ip[y]; --y) {} + days -= ip[y]; + out.tm_mon = y; + out.tm_mday = days + 1; + return 1; +} + +std::int32_t +t_time::year(const struct tm& t) const { + return t.tm_year + 1900; +} +std::int32_t +t_time::month(const struct tm& t) const { + return t.tm_mon + 1; +} +std::int32_t +t_time::day(const struct tm& t) const { + return t.tm_mday; +} +std::int32_t +t_time::hours(const struct tm& t) const { + return t.tm_hour; +} +std::int32_t +t_time::minutes(const struct tm& t) const { + return t.tm_min; +} +std::int32_t +t_time::seconds(const struct tm& t) const { + return t.tm_sec; +} +std::int32_t +t_time::microseconds() const // component +{ + std::int32_t micros = m_storage % 1000000; + return micros + ((micros >> 31) & 1000000); +} + +std::int64_t +t_time::as_seconds() const { + if (m_storage < 0 && ((m_storage % 1000000) != 0)) { + return m_storage / 1000000 - 1; + } + return m_storage / 1000000; +} + +std::string +t_time::str(const struct tm& t) const { + std::stringstream ss; + + double s = seconds(t) + microseconds() / 1000000.0; + + ss << year(t) << "-" << str_(month(t)) << "-" << str_(day(t)) << " " + << str_(hours(t)) << ":" << str_(minutes(t)) << ":" << std::setfill('0') + << std::setw(6) << std::fixed << std::setprecision(3) << s; + + return ss.str(); +} + +std::int32_t +days_before_year(std::int32_t year) { + std::int32_t y = year - 1; + if (y >= 0) { + return y * 365 + y / 4 - y / 100 + y / 400; + } + return -366; +} + +std::int32_t +days_before_month(std::int32_t year, std::int32_t month) { + if (month < 1 || month > 12) { + return 0; + } + return __mon_yday[isleap(year)][month - 1]; +} + +std::int32_t +ymd_to_ord(std::int32_t year, std::int32_t month, std::int32_t day) { + return days_before_year(year) + days_before_month(year, month) + day; +} + +std::int64_t +to_gmtime( + std::int32_t year, + std::int32_t month, + std::int32_t day, + std::int32_t hour, + std::int32_t min, + std::int32_t sec +) { + static std::int32_t EPOCH_ORD = ymd_to_ord(1970, 1, 1); + std::int64_t days = ymd_to_ord(year, month, day) - EPOCH_ORD; + std::int64_t res = ((days * 24 + hour) * 60 + min) * 60 + sec; + return static_cast(res); +} + +std::tm +gmtime_from_epoch_ms(std::int64_t ms) { + std::tm out{}; + std::int64_t secs = ms / 1000; + if (ms % 1000 < 0) { + secs -= 1; + } + + t_time::gmtime(out, secs, 0); + return out; +} + +t_time& +t_time::operator+=(const t_tdelta& d) { + m_storage += d.v; + return *this; +} + +t_time& +t_time::operator-=(const t_tdelta& d) { + m_storage -= d.v; + return *this; +} + +t_tdelta +operator-(const t_time& a, const t_time& b) { + return {a.m_storage - b.m_storage}; +} + +} // end namespace perspective + +namespace std { +std::ostream& +operator<<(std::ostream& s, const perspective::t_tdelta& td) { + s << "t_tdelta(" << td.v << ")"; + return s; +} + +std::ostream& +operator<<(std::ostream& os, const perspective::t_time& t) { + struct tm tstruct; + bool rcode = t.as_tm(tstruct); + if (rcode) { + os << "t_time<" << t.str(tstruct) << ">" << std::endl; + } else { + os << "t_time<" << t.raw_value() << ">" << std::endl; + } + + return os; +} +} // namespace std diff --git a/rust/perspective-server/cpp/perspective/src/cpp/tracing.cpp b/rust/perspective-server/cpp/perspective/src/cpp/tracing.cpp new file mode 100644 index 0000000..af11735 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/tracing.cpp @@ -0,0 +1,64 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define THR_BUFFER_NELEMS 1000000 +#define THR_MAX_FUNCNAME_LEN 16000; + +static thread_local perspective::t_instrec th_trace_buffer[THR_BUFFER_NELEMS]; +static thread_local perspective::std::int32_t th_traceidx; +static thread_local FILE* th_file; + +namespace perspective { +t_trace::t_trace() { write_record(TRACE_TYPE_FNTRACE_BEGIN); } + +t_trace::~t_trace() { write_record(TRACE_TYPE_FNTRACE_END); } + +void +t_trace::write_record(t_trace_type ttype) const { + if (th_traceidx == THR_BUFFER_NELEMS) { + flush_thbuffer(THR_BUFFER_NELEMS); + th_traceidx = 0; + } + t_instrec* ptr = th_trace_buffer + th_traceidx; + ptr = th_trace_buffer + th_traceidx; + ptr->m_trace_type = ttype; + ptr->m_time = psp_curtime(); + ptr->t_fntrace.m_fn = + __builtin_extract_return_addr(__builtin_return_address(0)); + ++th_traceidx; +} + +t_uindex +get_instrec_size() { + return sizeof(t_instrec); +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/tracing_impl_linux.cpp b/rust/perspective-server/cpp/perspective/src/cpp/tracing_impl_linux.cpp new file mode 100644 index 0000000..1029086 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/tracing_impl_linux.cpp @@ -0,0 +1,139 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#ifdef __linux__ +#include +#include +#include + +extern "C" { + +__attribute__((__constructor__)) void +th_trace_init() { + using namespace std; + using namespace perspective; + if (!th_file) { + pid_t pid = getpid(); + pid_t tid = syscall(__NR_gettid); + std::stringstream ss; + ss << "psptrace_" << pid << "_" << tid << ".trace"; + th_file = fopen(ss.str().c_str(), "wb"); + PSP_VERBOSE_ASSERT(th_file != 0, "Unable to open trace file"); + } else { + PSP_COMPLAIN_AND_ABORT("Thread file unexpectedly non null"); + } +} + +__attribute__((__destructor__)) void +th_trace_fini() { + using namespace std; + using namespace perspective; + flush_thbuffer(th_traceidx); + fclose(th_file); + + pid_t pid = getpid(); + pid_t tid = syscall(__NR_gettid); + + std::stringstream sso; + sso << "psptrace_" << pid << "_" << tid << ".syms"; + std::ofstream of(sso.str()); + + std::stringstream ssi; + ssi << "psptrace_" << pid << "_" << tid << ".trace"; + + int ifd = open(ssi.str().c_str(), O_RDONLY); + PSP_VERBOSE_ASSERT(ifd != -1, "Error opening file"); + + struct stat fb; + fstat(ifd, &fb); + + uint64_t ifsize = fb.st_size; + + if (ifsize == 0) { + return; + } + + void* iptr = mmap(0, ifsize, PROT_READ, MAP_SHARED, ifd, 0); + + PSP_VERBOSE_ASSERT(iptr != MAP_FAILED, "Error in mmap"); + PSP_VERBOSE_ASSERT( + ifsize % sizeof(t_instrec) == 0, "Partial record encountered" + ); + + std::int64_t ndrecs = ifsize / sizeof(t_instrec); + + t_instrec* irecs = static_cast(iptr); + + tsl::hopscotch_set fptrs; + + for (t_index idx = 0; idx < ndrecs; ++idx) { + t_instrec* irec = irecs + idx; + fptrs.emplace(irec->t_fntrace.m_fn); + } + + for (tsl::hopscotch_set::const_iterator iter = fptrs.begin(); + iter != fptrs.end(); + ++iter) { + + of << *iter << " "; + char** mangled; + void* code_arr[1]; + code_arr[0] = *iter; + char** stack_strings = backtrace_symbols(code_arr, 1); + + size_t sz = THR_MAX_FUNCNAME_LEN; + + char* function = static_cast(malloc(sz)); + char *begin = 0, *end = 0; + for (char* j = stack_strings[0]; *j; ++j) { + if (*j == '(') { + begin = j; + } else if (*j == '+') { + end = j; + } + } + if (begin && end) { + + *begin++ = ' '; + *end = '\0'; + int status; + char* ret = abi::__cxa_demangle(begin, function, &sz, &status); + if (ret) { + function = ret; + } else { + std::strncpy(function, begin, sz); + std::strncat(function, "()", sz); + function[sz - 1] = ' '; + } + + of << function << "\n"; + } else { + of << stack_strings[0] << "\n"; + } + free(function); + free(stack_strings); + } + + close(ifd); + munmap(iptr, ifsize); +} + +void +flush_thbuffer(perspective::std::int32_t elemidx) { + using namespace std; + using namespace perspective; + t_instrec* mbuf = th_trace_buffer; + fwrite(mbuf, sizeof(t_instrec), elemidx, th_file); +} +} // end extern c + +#endif // end _WIN32 diff --git a/rust/perspective-server/cpp/perspective/src/cpp/tracing_impl_osx.cpp b/rust/perspective-server/cpp/perspective/src/cpp/tracing_impl_osx.cpp new file mode 100644 index 0000000..df0da83 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/tracing_impl_osx.cpp @@ -0,0 +1,139 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#ifdef __APPLE__ +#include +#include +#include + +extern "C" { + +__attribute__((__constructor__)) void +th_trace_init() { + using namespace std; + using namespace perspective; + if (!th_file) { + pid_t pid = getpid(); + pid_t tid = syscall(__NR_gettid); + std::stringstream ss; + ss << "psptrace_" << pid << "_" << tid << ".trace"; + th_file = fopen(ss.str().c_str(), "wb"); + PSP_VERBOSE_ASSERT(th_file != 0, "Unable to open trace file"); + } else { + PSP_COMPLAIN_AND_ABORT("Thread file unexpectedly non null"); + } +} + +__attribute__((__destructor__)) void +th_trace_fini() { + using namespace std; + using namespace perspective; + flush_thbuffer(th_traceidx); + fclose(th_file); + + pid_t pid = getpid(); + pid_t tid = syscall(__NR_gettid); + + std::stringstream sso; + sso << "psptrace_" << pid << "_" << tid << ".syms"; + std::ofstream of(sso.str()); + + std::stringstream ssi; + ssi << "psptrace_" << pid << "_" << tid << ".trace"; + + int ifd = open(ssi.str().c_str(), O_RDONLY); + PSP_VERBOSE_ASSERT(ifd != -1, "Error opening file"); + + struct stat fb; + fstat(ifd, &fb); + + uint64_t ifsize = fb.st_size; + + if (ifsize == 0) { + return; + } + + void* iptr = mmap(0, ifsize, PROT_READ, MAP_SHARED, ifd, 0); + + PSP_VERBOSE_ASSERT(iptr != MAP_FAILED, "Error in mmap"); + PSP_VERBOSE_ASSERT( + ifsize % sizeof(t_instrec) == 0, "Partial record encountered" + ); + + std::int64_t ndrecs = ifsize / sizeof(t_instrec); + + t_instrec* irecs = static_cast(iptr); + + tsl::hopscotch_set fptrs; + + for (t_index idx = 0; idx < ndrecs; ++idx) { + t_instrec* irec = irecs + idx; + fptrs.emplace(irec->t_fntrace.m_fn); + } + + for (tsl::hopscotch_set::const_iterator iter = fptrs.begin(); + iter != fptrs.end(); + ++iter) { + + of << *iter << " "; + char** mangled; + void* code_arr[1]; + code_arr[0] = *iter; + char** stack_strings = backtrace_symbols(code_arr, 1); + + size_t sz = THR_MAX_FUNCNAME_LEN; + + char* function = static_cast(malloc(sz)); + char *begin = 0, *end = 0; + for (char* j = stack_strings[0]; *j; ++j) { + if (*j == '(') { + begin = j; + } else if (*j == '+') { + end = j; + } + } + if (begin && end) { + + *begin++ = ' '; + *end = '\0'; + int status; + char* ret = abi::__cxa_demangle(begin, function, &sz, &status); + if (ret) { + function = ret; + } else { + std::strncpy(function, begin, sz); + std::strncat(function, "()", sz); + function[sz - 1] = ' '; + } + + of << function << "\n"; + } else { + of << stack_strings[0] << "\n"; + } + free(function); + free(stack_strings); + } + + close(ifd); + munmap(iptr, ifsize); +} + +void +flush_thbuffer(perspective::std::int32_t elemidx) { + using namespace std; + using namespace perspective; + t_instrec* mbuf = th_trace_buffer; + fwrite(mbuf, sizeof(t_instrec), elemidx, th_file); +} +} // end extern c + +#endif // end _WIN32 diff --git a/rust/perspective-server/cpp/perspective/src/cpp/traversal.cpp b/rust/perspective-server/cpp/perspective/src/cpp/traversal.cpp new file mode 100644 index 0000000..a7ec718 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/traversal.cpp @@ -0,0 +1,911 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include +#include +#include + +namespace perspective { + +t_vdnode::t_vdnode() : m_expanded(0), m_depth(INVALID_INDEX) {} + +t_vdnode::t_vdnode(bool expanded, t_depth depth) : + m_expanded(static_cast(expanded)), + m_depth(depth) {} + +t_vdnode::t_vdnode(t_index expanded, t_depth depth) : + m_expanded(expanded), + m_depth(depth) {} + +t_vdnode::t_vdnode(bool expanded, bool has_children) : + m_expanded(static_cast(expanded)), + m_has_children(has_children) {} + +t_traversal::t_traversal(const std::shared_ptr& tree) : + m_tree(tree) { + t_stnode_vec rchildren; + tree->get_child_nodes(0, rchildren); + populate_root_children(rchildren); +} + +void +t_traversal::populate_root_children(const t_stnode_vec& rchildren) { + m_nodes = std::make_shared>(rchildren.size() + 1); + + // Initialize root + (*m_nodes)[0].m_expanded = true; + (*m_nodes)[0].m_depth = 0; + (*m_nodes)[0].m_rel_pidx = INVALID_INDEX; + (*m_nodes)[0].m_tnid = 0; + (*m_nodes)[0].m_ndesc = rchildren.size(); + (*m_nodes)[0].m_nchild = rchildren.size(); + + t_index count = 1; + + for (const auto& iter : rchildren) { + t_tvnode& cnode = (*m_nodes)[count]; + cnode.m_expanded = false; + cnode.m_depth = 1; + cnode.m_rel_pidx = count; + cnode.m_tnid = iter.m_idx; + cnode.m_ndesc = 0; + cnode.m_nchild = 0; + count += 1; + } +} + +void +t_traversal::populate_root_children(const std::shared_ptr& tree +) { + t_stnode_vec rchildren; + tree->get_child_nodes(0, rchildren); + populate_root_children(rchildren); +} + +t_index +t_traversal::expand_node(t_index exp_idx) { + if (m_leaves_only) { + return 0; + } + + t_tvnode& exp_tvnode = (*m_nodes)[exp_idx]; + + if (exp_tvnode.m_expanded) { + return 0; + } + + t_stnode_vec tchildren; + m_tree->get_child_nodes(exp_tvnode.m_tnid, tchildren); + t_index n_changed = tchildren.size(); + std::vector children = std::vector(n_changed); + + t_index count = 0; + for (const auto& iter : tchildren) { + t_tvnode& tv_node = children[count]; + tv_node.m_expanded = false; + tv_node.m_depth = exp_tvnode.m_depth + 1; + tv_node.m_rel_pidx = count + 1; + tv_node.m_tnid = iter.m_idx; + tv_node.m_ndesc = 0; + tv_node.m_nchild = 0; + count += 1; + } + + // Update node being expanded + exp_tvnode.m_expanded = !tchildren.empty(); + ; + exp_tvnode.m_ndesc += n_changed; + exp_tvnode.m_nchild = n_changed; + + // insert children of node into the traversal + m_nodes->insert( + m_nodes->begin() + exp_idx + 1, children.begin(), children.end() + ); + + // update ancestors about their new descendents + update_ancestors(exp_idx, n_changed); + update_sucessors(exp_idx, n_changed); + + return n_changed; +} + +t_index +t_traversal::expand_node( + const std::vector& sortby, t_index exp_idx, t_ctx2* ctx2 +) { + if (m_leaves_only) { + return 0; + } + + t_tvnode& exp_tvnode = (*m_nodes)[exp_idx]; + + if (exp_tvnode.m_expanded) { + return 0; + } + + t_stnode_vec tchildren; + m_tree->get_child_nodes(exp_tvnode.m_tnid, tchildren); + t_index n_changed = tchildren.size(); + t_index count = 0; + std::vector sorted_idx(n_changed); + + std::vector sortby_agg_indices(sortby.size()); + t_uindex scount = 0; + for (const auto& s : sortby) { + sortby_agg_indices[scount] = s.m_agg_index; + ++scount; + } + + if (!sortby.empty()) { + auto sortelems = std::make_shared>( + static_cast(n_changed) + ); + t_index num_aggs = sortby.size(); + std::vector aggregates(num_aggs); + + t_uindex child_idx = 0; + for (const auto& iter : tchildren) { + m_tree->get_aggregates_for_sorting( + iter.m_idx, sortby_agg_indices, aggregates, ctx2 + ); + (*sortelems)[count] = t_mselem(aggregates, child_idx); + ++count; + ++child_idx; + } + + std::vector sort_orders = get_sort_orders(sortby); + t_multisorter sorter(sortelems, sort_orders); + argsort(sorted_idx, sorter); + } else { + for (t_index i = 0, loop_end = sorted_idx.size(); i != loop_end; ++i) { + sorted_idx[i] = i; + } + } + + std::vector children = std::vector(n_changed); + count = 0; + for (long long idx : sorted_idx) { + t_tvnode& tv_node = children[count]; + tv_node.m_expanded = false; + tv_node.m_depth = exp_tvnode.m_depth + 1; + tv_node.m_rel_pidx = count + 1; + tv_node.m_tnid = tchildren[idx].m_idx; + tv_node.m_ndesc = 0; + tv_node.m_nchild = 0; + count += 1; + } + + // Update node being expanded + exp_tvnode.m_expanded = !sorted_idx.empty(); + exp_tvnode.m_ndesc += n_changed; + exp_tvnode.m_nchild = n_changed; + + // insert children of node into the traversal + m_nodes->insert( + m_nodes->begin() + exp_idx + 1, children.begin(), children.end() + ); + + // update ancestors about their new descendents + update_ancestors(exp_idx, n_changed); + update_sucessors(exp_idx, n_changed); + + return n_changed; +} + +t_index +t_traversal::collapse_node(t_index idx) { + if (m_leaves_only) { + return 0; + } + + t_tvnode& node = (*m_nodes)[idx]; + + if (!node.m_expanded) { + return 0; + } + + // Calculate span of descendents + t_index n_changed = node.m_ndesc; + + t_index bidx = idx + 1; + t_index eidx = bidx + n_changed; + + // remove entries from traversal + m_nodes->erase(m_nodes->begin() + bidx, m_nodes->begin() + eidx); + + // Update node being collapsed + node.m_expanded = false; + node.m_ndesc -= n_changed; + node.m_nchild = 0; + + // update ancestors about removal of their + // descendents + update_ancestors(idx, -n_changed); + update_sucessors(idx, -n_changed); + + return n_changed; +} + +void +t_traversal::add_node( + const std::vector& sortby, + const std::vector& indices, + t_index insert_level_idx, + t_ctx2* ctx2 +) { + std::vector tv_indices; + t_index collapsed_ancestor = INVALID_INDEX; + + get_expanded_span( + indices, tv_indices, collapsed_ancestor, insert_level_idx + ); + + if (static_cast(tv_indices.size()) == insert_level_idx) { + t_index p_tvidx = tv_indices.back(); + const t_tvnode& p_tvnode = (*m_nodes)[p_tvidx]; + t_index p_ptidx = p_tvnode.m_tnid; + t_index p_nchild = p_tvnode.m_nchild + 1; + t_index c_ptidx = indices[insert_level_idx]; + t_uindex cidx = m_tree->get_sibling_idx(p_ptidx, p_nchild, c_ptidx); + cidx = std::min(p_tvnode.m_nchild, cidx); + t_index cur_cidx = p_tvidx + 1; + for (t_uindex idx = 0; idx < cidx; ++idx) { + cur_cidx += (1 + (*m_nodes)[cur_cidx].m_ndesc); + } + + (*m_nodes)[p_tvidx].m_nchild += 1; + + t_depth depth = get_depth(p_tvidx) + 1; + t_tvnode new_node; + fill_travnode(&new_node, false, depth, cur_cidx - p_tvidx, 0, c_ptidx); + auto insert_iter = m_nodes->begin() + cur_cidx; + m_nodes->insert(insert_iter, new_node); + update_ancestors(cur_cidx, 1); + update_sucessors(cur_cidx, 1); + } +} + +t_index +t_traversal::update_ancestors(t_index nidx, t_index n_changed) { + if (nidx == 0) { + return 0; + } + + t_index pidx = nidx - (*m_nodes)[nidx].m_rel_pidx; + while (pidx > INVALID_INDEX) { + t_tvnode& node = (*m_nodes)[pidx]; + node.m_ndesc += n_changed; + if (pidx == 0) { + pidx = INVALID_INDEX; + } else { + pidx = pidx - node.m_rel_pidx; + } + } + return 0; +} + +t_index +t_traversal::update_sucessors(t_index nidx, t_index n_changed) { + t_tvnode* c_node = &((*m_nodes)[nidx]); + + while (c_node->m_depth > 0) { + t_index pidx = nidx - c_node->m_rel_pidx; + + const t_tvnode& p_node = (*m_nodes)[pidx]; + + t_index p_nchild = p_node.m_nchild; + t_index coffset = 1; + + for (int i = 0; i < p_nchild; i++) { + t_index curr_cidx = pidx + coffset; + t_tvnode& child_node = (*m_nodes)[curr_cidx]; + if (curr_cidx > nidx) { + child_node.m_rel_pidx += n_changed; + } + if (child_node.m_expanded) { + coffset = coffset + child_node.m_ndesc + 1; + } else { + coffset += 1; + } + } + + nidx = pidx; + c_node = &((*m_nodes)[nidx]); + } + return 0; +} + +t_index +t_traversal::get_tree_index(t_index idx) const { + return (*m_nodes)[idx].m_tnid; +} + +t_uindex +t_traversal::size() const { + return m_nodes->size(); +} + +t_depth +t_traversal::get_depth(t_index idx) const { + return (*m_nodes)[idx].m_depth; +} + +t_index +t_traversal::get_traversal_index(t_index idx) { + t_index rval = INVALID_INDEX; + + for (t_index i = 0, loop_end = m_nodes->size(); i < loop_end; ++i) { + if ((*m_nodes)[i].m_tnid == idx) { + rval = i; + break; + } + } + return rval; +} + +std::vector +t_traversal::get_view_nodes(t_index bidx, t_index eidx) const { + std::vector vec(eidx - bidx); + for (t_index i = bidx; i < eidx; i++) { + t_index idx = i - bidx; + const t_tvnode& tv_node = (*m_nodes)[i]; + vec[idx].m_expanded = static_cast(tv_node.m_expanded); + vec[idx].m_depth = tv_node.m_depth; + t_index tree_idx = get_tree_index(i); + vec[idx].m_has_children = m_tree->get_num_children(tree_idx) > 0; + } + return vec; +} + +void +t_traversal::get_expanded_span( + const std::vector& in_ptidxes, + std::vector& out_indexes, + t_index& out_collpsed_ancestor, + t_index insert_level_idx +) { + t_index pidx = 0; + t_index coffset = 1; + + out_indexes.push_back(0); + + for (t_index counter = 1, loop_end = in_ptidxes.size(); counter < loop_end; + counter++) { + bool level_node_found = false; + t_index level_idx = INVALID_INDEX; + t_index p_nchild = (*m_nodes)[pidx].m_nchild; + + if (counter >= insert_level_idx) { + p_nchild = p_nchild - 1; + } + + for (t_index cidx = 0; cidx < p_nchild; ++cidx) { + t_tvnode& cnode = (*m_nodes)[pidx + coffset]; + + if (static_cast(cnode.m_tnid) == in_ptidxes[counter]) { + level_node_found = true; + level_idx = pidx + coffset; + + if (cnode.m_expanded) { + pidx = pidx + coffset; + coffset = 1; + p_nchild = (*m_nodes)[pidx].m_nchild; + out_indexes.push_back(pidx); + break; + } + } else { + // TODO: next line + coffset = coffset + cnode.m_ndesc + 1; + } + } + + if (level_node_found && (!((*m_nodes)[level_idx].m_expanded))) { + out_collpsed_ancestor = level_idx; + break; + } + + if (!level_node_found) { + break; + } + } +} + +bool +t_traversal::validate_cells( + const std::vector>& cells +) const { + t_uindex trav_size = size(); + + for (const auto& cell : cells) { + auto r_tvidx = cell.first; + if (r_tvidx >= trav_size) { + return false; + } + } + return true; +} + +t_index +t_traversal::remove_subtree(t_index idx) { + t_tvnode& node = (*m_nodes)[idx]; + + // Calculate span of descendents + t_index n_changed = node.m_ndesc + 1; + + t_index bidx = idx; + t_index eidx = bidx + n_changed; + + update_sucessors(idx, -n_changed); + + // update ancestors about removal of their + // descendents + update_ancestors(idx, -n_changed); + + t_index pidx = idx - node.m_rel_pidx; + (*m_nodes)[pidx].m_nchild -= 1; + + // remove entries from traversal + m_nodes->erase(m_nodes->begin() + bidx, m_nodes->begin() + eidx); + + return n_changed; +} + +void +t_traversal::pprint() const { + for (t_index idx = 0, loop_end = m_nodes->size(); idx < loop_end; ++idx) { + const t_tvnode& node = (*m_nodes)[idx]; + const t_stnode tnode = m_tree->get_node(node.m_tnid); + for (t_uindex didx = 0; didx < node.m_depth; didx++) { + std::cout << "\t"; + } + std::cout << "tvidx: " << idx << " value: " << tnode.m_value + << " depth: " << node.m_depth + << " m_rel_pidx: " << node.m_rel_pidx + << " ndesc: " << node.m_ndesc << " tnid: " << node.m_tnid + << " nchild: " << node.m_nchild << '\n'; + } +} + +t_tvnode +t_traversal::get_node(t_index idx) const { + return (*m_nodes)[idx]; +} + +void +t_traversal::get_leaves(std::vector& out_data) const { + for (t_index curidx = 0, loop_end = m_nodes->size(); curidx < loop_end; + ++curidx) { + if (!(*m_nodes)[curidx].m_expanded) { + out_data.push_back(curidx); + } + } +} + +void +t_traversal::get_child_indices( + t_index nidx, std::vector>& out_data +) const { + const t_tvnode& tvnode = (*m_nodes)[nidx]; + t_index nchild = tvnode.m_nchild; + t_index coffset = 1; + + for (int i = 0; i < nchild; i++) { + t_index curr_cidx = nidx + coffset; + const t_tvnode& child_node = (*m_nodes)[curr_cidx]; + out_data.emplace_back(curr_cidx, child_node.m_tnid); + coffset = coffset + child_node.m_ndesc + 1; + } +} + +void +t_traversal::print_stats() { + std::cout << "Traversal size => " << m_nodes->size() << '\n'; +} + +t_index +t_traversal::get_num_tree_leaves(t_index idx) const { + const t_tvnode& node = (*m_nodes)[idx]; + + t_index rval = 0; + + for (t_index curidx = idx + 1, loop_end = idx + node.m_ndesc + 1; + curidx < loop_end; + ++curidx) { + if (!(*m_nodes)[curidx].m_expanded) { + ++rval; + } + } + + return rval; +} + +void +t_traversal::post_order(t_index nidx, std::vector& out_vec) { + std::vector> children; + get_child_indices(nidx, children); + + for (auto& idx : children) { + post_order(idx.first, out_vec); + } + + out_vec.push_back(nidx); +} + +// Traversal +t_index +t_traversal::set_depth( + const std::vector& sortby, t_depth depth, t_ctx2* ctx2 +) { + std::vector pending; + depth = depth + 1; + pending.push_back(0); + t_index n_changed = 0; + while (!pending.empty()) { + t_index curidx = pending.back(); + pending.pop_back(); + n_changed += expand_node(sortby, curidx, ctx2); + std::vector> children; + get_child_indices(curidx, children); + std::vector collapse; + for (const auto& child : children) { + const t_tvnode& tv_node = (*m_nodes)[child.first]; + + if (tv_node.m_depth < depth) { + pending.push_back(child.first); + } else if (tv_node.m_depth == depth && tv_node.m_expanded) { + collapse.push_back(child.first); + } + } + // Now collapse any children + for (auto rit = collapse.rbegin(); rit != collapse.rend(); ++rit) { + t_index curidx = *rit; + n_changed += collapse_node(curidx); + } + } + + return n_changed; +} + +std::vector +t_traversal::get_flattened_tree(t_index idx, t_depth stop_depth) const { + std::queue queue; + queue.push(idx); + std::vector rvec; + t_index rvec_idx = 1; + while (!queue.empty()) { + t_index hidx = queue.front(); + queue.pop(); + const t_tvnode& c_node = (*m_nodes)[hidx]; + t_depth curdepth = c_node.m_depth; + t_ftreenode rnode; + rnode.m_idx = c_node.m_tnid; + if (curdepth < stop_depth) { + t_index nchild = m_tree->get_num_children(rnode.m_idx); + rnode.m_fcidx = rvec_idx; + rnode.m_nchild = nchild; + rnode.m_depth = c_node.m_depth; + t_index curr_cidx = hidx + 1; + std::vector children(nchild); + for (int cidx = 0; cidx < nchild; cidx++) { + const t_tvnode& child_node = (*m_nodes)[curr_cidx]; + children[cidx] = curr_cidx; + if (child_node.m_expanded) { + curr_cidx = curr_cidx + child_node.m_ndesc + 1; + } else { + curr_cidx += 1; + } + rvec_idx++; + } + for (long long cidx : children) { + queue.push(cidx); + } + } else { + rnode.m_fcidx = 0; + rnode.m_nchild = 0; + rnode.m_depth = INVALID_INDEX; + } + rvec.push_back(rnode); + } + return rvec; +} + +t_index +t_traversal::tree_index_lookup(t_index idx, t_index bidx) const { + t_index tvidx = INVALID_INDEX; + for (t_index i = bidx, loop_end = m_nodes->size(); i < loop_end; ++i) { + if ((*m_nodes)[i].m_tnid == idx) { + tvidx = i; + break; + } + } + return tvidx; +} + +void +t_traversal::get_node_ancestors(t_index nidx, std::vector& ancestors) + const { + if (nidx == 0) { + return; + } + + t_index pidx = nidx - (*m_nodes)[nidx].m_rel_pidx; + while (pidx > INVALID_INDEX) { + ancestors.push_back(pidx); + const t_tvnode& node = (*m_nodes)[pidx]; + if (pidx == 0) { + pidx = INVALID_INDEX; + } else { + pidx = pidx - node.m_rel_pidx; + } + } +} + +void +t_traversal::get_expanded(std::vector& expanded_tidx) const { + // Ancestors of expanded nodes + std::set ancestors; + std::vector expanded; + + if (m_nodes->empty()) { + return; + } + + for (t_index i = m_nodes->size() - 1; i > -1; i--) { + const t_tvnode& node = (*m_nodes)[i]; + + if (node.m_expanded && ancestors.find(i) == ancestors.end()) { + expanded.push_back(i); + std::vector node_ancestors; + get_node_ancestors(i, node_ancestors); + ancestors.insert(node_ancestors.begin(), node_ancestors.end()); + } + } + + std::vector rval(expanded.size()); + + for (t_index i = 0, loop_end = rval.size(); i < loop_end; i++) { + const t_tvnode& node = (*m_nodes)[expanded[i]]; + rval[i] = node.m_tnid; + } + + std::swap(rval, expanded_tidx); +} + +void +t_traversal::drop_tree_indices(const std::vector& indices) { + if (indices.empty() || m_nodes->empty()) { + return; + } + + auto& nodes = *m_nodes; + const t_index n = static_cast(nodes.size()); + + // The previous implementation removed each dropped strand individually: a + // linear `tree_index_lookup` (O(N)) plus a mid-vector `erase` (O(N)) and a + // per-op `update_sucessors`/`update_ancestors` fix-up, i.e. O(D*N) for D + // drops. Instead, mark every dropped subtree and rebuild the pre-order node + // vector in a single O(N + D) pass, recomputing the relative-parent + // (m_rel_pidx), descendant (m_ndesc) and child (m_nchild) encoding in bulk. + // This mirrors the one-pass rebuild `sort_by` already uses. + tsl::hopscotch_set drop_set; + drop_set.reserve(indices.size()); + for (auto idx : indices) { + drop_set.insert(static_cast(idx)); + } + + // `remap[i]` = number of survivors in [0, i), which is also the new index of + // the survivor at old index `i`. A node is dead iff it lies within the + // subtree span of any dropped node; because the traversal is pre-order and + // dropped subtrees are nested-or-disjoint, a single non-decreasing "dead + // watermark" (the last index known to be inside a dropped subtree) + // classifies every node in one linear scan. `m_tnid` is unique per traversal + // node, so set membership matches what the old per-strand lookup found. + std::vector remap(n + 1); + t_index surv = 0; + t_index dead_until = INVALID_INDEX; + for (t_index i = 0; i < n; ++i) { + if (drop_set.find(nodes[i].m_tnid) != drop_set.end()) { + const t_index end = i + static_cast(nodes[i].m_ndesc); + if (end > dead_until) { + dead_until = end; + } + } + remap[i] = surv; + if (i > dead_until) { + ++surv; + } + } + remap[n] = surv; + + // The root (tnid 0, depth 0) is never a strand and so is never dropped, so + // at least it must survive. A future caller dropping the root would empty + // the traversal and OOB downstream consumers (e.g. get_expanded_span reads + // node 0), so assert the invariant here. + PSP_VERBOSE_ASSERT(surv >= 1, "drop_tree_indices dropped the root node"); + + if (surv == n) { + // No dropped strand was actually present in this traversal. + return; + } + + // Compact survivors into a fresh pre-order vector, recomputing the relative + // encoding from the survivor prefix-sum. + std::vector new_nodes(surv); + for (t_index old_idx = 0; old_idx < n; ++old_idx) { + // Alive iff a survivor was counted at this index. + if ((remap[old_idx + 1] - remap[old_idx]) != 1) { + continue; + } + + const t_index new_idx = remap[old_idx]; + t_tvnode node = nodes[old_idx]; + + // A surviving node's descendants stay contiguous immediately after it, + // so count the survivors over its original subtree span + // [old_idx, old_end] and subtract the node itself. + const t_index old_end = old_idx + static_cast(node.m_ndesc); + node.m_ndesc = + static_cast(remap[old_end + 1] - remap[old_idx] - 1); + + // A survivor's parent always survives (only whole subtrees are dropped), + // so its new parent index is well-defined. + if (node.m_depth > 0) { + const t_index old_parent = old_idx - node.m_rel_pidx; + node.m_rel_pidx = new_idx - remap[old_parent]; + } + + node.m_nchild = 0; // recomputed in the next pass + new_nodes[new_idx] = node; + } + + // Recompute direct-child counts: each surviving non-root node bumps its + // (surviving) parent's count. + for (t_index new_idx = 0; new_idx < surv; ++new_idx) { + const t_tvnode& node = new_nodes[new_idx]; + if (node.m_depth > 0) { + new_nodes[new_idx - node.m_rel_pidx].m_nchild += 1; + } + } + + std::swap(nodes, new_nodes); +} + +bool +t_traversal::is_valid_idx(t_index idx) const { + return idx >= 0 && idx < t_index(size()); +} + +const t_stree* +t_traversal::get_tree() const { + return m_tree.get(); +} + +bool +t_traversal::get_node_expanded(t_index idx) const { + if (idx < 0 || static_cast(idx) > m_nodes->size()) { + return false; + } + return m_nodes->at(idx).m_expanded; +} + +void +t_traversal::set_leaves_only(bool enabled, t_uindex leaf_depth) { + m_leaves_only = enabled; + m_leaf_depth = leaf_depth; + if (m_leaves_only) { + rebuild_from_leaves({}); + } +} + +bool +t_traversal::is_leaves_only() const { + return m_leaves_only; +} + +void +t_traversal::collect_leaves( + t_uindex tnid, + t_uindex current_depth, + const std::vector& sortby, + const std::vector& sortby_agg_indices, + const std::vector& sort_orders +) { + if (current_depth >= m_leaf_depth) { + t_tvnode node; + node.m_expanded = false; + node.m_depth = m_leaf_depth; + node.m_rel_pidx = 0; + node.m_ndesc = 0; + node.m_nchild = 0; + node.m_tnid = tnid; + m_nodes->push_back(node); + return; + } + + t_stnode_vec children; + m_tree->get_child_nodes(tnid, children); + + if (!sortby.empty() && children.size() > 1) { + auto n_children = children.size(); + auto sortelems = + std::make_shared>(n_children); + std::vector aggregates(sortby.size()); + + for (t_uindex i = 0; i < n_children; ++i) { + m_tree->get_aggregates_for_sorting( + children[i].m_idx, sortby_agg_indices, aggregates, nullptr + ); + (*sortelems)[i] = t_mselem(aggregates, i); + } + + std::vector sorted_idx(n_children); + t_multisorter sorter(sortelems, sort_orders); + argsort(sorted_idx, sorter); + + t_stnode_vec sorted_children(n_children); + for (t_uindex i = 0; i < n_children; ++i) { + sorted_children[i] = children[sorted_idx[i]]; + } + children = std::move(sorted_children); + } + + for (const auto& child : children) { + collect_leaves( + child.m_idx, current_depth + 1, sortby, sortby_agg_indices, + sort_orders + ); + } +} + +void +t_traversal::rebuild_from_leaves(const std::vector& sortby) { + m_nodes = std::make_shared>(); + + std::vector sortby_agg_indices(sortby.size()); + for (t_uindex i = 0; i < sortby.size(); ++i) { + sortby_agg_indices[i] = sortby[i].m_agg_index; + } + + std::vector sort_orders = get_sort_orders(sortby); + collect_leaves(0, 0, sortby, sortby_agg_indices, sort_orders); +} + +void +t_traversal::set_total_only(bool enabled) { + m_total_only = enabled; + if (m_total_only) { + rebuild_for_total(); + } +} + +bool +t_traversal::is_total_only() const { + return m_total_only; +} + +void +t_traversal::rebuild_for_total() { + m_nodes = std::make_shared>(); + t_tvnode node; + node.m_expanded = false; + node.m_depth = 0; + node.m_rel_pidx = 0; + node.m_ndesc = 0; + node.m_nchild = 0; + node.m_tnid = 0; + m_nodes->push_back(node); +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/traversal_nodes.cpp b/rust/perspective-server/cpp/perspective/src/cpp/traversal_nodes.cpp new file mode 100644 index 0000000..beb1ab6 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/traversal_nodes.cpp @@ -0,0 +1,34 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include + +namespace perspective { + +void +fill_travnode( + t_tvnode* node, + bool expanded, + t_uindex depth, + t_uindex rel_pidx, + t_uindex ndesc, + t_uindex tnid +) { + node->m_expanded = expanded; + node->m_depth = depth; + node->m_rel_pidx = rel_pidx; + node->m_ndesc = ndesc; + node->m_tnid = tnid; + node->m_nchild = 0; +} +}; // namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/tree_context_common.cpp b/rust/perspective-server/cpp/perspective/src/cpp/tree_context_common.cpp new file mode 100644 index 0000000..488f92a --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/tree_context_common.cpp @@ -0,0 +1,287 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace perspective { + +void +notify_sparse_tree_common( + const std::shared_ptr& strands, + const std::shared_ptr& strand_deltas, + const std::shared_ptr& tree, + const std::shared_ptr& traversal, + bool process_traversal, + const std::vector& aggregates, + const std::vector>& tree_sortby, + const std::vector& ctx_sortby, + const t_gstate& gstate, + const t_data_table& expression_master_table +) { + t_filter fltr; + if (t_env::log_data_nsparse_strands()) { + std::cout << "nsparse_strands" << '\n'; + strands->pprint(); + } + + if (t_env::log_data_nsparse_strand_deltas()) { + std::cout << "nsparse_strand_deltas" << '\n'; + strand_deltas->pprint(); + } + + const auto& pivots = tree->get_pivots(); + + t_dtree dtree(strands, pivots, tree_sortby); + dtree.init(); + + dtree.check_pivot(fltr, pivots.size() + 1); + + if (t_env::log_data_nsparse_dtree()) { + std::cout << "nsparse_dtree" << '\n'; + dtree.pprint(fltr); + } + + t_dtree_ctx dctx(strands, strand_deltas, dtree, aggregates); + + dctx.init(); + + tree->update_shape_from_static(dctx); + + auto zero_strands = tree->zero_strands(); + + bool is_leaves_only = + process_traversal && traversal != nullptr && traversal->is_leaves_only(); + bool is_total_only = + process_traversal && traversal != nullptr && traversal->is_total_only(); + + if (process_traversal && !is_leaves_only && !is_total_only) { + t_uindex t_osize = traversal->size(); + traversal->drop_tree_indices(zero_strands); + t_uindex t_nsize = traversal->size(); + if (t_osize != t_nsize) { + tree->set_has_deltas(true); + } + } + + auto non_zero_ids = tree->non_zero_ids(zero_strands); + auto non_zero_leaves = tree->non_zero_leaves(zero_strands); + + tree->drop_zero_strands(); + + tree->populate_leaf_index(non_zero_leaves); + + tree->update_aggs_from_static(dctx, gstate, expression_master_table); + + if (is_total_only) { + traversal->rebuild_for_total(); + } else if (is_leaves_only) { + traversal->rebuild_from_leaves(ctx_sortby); + } else { + std::set visited; + + struct t_leaf_path { + std::vector m_path; + t_uindex m_lfidx; + }; + + std::vector leaf_paths(non_zero_leaves.size()); + + t_uindex count = 0; + + for (auto lfidx : non_zero_leaves) { + leaf_paths[count].m_lfidx = lfidx; + tree->get_sortby_path(lfidx, leaf_paths[count].m_path); + std::reverse( + leaf_paths[count].m_path.begin(), + leaf_paths[count].m_path.end() + ); + ++count; + } + + std::sort( + leaf_paths.begin(), + leaf_paths.end(), + [](const t_leaf_path& a, const t_leaf_path& b) { + return a.m_path < b.m_path; + } + ); + + if (!leaf_paths.empty() && (traversal != nullptr) + && traversal->size() == 1) { + if (traversal->get_node(0).m_expanded) { + traversal->populate_root_children(tree); + } + } else { + for (const auto& lpath : leaf_paths) { + t_uindex lfidx = lpath.m_lfidx; + auto ancestry = tree->get_ancestry(lfidx); + + t_uindex num_tnodes_existed = 0; + + for (auto nidx : ancestry) { + if (non_zero_ids.find(nidx) == non_zero_ids.end() + || visited.find(nidx) != visited.end()) { + ++num_tnodes_existed; + } else { + break; + } + } + + if (process_traversal) { + traversal->add_node( + ctx_sortby, ancestry, num_tnodes_existed + ); + } + + for (auto nidx : ancestry) { + visited.insert(nidx); + } + } + } + } +} + +void +notify_sparse_tree( + const std::shared_ptr& tree, + const std::shared_ptr& traversal, + bool process_traversal, + const std::vector& aggregates, + const std::vector>& tree_sortby, + const std::vector& ctx_sortby, + const t_data_table& flattened, + const t_data_table& delta, + const t_data_table& prev, + const t_data_table& current, + const t_data_table& transitions, + const t_data_table& existed, + const t_config& config, + const t_gstate& gstate, + const t_data_table& expression_master_table +) { + + auto strand_values = tree->build_strand_table( + flattened, delta, prev, current, transitions, aggregates, config + ); + + auto strands = strand_values.first; + auto strand_deltas = strand_values.second; + notify_sparse_tree_common( + strands, + strand_deltas, + tree, + traversal, + process_traversal, + aggregates, + tree_sortby, + ctx_sortby, + gstate, + expression_master_table + ); +} + +void +notify_sparse_tree( + const std::shared_ptr& tree, + const std::shared_ptr& traversal, + bool process_traversal, + const std::vector& aggregates, + const std::vector>& tree_sortby, + const std::vector& ctx_sortby, + const t_data_table& flattened, + const t_config& config, + const t_gstate& gstate, + const t_data_table& expression_master_table +) { + auto strand_values = + tree->build_strand_table(flattened, aggregates, config); + + auto strands = strand_values.first; + auto strand_deltas = strand_values.second; + notify_sparse_tree_common( + strands, + strand_deltas, + tree, + traversal, + process_traversal, + aggregates, + tree_sortby, + ctx_sortby, + gstate, + expression_master_table + ); +} + +std::vector +ctx_get_expansion_state( + const std::shared_ptr& tree, + const std::shared_ptr& traversal +) { + std::vector paths; + std::vector expanded; + traversal->get_expanded(expanded); + + for (long long i : expanded) { + std::vector path; + tree->get_path(i, path); + paths.emplace_back(path); + } + return paths; +} + +std::vector +ctx_get_path( + const std::shared_ptr& tree, + const std::shared_ptr& traversal, + t_index idx +) { + if (idx < 0 || idx >= t_index(traversal->size())) { + std::vector rval; + return rval; + } + + auto tree_index = traversal->get_tree_index(idx); + std::vector rval; + tree->get_path(tree_index, rval); + return rval; +} + +std::vector +ctx_get_flattened_tree( + t_index idx, + t_depth stop_depth, + t_traversal& trav, + const t_config& config, + const std::vector& sortby +) { + t_index ptidx = trav.get_tree_index(idx); + trav.set_depth(sortby, stop_depth); + if (!sortby.empty()) { + trav.sort_by(config, sortby, *(trav.get_tree())); + } + t_index new_tvidx = trav.tree_index_lookup(ptidx, idx); + return trav.get_flattened_tree(new_tvidx, stop_depth); +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/update_task.cpp b/rust/perspective-server/cpp/perspective/src/cpp/update_task.cpp new file mode 100644 index 0000000..35b4964 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/update_task.cpp @@ -0,0 +1,49 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include + +namespace perspective { +t_update_task::t_update_task(t_pool& pool) : m_pool(pool) {} + +void +t_update_task::run(std::optional> callback) { + auto work_to_do = m_pool.m_data_remaining.load(); + m_pool.m_data_remaining.store(false); + + if (work_to_do) { + for (auto* g : m_pool.m_gnodes) { + if (g != nullptr) { + t_uindex num_input_ports = g->num_input_ports(); + + // Call process for each port, and notify the updates from + // each port individually. + for (t_uindex port_id = 0; port_id < num_input_ports; + ++port_id) { + bool did_notify_context = g->process(port_id); + if (did_notify_context) { + if (callback) { + (*callback)(port_id); + } + m_pool.notify_userspace(port_id); + } + g->clear_output_ports(); + } + } + } + } + + m_pool.inc_epoch(); +} +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/utils.cpp b/rust/perspective-server/cpp/perspective/src/cpp/utils.cpp new file mode 100644 index 0000000..401b385 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/utils.cpp @@ -0,0 +1,195 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +// emscripten llvm-upstream fails to compile this module .. +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef PSP_ENABLE_WASM +#include +#include +#else +#include +#endif + +#include +namespace perspective { + +std::string +unique_path(const std::string& path_prefix) { +#ifdef PSP_ENABLE_WASM + // `boost::uuids::random_generator()` seeds itself by opening `/dev/urandom` + // directly, which aborts under emscripten's `NO_FILESYSTEM`. We don't need + // cryptographic randomness for an OPFS key — a process-unique counter, + // seeded from wall-clock time (via `clock_time_get`, no filesystem) so keys + // differ across reloads, is sufficient. + static std::atomic s_counter{ + static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch() + ) + .count() + ) + }; + std::stringstream ss; + ss << path_prefix << s_counter.fetch_add(1); + return ss.str(); +#else + std::stringstream ss; + ss << path_prefix << boost::uuids::random_generator()(); + return ss.str(); +#endif +} + +std::string +create_backing_store_dir(const std::string& prefix) { +#ifdef PSP_ENABLE_WASM + // On WASM there is no OS filesystem (NO_FILESYSTEM): this is just a unique + // logical path used as the OPFS key prefix for a table's column files. The + // actual OPFS directory/file is created lazily by the JS bridge + // (`perspective-server.poly.ts` `opfsOpenFile`) when the residency manager + // first flushes a column. `/perspective` is the OPFS root namespace. + return unique_path(std::string("/perspective/") + prefix); +#else + namespace fs = std::filesystem; + fs::path dir = fs::temp_directory_path() / unique_path(prefix); + fs::create_directories(dir); + return dir.string(); +#endif +} + +template +static bool +parse_date_time( + std::tm& tm, + std::chrono::system_clock::time_point& tp, + std::string_view date_time_str, + Formats... formats +) { + std::istringstream ss; + auto try_format = [&ss, &tm, &date_time_str, &tp](std::string_view format) { + std::memset(&tm, 0, sizeof(tm)); + std::memset(&tp, 0, sizeof(tp)); + LOG_DEBUG("Testing Format: " << format); + LOG_DEBUG("Date Time String: " << date_time_str); + ss.clear(); + ss.str(date_time_str.data()); + ss >> std::get_time(&tm, format.data()); + LOG_DEBUG( + "Parsed date: " << tm.tm_year + 1900 << "-" << tm.tm_mon + 1 << "-" + << tm.tm_mday << " " << tm.tm_hour << ":" + << tm.tm_min << ":" << tm.tm_sec + ); + + // Naive datetime strings are documented to be UTC — `std::mktime` + // would interpret the parsed civil time in the host timezone. + tp = std::chrono::system_clock::from_time_t(to_gmtime( + tm.tm_year + 1900, + tm.tm_mon + 1, + tm.tm_mday, + tm.tm_hour, + tm.tm_min, + tm.tm_sec + )); + + if (!ss.eof() && ss.peek() == '.') { + ss.ignore(); + std::int32_t ms; + const auto len = (date_time_str.size() - ss.tellg()); + ss >> ms; + LOG_DEBUG("Parsed milliseconds: " << ms); + if (len == 6) { + LOG_DEBUG("Parsed microseconds: " << ms); + tp += std::chrono::microseconds(ms); + } else if (len == 3) { + LOG_DEBUG("Parsed milliseconds: " << ms); + tp += std::chrono::milliseconds(ms); + } + } + + if (!ss.eof() && ss.peek() == 'Z') { + ss.ignore(); + } + + if (!ss.eof() && ss.peek() != EOF) { + char c; + ss >> c; + LOG_DEBUG("Failed to parse datetime: expected EOF and got " << c); + return false; + } + + LOG_DEBUG("Failed: " << ss.fail()); + return !ss.fail(); + }; + + return (... || try_format(formats)); +} + +bool +parse_all_date_time( + std::tm& tm, + std::chrono::system_clock::time_point& tp, + std::string_view date_time_str +) { + + return parse_date_time( + tm, + tp, + date_time_str, + // "%m/%d/%Y, %H:%M:%S %p", + "%Y-%m-%dT%H:%M:%S", + "%m-%d-%Y %H:%M:%S", + "%m/%d/%Y %H:%M:%S", + "%Y-%m-%d %H:%M:%S", + "%Y/%m/%dT%H:%M:%S", + "%Y/%m/%d %H:%M:%S", + "%m-%d-%Y %H:%M", + "%m/%d/%Y %H:%M", + "%Y-%m-%d %H:%M", + "%Y/%m/%d %H:%M", + "%m-%d-%Y %H", + "%m/%d/%Y %H", + "%Y-%m-%d %H", + "%Y/%m/%d %H", + "%m-%d-%Y", + "%m/%d/%Y", + "%Y-%m-%d", + "%Y/%m/%d", + "%Y %m %d" + ); +} + +bool +parse_all_date_time( + std::chrono::system_clock::time_point& tp, std::string_view date_time_str +) { + std::tm tm; + const auto result = + apachearrow::parseAsArrowTimestamp(std::string(date_time_str)); + if (result.has_value()) { + std::chrono::milliseconds dur(*result); + tp = std::chrono::time_point(dur); + return true; + } + + return parse_all_date_time(tm, tp, date_time_str); +} + +} // namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/cpp/view.cpp b/rust/perspective-server/cpp/perspective/src/cpp/view.cpp new file mode 100644 index 0000000..0dc71c2 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/view.cpp @@ -0,0 +1,2834 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +std::string +join_column_names( + const std::vector& names, const std::string& separator +) { + if (names.empty()) { + return ""; + } + if (names.size() == 1) { + return names.at(0).to_string(); + } + std::ostringstream ss; + for (auto i = 0; i < names.size() - 1; ++i) { + std::string str = names.at(i).to_string(); + ss << str; + ss << separator; + } + ss << names.at(names.size() - 1).to_string(); + return ss.str(); +} + +template +View::View( + std::shared_ptr
    table, + std::shared_ptr ctx, + std::string name, + std::string separator, + std::shared_ptr view_config +) : + m_table(std::move(std::move(table))), + m_ctx(ctx), + m_name(std::move(name)), + m_separator(std::move(separator)), + m_view_config(std::move(std::move(view_config))) { + m_row_pivots = m_view_config->get_row_pivots(); + m_column_pivots = m_view_config->get_column_pivots(); + m_aggregates = m_view_config->get_aggspecs(); + m_columns = m_view_config->get_columns(); + m_filter = m_view_config->get_fterm(); + m_sort = m_view_config->get_sortspec(); + m_expressions = m_view_config->get_expressions(); + + // Add hidden columns used in sorts to the `m_hidden_sort` vector. + if (!m_sort.empty()) { + _find_hidden_sort(m_sort); + } + + if (!m_column_pivots.empty()) { + auto column_sort = m_view_config->get_col_sortspec(); + _find_hidden_sort(column_sort); + } + + // configure data window for `get_data` and `row_delta` + // Column-only views skip the grand total row (offset=1), but + // total_only mode needs to return exactly that row. + is_column_only() && !m_view_config->is_total_only() ? m_row_offset = 1 + : m_row_offset = 0; + + // TODO: make sure is 0 for column only - right now get_data returns row + // path for everything + sides() > 0 ? m_col_offset = 1 : m_col_offset = 0; + + // TODO: add index shifting ability +} + +template +View::~View() { + auto pool = m_table->get_pool(); + auto gnode = m_table->get_gnode(); + PSP_GIL_UNLOCK(); + + // TODO: need to invalidate memory used by previous computed columns + // without affecting views that depend on those computed columns. + pool->unregister_context(gnode->get_id(), m_name); +} + +template +std::shared_ptr +View::get_view_config() const { + return m_view_config; +} + +template <> +std::int32_t +View::sides() const { + return 0; +} + +template <> +std::int32_t +View::sides() const { + return 0; +} + +template <> +std::int32_t +View::sides() const { + return 1; +} + +template <> +std::int32_t +View::sides() const { + return 2; +} + +template +std::int32_t +View::num_rows() const { + if (is_column_only() && !m_view_config->is_total_only()) { + return m_ctx->get_row_count() - 1; + } + return m_ctx->get_row_count(); +} + +template +std::int32_t +View::num_columns() const { + return m_ctx->unity_get_column_count(); +} + +/** + * @brief Return correct number of columns when headers need to be skipped. + * + * @tparam + * @return std::int32_t + */ +template <> +std::int32_t +View::num_columns() const { + if (!m_sort.empty()) { + auto depth = m_column_pivots.size(); + auto col_length = m_ctx->unity_get_column_count(); + + // Hidden sort columns (sort keys not in `m_columns`) are + // interleaved into unity at the same depth as visible leaves; + // filter them out so `num_columns` reports the user-visible + // count. The `start_col`/`end_col` viewport math in + // `parse_format_options` and the `to_*` writers all index in + // this visible-only space. + const auto aggs = m_ctx->get_aggregates(); + std::vector aggregate_names(aggs.size()); + for (auto i = 0; i < aggs.size(); ++i) { + aggregate_names[i] = aggs[i].name(); + } + + auto count = 0; + for (t_uindex i = 0; i < col_length; ++i) { + if (m_ctx->unity_get_column_path(i + 1).size() != depth) { + continue; + } + + if (!m_hidden_sort.empty()) { + const std::string& agg_name = + aggregate_names[i % aggregate_names.size()]; + if (std::find( + m_hidden_sort.begin(), m_hidden_sort.end(), agg_name + ) != m_hidden_sort.end()) { + continue; + } + } + + count++; + } + return count; + } + return m_ctx->unity_get_column_count(); +} + +// Metadata construction +template +std::vector> +View::column_names(bool skip, std::int32_t depth) const { + std::vector> names; + const std::vector aggs = m_ctx->get_aggregates(); + std::vector aggregate_names(aggs.size()); + + for (auto i = 0; i < aggs.size(); ++i) { + aggregate_names[i] = aggs[i].name(); + } + + for (t_uindex key = 0, max = m_ctx->unity_get_column_count(); key != max; + ++key) { + const std::string& name = aggregate_names[key % aggregate_names.size()]; + + if (name == "psp_okey") { + continue; + } + + std::vector col_path = m_ctx->unity_get_column_path(key + 1); + if (skip && col_path.size() < static_cast(depth)) { + continue; + } + + std::vector new_path; + for (auto path = col_path.rbegin(); path != col_path.rend(); ++path) { + new_path.push_back(*path); + } + new_path.push_back( + m_ctx->get_aggregate_name(key % aggregate_names.size()) + ); + names.push_back(new_path); + } + + return names; +} + +template <> +std::vector> +View::column_names(bool skip, std::int32_t depth) const { + std::vector> names; + + for (t_uindex key = 0, max = m_ctx->unity_get_column_count(); key != max; + ++key) { + t_tscalar name = m_ctx->get_column_name(key); + if (strcmp(name.get(), "psp_okey") == 0) { + continue; + }; + std::vector col_path; + col_path.push_back(name); + names.push_back(col_path); + } + + return names; +} + +template <> +std::vector> +View::column_names(bool skip, std::int32_t depth) const { + std::vector> names; + + for (t_uindex key = 0, max = m_ctx->unity_get_column_count(); key != max; + ++key) { + t_tscalar name = m_ctx->get_column_name(key); + if (strcmp(name.get(), "psp_okey") == 0) { + continue; + }; + std::vector col_path; + col_path.push_back(name); + names.push_back(col_path); + } + + return names; +} + +template +std::vector> +View::column_names_range( + bool skip, std::int32_t depth, t_uindex start_col, t_uindex end_col +) const { + std::vector> names; + const std::vector aggs = m_ctx->get_aggregates(); + std::vector aggregate_names(aggs.size()); + + for (auto i = 0; i < aggs.size(); ++i) { + aggregate_names[i] = aggs[i].name(); + } + + auto col_count = m_ctx->unity_get_column_count(); + // start_col++; + // end_col++; + + t_uindex key = 0; + while (key < start_col && key < col_count) { + key++; + const std::string& name = aggregate_names[key % aggregate_names.size()]; + if (name == "psp_okey") { + start_col += 1; + end_col += 1; + continue; + } + + std::vector col_path = m_ctx->unity_get_column_path(key + 1); + if (skip && col_path.size() < static_cast(depth)) { + start_col += 1; + end_col += 1; + continue; + } + + if (!m_hidden_sort.empty()) { + if (std::find(m_hidden_sort.begin(), m_hidden_sort.end(), name) + != m_hidden_sort.end()) { + start_col += 1; + end_col += 1; + continue; + } + } + } + + for (t_uindex max = std::min(end_col, col_count); key <= max; ++key) { + const std::string& name = aggregate_names[key % aggregate_names.size()]; + + if (name == "psp_okey") { + end_col += 1; + max = std::min(end_col, col_count); + continue; + } + + std::vector col_path = m_ctx->unity_get_column_path(key + 1); + if (skip && col_path.size() < static_cast(depth)) { + end_col += 1; + max = std::min(end_col, col_count); + continue; + } + + if (!m_hidden_sort.empty()) { + if (std::find(m_hidden_sort.begin(), m_hidden_sort.end(), name) + != m_hidden_sort.end()) { + end_col += 1; + max = std::min(end_col, col_count); + continue; + } + } + + std::vector new_path; + for (auto path = col_path.rbegin(); path != col_path.rend(); ++path) { + new_path.push_back(*path); + } + + new_path.push_back( + m_ctx->get_aggregate_name(key % aggregate_names.size()) + ); + + names.push_back(new_path); + } + + return names; +} + +template <> +std::vector> +View::column_names_range( + bool skip, std::int32_t depth, t_uindex start_col, t_uindex end_col +) const { + std::vector> names; + for (t_uindex key = start_col, + max = std::min(end_col, m_ctx->unity_get_column_count()); + key != max; + ++key) { + t_tscalar name = m_ctx->get_column_name(key); + if (strcmp(name.get(), "psp_okey") == 0) { + continue; + }; + + std::vector col_path; + col_path.push_back(name); + names.push_back(col_path); + } + + return names; +} + +template <> +std::vector> +View::column_names_range( + bool skip, std::int32_t depth, t_uindex start_col, t_uindex end_col +) const { + std::vector> names; + + for (t_uindex key = start_col, + max = std::min(end_col, m_ctx->unity_get_column_count()); + key != max; + ++key) { + t_tscalar name = m_ctx->get_column_name(key); + if (strcmp(name.get(), "psp_okey") == 0) { + continue; + }; + std::vector col_path; + col_path.push_back(name); + names.push_back(col_path); + } + + return names; +} + +template +std::vector> +View::column_paths() const { + auto num_column_pivots = m_column_pivots.size(); + auto names = column_names(true, num_column_pivots); + if (!m_hidden_sort.empty()) { + // make a new vector so we don't have to erase while iterating + std::vector> visible_column_paths; + + for (const auto& column : names) { + // Remove undisplayed column names used to sort + std::string name = column.back().to_string(); + if (std::find(m_hidden_sort.begin(), m_hidden_sort.end(), name) + == m_hidden_sort.end()) { + visible_column_paths.push_back(column); + } + } + + return visible_column_paths; + } + + return names; +} + +template +std::vector> +View::column_paths_range(t_uindex start_col, t_uindex end_col) const { + auto num_column_pivots = m_column_pivots.size(); + auto names = + column_names_range(true, num_column_pivots, start_col, end_col); + + if (!m_hidden_sort.empty()) { + // make a new vector so we don't have to erase while iterating + std::vector> visible_column_paths; + for (const auto& column : names) { + // Remove undisplayed column names used to sort + std::string name = column.back().to_string(); + if (std::find(m_hidden_sort.begin(), m_hidden_sort.end(), name) + == m_hidden_sort.end()) { + visible_column_paths.push_back(column); + } + } + + return visible_column_paths; + } + + return names; +} + +template +std::vector> +View::column_paths_string() const { + auto paths = column_paths(); + std::vector> out; + out.reserve(paths.size()); + for (const auto& path : paths) { + std::vector row; + row.reserve(path.size()); + for (const auto& c : path) { + row.push_back(c.to_string()); + } + + out.push_back(row); + } + + return out; +} + +template +std::map +View::schema() const { + // TODO: should revert to m_table + auto schema = m_ctx->get_schema(); + auto _types = schema.types(); + auto names = schema.columns(); + + std::map types; + std::map new_schema; + + for (std::size_t i = 0, max = names.size(); i != max; ++i) { + types[names[i]] = _types[i]; + } + + auto col_names = column_names(false); + for (const std::vector& name : col_names) { + // Pull out the main aggregate column + std::string agg_name = name.back().to_string(); + std::string type_string = dtype_to_str(types[agg_name]); + new_schema[agg_name] = type_string; + + if ((!m_row_pivots.empty() || m_view_config->is_total_only()) && (!is_column_only() || m_view_config->is_total_only())) { + new_schema[agg_name] = + _map_aggregate_types(agg_name, new_schema[agg_name]); + } + } + + return new_schema; +} + +template <> +std::map +View::schema() const { + t_schema schema = m_ctx->get_schema(); + std::vector _types = schema.types(); + std::vector names = schema.columns(); + + std::map types; + for (std::size_t i = 0, max = names.size(); i != max; ++i) { + types[names[i]] = _types[i]; + } + + std::vector> cols = column_names(false); + std::map new_schema; + + for (auto& col : cols) { + std::string name = col.back().to_string(); + if (name == "psp_okey") { + continue; + } + new_schema[name] = dtype_to_str(types[name]); + } + + return new_schema; +} + +template <> +std::map +View::schema() const { + const t_schema& schema = m_ctx->get_schema(); + const std::vector& _types = schema.types(); + const std::vector& names = schema.columns(); + + std::map types; + for (std::size_t i = 0, max = names.size(); i != max; ++i) { + types[names[i]] = _types[i]; + } + + std::vector> cols = column_names(false); + std::map new_schema; + + for (auto& col : cols) { + std::string name = col.back().to_string(); + if (name == "psp_okey") { + continue; + } + new_schema[name] = dtype_to_str(types[name]); + } + + return new_schema; +} + +template +std::map +View::expression_schema() const { + auto schema = m_ctx->get_schema(); + auto _types = schema.types(); + auto names = schema.columns(); + + std::map types; + std::map new_schema; + + for (std::size_t i = 0, max = names.size(); i != max; ++i) { + types[names[i]] = _types[i]; + } + + for (const auto& expr : m_expressions) { + const std::string& expression_alias = expr->get_expression_alias(); + new_schema[expression_alias] = dtype_to_str(expr->get_dtype()); + + if ((!m_row_pivots.empty() || m_view_config->is_total_only()) && (!is_column_only() || m_view_config->is_total_only())) { + new_schema[expression_alias] = _map_aggregate_types( + expression_alias, new_schema[expression_alias] + ); + } + } + + return new_schema; +} + +template <> +std::map +View::expression_schema() const { + return {}; +} + +template <> +std::map +View::expression_schema() const { + const t_schema& schema = m_ctx->get_schema(); + const std::vector& _types = schema.types(); + const std::vector& names = schema.columns(); + + std::map types; + for (std::size_t i = 0, max = names.size(); i != max; ++i) { + types[names[i]] = _types[i]; + } + + std::map new_schema; + + for (const auto& expr : m_expressions) { + const std::string& expression_alias = expr->get_expression_alias(); + new_schema[expression_alias] = dtype_to_str(expr->get_dtype()); + } + + return new_schema; +} + +template +std::pair +View::get_min_max(const std::string& colname) const { + return m_ctx->get_min_max(colname); +} + +template <> +std::shared_ptr> +View::get_data( + t_uindex start_row, t_uindex end_row, t_uindex start_col, t_uindex end_col +) const { + std::vector slice = + m_ctx->get_data(start_row, end_row, start_col, end_col); + auto col_names = column_names(); + auto data_slice_ptr = std::make_shared>( + m_ctx, + start_row, + end_row, + start_col, + end_col, + m_row_offset, + m_col_offset, + slice, + col_names + ); + return data_slice_ptr; +} + +template <> +std::shared_ptr> +View::get_data( + t_uindex start_row, t_uindex end_row, t_uindex start_col, t_uindex end_col +) const { + std::vector slice = + m_ctx->get_data(start_row, end_row, start_col, end_col); + auto col_names = column_names(); + auto data_slice_ptr = std::make_shared>( + m_ctx, + start_row, + end_row, + start_col, + end_col, + m_row_offset, + m_col_offset, + slice, + col_names + ); + return data_slice_ptr; +} + +template <> +std::shared_ptr> +View::get_data( + t_uindex start_row, t_uindex end_row, t_uindex start_col, t_uindex end_col +) const { + std::vector slice = + m_ctx->get_data(start_row, end_row, start_col, end_col); + auto col_names = column_names(); + t_tscalar row_path; + row_path.set("__ROW_PATH__"); + col_names.insert(col_names.begin(), std::vector{row_path}); + auto data_slice_ptr = std::make_shared>( + m_ctx, + start_row, + end_row, + start_col, + end_col, + m_row_offset, + m_col_offset, + slice, + col_names + ); + return data_slice_ptr; +} + +template <> +std::shared_ptr> +View::get_data( + t_uindex start_row, t_uindex end_row, t_uindex start_col, t_uindex end_col +) const { + std::vector slice; + std::vector column_indices; + std::vector> cols; + bool is_sorted = !m_sort.empty(); + + if (is_column_only()) { + start_row += m_row_offset; + end_row += m_row_offset; + } + + if (is_sorted) { + /** + * Perspective generates headers for sorted columns, so we have to + * skip them in the underlying slice. + * + * Hidden sort columns (sort keys absent from `m_columns`) are also + * interleaved into unity at the same depth as visible leaves — + * filter them out here so `column_indices`, `cols`, and the packed + * `slice` all index in visible-only space. `start_col`/`end_col` + * are user-visible column indices, and downstream `to_*` writers + * iterate `slice` without re-applying any hidden-skip modulo. + */ + t_uindex start_col_index = start_col; + t_uindex end_col_index = end_col; + + // Only construct column_indices if start_col > end_col as get_data will + // handle the incorrect data window properly, which is consistent + // with the implementation for when the context is not sorted. + if (start_col < end_col) { + auto depth = m_column_pivots.size(); + auto col_length = m_ctx->unity_get_column_count(); + + const auto aggs = m_ctx->get_aggregates(); + std::vector aggregate_names(aggs.size()); + for (auto i = 0; i < aggs.size(); ++i) { + aggregate_names[i] = aggs[i].name(); + } + + column_indices.push_back(0); + for (t_uindex i = 0; i < col_length; ++i) { + auto col_path = m_ctx->unity_get_column_path(i + 1); + if (col_path.size() != depth) { + continue; + } + + if (!m_hidden_sort.empty()) { + const std::string& agg_name = + aggregate_names[i % aggregate_names.size()]; + if (std::find( + m_hidden_sort.begin(), + m_hidden_sort.end(), + agg_name + ) != m_hidden_sort.end()) { + continue; + } + } + + column_indices.push_back(i + 1); + + std::vector new_path; + for (auto path = col_path.rbegin(); path != col_path.rend(); + ++path) { + new_path.push_back(*path); + } + new_path.push_back( + m_ctx->get_aggregate_name(i % aggregate_names.size()) + ); + cols.push_back(new_path); + } + + // Filter down column indices by user-provided start/end columns + column_indices = std::vector( + column_indices.begin() + start_col, + column_indices.begin() + + std::min(end_col, (t_uindex)column_indices.size()) + ); + + // If start_col == end_col, then column_indices will be an empty + // vector. Only try to access the first and last elements if the + // vector is not empty. `get_data` correctly handles cases where + // start == end and start < end. + if (!column_indices.empty()) { + start_col_index = column_indices.front(); + end_col_index = column_indices.back() + 1; + } + } + + std::vector slice_with_headers = + m_ctx->get_data(start_row, end_row, start_col_index, end_col_index); + + auto iter = slice_with_headers.begin(); + while (iter != slice_with_headers.end()) { + t_uindex prev = column_indices.front(); + for (unsigned long long col_num : column_indices) { + iter += col_num - prev; + prev = col_num; + slice.push_back(*iter); + } + if (iter != slice_with_headers.end()) { + iter++; + } + } + } else { + cols = column_names(); + slice = m_ctx->get_data(start_row, end_row, start_col, end_col); + } + // TODO: we need to just use column_paths everywhere instead of row path + // insertion manually, this causes issues with needing to skip row paths + t_tscalar row_path; + row_path.set("__ROW_PATH__"); + cols.insert(cols.begin(), std::vector{row_path}); + auto data_slice_ptr = std::make_shared>( + m_ctx, + start_row, + end_row, + start_col, + end_col, + m_row_offset, + m_col_offset, + slice, + cols, + column_indices + ); + return data_slice_ptr; +} + +template +std::shared_ptr +View::to_arrow( + std::int32_t start_row, + std::int32_t end_row, + std::int32_t start_col, + std::int32_t end_col, + bool emit_group_by, + bool compress, + bool emit_legacy_row_path_names +) const { + PSP_GIL_UNLOCK(); + PSP_READ_LOCK(*get_lock()); + + std::shared_ptr> data_slice = + get_data(start_row, end_row, start_col, end_col); + return data_slice_to_arrow(data_slice, emit_group_by, compress, emit_legacy_row_path_names); +}; + +template <> +std::shared_ptr +View::to_csv( + std::int32_t start_row, + std::int32_t end_row, + std::int32_t start_col, + std::int32_t end_col +) const { + PSP_GIL_UNLOCK(); + PSP_READ_LOCK(*get_lock()); + + // See generic instance. + if (is_column_only() && m_ctx->unity_get_column_count() == 0) { + return std::make_shared(""); + } + + std::shared_ptr> data_slice = + get_data(start_row, end_row, start_col, end_col); + return data_slice_to_csv(data_slice); +}; + +template <> +std::shared_ptr +View::to_csv( + std::int32_t start_row, + std::int32_t end_row, + std::int32_t start_col, + std::int32_t end_col +) const { + PSP_GIL_UNLOCK(); + PSP_READ_LOCK(*get_lock()); + std::shared_ptr> data_slice = + get_data(start_row, end_row, start_col, end_col); + return data_slice_to_csv(data_slice); +}; + +template +std::shared_ptr +View::to_csv( + std::int32_t start_row, + std::int32_t end_row, + std::int32_t start_col, + std::int32_t end_col +) const { + PSP_GIL_UNLOCK(); + PSP_READ_LOCK(*get_lock()); + + // Arrow has a bug which miscalculates CSV header size as 1 when there are + // no columns (and hence no rows) in the dataset, so intercept these calls/ + if (m_ctx->unity_get_column_count() == 0) { + return std::make_shared(""); + } + + std::shared_ptr> data_slice = + get_data(start_row, end_row, start_col, end_col); + return data_slice_to_csv(data_slice); +}; + +template +std::pair, std::shared_ptr> +View::data_slice_to_batches( + bool emit_group_by, std::shared_ptr> data_slice, + bool emit_legacy_row_path_names +) const { + // From the data slice, get all the metadata we need + t_get_data_extents extents = data_slice->get_data_extents(); + std::int32_t start_col = extents.m_scol; + std::int32_t end_col = extents.m_ecol; + + const std::vector& slice = data_slice->get_slice(); + const std::vector>& names = + data_slice->get_column_names(); + auto stride = data_slice->get_stride(); + auto num_sides = sides(); + + std::vector> vectors; + std::vector> fields; + std::int32_t num_columns = end_col - start_col; + std::vector row_pivots = m_view_config->get_row_pivots(); + t_uindex num_row_paths = emit_group_by ? row_pivots.size() : 0; + if (num_columns + num_row_paths > 0) { + fields.resize(num_columns + num_row_paths); + vectors.resize(num_columns + num_row_paths); + } + + auto num_output_row_paths = is_column_only() ? 0 : num_row_paths; + + t_uindex write_idx = 0; + if (emit_group_by && num_output_row_paths > 0) { + auto schema = m_table->get_schema(); + for (auto rpidx = 0; rpidx < num_row_paths; ++rpidx) { + std::string column_name = row_pivots.at(rpidx); + std::string row_path_name; + if (emit_legacy_row_path_names) { + row_path_name = column_name; + row_path_name += " (Group by "; + row_path_name += std::to_string(rpidx + 1); + row_path_name += ")"; + } else { + row_path_name = "__ROW_PATH_"; + row_path_name += std::to_string(rpidx); + row_path_name += "__"; + } + + // Get the "table" type for this column, as row_pivots are not in + // the view schema. + t_dtype dtype; + if (schema.has_column(column_name)) { + dtype = schema.get_dtype(column_name); + } else { + for (const auto& expr : m_expressions) { + std::string expression_alias = expr->get_expression_alias(); + if (expr->get_expression_alias() == column_name) { + dtype = expr->get_dtype(); + break; + } + } + } + + std::shared_ptr arr; + switch (dtype) { + case DTYPE_INT8: { + fields[write_idx] = + arrow::field(row_path_name, arrow::int8()); + vectors[write_idx] = apachearrow::numeric_col_to_array< + arrow::Int8Type, + std::int8_t>(extents, [&, rpidx](t_uindex ridx) { + auto depth = m_ctx->unity_get_row_depth(ridx); + if (rpidx < depth) { + return m_ctx->unity_get_row_path(ridx).at( + (depth - 1) - rpidx + ); + } + return mknone(); + }); + } break; + case DTYPE_UINT8: { + fields[write_idx] = + arrow::field(row_path_name, arrow::uint8()); + vectors[write_idx] = apachearrow::numeric_col_to_array< + arrow::UInt8Type, + std::uint8_t>(extents, [&, rpidx](t_uindex ridx) { + auto depth = m_ctx->unity_get_row_depth(ridx); + if (rpidx < depth) { + return m_ctx->unity_get_row_path(ridx).at( + (depth - 1) - rpidx + ); + } + return mknone(); + }); + } break; + case DTYPE_INT16: { + fields[write_idx] = + arrow::field(row_path_name, arrow::int16()); + vectors[write_idx] = apachearrow::numeric_col_to_array< + arrow::Int16Type, + std::int16_t>(extents, [&, rpidx](t_uindex ridx) { + auto depth = m_ctx->unity_get_row_depth(ridx); + if (rpidx < depth) { + return m_ctx->unity_get_row_path(ridx).at( + (depth - 1) - rpidx + ); + } + return mknone(); + }); + } break; + case DTYPE_UINT16: { + fields[write_idx] = + arrow::field(row_path_name, arrow::uint16()); + vectors[write_idx] = apachearrow::numeric_col_to_array< + arrow::UInt16Type, + std::uint16_t>(extents, [&, rpidx](t_uindex ridx) { + auto depth = m_ctx->unity_get_row_depth(ridx); + if (rpidx < depth) { + return m_ctx->unity_get_row_path(ridx).at( + (depth - 1) - rpidx + ); + } + return mknone(); + }); + } break; + case DTYPE_INT32: { + fields[write_idx] = + arrow::field(row_path_name, arrow::int32()); + vectors[write_idx] = apachearrow::numeric_col_to_array< + arrow::Int32Type, + std::int32_t>(extents, [&, rpidx](t_uindex ridx) { + auto depth = m_ctx->unity_get_row_depth(ridx); + if (rpidx < depth) { + return m_ctx->unity_get_row_path(ridx).at( + (depth - 1) - rpidx + ); + } + return mknone(); + }); + } break; + case DTYPE_UINT32: { + fields[write_idx] = + arrow::field(row_path_name, arrow::uint32()); + vectors[write_idx] = apachearrow::numeric_col_to_array< + arrow::UInt32Type, + std::uint32_t>(extents, [&, rpidx](t_uindex ridx) { + auto depth = m_ctx->unity_get_row_depth(ridx); + if (rpidx < depth) { + return m_ctx->unity_get_row_path(ridx).at( + (depth - 1) - rpidx + ); + } + return mknone(); + }); + } break; + case DTYPE_INT64: { + fields[write_idx] = + arrow::field(row_path_name, arrow::int64()); + vectors[write_idx] = apachearrow::numeric_col_to_array< + arrow::Int64Type, + std::int64_t>(extents, [&, rpidx](t_uindex ridx) { + auto depth = m_ctx->unity_get_row_depth(ridx); + if (rpidx < depth) { + return m_ctx->unity_get_row_path(ridx).at( + (depth - 1) - rpidx + ); + } + return mknone(); + }); + } break; + case DTYPE_UINT64: { + fields[write_idx] = + arrow::field(row_path_name, arrow::uint64()); + vectors[write_idx] = apachearrow::numeric_col_to_array< + arrow::UInt64Type, + std::uint64_t>(extents, [&, rpidx](t_uindex ridx) { + auto depth = m_ctx->unity_get_row_depth(ridx); + if (rpidx < depth) { + return m_ctx->unity_get_row_path(ridx).at( + (depth - 1) - rpidx + ); + } + return mknone(); + }); + } break; + case DTYPE_FLOAT32: { + fields[write_idx] = + arrow::field(row_path_name, arrow::float32()); + vectors[write_idx] = apachearrow::numeric_col_to_array< + arrow::FloatType, + float>(extents, [&, rpidx](t_uindex ridx) { + auto depth = m_ctx->unity_get_row_depth(ridx); + if (rpidx < depth) { + return m_ctx->unity_get_row_path(ridx).at( + (depth - 1) - rpidx + ); + } + return mknone(); + }); + } break; + case DTYPE_FLOAT64: { + fields[write_idx] = + arrow::field(row_path_name, arrow::float64()); + vectors[write_idx] = apachearrow::numeric_col_to_array< + arrow::DoubleType, + double>(extents, [&, rpidx](t_uindex ridx) { + auto depth = m_ctx->unity_get_row_depth(ridx); + if (rpidx < depth) { + return m_ctx->unity_get_row_path(ridx).at( + (depth - 1) - rpidx + ); + } + return mknone(); + }); + } break; + case DTYPE_DATE: { + fields[write_idx] = + arrow::field(row_path_name, arrow::date32()); + vectors[write_idx] = apachearrow::date_col_to_array( + extents, + [&, rpidx](t_uindex ridx) { + auto depth = m_ctx->unity_get_row_depth(ridx); + if (rpidx < depth) { + return m_ctx->unity_get_row_path(ridx).at( + (depth - 1) - rpidx + ); + } + return mknone(); + } + ); + } break; + case DTYPE_TIME: { + fields[write_idx] = arrow::field( + row_path_name, arrow::timestamp(arrow::TimeUnit::MILLI) + ); + vectors[write_idx] = apachearrow::timestamp_col_to_array( + extents, + [&, rpidx](t_uindex ridx) { + auto depth = m_ctx->unity_get_row_depth(ridx); + if (rpidx < depth) { + return m_ctx->unity_get_row_path(ridx).at( + (depth - 1) - rpidx + ); + } + return mknone(); + } + ); + } break; + case DTYPE_BOOL: { + fields[write_idx] = + arrow::field(row_path_name, arrow::boolean()); + vectors[write_idx] = apachearrow::boolean_col_to_array( + extents, + [&, rpidx](t_uindex ridx) { + auto depth = m_ctx->unity_get_row_depth(ridx); + if (rpidx < depth) { + return m_ctx->unity_get_row_path(ridx).at( + (depth - 1) - rpidx + ); + } + return mknone(); + } + ); + } break; + case DTYPE_STR: { + fields[write_idx] = arrow::field( + row_path_name, + arrow::dictionary(arrow::int32(), arrow::utf8()) + ); + vectors[write_idx] = + apachearrow::string_col_to_dictionary_array( + extents, + [&, rpidx](t_uindex ridx) { + auto depth = m_ctx->unity_get_row_depth(ridx); + if (rpidx < depth) { + return m_ctx->unity_get_row_path(ridx).at( + (depth - 1) - rpidx + ); + } + return mknone(); + } + ); + } break; + case DTYPE_OBJECT: + default: { + std::stringstream ss; + ss << "Cannot serialize column `" << row_path_name + << "` of type `" << get_dtype_descr(dtype) + << "` to Arrow format." << std::endl; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + } + + write_idx++; + } + } + + // calculate the number of columns (including __ROW_PATH__) minus + // the number of hidden sorts, so we can skip hidden sorts. + // t_uindex num_view_columns = num_columns - m_hidden_sort.size(); + t_uindex num_view_columns = m_columns.size(); + + // The modulo skip below is for layouts (currently ctx1 sorted with a + // hidden sort key) where the slice still carries hidden columns and + // the iteration `tidx + start_col` walks the interleaved/appended + // hidden positions. ctx2 sorted views pre-filter hidden columns in + // `get_data` and signal that by populating `column_indices` — when + // that's the case the slice already contains only visible data and + // the modulo would erroneously drop legitimate visible columns. + bool slice_is_visible_only = !data_slice->get_column_indices().empty(); + + std::vector indices; + for (auto tidx = 0; tidx < end_col - start_col; ++tidx) { + auto cidx = tidx + start_col; + if (cidx == start_col && num_sides > 0) { + continue; + } + + // Do not output hidden sort columns - they are always at the end + // of the columns list. + if (!slice_is_visible_only + && (num_view_columns + m_hidden_sort.size()) > 0 + && ((cidx - (num_sides > 0 ? 1 : 0)) + % (num_view_columns + m_hidden_sort.size())) + >= num_view_columns) { + continue; + } + + indices.push_back(tidx); + } + + // TODO For some reason, this parallel call doesn't benefit from + // parallelism. + // When the slice was pre-filtered to visible-only by ctx2's + // `get_data` (option B), `cidx` indexes into visible-space + // (matching `names` and the flat `slice` layout), but + // `get_column_dtype` still expects a unity-column position. + // `column_indices` maps visible → unity for that case. + const std::vector& slice_col_indices = + data_slice->get_column_indices(); + + parallel_for(int(indices.size()), [&](auto iidx) { + // for (auto iidx = 0; iidx < indices.size(); iidx++) { + auto ccidx = iidx + num_output_row_paths; + auto cidx = indices[iidx] + start_col; + + std::vector col_path = names.at(cidx); + t_uindex dtype_cidx = + slice_is_visible_only ? slice_col_indices.at(cidx - start_col) + : cidx; + t_dtype dtype = get_column_dtype(dtype_cidx); + + // mean and weighted mean uses DTYPE_F64PAIR on the aggtable, which + // is the dtype returned by get_column_dtype. However, in the output + // data slice they are DTYPE_FLOAT64 and the f64 pair is not exposed + // outside of the sparse tree. Thus, treat f64 pair as DTYPE_FLOAT64 + // for arrow serialization. + if (dtype == DTYPE_F64PAIR) { + dtype = DTYPE_FLOAT64; + } + + std::string name; + + if (num_sides > 1) { + name = join_column_names(col_path, m_separator); + } else { + name = col_path.at(col_path.size() - 1).to_string(); + } + + std::shared_ptr arr; + switch (dtype) { + case DTYPE_INT8: { + fields[ccidx] = arrow::field(name, arrow::int8()); + vectors[ccidx] = apachearrow::numeric_col_to_array< + arrow::Int8Type, + std::int8_t>(extents, [&](t_uindex ridx) { + return slice + [(ridx - extents.m_srow) * stride + + (cidx - extents.m_scol)]; + }); + } break; + case DTYPE_UINT8: { + fields[ccidx] = arrow::field(name, arrow::uint8()); + vectors[ccidx] = apachearrow::numeric_col_to_array< + arrow::UInt8Type, + std::uint8_t>(extents, [&](t_uindex ridx) { + return slice + [(ridx - extents.m_srow) * stride + + (cidx - extents.m_scol)]; + }); + } break; + case DTYPE_INT16: { + fields[ccidx] = arrow::field(name, arrow::int16()); + vectors[ccidx] = apachearrow::numeric_col_to_array< + arrow::Int16Type, + std::int16_t>(extents, [&](t_uindex ridx) { + return slice + [(ridx - extents.m_srow) * stride + + (cidx - extents.m_scol)]; + }); + } break; + case DTYPE_UINT16: { + fields[ccidx] = arrow::field(name, arrow::uint16()); + vectors[ccidx] = apachearrow::numeric_col_to_array< + arrow::UInt16Type, + std::uint16_t>(extents, [&](t_uindex ridx) { + return slice + [(ridx - extents.m_srow) * stride + + (cidx - extents.m_scol)]; + }); + } break; + case DTYPE_INT32: { + fields[ccidx] = arrow::field(name, arrow::int32()); + vectors[ccidx] = apachearrow::numeric_col_to_array< + arrow::Int32Type, + std::int32_t>(extents, [&](t_uindex ridx) { + return slice + [(ridx - extents.m_srow) * stride + + (cidx - extents.m_scol)]; + }); + } break; + case DTYPE_UINT32: { + fields[ccidx] = arrow::field(name, arrow::uint32()); + vectors[ccidx] = apachearrow::numeric_col_to_array< + arrow::UInt32Type, + std::uint32_t>(extents, [&](t_uindex ridx) { + return slice + [(ridx - extents.m_srow) * stride + + (cidx - extents.m_scol)]; + }); + } break; + case DTYPE_INT64: { + fields[ccidx] = arrow::field(name, arrow::int64()); + vectors[ccidx] = apachearrow::numeric_col_to_array< + arrow::Int64Type, + std::int64_t>(extents, [&](t_uindex ridx) { + return slice + [(ridx - extents.m_srow) * stride + + (cidx - extents.m_scol)]; + }); + } break; + case DTYPE_UINT64: { + fields[ccidx] = arrow::field(name, arrow::uint64()); + vectors[ccidx] = apachearrow::numeric_col_to_array< + arrow::UInt64Type, + std::uint64_t>(extents, [&](t_uindex ridx) { + return slice + [(ridx - extents.m_srow) * stride + + (cidx - extents.m_scol)]; + }); + } break; + case DTYPE_FLOAT32: { + fields[ccidx] = arrow::field(name, arrow::float32()); + vectors[ccidx] = + apachearrow::numeric_col_to_array( + extents, + [&](t_uindex ridx) { + return slice + [(ridx - extents.m_srow) * stride + + (cidx - extents.m_scol)]; + } + ); + } break; + case DTYPE_FLOAT64: { + fields[ccidx] = arrow::field(name, arrow::float64()); + vectors[ccidx] = apachearrow::numeric_col_to_array< + arrow::DoubleType, + double>(extents, [&](t_uindex ridx) { + return slice + [(ridx - extents.m_srow) * stride + + (cidx - extents.m_scol)]; + }); + } break; + case DTYPE_DATE: { + fields[ccidx] = arrow::field(name, arrow::date32()); + vectors[ccidx] = + apachearrow::date_col_to_array(extents, [&](t_uindex ridx) { + return slice + [(ridx - extents.m_srow) * stride + + (cidx - extents.m_scol)]; + }); + } break; + case DTYPE_TIME: { + fields[ccidx] = arrow::field( + name, arrow::timestamp(arrow::TimeUnit::MILLI) + ); + vectors[ccidx] = apachearrow::timestamp_col_to_array( + extents, + [&](t_uindex ridx) { + return slice + [(ridx - extents.m_srow) * stride + + (cidx - extents.m_scol)]; + } + ); + } break; + case DTYPE_BOOL: { + fields[ccidx] = arrow::field(name, arrow::boolean()); + vectors[ccidx] = apachearrow::boolean_col_to_array( + extents, + [&](t_uindex ridx) { + return slice + [(ridx - extents.m_srow) * stride + + (cidx - extents.m_scol)]; + } + ); + } break; + case DTYPE_STR: { + fields[ccidx] = arrow::field( + name, arrow::dictionary(arrow::int32(), arrow::utf8()) + ); + vectors[ccidx] = apachearrow::string_col_to_dictionary_array( + extents, + [&](t_uindex ridx) { + return slice + [(ridx - extents.m_srow) * stride + + (cidx - extents.m_scol)]; + } + ); + } break; + case DTYPE_OBJECT: + default: { + std::stringstream ss; + ss << "Cannot serialize column `" << name << "` of type `" + << get_dtype_descr(dtype) << "` to Arrow format." + << std::endl; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + } + }); + // } + + fields.resize(indices.size() + num_output_row_paths); + vectors.resize(indices.size() + num_output_row_paths); + + auto arrow_schema = arrow::schema(fields); + auto num_rows = data_slice->num_rows(); + std::shared_ptr batches = + arrow::RecordBatch::Make(arrow_schema, num_rows, vectors); + auto valid = batches->Validate(); + if (!valid.ok()) { + std::stringstream ss; + ss << "Invalid RecordBatch: " << valid.message() << std::endl; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + + return std::make_pair(arrow_schema, batches); +} + +template +std::shared_ptr +View::data_slice_to_arrow( + std::shared_ptr> data_slice, + bool emit_group_by, + bool compress, + bool emit_legacy_row_path_names +) const { + std::pair< + std::shared_ptr, + std::shared_ptr> + pairs = data_slice_to_batches(emit_group_by, data_slice, emit_legacy_row_path_names); + std::shared_ptr batches = pairs.second; + std::shared_ptr arrow_schema = pairs.first; + arrow::Result> allocated = + arrow::AllocateResizableBuffer(0); + if (!allocated.ok()) { + std::stringstream ss; + ss << "Failed to allocate buffer: " << allocated.status().message() + << std::endl; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + + std::shared_ptr buffer; + buffer = *allocated; + arrow::io::BufferOutputStream sink(buffer); + auto options = arrow::ipc::IpcWriteOptions::Defaults(); + if (compress) { + auto codec = arrow::util::Codec::Create(arrow::Compression::LZ4_FRAME); + options.codec = std::move(codec).ValueUnsafe(); + } + +#ifdef PSP_PARALLEL_FOR + options.use_threads = true; +#else + options.use_threads = false; +#endif + + auto res = arrow::ipc::MakeStreamWriter(&sink, arrow_schema, options); + std::shared_ptr writer = *res; + PSP_CHECK_ARROW_STATUS(writer->WriteRecordBatch(*batches)); + PSP_CHECK_ARROW_STATUS(writer->Close()); + PSP_CHECK_ARROW_STATUS(sink.Close()); + return std::make_shared(buffer->ToString()); +} + +template +std::shared_ptr +View::data_slice_to_csv(std::shared_ptr> data_slice +) const { + std::pair< + std::shared_ptr, + std::shared_ptr> + pairs = data_slice_to_batches(true, data_slice); + std::shared_ptr batches = pairs.second; + std::shared_ptr arrow_schema = pairs.first; + arrow::Result> allocated = + arrow::AllocateResizableBuffer(0); + if (!allocated.ok()) { + std::stringstream ss; + ss << "Failed to allocate buffer: " << allocated.status().message() + << '\n'; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + + std::shared_ptr buffer; + buffer = *allocated; + arrow::io::BufferOutputStream sink(buffer); + auto write_options = arrow::csv::WriteOptions::Defaults(); + auto maybe_writer = + arrow::csv::MakeCSVWriter(&sink, arrow_schema, write_options); + std::shared_ptr writer = *maybe_writer; + PSP_CHECK_ARROW_STATUS(writer->WriteRecordBatch(*batches)); + PSP_CHECK_ARROW_STATUS(writer->Close()); + PSP_CHECK_ARROW_STATUS(sink.Close()); + return std::make_shared(buffer->ToString()); +} + +// Delta calculation +template +bool +View::_get_deltas_enabled() const { + return m_ctx->get_deltas_enabled(); +} + +template +void +View::_set_deltas_enabled(bool enabled_state) { + m_ctx->set_deltas_enabled(enabled_state); +} + +// Pivot table operations +template +bool +View::get_row_expanded(std::int32_t ridx) const { + return m_ctx->unity_get_row_expanded(ridx); +} + +template <> +t_index +View::expand(std::int32_t ridx, std::int32_t row_pivot_length) { + return ridx; +} + +template <> +t_index +View::expand(std::int32_t ridx, std::int32_t row_pivot_length) { + return ridx; +} + +template <> +t_index +View::expand(std::int32_t ridx, std::int32_t row_pivot_length) { +#ifdef PSP_PARALLEL_FOR + PSP_WRITE_LOCK(*get_lock()) +#endif + return m_ctx->open(ridx); +} + +template <> +t_index +View::expand(std::int32_t ridx, std::int32_t row_pivot_length) { +#ifdef PSP_PARALLEL_FOR + PSP_WRITE_LOCK(*get_lock()) +#endif + if (m_ctx->unity_get_row_depth(ridx) < t_uindex(row_pivot_length)) { + return m_ctx->open(t_header::HEADER_ROW, ridx); + } + return ridx; +} + +template <> +t_index +View::collapse(std::int32_t ridx) { + return ridx; +} + +template <> +t_index +View::collapse(std::int32_t ridx) { + return ridx; +} + +template <> +t_index +View::collapse(std::int32_t ridx) { +#ifdef PSP_PARALLEL_FOR + PSP_WRITE_LOCK(*get_lock()) +#endif + return m_ctx->close(ridx); +} + +template <> +t_index +View::collapse(std::int32_t ridx) { +#ifdef PSP_PARALLEL_FOR + PSP_WRITE_LOCK(*get_lock()) +#endif + return m_ctx->close(t_header::HEADER_ROW, ridx); +} + +template <> +void +View::set_depth(std::int32_t depth, std::int32_t row_pivot_length) {} + +template <> +void +View::set_depth(std::int32_t depth, std::int32_t row_pivot_length) {} + +template <> +void +View::set_depth(std::int32_t depth, std::int32_t row_pivot_length) { + if (row_pivot_length >= depth) { + m_ctx->set_depth(depth); + } else { + std::cout << "Cannot expand past " << std::to_string(row_pivot_length) + << '\n'; + } +} + +template <> +void +View::set_depth(std::int32_t depth, std::int32_t row_pivot_length) { + if (row_pivot_length >= depth) { + m_ctx->set_depth(t_header::HEADER_ROW, depth); + } else { + std::cout << "Cannot expand past " << std::to_string(row_pivot_length) + << '\n'; + } +} + +// Getters +template +std::shared_ptr +View::get_context() const { + return m_ctx; +} + +template +std::vector +View::get_row_pivots() const { + return m_row_pivots; +} + +template +std::vector +View::get_column_pivots() const { + return m_column_pivots; +} + +template +std::vector +View::get_aggregates() const { + return m_aggregates; +} + +template +std::vector +View::get_filter() const { + return m_filter; +} + +template +std::vector +View::get_sort() const { + return m_sort; +} + +template +std::vector> +View::get_expressions() const { + return m_expressions; +} + +template <> +std::vector +View::get_row_path(t_uindex idx) const { + return {}; +} + +template <> +std::vector +View::get_row_path(t_uindex idx) const { + return {}; +} + +template +std::vector +View::get_row_path(t_uindex idx) const { + return m_ctx->unity_get_row_path(idx); +} + +template +t_stepdelta +View::get_step_delta(t_index bidx, t_index eidx) const { + return m_ctx->get_step_delta(bidx, eidx); +} + +template <> +t_stepdelta +View::get_step_delta(t_index bidx, t_index eidx) const { + return {}; +} + +template +std::shared_ptr> +View::get_row_delta() const { + t_rowdelta delta = m_ctx->get_row_delta(); + const std::vector& data = delta.data; + t_uindex num_rows_changed = delta.num_rows_changed; + std::vector> paths = + column_names(true, m_column_pivots.size()); + + t_uindex num_sides = sides(); + + // Add __ROW_PATH__ to the beginning for column only or for 2-sided + // sorted contexts where we used `column_names`, which does not add + // __ROW_PATH__ automatically. + if (num_sides > 0) { + t_tscalar row_path; + row_path.set("__ROW_PATH__"); + paths.insert(paths.begin(), std::vector{row_path}); + } + + // `delta.data` is packed against the full unity column layout + // (including hidden sort columns), so `ncols`/stride must match + // `paths` — *not* `num_columns()`, which for ctx2 sorted views + // collapses hidden columns out and would produce a wrong stride. + // `data_slice_to_batches`' modulo skip strips hidden columns from + // the eventual Arrow output for this path. + t_uindex ncols = paths.size(); + + return std::make_shared>( + m_ctx, + 0, + num_rows_changed, + 0, + ncols, + m_row_offset, + m_col_offset, + data, + paths + ); +} + +template +t_dtype +View::get_column_dtype(t_uindex idx) const { + return m_ctx->get_column_dtype(idx); +} + +template +bool +View::is_column_only() const { + return m_view_config->is_column_only(); +} + +#ifdef PSP_PARALLEL_FOR +template +std::shared_mutex* +View::get_lock() const { + return m_table->get_pool()->get_lock(); +} +#endif + +/****************************************************************************** + * + * Private + */ + +template +std::string +View::_map_aggregate_types( + const std::string& name, const std::string& typestring +) const { + + for (const t_aggspec& agg : m_aggregates) { + if (agg.name() == name) { + switch (agg.agg()) { + case AGGTYPE_DISTINCT_COUNT: + case AGGTYPE_COUNT: { + return "integer"; + } break; + case AGGTYPE_MEAN: + case AGGTYPE_MEAN_BY_COUNT: + case AGGTYPE_WEIGHTED_MEAN: + case AGGTYPE_PCT_SUM_PARENT: + case AGGTYPE_PCT_SUM_GRAND_TOTAL: + case AGGTYPE_VARIANCE: + case AGGTYPE_STANDARD_DEVIATION: { + return "float"; + } break; + default: { + return typestring; + } break; + } + } + } + + return typestring; +} + +void +write_scalar( + t_tscalar scalar, + bool is_formatted, + rapidjson::Writer& writer +) { + + if (!scalar.is_valid()) { + writer.Null(); + return; + } + + switch (scalar.get_dtype()) { + case DTYPE_NONE: + writer.Null(); + break; + case DTYPE_BOOL: + writer.Bool(scalar.get()); + break; + case DTYPE_UINT8: + case DTYPE_INT8: + writer.Int(scalar.get()); + break; + case DTYPE_UINT16: + case DTYPE_INT16: + writer.Int(scalar.get()); + break; + case DTYPE_UINT32: + case DTYPE_INT32: + writer.Int(scalar.get()); + break; + case DTYPE_UINT64: + case DTYPE_INT64: + writer.Int64(scalar.get()); + break; + case DTYPE_FLOAT32: + if (scalar.is_nan()) { + writer.Null(); + } else { + writer.Double(scalar.get()); + } + break; + case DTYPE_FLOAT64: + if (scalar.is_nan()) { + writer.Null(); + } else { + writer.Double(scalar.get()); + } + break; + case DTYPE_STR: + writer.String(scalar.get()); + break; + case DTYPE_TIME: + if (is_formatted) { + writer.String(scalar.to_string().c_str()); + } else { + writer.Int64(scalar.get()); + } + + break; + case DTYPE_DATE: { + if (is_formatted) { + writer.String(scalar.to_string().c_str()); + } else { + writer.Int64(scalar.get().as_epoch_ms()); + } + break; + } + + default: + break; + } +} + +template +void +View::write_row_path( + t_uindex start_row, + t_uindex end_row, + bool has_row_path, + bool is_formatted, + rapidjson::Writer& writer +) const { + + if (has_row_path) { + writer.Key("__ROW_PATH__"); + writer.StartArray(); + for (auto r = start_row; r < end_row; ++r) { + writer.StartArray(); + const auto row_path = get_row_path(r); + + // Question: Why are the row paths reversed? + for (auto entry = row_path.size(); entry > 0; entry--) { + const t_tscalar& scalar = row_path[entry - 1]; + write_scalar(scalar, is_formatted, writer); + } + + writer.EndArray(); + } + + writer.EndArray(); + } +} + +std::string +col_path_to_legacy(const std::vector& col_path) { + std::stringstream column_name; + if (!col_path.empty()) { + for (auto i = 0; i < col_path.size() - 1; ++i) { + column_name << col_path[i].to_string() << "|"; + } + } + + column_name << col_path[col_path.size() - 1].get(); + return column_name.str(); +} + +template +void +View::write_column( + t_uindex c, + t_uindex start_row, + t_uindex end_row, + bool has_row_path, + bool is_formatted, + std::shared_ptr> slice, + const std::vector>& col_names, + rapidjson::Writer& writer +) const { + + writer.Key(col_path_to_legacy(col_names.at(c)).c_str()); + writer.StartArray(); + + for (auto r = start_row; r < end_row; ++r) { + auto scalar = slice->get(r, c); + + write_scalar(scalar, is_formatted, writer); + } + + writer.EndArray(); +} + +template +void +View::write_index_column( + t_uindex start_row, + t_uindex end_row, + bool has_row_path, + bool is_formatted, + std::shared_ptr> slice, + rapidjson::Writer& writer +) const { + writer.Key("__INDEX__"); + writer.StartArray(); + + for (auto r = start_row; r < end_row; ++r) { + std::vector keys = slice->get_pkeys(r, 0); + + writer.StartArray(); + for (auto i = keys.size(); i > 0; --i) { + auto scalar = keys[i - 1]; + + write_scalar(scalar, is_formatted, writer); + } + + writer.EndArray(); + } + + writer.EndArray(); +} + +template +std::string +View::to_rows( + t_uindex start_row, + t_uindex end_row, + t_uindex start_col, + t_uindex end_col, + t_uindex hidden, + bool is_formatted, + bool get_pkeys, + bool get_ids, + t_uindex num_sides, + bool has_row_path, + const std::string& nidx, + t_uindex columns_length, + t_uindex group_by_length +) const { + PSP_GIL_UNLOCK(); + PSP_READ_LOCK(*get_lock()); + auto slice = get_data(start_row, end_row, start_col, end_col); + auto& col_names = slice->get_column_names(); + rapidjson::StringBuffer s; + rapidjson::Writer writer(s); + writer.StartArray(); + + if ((start_row == end_row || start_col == end_col) && !get_ids + && !get_pkeys) { + writer.EndArray(); + return s.GetString(); + } + + std::vector column_names; + for (auto c = start_col; c < end_col; ++c) { + column_names.emplace_back( + col_names[c][col_names[c].size() - 1].template get() + ); + } + + // These columns don't exist as far as the view/table is concerned. They're + // scoped to the serialization of the view itself. + // + // The issue is that if the viewport is past the + // end of all the view's columns, we still want to run this loop if __ID__ + // and __INDEX__ are within the viewport. At the same time, we want to never + // return a list of empty objects when all of the physical and "virtual" + // columns are out of bounds. + auto num_virtual_columns = (int)get_pkeys + (int)get_ids; + + if (start_col <= (end_col + num_virtual_columns)) { + for (auto r = start_row; r < end_row; ++r) { + writer.StartObject(); + if (get_ids) { + std::pair pair{r, 0}; + std::vector> vec{pair}; + const auto keys = m_ctx->get_pkeys(vec); + const t_tscalar& scalar = keys[0]; + writer.Key("__ID__"); + writer.StartArray(); + write_scalar(scalar, is_formatted, writer); + writer.EndArray(); + } + + if (get_pkeys) { + std::vector keys = slice->get_pkeys(r, 0); + writer.Key("__INDEX__"); + writer.StartArray(); + for (auto i = keys.size(); i > 0; --i) { + auto scalar = keys[i - 1]; + write_scalar(scalar, is_formatted, writer); + } + + writer.EndArray(); + } + + for (auto c = start_col; c < end_col; ++c) { + writer.Key(column_names[c - start_col].c_str()); + auto scalar = slice->get(r, c); + write_scalar(scalar, is_formatted, writer); + } + + writer.EndObject(); + } + } + + writer.EndArray(); + return s.GetString(); +} + +template <> +std::string +View::to_rows( + t_uindex start_row, + t_uindex end_row, + t_uindex start_col, + t_uindex end_col, + t_uindex hidden, + bool is_formatted, + bool get_pkeys, + bool get_ids, + t_uindex num_sides, + bool has_row_path, + const std::string& nidx, + t_uindex columns_length, + t_uindex group_by_length +) const { + PSP_GIL_UNLOCK(); + PSP_READ_LOCK(*get_lock()); + auto slice = get_data(start_row, end_row, start_col, end_col); + const auto& col_names = slice->get_column_names(); + rapidjson::StringBuffer s; + rapidjson::Writer writer(s); + writer.StartArray(); + + if (start_row == end_row || (start_col == end_col && !has_row_path)) { + writer.EndArray(); + return s.GetString(); + } + + std::vector column_names; + for (auto c = start_col + 1; c < end_col; ++c) { + if (c > columns_length) { + continue; + } + + column_names.emplace_back( + col_names[c][col_names[c].size() - 1].template get() + ); + } + + for (auto r = start_row; r < end_row; ++r) { + // Row + writer.StartObject(); + + // `__ROW_PATH__` + writer.Key("__ROW_PATH__"); + writer.StartArray(); + const auto row_path = get_row_path(r); + for (auto entry = row_path.size(); entry > 0; entry--) { + const t_tscalar& scalar = row_path[entry - 1]; + write_scalar(scalar, is_formatted, writer); + } + + writer.EndArray(); + + if (get_ids) { + writer.Key("__ID__"); + writer.StartArray(); + for (auto entry = row_path.size(); entry > 0; entry--) { + const t_tscalar& scalar = row_path[entry - 1]; + write_scalar(scalar, is_formatted, writer); + } + + writer.EndArray(); + } + + if (get_pkeys) { + std::vector keys = slice->get_pkeys(r, 0); + writer.Key("__INDEX__"); + writer.StartArray(); + for (auto i = keys.size(); i > 0; --i) { + auto scalar = keys[i - 1]; + write_scalar(scalar, is_formatted, writer); + } + + writer.EndArray(); + } + + // Columns + for (auto c = start_col + 1; c < end_col; ++c) { + if (c >= columns_length + 1) { + continue; + } + + writer.Key(column_names[c - (start_col + 1)].c_str()); + auto scalar = slice->get(r, c); + write_scalar(scalar, is_formatted, writer); + } + + writer.EndObject(); + } + + writer.EndArray(); + return s.GetString(); +} + +template <> +std::string +View::to_rows( + t_uindex start_row, + t_uindex end_row, + t_uindex start_col, + t_uindex end_col, + t_uindex hidden, + bool is_formatted, + bool get_pkeys, + bool get_ids, + t_uindex num_sides, + bool has_row_path, + const std::string& nidx, + t_uindex columns_length, + t_uindex group_by_length +) const { + PSP_GIL_UNLOCK(); + PSP_READ_LOCK(*get_lock()); + auto slice = get_data(start_row, end_row, start_col, end_col); + const auto& col_names = slice->get_column_names(); + rapidjson::StringBuffer s; + rapidjson::Writer writer(s); + writer.StartArray(); + + if (start_row == end_row || (start_col == end_col && !has_row_path)) { + writer.EndArray(); + return s.GetString(); + } + + std::vector column_names; + for (auto c = start_col + 1; c < end_col; ++c) { + column_names.emplace_back(col_path_to_legacy(col_names.at(c))); + } + + bool column_only = is_column_only(); + for (auto r = start_row; r < end_row; ++r) { + // Row + writer.StartObject(); + + // `__ROW_PATH__` + const auto row_path = get_row_path(r); + if (!column_only) { + writer.Key("__ROW_PATH__"); + writer.StartArray(); + for (auto entry = row_path.size(); entry > 0; entry--) { + const t_tscalar& scalar = row_path[entry - 1]; + write_scalar(scalar, is_formatted, writer); + } + + writer.EndArray(); + } + + if (get_ids) { + writer.Key("__ID__"); + writer.StartArray(); + for (auto entry = row_path.size(); entry > 0; entry--) { + const t_tscalar& scalar = row_path[entry - 1]; + write_scalar(scalar, is_formatted, writer); + } + + writer.EndArray(); + } + + if (get_pkeys) { + std::vector keys = slice->get_pkeys(r, 0); + writer.Key("__INDEX__"); + writer.StartArray(); + for (auto i = keys.size(); i > 0; --i) { + auto scalar = keys[i - 1]; + write_scalar(scalar, is_formatted, writer); + } + + writer.EndArray(); + } + + // Columns. ctx2 row-sorted views pre-filter hidden columns in + // `get_data` (signalled by non-empty `column_indices`); for + // column-sort-only views the slice still carries them and the + // modulo below drops them on the way out. + for (auto c = start_col + 1; c < end_col; ++c) { + if (slice->get_column_indices().empty() + && ((c - 1) % (columns_length + hidden)) >= columns_length) { + continue; + } + writer.Key(column_names[c - (start_col + 1)].c_str()); + auto scalar = slice->get(r, c); + write_scalar(scalar, is_formatted, writer); + } + + writer.EndObject(); + } + + writer.EndArray(); + return s.GetString(); +} + +// template <> +// std::string +// View::to_rows(t_uindex start_row, t_uindex end_row, t_uindex +// start_col, +// t_uindex end_col, t_uindex hidden, bool is_formatted, bool get_pkeys, +// bool get_ids, bool leaves_only, t_uindex num_sides, bool has_row_path, +// std::string nidx, t_uindex columns_length, t_uindex group_by_length) +// const { PSP_GIL_UNLOCK(); PSP_READ_LOCK(*get_lock()); const auto slice = +// get_data(start_row, end_row, start_col, end_col); const auto& col_names = +// slice->get_column_names(); rapidjson::StringBuffer s; +// rapidjson::Writer writer(s); +// writer.StartObject(); +// write_row_path( +// start_row, end_row, has_row_path, leaves_only, is_formatted, writer); +// if (get_ids) { +// writer.Key("__ID__"); +// writer.StartArray(); +// for (auto r = start_row; r < end_row; ++r) { +// writer.StartArray(); +// const auto row_path = m_ctx->get_row_path(r); +// for (auto entry = row_path.size(); entry > 0; entry--) { +// const t_tscalar& scalar = row_path[entry - 1]; +// write_scalar(scalar, is_formatted, writer); +// } + +// writer.EndArray(); +// } + +// writer.EndArray(); +// } + +// for (auto c = start_col + 1; c < end_col; ++c) { +// // Hidden columns are always at the end of the column names +// // list, and we need to skip them from the output. +// if (((c - 1) % (columns_length + hidden)) >= columns_length) { +// continue; +// } else { +// write_column(c, start_row, end_row, has_row_path, leaves_only, +// is_formatted, slice, col_names, writer); +// } +// } + +// if (get_pkeys) { +// write_index_column(start_row, end_row, has_row_path, leaves_only, +// is_formatted, slice, writer); +// } + +// writer.EndObject(); +// return s.GetString(); +// } + +template +std::string +View::to_ndjson( + t_uindex start_row, + t_uindex end_row, + t_uindex start_col, + t_uindex end_col, + t_uindex hidden, + bool is_formatted, + bool get_pkeys, + bool get_ids, + t_uindex num_sides, + bool has_row_path, + const std::string& nidx, + t_uindex columns_length, + t_uindex group_by_length +) const { + PSP_GIL_UNLOCK(); + PSP_READ_LOCK(*get_lock()); + auto slice = get_data(start_row, end_row, start_col, end_col); + const auto& col_names = slice->get_column_names(); + if (start_row == end_row || (start_col == end_col && !has_row_path)) { + return ""; + } + + std::vector column_names; + for (auto c = start_col; c < end_col; ++c) { + column_names.emplace_back( + col_names[c][col_names[c].size() - 1].template get() + ); + } + + // These columns don't exist as far as the view/table is concerned. They're + // scoped to the serialization of the view itself. + // + // The issue is that if the viewport is past the + // end of all the view's columns, we still want to run this loop if __ID__ + // and __INDEX__ are within the viewport. At the same time, we want to never + // return a list of empty objects when all of the physical and "virtual" + // columns are out of bounds. + auto num_virtual_columns = (int)get_pkeys + (int)get_ids; + std::stringstream ndjson; + if (start_col <= (end_col + num_virtual_columns)) { + for (auto r = start_row; r < end_row; ++r) { + if (r != start_row) { + ndjson << "\n"; + } + + rapidjson::StringBuffer s; + rapidjson::Writer writer(s); + + writer.StartObject(); + if (get_ids) { + std::pair pair{r, 0}; + std::vector> vec{pair}; + const auto keys = m_ctx->get_pkeys(vec); + const t_tscalar& scalar = keys[0]; + writer.Key("__ID__"); + writer.StartArray(); + write_scalar(scalar, is_formatted, writer); + writer.EndArray(); + } + + if (get_pkeys) { + std::vector keys = slice->get_pkeys(r, 0); + writer.Key("__INDEX__"); + writer.StartArray(); + for (auto i = keys.size(); i > 0; --i) { + auto scalar = keys[i - 1]; + write_scalar(scalar, is_formatted, writer); + } + + writer.EndArray(); + } + + for (auto c = start_col; c < end_col; ++c) { + writer.Key(column_names[c - start_col].c_str()); + auto scalar = slice->get(r, c); + write_scalar(scalar, is_formatted, writer); + } + + writer.EndObject(); + ndjson << s.GetString(); + } + } + + return ndjson.str(); +} + +template <> +std::string +View::to_ndjson( + t_uindex start_row, + t_uindex end_row, + t_uindex start_col, + t_uindex end_col, + t_uindex hidden, + bool is_formatted, + bool get_pkeys, + bool get_ids, + t_uindex num_sides, + bool has_row_path, + const std::string& nidx, + t_uindex columns_length, + t_uindex group_by_length +) const { + PSP_GIL_UNLOCK(); + PSP_READ_LOCK(*get_lock()); + auto slice = get_data(start_row, end_row, start_col, end_col); + const auto& col_names = slice->get_column_names(); + if (start_row == end_row || (start_col == end_col && !has_row_path)) { + return ""; + } + + std::vector column_names; + for (auto c = start_col + 1; c < end_col; ++c) { + if (c > columns_length) { + continue; + } + + column_names.emplace_back( + col_names[c][col_names[c].size() - 1].template get() + ); + } + + std::stringstream ndjson; + for (auto r = start_row; r < end_row; ++r) { + if (r != start_row) { + ndjson << "\n"; + } + + rapidjson::StringBuffer s; + rapidjson::Writer writer(s); + + // Row + writer.StartObject(); + + // `__ROW_PATH__` + writer.Key("__ROW_PATH__"); + writer.StartArray(); + const auto row_path = get_row_path(r); + for (auto entry = row_path.size(); entry > 0; entry--) { + const t_tscalar& scalar = row_path[entry - 1]; + write_scalar(scalar, is_formatted, writer); + } + + writer.EndArray(); + + if (get_ids) { + writer.Key("__ID__"); + writer.StartArray(); + for (auto entry = row_path.size(); entry > 0; entry--) { + const t_tscalar& scalar = row_path[entry - 1]; + write_scalar(scalar, is_formatted, writer); + } + + writer.EndArray(); + } + + if (get_pkeys) { + std::vector keys = slice->get_pkeys(r, 0); + writer.Key("__INDEX__"); + writer.StartArray(); + for (auto i = keys.size(); i > 0; --i) { + auto scalar = keys[i - 1]; + write_scalar(scalar, is_formatted, writer); + } + + writer.EndArray(); + } + + // Columns + for (auto c = start_col + 1; c < end_col; ++c) { + if (c >= columns_length + 1) { + continue; + } + + writer.Key(column_names[c - (start_col + 1)].c_str()); + auto scalar = slice->get(r, c); + write_scalar(scalar, is_formatted, writer); + } + + writer.EndObject(); + ndjson << s.GetString(); + } + + // writer.EndArray(); + return ndjson.str(); +} + +template <> +std::string +View::to_ndjson( + t_uindex start_row, + t_uindex end_row, + t_uindex start_col, + t_uindex end_col, + t_uindex hidden, + bool is_formatted, + bool get_pkeys, + bool get_ids, + t_uindex num_sides, + bool has_row_path, + const std::string& nidx, + t_uindex columns_length, + t_uindex group_by_length +) const { + PSP_GIL_UNLOCK(); + PSP_READ_LOCK(*get_lock()); + auto slice = get_data(start_row, end_row, start_col, end_col); + const auto& col_names = slice->get_column_names(); + if (start_row == end_row || (start_col == end_col && !has_row_path)) { + return ""; + } + + std::vector column_names; + for (auto c = start_col + 1; c < end_col; ++c) { + column_names.emplace_back(col_path_to_legacy(col_names.at(c))); + } + + bool column_only = is_column_only(); + std::stringstream ndjson; + for (auto r = start_row; r < end_row; ++r) { + if (r != start_row) { + ndjson << "\n"; + } + + rapidjson::StringBuffer s; + rapidjson::Writer writer(s); + + // Row + writer.StartObject(); + + // `__ROW_PATH__` + const auto row_path = get_row_path(r); + if (!column_only) { + writer.Key("__ROW_PATH__"); + writer.StartArray(); + for (auto entry = row_path.size(); entry > 0; entry--) { + const t_tscalar& scalar = row_path[entry - 1]; + write_scalar(scalar, is_formatted, writer); + } + + writer.EndArray(); + } + + if (get_ids) { + writer.Key("__ID__"); + writer.StartArray(); + for (auto entry = row_path.size(); entry > 0; entry--) { + const t_tscalar& scalar = row_path[entry - 1]; + write_scalar(scalar, is_formatted, writer); + } + + writer.EndArray(); + } + + if (get_pkeys) { + std::vector keys = slice->get_pkeys(r, 0); + writer.Key("__INDEX__"); + writer.StartArray(); + for (auto i = keys.size(); i > 0; --i) { + auto scalar = keys[i - 1]; + write_scalar(scalar, is_formatted, writer); + } + + writer.EndArray(); + } + + // Columns. ctx2 row-sorted views pre-filter hidden columns in + // `get_data` (signalled by non-empty `column_indices`); for + // column-sort-only views the slice still carries them and the + // modulo below drops them on the way out. + for (auto c = start_col + 1; c < end_col; ++c) { + if (slice->get_column_indices().empty() + && ((c - 1) % (columns_length + hidden)) >= columns_length) { + continue; + } + writer.Key(column_names[c - (start_col + 1)].c_str()); + auto scalar = slice->get(r, c); + write_scalar(scalar, is_formatted, writer); + } + + writer.EndObject(); + ndjson << s.GetString(); + } + + return ndjson.str(); +} + +template +std::string +View::to_columns( + t_uindex start_row, + t_uindex end_row, + t_uindex start_col, + t_uindex end_col, + t_uindex hidden, + bool is_formatted, + bool get_pkeys, + bool get_ids, + t_uindex num_sides, + bool _has_row_path, + const std::string& nidx, + t_uindex columns_length, + t_uindex group_by_length +) const { + PSP_GIL_UNLOCK(); + PSP_READ_LOCK(*get_lock()); + auto slice = get_data(start_row, end_row, start_col, end_col); + const std::vector>& col_names = + slice->get_column_names(); + + rapidjson::StringBuffer s; + rapidjson::Writer writer(s); + writer.StartObject(); + for (auto c = start_col; c < end_col; ++c) { + write_column( + c, + start_row, + end_row, + false, + is_formatted, + slice, + col_names, + writer + ); + } + + if (get_pkeys) { + write_index_column( + start_row, end_row, false, is_formatted, slice, writer + ); + } + + if (get_ids) { + writer.Key("__ID__"); + writer.StartArray(); + for (auto x = start_row; x < end_row; ++x) { + std::pair pair{x, 0}; + std::vector> vec{pair}; + const auto keys = m_ctx->get_pkeys(vec); + const t_tscalar& scalar = keys[0]; + writer.StartArray(); + write_scalar(scalar, is_formatted, writer); + writer.EndArray(); + } + + writer.EndArray(); + } + + writer.EndObject(); + return s.GetString(); +} + +template <> +std::string +View::to_columns( + t_uindex start_row, + t_uindex end_row, + t_uindex start_col, + t_uindex end_col, + t_uindex hidden, + bool is_formatted, + bool get_pkeys, + bool get_ids, + t_uindex num_sides, + bool has_row_path, + const std::string& nidx, + t_uindex columns_length, + t_uindex group_by_length +) const { + PSP_GIL_UNLOCK(); + PSP_READ_LOCK(*get_lock()); + + auto slice = get_data(start_row, end_row, start_col, end_col); + const auto& col_names = slice->get_column_names(); + rapidjson::StringBuffer s; + rapidjson::Writer writer(s); + writer.StartObject(); + write_row_path(start_row, end_row, has_row_path, is_formatted, writer); + if (get_ids) { + writer.Key("__ID__"); + writer.StartArray(); + for (auto r = start_row; r < end_row; ++r) { + writer.StartArray(); + const auto row_path = m_ctx->get_row_path(r); + for (auto entry = row_path.size(); entry > 0; entry--) { + const t_tscalar& scalar = row_path[entry - 1]; + write_scalar(scalar, is_formatted, writer); + } + + writer.EndArray(); + } + + writer.EndArray(); + } + + // Hidden columns are always at the end of the column names + // list, and we need to skip them from the output. + for (auto c = start_col + 1; c < end_col; ++c) { + if ((c - 1) >= columns_length) { + continue; + } + write_column( + c, + start_row, + end_row, + true, + is_formatted, + slice, + col_names, + writer + ); + } + + if (get_pkeys) { + write_index_column( + start_row, end_row, true, is_formatted, slice, writer + ); + } + + writer.EndObject(); + return s.GetString(); +} + +template <> +std::string +View::to_columns( + t_uindex start_row, + t_uindex end_row, + t_uindex start_col, + t_uindex end_col, + t_uindex hidden, + bool is_formatted, + bool get_pkeys, + bool get_ids, + t_uindex num_sides, + bool has_row_path, + const std::string& nidx, + t_uindex columns_length, + t_uindex group_by_length +) const { + PSP_GIL_UNLOCK(); + PSP_READ_LOCK(*get_lock()); + const auto slice = get_data(start_row, end_row, start_col, end_col); + const auto& col_names = slice->get_column_names(); + rapidjson::StringBuffer s; + rapidjson::Writer writer(s); + writer.StartObject(); + write_row_path( + start_row, end_row, has_row_path, is_formatted, writer + ); + + if (get_ids) { + writer.Key("__ID__"); + writer.StartArray(); + for (auto r = start_row; r < end_row; ++r) { + writer.StartArray(); + const auto row_path = m_ctx->get_row_path(r); + for (auto entry = row_path.size(); entry > 0; entry--) { + const t_tscalar& scalar = row_path[entry - 1]; + write_scalar(scalar, is_formatted, writer); + } + + writer.EndArray(); + } + + writer.EndArray(); + } + + LOG_DEBUG("Using ctx2 to_columns"); + + // ctx2 *row-sorted* views go through `get_data`'s sorted branch + // which pre-filters hidden sort columns from both `col_names` and + // the packed slice (signalled by a non-empty `column_indices`). + // Column-sort-only views (no row sort) skip that branch — the slice + // still carries hidden columns interleaved at every + // `(columns_length + hidden)`-th unity position, and the modulo + // below drops them from the output. + bool slice_is_visible_only = !slice->get_column_indices().empty(); + for (auto c = start_col + 1; c < end_col; ++c) { + if (!slice_is_visible_only + && ((c - 1) % (columns_length + hidden)) >= columns_length) { + LOG_DEBUG("Skipping column {}" << col_path_to_legacy(col_names[c])); + continue; + } + LOG_DEBUG("Writing column {}" << col_path_to_legacy(col_names[c])); + write_column( + c, + start_row, + end_row, + has_row_path, + is_formatted, + slice, + col_names, + writer + ); + } + + if (get_pkeys) { + write_index_column( + start_row, + end_row, + has_row_path, + is_formatted, + slice, + writer + ); + } + + writer.EndObject(); + return s.GetString(); +} + +template +void +View::_find_hidden_sort(const std::vector& sort) { + for (const t_sortspec& s : sort) { + bool hidden = std::find(m_columns.begin(), m_columns.end(), s.m_colname) + == m_columns.end(); + if (hidden) { + // Store the actual column, not the composite column path + m_hidden_sort.push_back(s.m_colname); + } + } +} + +// Explicitly instantiate View for each context +template class View; +template class View; +template class View; +template class View; +} // end namespace perspective \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/cpp/view_config.cpp b/rust/perspective-server/cpp/perspective/src/cpp/view_config.cpp new file mode 100644 index 0000000..f1b2bd5 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/view_config.cpp @@ -0,0 +1,477 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include "perspective/base.h" +#include + +#include + +namespace perspective { + +t_view_config::t_view_config( + t_vocab vocab, + const std::vector& row_pivots, + const std::vector& column_pivots, + const tsl::ordered_map>& aggregates, + const std::vector& columns, + const std::vector< + std::tuple>>& filter, + const std::vector>& sort, + const std::vector>& expressions, + std::string filter_op, + bool column_only, + bool leaves_only, + bool total_only +) : + m_init(false), + m_vocab(std::move(vocab)), + m_row_pivots(row_pivots), + m_column_pivots(column_pivots), + m_aggregates(aggregates), + m_columns(columns), + m_filter(filter), + m_sort(sort), + m_expressions(expressions), + m_row_pivot_depth(-1), + m_column_pivot_depth(-1), + m_filter_op(std::move(filter_op)), + m_column_only(column_only), + m_leaves_only(leaves_only), + m_total_only(total_only) {} + +void +t_view_config::init(const std::shared_ptr& schema) { + validate(schema); + fill_aggspecs(schema); + fill_fterm(); + fill_sortspec(); + + m_init = true; +} + +void +t_view_config::validate(const std::shared_ptr& schema) { + std::unordered_set expression_aliases; + expression_aliases.reserve(m_expressions.size()); + + for (const auto& expr : m_expressions) { + expression_aliases.insert(expr->get_expression_alias()); + } + + for (const std::string& col : m_columns) { + if (!schema->has_column(col) && expression_aliases.count(col) == 0) { + std::stringstream ss; + ss << "Invalid column '" << col << "' found in View columns." + << '\n'; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + } + + for (const auto& agg : m_aggregates) { + const std::string& col = agg.first; + if (!schema->has_column(col) && expression_aliases.count(col) == 0) { + std::stringstream ss; + ss << "Invalid column '" << col << "' found in View aggregates." + << '\n'; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + } + + for (const std::string& col : m_row_pivots) { + if (!schema->has_column(col) && expression_aliases.count(col) == 0) { + std::stringstream ss; + ss << "Invalid column '" << col << "' found in View group_by." + << '\n'; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + } + + for (const std::string& col : m_column_pivots) { + if (!schema->has_column(col) && expression_aliases.count(col) == 0) { + std::stringstream ss; + ss << "Invalid column '" << col << "' found in View split_by." + << '\n'; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + } + + for (const auto& filter : m_filter) { + const std::string& col = std::get<0>(filter); + if (!schema->has_column(col) && expression_aliases.count(col) == 0) { + std::stringstream ss; + ss << "Invalid column '" << col << "' found in View filters." + << '\n'; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + } + + for (const auto& sort : m_sort) { + const std::string& col = sort[0]; + if (!schema->has_column(col) && expression_aliases.count(col) == 0) { + std::stringstream ss; + ss << "Invalid column '" << col << "' found in View sorts." << '\n'; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + } +} + +std::vector> +t_view_config::get_used_expressions() { + std::vector> exprs = m_expressions; + tsl::hopscotch_set used_cols( + m_columns.begin(), m_columns.end() + ); + + std::copy( + m_row_pivots.begin(), + m_row_pivots.end(), + std::inserter(used_cols, used_cols.end()) + ); + + for (const auto& i : m_filter) { + used_cols.insert(std::get<0>(i)); + } + + for (const auto& i : m_sort) { + used_cols.insert(i[0]); + } + + std::copy( + m_column_pivots.begin(), + m_column_pivots.end(), + std::inserter(used_cols, used_cols.end()) + ); + + for (const auto& agg : m_aggregates) { + if (std::find(m_columns.begin(), m_columns.end(), agg.first) + == m_columns.end()) { + continue; + } + const auto& aggregate = agg.second; + for (std::size_t i = 1; i < aggregate.size(); ++i) { + used_cols.insert(aggregate[i]); + } + } + + auto iter = std::remove_if( + exprs.begin(), + exprs.end(), + [&](std::shared_ptr& i) { + bool unused = + used_cols.find(i->get_expression_alias()) == used_cols.end(); + +#ifdef PSP_DEBUG + if (unused) { + std::cout << "Unused expression `" << i->get_expression_alias() + << "`" + << "\n"; + } +#endif + + return unused; + } + ); + + if (iter != exprs.end()) { + exprs.erase(iter, exprs.end()); + } + + return exprs; +} + +void +t_view_config::add_filter_term( + const std::tuple>& term +) { + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + m_filter.push_back(term); +} + +void +t_view_config::set_row_pivot_depth(std::int32_t depth) { + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + m_row_pivot_depth = depth; +} + +void +t_view_config::set_column_pivot_depth(std::int32_t depth) { + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + m_column_pivot_depth = depth; +} + +std::vector +t_view_config::get_row_pivots() const { + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_row_pivots; +} + +std::vector +t_view_config::get_column_pivots() const { + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_column_pivots; +} + +std::vector +t_view_config::get_aggspecs() const { + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_aggspecs; +} + +std::vector +t_view_config::get_columns() const { + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_columns; +} + +std::vector +t_view_config::get_fterm() const { + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_fterm; +} + +std::vector +t_view_config::get_sortspec() const { + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_sortspec; +} + +std::vector +t_view_config::get_col_sortspec() const { + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_col_sortspec; +} + +std::vector> +t_view_config::get_expressions() const { + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_expressions; +} + +t_filter_op +t_view_config::get_filter_op() const { + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return str_to_filter_op(m_filter_op); +} + +bool +t_view_config::is_column_only() const { + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_column_only; +} + +bool +t_view_config::is_leaves_only() const { + return m_leaves_only; +} + +bool +t_view_config::is_total_only() const { + return m_total_only; +} + +std::int32_t +t_view_config::get_row_pivot_depth() const { + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_row_pivot_depth; +} + +std::int32_t +t_view_config::get_column_pivot_depth() const { + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + return m_column_pivot_depth; +} + +// PRIVATE +void +t_view_config::fill_aggspecs(const std::shared_ptr& schema) { + auto max_agg_count = m_columns.size() + m_sort.size(); + m_aggspecs.reserve(max_agg_count); + m_aggregate_names.reserve(max_agg_count); + + // Iterate through the columns array - if the column is in the + // aggregates map, use the specified aggregate or generate a default. If + // an aggregate is in the aggregate map but NOT in the columns list, + // it is ignored and NOT applied. + for (const std::string& column : m_columns) { + t_dtype dtype = schema->get_dtype(column); + const auto& agg_iter = m_aggregates.find(column); + + if (agg_iter != m_aggregates.end()) { + // If the column name is in the aggregate map, use the custom + // aggregate as defined by the map and add it to m_aggspecs + // and m_aggregate_names. + const std::vector& aggregate = agg_iter->second; + make_aggspec(column, aggregate, dtype); + } else { + // Generate a default aggregate based on the column type. + std::vector dependencies{t_dep(column, DEPTYPE_COLUMN)}; + t_aggtype agg_type; + (m_column_only && !m_total_only) + ? agg_type = AGGTYPE_ANY + : agg_type = _get_default_aggregate(dtype); + m_aggspecs.emplace_back(column, agg_type, dependencies); + m_aggregate_names.push_back(column); + } + } + + // construct aggspecs for hidden sorts + for (auto sort : m_sort) { + std::string column = sort[0]; + + bool is_hidden_column = + std::find(m_columns.begin(), m_columns.end(), column) + == m_columns.end(); + + if (is_hidden_column) { + bool is_row_pivot = + std::find(m_row_pivots.begin(), m_row_pivots.end(), column) + != m_row_pivots.end(); + bool is_column_pivot = + std::find( + m_column_pivots.begin(), m_column_pivots.end(), column + ) + != m_column_pivots.end(); + bool is_column_only = + (m_row_pivots.empty() || m_column_only) && !m_total_only; + bool is_row_sort = sort[1].rfind("col", 0) != 0; + + std::vector dependencies{t_dep(column, DEPTYPE_COLUMN)}; + t_aggtype agg_type; + + if (is_column_only) { + // Always sort by `ANY` in column only views + agg_type = t_aggtype::AGGTYPE_ANY; + } else if ((is_row_pivot && is_row_sort) || (is_column_pivot && !is_row_sort)) { + // Otherwise if the hidden column is in pivot on the same axis, + // use `UNIQUE` + agg_type = t_aggtype::AGGTYPE_UNIQUE; + } else if (m_aggregates.count(column) > 0) { + auto col = m_aggregates.at(column); + if (col.at(0) == "weighted mean") { + dependencies.emplace_back(col.at(1), DEPTYPE_COLUMN); + agg_type = AGGTYPE_WEIGHTED_MEAN; + } else if (col.at(0) == "max by") { + dependencies.emplace_back(col.at(1), DEPTYPE_COLUMN); + agg_type = AGGTYPE_MAX_BY; + } else if (col.at(0) == "min by") { + dependencies.emplace_back(col.at(1), DEPTYPE_COLUMN); + agg_type = AGGTYPE_MIN_BY; + } else { + agg_type = str_to_aggtype(col.at(0)); + } + } else { + t_dtype dtype = schema->get_dtype(column); + agg_type = _get_default_aggregate(dtype); + } + + m_aggspecs.emplace_back(column, agg_type, dependencies); + m_aggregate_names.push_back(column); + } + } +} + +void +t_view_config::fill_fterm() { + for (auto filter : m_filter) { + t_filter_op op = str_to_filter_op(std::get<1>(filter)); + switch (op) { + case FILTER_OP_NOT_IN: + case FILTER_OP_IN: { + m_fterm.emplace_back( + std::get<0>(filter), op, mktscalar(0), std::get<2>(filter) + ); + } break; + default: { + t_tscalar filter_term = std::get<2>(filter)[0]; + m_fterm.emplace_back( + std::get<0>(filter), + op, + filter_term, + std::vector() + ); + } + } + } +} + +void +t_view_config::fill_sortspec() { + for (auto s : m_sort) { + t_index agg_index = get_aggregate_index(s[0]); + t_sorttype sort_type = str_to_sorttype(s[1]); + + auto spec = t_sortspec(s[0], agg_index, sort_type); + + bool is_column_sort = s[1].find("col") != std::string::npos; + if (is_column_sort) { + m_col_sortspec.push_back(spec); + } else { + m_sortspec.push_back(spec); + } + } +} + +t_index +t_view_config::get_aggregate_index(const std::string& column) const { + auto it = + std::find(m_aggregate_names.begin(), m_aggregate_names.end(), column); + if (it != m_aggregate_names.end()) { + return t_index(std::distance(m_aggregate_names.begin(), it)); + } + return t_index(); +} + +void +t_view_config::make_aggspec( + const std::string& column, + const std::vector& aggregate, + t_dtype dtype +) { + t_aggtype agg_type; + t_aggspec aggspec; + + // Maximum of 2 dependencies, based on the aggregate type + std::vector dependencies{t_dep(column, DEPTYPE_COLUMN)}; + dependencies.reserve(2); + + if (m_column_only && !m_total_only) { + agg_type = t_aggtype::AGGTYPE_ANY; + } else { + if (aggregate.at(0) == "weighted mean") { + dependencies.emplace_back(aggregate.at(1), DEPTYPE_COLUMN); + agg_type = AGGTYPE_WEIGHTED_MEAN; + } else if (aggregate.at(0) == "max by") { + dependencies.emplace_back(aggregate.at(1), DEPTYPE_COLUMN); + agg_type = AGGTYPE_MAX_BY; + } else if (aggregate.at(0) == "min by") { + dependencies.emplace_back(aggregate.at(1), DEPTYPE_COLUMN); + agg_type = AGGTYPE_MIN_BY; + } else { + agg_type = str_to_aggtype(aggregate.at(0)); + } + } + + if (agg_type == AGGTYPE_FIRST || agg_type == AGGTYPE_LAST_BY_INDEX + || agg_type == AGGTYPE_LAST_MINUS_FIRST) { + dependencies.emplace_back("psp_okey", DEPTYPE_COLUMN); + aggspec = t_aggspec( + column, column, agg_type, dependencies, SORTTYPE_ASCENDING + ); + } else { + aggspec = t_aggspec(column, agg_type, dependencies); + } + + m_aggspecs.push_back(aggspec); + m_aggregate_names.push_back(column); +} + +} // end namespace perspective \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/cpp/vocab.cpp b/rust/perspective-server/cpp/perspective/src/cpp/vocab.cpp new file mode 100644 index 0000000..7c4a452 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/cpp/vocab.cpp @@ -0,0 +1,273 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include + +#include + +namespace perspective { + +t_vocab::t_vocab() : m_vlenidx(0) { + m_vlendata = std::make_shared(); + m_extents = std::make_shared(); +} + +t_vocab::t_vocab(const t_column_recipe& r) : m_vlenidx(r.m_vlenidx) { + if (is_vlen_dtype(r.m_dtype)) { + m_vlendata = std::make_shared(r.m_vlendata); + m_extents = std::make_shared(r.m_extents); + } else { + m_vlendata = std::make_shared(); + m_extents = std::make_shared(); + } +} + +t_vocab::t_vocab( + const t_lstore_recipe& vlendata_recipe, + const t_lstore_recipe& extents_recipe +) : + m_vlenidx(0) { + m_vlendata = std::make_shared(vlendata_recipe); + m_extents = std::make_shared(extents_recipe); +} + +void +t_vocab::rebuild_map() { + m_map.clear(); + m_map.reserve((size_t)m_vlenidx); + for (t_uindex idx = 0; idx < m_vlenidx; ++idx) { + m_map[unintern_c(idx)] = idx; + } +} + +void +t_vocab::reserve(size_t total_string_size, size_t string_count) { + m_vlendata->reserve(total_string_size); + m_extents->reserve(sizeof(std::pair) * string_count); + rebuild_map(); +} + +bool +t_vocab::string_exists(const char* c, t_uindex& interned) const { + auto iter = m_map.find(c); + + if (iter == m_map.end()) { + return false; + } + + interned = iter->second; + return true; +} + +bool +t_vocab::string_exists( + const char* c, std::size_t hash, t_uindex& interned +) const { + auto iter = m_map.find(c, hash); + + if (iter == m_map.end()) { + return false; + } + + interned = iter->second; + return true; +} + +t_uindex +t_vocab::get_interned(const char* s) { + std::size_t bytelength = strlen(s); + return get_interned(s, bytelength, boost::hash_range(s, s + bytelength)); +} + +t_uindex +t_vocab::get_interned( + const char* s, std::size_t bytelength, std::size_t hash +) { +#ifdef PSP_COLUMN_VERIFY + PSP_VERBOSE_ASSERT(s != 0, "Null string"); +#endif + + t_sidxmap::iterator iter = m_map.find(s, hash); + + t_uindex idx; + t_uindex bidx; + t_uindex eidx; + t_uindex len = bytelength + 1; + + if (iter == m_map.end()) { + idx = genidx(); + + bidx = m_vlendata->size(); + eidx = bidx + len; + const void* obase = m_vlendata->get_nth(0); + const void* oebase = + m_extents->get_nth>(0); + m_vlendata->push_back(static_cast(s), len); + m_extents->push_back(std::pair(bidx, eidx)); + const void* nbase = m_vlendata->get_nth(0); + const void* nebase = + m_extents->get_nth>(0); + if ((obase == nbase) && (oebase == nebase)) { + m_map[unintern_c(idx)] = idx; + } else { + rebuild_map(); + } + } else { + idx = iter->second; + } + + return idx; +} + +t_uindex +t_vocab::genidx() { + return m_vlenidx++; +} + +void +t_vocab::init(bool from_recipe) { + m_vlendata->init(); + m_extents->init(); + if (from_recipe) { + rebuild_map(); + } +} + +t_uindex +t_vocab::get_interned(const std::string& s) { + return get_interned(s.c_str()); +} + +void +t_vocab::verify() const { + std::map rlookup; + + for (const auto& kv : m_map) { + rlookup[kv.second] = kv.first; + } + + tsl::hopscotch_set seen; + + for (t_uindex idx = 1; idx < m_vlenidx; ++idx) { + std::stringstream ss; + ss << "idx => " << idx << " not found"; + PSP_VERBOSE_ASSERT(rlookup.find(idx) != rlookup.end(), ss.str()); + + std::string curstr = std::string(rlookup.at(idx)); + + PSP_VERBOSE_ASSERT( + seen.find(curstr) == seen.end(), "string encountered again" + ); + + PSP_VERBOSE_ASSERT( + std::string(unintern_c(idx)) == curstr, "String mismatch" + ); + } +} + +void +t_vocab::verify_size() const { + PSP_VERBOSE_ASSERT( + m_vlenidx == m_map.size(), "Size and vlenidx size dont line up" + ); + + PSP_VERBOSE_ASSERT( + m_vlenidx * sizeof(std::pair) + <= m_extents->capacity(), + "Not enough space reserved for extents" + ); +} + +t_uindex +t_vocab::nbytes() const { + t_uindex rv = 0; + rv += m_vlendata->capacity(); + rv += m_extents->capacity(); + return rv; +} + +void +t_vocab::fill( + const t_lstore& o_vlen, const t_lstore& o_extents, t_uindex vlenidx +) { + m_vlendata->fill(o_vlen); + m_extents->fill(o_extents); + m_vlenidx = vlenidx; +} + +void +t_vocab::copy_vocabulary(const t_vocab& other) { + m_vlenidx = other.m_vlenidx; + m_vlendata = other.m_vlendata->clone(); + m_extents = other.m_extents->clone(); + rebuild_map(); +} + +void +t_vocab::pprint_vocabulary() const { + std::cout << "vocabulary =========\n"; + for (t_uindex idx = 0; idx < m_vlenidx; ++idx) { + std::cout << "\t" << idx << " => '" << unintern_c(idx) << "'" << '\n'; + } + + std::cout << "end vocabulary =========\n"; +} + +const char* +t_vocab::unintern_c(t_uindex idx) const { + const std::pair* p = + m_extents->get_nth>(idx); + const char* rv = static_cast(m_vlendata->get_ptr(p->first)); + return rv; +} + +void +t_vocab::clone(const t_vocab& v) { + m_vlendata->fill(*(v.m_vlendata)); + m_extents->fill(*(v.m_extents)); + m_vlenidx = v.m_vlenidx; + rebuild_map(); +} + +void +t_vocab::set_vlenidx(t_uindex idx) { + m_vlenidx = idx; +} + +t_extent_pair* +t_vocab::get_extents_base() { + return m_extents->get(0); +} + +unsigned char* +t_vocab::get_vlen_base() { + return m_vlendata->get(0); +} + +std::shared_ptr +t_vocab::get_vlendata() { + return m_vlendata; +} + +std::shared_ptr +t_vocab::get_extents() { + return m_extents; +} + +t_uindex +t_vocab::get_vlenidx() const { + return m_vlenidx; +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/aggregate.h b/rust/perspective-server/cpp/perspective/src/include/perspective/aggregate.h new file mode 100644 index 0000000..22b1e95 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/aggregate.h @@ -0,0 +1,350 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +template +class PERSPECTIVE_EXPORT t_aggimpl { +public: + typedef RAW_DATA_T t_raw_data; + typedef ROLLING_T t_rolling; + typedef RESULT_T t_result; + + RESULT_T value(ROLLING_T rs); + ROLLING_T reduce(const RAW_DATA_T* biter, const RAW_DATA_T* eiter); + ROLLING_T roll_up(const ROLLING_T* biter, const ROLLING_T* eiter); +}; + +template +class PERSPECTIVE_EXPORT t_aggimpl_sum + : public t_aggimpl { +public: + RESULT_T + value(ROLLING_T rs) { return RESULT_T(rs); } + + ROLLING_T + reduce(const RAW_DATA_T* biter, const RAW_DATA_T* eiter) { + ROLLING_T value = static_cast(0); + auto n = eiter - biter; +#pragma omp simd reduction(+ : value) + for (decltype(n) i = 0; i < n; ++i) { + value += static_cast(biter[i]); + } + return value; + } + + ROLLING_T + roll_up(const ROLLING_T* biter, const ROLLING_T* eiter) { + ROLLING_T value = static_cast(0); + auto n = eiter - biter; +#pragma omp simd reduction(+ : value) + for (decltype(n) i = 0; i < n; ++i) { + value += biter[i]; + } + return value; + } +}; + +template +class PERSPECTIVE_EXPORT t_aggimpl_mul + : public t_aggimpl { +public: + RESULT_T + value(ROLLING_T rs) { return RESULT_T(rs); } + + ROLLING_T + reduce(const RAW_DATA_T* biter, const RAW_DATA_T* eiter) { + ROLLING_T value = std::accumulate( + biter, + eiter, + static_cast(1), + std::multiplies() + ); + return value; + } + + ROLLING_T + roll_up(const ROLLING_T* biter, const ROLLING_T* eiter) { + ROLLING_T value = std::accumulate( + biter, + eiter, + static_cast(1), + std::multiplies() + ); + return value; + } +}; + +template +class PERSPECTIVE_EXPORT t_aggimpl_count + : public t_aggimpl { +public: + RESULT_T + value(ROLLING_T rs) { return RESULT_T(rs); } + + ROLLING_T + reduce(const RAW_DATA_T* biter, const RAW_DATA_T* eiter) { + // count will be filled in later based on nstrands + ROLLING_T value(0); + return value; + } + + ROLLING_T + roll_up(const ROLLING_T* biter, const ROLLING_T* eiter) { + ROLLING_T value(0); + return value; + } +}; + +template +class PERSPECTIVE_EXPORT t_aggimpl_mean + : public t_aggimpl { +public: + RESULT_T + value(ROLLING_T rs) { return rs.first / rs.second; } + + ROLLING_T + reduce(const RAW_DATA_T* biter, const RAW_DATA_T* eiter) { + double sum = 0.0; + auto n = eiter - biter; +#pragma omp simd reduction(+ : sum) + for (decltype(n) i = 0; i < n; ++i) { + sum += static_cast(biter[i]); + } + + double count = static_cast(n); + return ROLLING_T(sum, count); + } + + ROLLING_T + roll_up(const ROLLING_T* biter, const ROLLING_T* eiter) { + double sum = 0.0; + double count = 0.0; + auto niter = eiter - biter; + + for (decltype(niter) idx = 0; idx < niter; ++idx) { + sum += biter[idx].first; + count += biter[idx].second; + } + + return ROLLING_T(sum, count); + } +}; + +template +class PERSPECTIVE_EXPORT t_aggimpl_hwm + : public t_aggimpl { +public: + RESULT_T + value(ROLLING_T rs) { return RESULT_T(rs); } + + ROLLING_T + reduce(const RAW_DATA_T* biter, const RAW_DATA_T* eiter) { + if (biter >= eiter) { + return ROLLING_T(); + } + return ROLLING_T(*(std::max_element(biter, eiter))); + } + + ROLLING_T + roll_up(const ROLLING_T* biter, const ROLLING_T* eiter) { + if (biter >= eiter) { + return ROLLING_T(); + } + return *(std::max_element(biter, eiter)); + } +}; + +template +class PERSPECTIVE_EXPORT t_aggimpl_lwm + : public t_aggimpl { +public: + RESULT_T + value(ROLLING_T rs) { return RESULT_T(rs); } + + ROLLING_T + reduce(const RAW_DATA_T* biter, const RAW_DATA_T* eiter) { + if (biter >= eiter) { + return ROLLING_T(); + } + return ROLLING_T(*(std::min_element(biter, eiter))); + } + + ROLLING_T + roll_up(const ROLLING_T* biter, const ROLLING_T* eiter) { + if (biter >= eiter) { + return ROLLING_T(); + } + return *(std::min_element(biter, eiter)); + } +}; + +class PERSPECTIVE_EXPORT t_aggregate { +public: + t_aggregate( + const t_dtree& tree, + t_aggtype aggtype, + std::vector> icolumns, + std::shared_ptr column + ); + void init(); + + template + void build_aggregate(); + +private: + const t_dtree& m_tree; + t_aggtype m_aggtype; + std::vector> m_icolumns; + std::shared_ptr m_ocolumn; +}; + +template < + typename AGGIMPL_T, + typename std::enable_if< + !std::is_same::value, + int>::type = 0> +void +build_aggregate_helper( + AGGIMPL_T& aggimpl, + t_index bcidx, + t_index ecidx, + t_column* ocolumn, + t_index nidx +) { + typedef typename AGGIMPL_T::t_rolling t_rolling; + const t_rolling* base = ocolumn->get_nth_base(); + const t_rolling* biter = base + bcidx; + const t_rolling* eiter = base + ecidx; + t_rolling rolling = aggimpl.roll_up(biter, eiter); + ocolumn->set_nth(nidx, rolling); +} + +template < + typename AGGIMPL_T, + typename std::enable_if< + std::is_same::value, + int>::type = 0> +void +build_aggregate_helper( + AGGIMPL_T& aggimpl, + t_index bcidx, + t_index ecidx, + t_column* ocolumn, + t_index nidx +) { + const t_column* c_ocolumn = ocolumn; + typedef typename AGGIMPL_T::t_rolling t_rolling; + std::vector sbuf(ecidx - bcidx); + for (t_index sidx = bcidx; sidx < ecidx; ++sidx) { + + auto tmpv = c_ocolumn->get_nth(static_cast(sidx)); + sbuf[sidx - bcidx] = tmpv; + } + + if (!sbuf.empty()) { + + t_rolling rolling = + aggimpl.roll_up(sbuf.data(), sbuf.data() + sbuf.size()); + ocolumn->set_nth(nidx, rolling); + } +} + +template +void +t_aggregate::build_aggregate() { + + typedef typename AGGIMPL_T::t_rolling t_rolling; + typedef typename AGGIMPL_T::t_raw_data t_raw_data; + typedef typename t_dtree::t_tnode t_node; + + t_depth n_levels = m_tree.last_level(); + + AGGIMPL_T aggimpl; + + typedef typename t_dtree::t_tnode t_tnode; + t_column* ocolumn = m_ocolumn.get(); + + PSP_VERBOSE_ASSERT( + m_icolumns.size() == 1, "Multiple input dependencies not supported yet" + ); + + const t_column* icptr = m_icolumns[0].get(); + t_uindex icptr_size = icptr->size(); + + if (icptr_size == 0) { + return; + } + + const t_column* lcptr = m_tree.get_leaf_cptr(); + const t_uindex* base_lcptr = lcptr->get(0); + + // Lazily sized buffer — allocated to max leaf range, not entire column. + std::vector buffer; + + for (t_index level_idx = n_levels; level_idx > -1; level_idx--) { + std::pair markers = + m_tree.get_level_markers(level_idx); + + t_index bidx = markers.first; + t_index eidx = markers.second; + + if (level_idx == n_levels) { + for (t_index nidx = bidx; nidx < eidx; nidx++) { + const t_node* n_ = m_tree.get_node_ptr(nidx); + const t_tnode& n = *n_; + const t_uindex* blptr = base_lcptr + n.m_flidx; + const t_uindex* elptr = blptr + n.m_nleaves; + + PSP_VERBOSE_ASSERT(elptr > blptr, "Unexpected pointers"); + + t_uindex leaf_count = elptr - blptr; + if (buffer.size() < leaf_count) { + buffer.resize(leaf_count); + } + + icptr->fill(buffer, blptr, elptr); + + t_raw_data* biter = &buffer[0]; + t_raw_data* eiter = biter + leaf_count; + auto tmp = aggimpl.reduce(biter, eiter); + ocolumn->set_nth(nidx, tmp); + } + } else { + // for all nodes in level + for (t_index nidx = bidx; nidx < eidx; nidx++) { + const t_node* n_ = m_tree.get_node_ptr(nidx); + const t_node& n = *n_; + + t_index bcidx = n.m_fcidx; + t_index ecidx = bcidx + n.m_nchild; + + build_aggregate_helper(aggimpl, bcidx, ecidx, ocolumn, nidx); + } + } + } +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/aggspec.h b/rust/perspective-server/cpp/perspective/src/include/perspective/aggspec.h new file mode 100644 index 0000000..3105030 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/aggspec.h @@ -0,0 +1,120 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +struct PERSPECTIVE_EXPORT t_col_name_type { + t_col_name_type(); + t_col_name_type(std::string name, t_dtype type); + std::string m_name; + t_dtype m_type; +}; + +class PERSPECTIVE_EXPORT t_aggspec { +public: + t_aggspec(); + + ~t_aggspec(); + + t_aggspec( + const std::string& aggname, + t_aggtype agg, + const std::vector& dependencies + ); + + t_aggspec( + const std::string& aggname, t_aggtype agg, const std::string& dep + ); + + t_aggspec(t_aggtype agg, const std::string& dep); + + t_aggspec( + std::string aggname, + std::string disp_name, + t_aggtype agg, + const std::vector& dependencies + ); + + t_aggspec( + std::string aggname, + std::string disp_name, + t_aggtype agg, + const std::vector& dependencies, + t_sorttype sort_type + ); + + t_aggspec( + std::string aggname, + std::string disp_aggname, + t_aggtype agg, + t_uindex agg_one_idx, + t_uindex agg_two_idx, + double agg_one_weight, + double agg_two_weight + ); + + std::string name() const; + t_tscalar name_scalar() const; + std::string disp_name() const; + t_aggtype agg() const; + std::string agg_str() const; + const std::vector& get_dependencies() const; + t_sorttype get_sort_type() const; + + t_uindex get_agg_one_idx() const; + t_uindex get_agg_two_idx() const; + + double get_agg_one_weight() const; + double get_agg_two_weight() const; + + t_invmode get_inv_mode() const; + + std::vector get_input_depnames() const; + std::vector get_output_depnames() const; + + std::vector get_output_specs(const t_schema& schema) const; + std::vector + mk_col_name_type_vec(const std::string& name, t_dtype dtype) const; + bool is_combiner_agg() const; + bool is_reducer_agg() const; + + bool is_non_delta() const; + + std::string get_first_depname() const; + +private: + std::string m_name; + std::string m_disp_name; + t_aggtype m_agg; + std::vector m_dependencies; + std::vector m_odependencies; + t_sorttype m_sort_type; + t_uindex m_agg_one_idx; + t_uindex m_agg_two_idx; + double m_agg_one_weight; + double m_agg_two_weight; + t_invmode m_invmode; + // t_uindex m_kernel; +}; + +PERSPECTIVE_EXPORT t_dtype get_simple_accumulator_type(t_dtype coltype); + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/arg_sort.h b/rust/perspective-server/cpp/perspective/src/include/perspective/arg_sort.h new file mode 100644 index 0000000..e172c53 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/arg_sort.h @@ -0,0 +1,95 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include + +namespace perspective { + +struct t_multisorter; + +PERSPECTIVE_EXPORT void +argsort(std::vector& output, const t_multisorter& sorter); + +template +struct t_argsort_comparator_impl { + explicit t_argsort_comparator_impl(const std::vector& v) : + m_v(v) {} + + bool operator()(t_index a, t_index b) const; + + const std::vector& m_v; +}; + +template <> +inline bool +t_argsort_comparator_impl::operator()( + t_index a, t_index b +) const { + return m_v[a] < m_v[b]; +} + +template <> +inline bool +t_argsort_comparator_impl::operator()( + t_index a, t_index b +) const { + return m_v[a] > m_v[b]; +} + +template <> +inline bool +t_argsort_comparator_impl::operator()( + t_index a, t_index b +) const { + return std::abs(m_v[a].to_double()) < std::abs(m_v[b].to_double()); +} + +template <> +inline bool +t_argsort_comparator_impl::operator()( + t_index a, t_index b +) const { + return std::abs(m_v[a].to_double()) > std::abs(m_v[b].to_double()); +} + +template <> +inline bool +t_argsort_comparator_impl::operator()( + t_index a, t_index b +) const { + return a < b; +} + +// Legacy non-template comparator kept for API compatibility +struct PERSPECTIVE_EXPORT t_argsort_comparator { + t_argsort_comparator( + const std::vector& v, const t_sorttype& sort_type + ); + + bool operator()(t_index a, t_index b) const; + + const std::vector& m_v; + t_sorttype m_sort_type; +}; + +PERSPECTIVE_EXPORT void simple_argsort( + std::vector& v, + std::vector& output, + const t_sorttype& sort_type +); + +} // namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/arrow_csv.h b/rust/perspective-server/cpp/perspective/src/include/perspective/arrow_csv.h new file mode 100644 index 0000000..ebf9a34 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/arrow_csv.h @@ -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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include + +namespace perspective { +namespace apachearrow { + + std::optional parseAsArrowTimestamp(const std::string& input); + + /** + * @brief Initialize the arrow loader with a CSV. + * + * @param ptr + */ + std::shared_ptr<::arrow::Table> csvToTable( + const std::string_view& csv, + bool is_update, + std::unordered_map>& + schema + ); + +} // namespace apachearrow +} // namespace perspective \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/arrow_loader.h b/rust/perspective-server/cpp/perspective/src/include/perspective/arrow_loader.h new file mode 100644 index 0000000..87b3c4d --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/arrow_loader.h @@ -0,0 +1,111 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { +namespace apachearrow { + + class PERSPECTIVE_EXPORT ArrowLoader { + public: + ArrowLoader(); + ~ArrowLoader(); + ArrowLoader(ArrowLoader&& other) noexcept; + + /** + * @brief Initialize the arrow loader with a pointer to a binary. + * + * @param ptr + */ + void initialize(const std::uint8_t* ptr, std::uint32_t); + + /** + * @brief Initialize the arrow loader with a CSV. + * + * @param ptr + */ + void init_csv( + const std::string_view& csv, + bool is_update, + std::unordered_map>& + schema + ); + + /** + * @brief Given an arrow binary and a data table, load the arrow into + * Perspective. If updating an existing table, use the `input_schema` + * of the table and respect it as much as possible. + * + * @param tbl + * @param input_schema + * @param index + * @param offset + * @param is_update + */ + void fill_table( + t_data_table& tbl, + const t_schema& input_schema, + const std::string& index, + std::uint32_t offset, + bool is_update + ); + + std::vector names() const; + std::vector types() const; + std::uint32_t row_count() const; + + private: + void fill_column( + t_data_table& tbl, + const std::shared_ptr& col, + const std::string& name, + std::int32_t cidx, + t_dtype type, + std::string& raw_type, + bool is_update + ); + + std::shared_ptr m_table; + std::vector m_names; + std::vector m_types; + }; + + template + void iter_col_copy( + const std::shared_ptr& dest, + std::shared_ptr src, + const int64_t offset, + const int64_t len + ); + + void copy_array( + const std::shared_ptr& dest, + const std::shared_ptr& src, + const int64_t offset, + const int64_t len + ); + +} // namespace apachearrow +} // namespace perspective \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/arrow_writer.h b/rust/perspective-server/cpp/perspective/src/include/perspective/arrow_writer.h new file mode 100644 index 0000000..6e600c5 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/arrow_writer.h @@ -0,0 +1,356 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include + +namespace perspective { +namespace apachearrow { + + /** + * @brief Return a value from a `t_scalar` cast to `T`. + * + * @tparam DataType + * @tparam ArrowDataType + * @tparam ArrowValueType + * @param t + * @return ArrowValueType + */ + template + T get_scalar(t_tscalar& t); + + /** + * @brief Retrieve the correct index into the data slice for the given + * column and row. This is a redefinition of the method in `t_data_slice`, + * except it does not rely on any instance variables. + * + * @param cidx + * @param ridx + * @param stride + * @param extents + * @return std::int32_t + */ + std::int32_t get_idx( + std::int32_t cidx, + std::int32_t ridx, + std::int32_t stride, + t_get_data_extents extents + ); + + /** + * @brief Build an `arrow::Array` from a column typed as `DTYPE_BOOL.` + * + * @param data + * @param offset + * @param stride + */ + std::shared_ptr boolean_col_to_array( + const std::vector& data, + std::int32_t cidx, + std::int32_t stride, + t_get_data_extents extents + ); + + /** + * @brief Build an `arrow::Array` from a column typed as `DTYPE_DATE.` + * Separated out from the main templated `col_to_array` as `t_date` is an + * `uint32_t` that needs to be written into the `arrow::Array` as an + * `int32_t`. + * + * @param data + * @param offset + * @param stride + */ + std::shared_ptr date_col_to_array( + const std::vector& data, + std::int32_t cidx, + std::int32_t stride, + t_get_data_extents extents + ); + + /** + * @brief Build an `arrow::Array` from a column typed as `DTYPE_TIME`. + * Separated out from the main templated `col_to_array` as + * `arrow::timestamp()` has parameters that need to be filled. + * + * @param data + * @param offset + * @param stride + */ + std::shared_ptr timestamp_col_to_array( + const std::vector& data, + std::int32_t cidx, + std::int32_t stride, + t_get_data_extents extents + ); + + /** + * @brief Build an `arrow::Array` from a column typed as `DTYPE_STR`, using + * arrow's `DictionaryArray` constructors. + * + * @param data + * @param offset + * @param stride + * @return std::shared_ptr + */ + template + std::shared_ptr + string_col_to_dictionary_array(t_get_data_extents extents, F f) { + t_vocab vocab; + vocab.init(false); + arrow::Int32Builder indices_builder; + arrow::StringBuilder values_builder; + auto reserve_status = + indices_builder.Reserve(extents.m_erow - extents.m_srow); + if (!reserve_status.ok()) { + std::stringstream ss; + ss << "Failed to allocate buffer for column: " + << reserve_status.message() << "\n"; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + + for (int ridx = extents.m_srow; ridx < extents.m_erow; ++ridx) { + // auto idx = get_idx(cidx, ridx, stride, extents); + t_tscalar scalar = f(ridx); + if (scalar.is_valid() && scalar.get_dtype() != DTYPE_NONE) { + auto adx = vocab.get_interned(scalar.get()); + indices_builder.UnsafeAppend(adx); + } else { + indices_builder.UnsafeAppendNull(); + } + } + + // get str out of vocab + for (auto i = 0; i < vocab.get_vlenidx(); i++) { + const char* str = vocab.unintern_c(i); + arrow::Status s = values_builder.Append(str, strlen(str)); + if (!s.ok()) { + std::stringstream ss; + ss << "Could not append string to dictionary array: " + << s.message() << "\n"; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + } + + // Write dictionary indices + std::shared_ptr indices_array; + arrow::Status indices_status = indices_builder.Finish(&indices_array); + if (!indices_status.ok()) { + std::stringstream ss; + ss << "Could not write indices for dictionary array: " + << indices_status.message() << "\n"; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + + // Write dictionary values + std::shared_ptr values_array; + arrow::Status values_status = values_builder.Finish(&values_array); + if (!values_status.ok()) { + std::stringstream ss; + ss << "Could not write values for dictionary array: " + << values_status.message() << "\n"; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + auto dictionary_type = arrow::dictionary(arrow::int32(), arrow::utf8()); + + arrow::Result> result = + arrow::DictionaryArray::FromArrays( + dictionary_type, indices_array, values_array + ); + + if (!result.ok()) { + std::stringstream ss; + ss << "Could not write values for dictionary array: " + << result.status().message() << "\n"; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + + return *result; + } + + /** + * @brief Build an `arrow::Array` from a column contained in `data`. Column + * building methods read from the vector of scalars that make up the data + * slice, as `t_data_slice` is templated on the context type and we'd like + * to avoid template hell. + * + * @tparam ArrowDataType + * @param data + * @param offset + * @param stride + * @return std::shared_ptr + */ + template + std::shared_ptr + numeric_col_to_array(t_get_data_extents extents, F f) { + // NumericBuilder encompasses the most types (int/float/datetime) + arrow::NumericBuilder array_builder; + auto reserve_status = + array_builder.Reserve(extents.m_erow - extents.m_srow); + if (!reserve_status.ok()) { + std::stringstream ss; + ss << "Failed to allocate buffer for column: " + << reserve_status.message() << "\n"; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + + for (int ridx = extents.m_srow; ridx < extents.m_erow; ++ridx) { + t_tscalar scalar = f(ridx); + if (scalar.is_valid() && scalar.get_dtype() != DTYPE_NONE) { + ArrowValueType val = get_scalar(scalar); + array_builder.UnsafeAppend(val); + } else { + array_builder.UnsafeAppendNull(); + } + } + + // Point to base `arrow::Array` instead of derived, so we don't have to + // template the caller. + std::shared_ptr array; + arrow::Status status = array_builder.Finish(&array); + if (!status.ok()) { + PSP_COMPLAIN_AND_ABORT(status.message()); + } + return array; + } + + template + std::shared_ptr + boolean_col_to_array(t_get_data_extents extents, F f) { + arrow::BooleanBuilder array_builder; + auto reserve_status = + array_builder.Reserve(extents.m_erow - extents.m_srow); + if (!reserve_status.ok()) { + std::stringstream ss; + ss << "Failed to allocate buffer for column: " + << reserve_status.message() << "\n"; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + + for (int ridx = extents.m_srow; ridx < extents.m_erow; ++ridx) { + t_tscalar scalar = f(ridx); + arrow::Status s; + if (scalar.is_valid() && scalar.get_dtype() != DTYPE_NONE) { + array_builder.UnsafeAppend(get_scalar(scalar)); + } else { + array_builder.UnsafeAppendNull(); + } + } + + std::shared_ptr array; + arrow::Status status = array_builder.Finish(&array); + if (!status.ok()) { + PSP_COMPLAIN_AND_ABORT( + "Could not serialize boolean column: " + status.message() + ); + } + return array; + } + + template + std::shared_ptr + date_col_to_array(t_get_data_extents extents, F f) { + arrow::Date32Builder array_builder; + auto reserve_status = + array_builder.Reserve(extents.m_erow - extents.m_srow); + if (!reserve_status.ok()) { + std::stringstream ss; + ss << "Failed to allocate buffer for column: " + << reserve_status.message() << "\n"; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + + for (int ridx = extents.m_srow; ridx < extents.m_erow; ++ridx) { + t_tscalar scalar = f(ridx); + if (scalar.is_valid() && scalar.get_dtype() != DTYPE_NONE) { + t_date val = scalar.get(); + // years are signed, while month/days are unsigned + date::year year{val.year()}; + // Increment month by 1, as date::month is [1-12] but + // t_date::month() is [0-11] + date::month month{static_cast(val.month() + 1)}; + date::day day{static_cast(val.day())}; + date::year_month_day ymd(year, month, day); + date::sys_days days_since_epoch = ymd; + array_builder.UnsafeAppend(static_cast( + days_since_epoch.time_since_epoch().count() + )); + } else { + array_builder.UnsafeAppendNull(); + } + } + + std::shared_ptr array; + arrow::Status status = array_builder.Finish(&array); + if (!status.ok()) { + PSP_COMPLAIN_AND_ABORT( + "Could not serialize date column: " + status.message() + ); + } + return array; + } + + template + std::shared_ptr + timestamp_col_to_array(t_get_data_extents extents, F f) { + // TimestampType requires parameters, so initialize them here + std::shared_ptr type = + arrow::timestamp(arrow::TimeUnit::MILLI); + arrow::TimestampBuilder array_builder( + type, arrow::default_memory_pool() + ); + auto reserve_status = + array_builder.Reserve(extents.m_erow - extents.m_srow); + if (!reserve_status.ok()) { + std::stringstream ss; + ss << "Failed to allocate buffer for column: " + << reserve_status.message() << "\n"; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + + for (int ridx = extents.m_srow; ridx < extents.m_erow; ++ridx) { + t_tscalar scalar = f(ridx); + if (scalar.is_valid() && scalar.get_dtype() != DTYPE_NONE) { + array_builder.UnsafeAppend(get_scalar(scalar)); + } else { + array_builder.UnsafeAppendNull(); + } + } + + std::shared_ptr array; + arrow::Status status = array_builder.Finish(&array); + if (!status.ok()) { + PSP_COMPLAIN_AND_ABORT( + "Could not serialize timestamp column: " + status.message() + ); + } + return array; + } + +} // namespace apachearrow +} // namespace perspective \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/base.h b/rust/perspective-server/cpp/perspective/src/include/perspective/base.h new file mode 100644 index 0000000..0df6c43 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/base.h @@ -0,0 +1,562 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once + +#ifdef WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif // WIN32 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +const std::int32_t PSP_VERSION = 67; +const double PSP_TABLE_GROW_RATIO = 1.3; + +#ifdef WIN32 +#define PSP_RESTRICT __restrict +#define PSP_THR_LOCAL __declspec(thread) +#else +#define PSP_RESTRICT __restrict__ +#define PSP_THR_LOCAL __thread +#endif // WIN32 + +const t_index INVALID_INDEX = -1; + +#define DEFAULT_CAPACITY 4000 +#define DEFAULT_CHUNK_SIZE 4000 +#define DEFAULT_EMPTY_CAPACITY 8 +#define ROOT_AGGIDX 0 +#ifndef CHAR_BIT +#define CHAR_BIT 8 +#endif + +/** + * @brief Given an error message, throw the error in the binding language: + * + * WASM and Pyodide: throws an `Error()` with the error message. + * Python: throws a `PerspectiveCppException` with the error message. + * + * @param message + * @return PERSPECTIVE_EXPORT + */ +[[noreturn]] +PERSPECTIVE_EXPORT void psp_abort(const std::string& message); + +// #define PSP_TRACE_SENTINEL() t_trace _psp_trace_sentinel; +#define PSP_TRACE_SENTINEL() +#define _ID(x) \ + x // https://stackoverflow.com/questions/25144589/c-macro-overloading-is-not-working +#define GET_PSP_VERBOSE_ASSERT(_1, _2, _3, NAME, ...) NAME + +#define PSP_CHECK_ARROW_STATUS(X) \ + { \ + ::arrow::Status st = X; \ + if (!st.ok()) { \ + std::stringstream ss; \ + ss << "Arrow operation failed: " << st.message(); \ + PSP_COMPLAIN_AND_ABORT(ss.str()) \ + } \ + } + +#ifdef PSP_DEBUG +#define PSP_VERBOSE_ASSERT1(COND, MSG) \ + { \ + if (!(COND)) { \ + std::stringstream ss; \ + ss << __FILE__ << ":" << __LINE__ << ": " << MSG << " : " \ + << perspective::get_error_str(); \ + perror(ss.str().c_str()); \ + psp_abort("Verbose assert failed!"); \ + } \ + } + +#define PSP_VERBOSE_ASSERT2(COND, EXPR, MSG) \ + { \ + if (!(COND EXPR)) { \ + std::stringstream ss; \ + ss << __FILE__ << ":" << __LINE__ << ": " << MSG << " : " \ + << perspective::get_error_str(); \ + perror(ss.str().c_str()); \ + psp_abort("Verbose assert failed!"); \ + } \ + } + +#define PSP_ASSERT_SIMPLE_TYPE(X) \ +static_assert( \ +std::is_pod::value && std::is_standard_layout::value , \ +" Unsuitable type found. " + +// #define LOG_LIFETIMES 1 + +#ifdef LOG_LIFETIMES +#define LOG_CONSTRUCTOR(X) \ + std::cout << "constructing L: " << __LINE__ << " " << (X) << " <" << this \ + << ">" \ + << "\n"; + +#define LOG_DESTRUCTOR(X) \ + std::cout << "destroying L: " << __LINE__ << " " << (X) << " <" << this \ + << ">" \ + << "\n"; + +#define LOG_INIT(X) \ + std::cout << "initing L: " << __LINE__ << " " << (X) << " <" << this \ + << ">" \ + << "\n"; +#else +#define LOG_CONSTRUCTOR(X) +#define LOG_DESTRUCTOR(X) +#define LOG_INIT(X) +#endif +#define LOG_DEBUG(X) \ + std::cout << __FILE__ << ":" << __LINE__ << " DEBUG: " << X << '\n' +#else +#define PSP_VERBOSE_ASSERT1(COND, MSG) \ + { \ + if (!(COND)) { \ + std::stringstream ss; \ + ss << MSG; \ + psp_abort(ss.str()); \ + } \ + } + +#define PSP_VERBOSE_ASSERT2(EXPR, COND, MSG) \ + { \ + if (!(EXPR COND)) { \ + std::stringstream ss; \ + ss << MSG; \ + psp_abort(ss.str()); \ + } \ + } + +#define PSP_ASSERT_SIMPLE_TYPE(X) +#define LOG_CONSTRUCTOR(X) +#define LOG_DESTRUCTOR(X) +#define LOG_INIT(X) +#define LOG_DEBUG(X) +#endif + +#if defined(PSP_ENABLE_WASM) && !defined(PSP_PYODIDE) +#define ESM_EXPORT(X) __attribute__((import_module("env"), import_name(X))) + +PERSPECTIVE_EXPORT ESM_EXPORT("psp_stack_trace") extern "C" const + char* psp_stack_trace(); + +PERSPECTIVE_EXPORT ESM_EXPORT("psp_heap_size") extern "C" size_t + psp_heap_size(); + +#endif + +#if defined(PSP_DEBUG) && defined(PSP_ENABLE_WASM) && !defined(PSP_PYODIDE) +#define PSP_COMPLAIN_AND_ABORT(X) \ + { \ + std::stringstream __SS__; \ + __SS__ << (X) << "\n"; \ + __SS__ << perspective::psp_stack_trace(); \ + std::cout << __SS__.str() << '\n'; \ + perspective::psp_abort(__SS__.str()); \ + } +#else +#define PSP_COMPLAIN_AND_ABORT(X) \ + { perspective::psp_abort(X); } +#endif + +#define PSP_VERBOSE_ASSERT(...) \ + _ID(GET_PSP_VERBOSE_ASSERT(__VA_ARGS__, PSP_VERBOSE_ASSERT2, PSP_VERBOSE_ASSERT1)( \ + __VA_ARGS__ \ + )) + +// Currently only supporting single ports +enum t_gnode_processing_mode { + NODE_PROCESSING_SIMPLE_DATAFLOW, + NODE_PROCESSING_KERNEL +}; + +enum t_pivot_mode { + PIVOT_MODE_NORMAL, + PIVOT_MODE_KERNEL, + PIVOT_MODE_CONCATENATE, + PIVOT_MODE_TIME_BUCKET_MIN, + PIVOT_MODE_TIME_BUCKET_HOUR, + PIVOT_MODE_TIME_BUCKET_DAY, + PIVOT_MODE_TIME_BUCKET_WEEK, + PIVOT_MODE_TIME_BUCKET_MONTH, + PIVOT_MODE_TIME_BUCKET_YEAR +}; + +enum t_select_mode { + SELECT_MODE_ALL, + SELECT_MODE_RANGE, + SELECT_MODE_MASK, + SELECT_MODE_PKEY, + SELECT_MODE_KERNEL +}; + +enum t_backing_store { BACKING_STORE_MEMORY, BACKING_STORE_DISK }; + +enum t_filter_op { + FILTER_OP_LT, + FILTER_OP_LTEQ, + FILTER_OP_GT, + FILTER_OP_GTEQ, + FILTER_OP_EQ, + FILTER_OP_NE, + FILTER_OP_BEGINS_WITH, + FILTER_OP_ENDS_WITH, + FILTER_OP_CONTAINS, + FILTER_OP_OR, + FILTER_OP_IN, + FILTER_OP_NOT_IN, + FILTER_OP_AND, + FILTER_OP_IS_NULL, + FILTER_OP_IS_NOT_NULL +}; + +PERSPECTIVE_EXPORT std::string filter_op_to_str(t_filter_op op); +PERSPECTIVE_EXPORT t_filter_op str_to_filter_op(const std::string& str); + +enum t_header { HEADER_ROW, HEADER_COLUMN }; + +enum t_sorttype { + SORTTYPE_ASCENDING, + SORTTYPE_DESCENDING, + SORTTYPE_NONE, + SORTTYPE_ASCENDING_ABS, + SORTTYPE_DESCENDING_ABS +}; + +PERSPECTIVE_EXPORT t_sorttype str_to_sorttype(const std::string& str); +PERSPECTIVE_EXPORT std::string sorttype_to_str(t_sorttype type); + +enum t_aggtype { + AGGTYPE_SUM, + AGGTYPE_MUL, + AGGTYPE_COUNT, + AGGTYPE_MEAN, + AGGTYPE_WEIGHTED_MEAN, + AGGTYPE_UNIQUE, + AGGTYPE_ANY, + AGGTYPE_Q1, + AGGTYPE_Q3, + AGGTYPE_MEDIAN, + AGGTYPE_JOIN, + AGGTYPE_SCALED_DIV, + AGGTYPE_SCALED_ADD, + AGGTYPE_SCALED_MUL, + AGGTYPE_DOMINANT, + AGGTYPE_FIRST, + AGGTYPE_LAST_BY_INDEX, + AGGTYPE_LAST_MINUS_FIRST, + AGGTYPE_PY_AGG, + AGGTYPE_AND, + AGGTYPE_OR, + AGGTYPE_LAST_VALUE, + AGGTYPE_HIGH_WATER_MARK, + AGGTYPE_LOW_WATER_MARK, + AGGTYPE_MAX, + AGGTYPE_MIN, + AGGTYPE_MAX_BY, + AGGTYPE_MIN_BY, + AGGTYPE_HIGH_MINUS_LOW, + AGGTYPE_UDF_COMBINER, + AGGTYPE_UDF_REDUCER, + AGGTYPE_SUM_ABS, + AGGTYPE_ABS_SUM, + AGGTYPE_SUM_NOT_NULL, + AGGTYPE_MEAN_BY_COUNT, + AGGTYPE_IDENTITY, + AGGTYPE_DISTINCT_COUNT, + AGGTYPE_DISTINCT_LEAF, + AGGTYPE_PCT_SUM_PARENT, + AGGTYPE_PCT_SUM_GRAND_TOTAL, + AGGTYPE_VARIANCE, + AGGTYPE_STANDARD_DEVIATION, + AGGTYPE_GMV +}; + +PERSPECTIVE_EXPORT t_aggtype str_to_aggtype(const std::string& str); +PERSPECTIVE_EXPORT t_aggtype _get_default_aggregate(t_dtype dtype); +PERSPECTIVE_EXPORT std::string _get_default_aggregate_string(t_dtype dtype); + +enum t_totals { TOTALS_BEFORE, TOTALS_HIDDEN, TOTALS_AFTER }; + +enum t_ctx_type { + UNIT_CONTEXT, + ZERO_SIDED_CONTEXT, + ONE_SIDED_CONTEXT, + TWO_SIDED_CONTEXT, + GROUPED_ZERO_SIDED_CONTEXT, + GROUPED_PKEY_CONTEXT, + GROUPED_COLUMNS_CONTEXT +}; + +enum t_op { OP_INSERT, OP_DELETE, OP_CLEAR }; + +enum t_value_transition { + VALUE_TRANSITION_EQ_FF, // Value did not change, and row remains invalid + // VALUE_TRANSITION_EQ_FT nonsensical + // VALUE_TRANSITION_EQ_TF nonsensical + VALUE_TRANSITION_EQ_TT, // Value did not change, and row remains valid + // VALUE_TRANSITION_NEQ_FF nonsensical + VALUE_TRANSITION_NEQ_FT, // Value changed, and row changed from invalid to + // valid + VALUE_TRANSITION_NEQ_TF, // Value changed, and row changed from valid to + // invalid + VALUE_TRANSITION_NEQ_TT, // Value changed and row remains valid + // VALUE_TRANSITION_EQ_FDF, nonsensical + // VALUE_TRANSITION_EQ_FDT, nonsensical + // VALUE_TRANSITION_EQ_TDF, nonsensical + // VALUE_TRANSITION_EQ_TDT, nonsensical + // VALUE_TRANSITION_NEQ_FDF, nonsensical + // VALUE_TRANSITION_NEQ_FDT, nonsensical + VALUE_TRANSITION_NEQ_TDF, + VALUE_TRANSITION_NEQ_TDT, + VALUE_TRANSITION_NVEQ_FT +}; + +enum t_gnode_type { + GNODE_TYPE_PKEYED, // Explicit user set pkey +}; + +enum t_gnode_port { + PSP_PORT_FLATTENED, // same schema as iport (pkey,op) + PSP_PORT_DELTA, // same schema as state + PSP_PORT_PREV, // same schema as state + PSP_PORT_CURRENT, // same schema as state + PSP_PORT_TRANSITIONS, // same schema as state + PSP_PORT_EXISTED // same schema as state +}; + +enum t_ctx_feature { + CTX_FEAT_PROCESS, + CTX_FEAT_DELTA, + CTX_FEAT_ALERT, + CTX_FEAT_ENABLED, + CTX_FEAT_LAST_FEATURE +}; + +enum t_deptype { DEPTYPE_COLUMN, DEPTYPE_AGG, DEPTYPE_SCALAR }; + +enum t_cmp_op { + CMP_OP_LT, + CMP_OP_LTEQ, + CMP_OP_GT, + CMP_OP_GTEQ, + CMP_OP_EQ, + CMP_OP_NE, + CMP_OP_BEGINS_WITH, + CMP_OP_ENDS_WITH, + CMP_OP_CONTAINS, + CMP_OP_OR, + CMP_OP_IN, + CMP_OP_AND +}; + +enum t_invmode { + INV_SUBST_CANONICAL, // Substitute canonical data with canonical + // forms + INV_EXCLUDE, // Exclude invalid data from calculations + INV_PROPAGATE // Propagate invalid virally +}; + +enum t_range_mode { + RANGE_ROW, + RANGE_ROW_COLUMN, + RANGE_ROW_PATH, + RANGE_ROW_COLUMN_PATH, + RANGE_ALL, + RANGE_EXPR +}; + +enum t_fetch { + FETCH_RANGE, + FETCH_ROW_PATHS, + FETCH_COLUMN_PATHS, + FETCH_ROW_INDICES, + FETCH_COLUMN_INDICES, + FETCH_ROW_DATA_SLICE, + FETCH_COLUMN_DATA_SLICE, + FETCH_STYLE_SLICE, + FETCH_USER_DATA_SLICE, + FETCH_ROW_DEPTH, + FETCH_COLUMN_DEPTH, + FETCH_IS_ROW_EXPANDED, + FETCH_IS_COLUMN_EXPANDED, + FETCH_IS_ROOT, + FETCH_COLUMN_NAMES, + FETCH_CONFIG +}; + +enum t_fmode { FMODE_SIMPLE_CLAUSES, FMODE_JIT_EXPR }; + +#ifdef WIN32 +#define PSP_NON_COPYABLE(X) +#else +#define PSP_NON_COPYABLE(X) \ + X(const X&) = delete; \ + X& operator=(const X&) = delete +#endif + +PERSPECTIVE_EXPORT std::string get_error_str(); +PERSPECTIVE_EXPORT bool is_numeric_type(t_dtype dtype); +PERSPECTIVE_EXPORT bool is_floating_point(t_dtype dtype); +PERSPECTIVE_EXPORT bool is_linear_order_type(t_dtype dtype); +PERSPECTIVE_EXPORT std::string get_dtype_descr(t_dtype dtype); +PERSPECTIVE_EXPORT std::string dtype_to_str(t_dtype dtype); +PERSPECTIVE_EXPORT t_dtype str_to_dtype(const std::string& typestring); +PERSPECTIVE_EXPORT std::string get_status_descr(t_status dtype); +PERSPECTIVE_EXPORT t_uindex get_dtype_size(t_dtype dtype); +PERSPECTIVE_EXPORT bool is_vlen_dtype(t_dtype dtype); +PERSPECTIVE_EXPORT bool is_neq_transition(t_value_transition t); +PERSPECTIVE_EXPORT std::string value_transition_to_str(t_value_transition t); + +template +inline std::ostream& +operator<<(std::ostream& os, const std::vector& row) { + for (int i = 0, loop_end = row.size(); i < loop_end; ++i) { + os << row[i] << ", "; + } + + return os; +} + +template +inline std::ostream& +operator<<(std::ostream& os, const std::pair& p) { + os << "<" << p.first << ", " << p.second << ">"; + return os; +} + +void check_init(bool init, const char* file, std::int32_t line); + +t_uindex root_pidx(); + +struct PERSPECTIVE_EXPORT t_cmp_charptr { + bool + operator()(const char* a, const char* b) const { + return std::strcmp(a, b) < 0; + } +}; + +template +struct binary_function { + using first_argument_type = Arg1; + using second_argument_type = Arg2; + using result_type = Result; +}; + +struct t_cchar_umap_cmp + : public binary_function { + inline bool + operator()(const char* x, const char* y) const { + return strcmp(x, y) == 0; + } +}; + +struct t_cchar_umap_hash { + inline t_uindex + operator()(const char* s) const { + return boost::hash_range(s, s + std::strlen(s)); + } +}; + +bool is_internal_colname(const std::string& c); + +bool is_deterministic_sized(t_dtype dtype); + +template +std::string +psp_to_str(const DATA_T& s) { + std::stringstream ss; + ss << s; + return ss.str(); +} + +template +PERSPECTIVE_EXPORT t_dtype type_to_dtype(); + +template <> +PERSPECTIVE_EXPORT t_dtype type_to_dtype(); + +template <> +PERSPECTIVE_EXPORT t_dtype type_to_dtype(); + +template <> +PERSPECTIVE_EXPORT t_dtype type_to_dtype(); + +template <> +PERSPECTIVE_EXPORT t_dtype type_to_dtype(); + +template <> +PERSPECTIVE_EXPORT t_dtype type_to_dtype(); + +template <> +PERSPECTIVE_EXPORT t_dtype type_to_dtype(); + +template <> +PERSPECTIVE_EXPORT t_dtype type_to_dtype(); + +template <> +PERSPECTIVE_EXPORT t_dtype type_to_dtype(); + +template <> +PERSPECTIVE_EXPORT t_dtype type_to_dtype(); + +template <> +PERSPECTIVE_EXPORT t_dtype type_to_dtype(); + +template <> +PERSPECTIVE_EXPORT t_dtype type_to_dtype(); + +template <> +PERSPECTIVE_EXPORT t_dtype type_to_dtype(); + +template <> +PERSPECTIVE_EXPORT t_dtype type_to_dtype(); + +template <> +PERSPECTIVE_EXPORT t_dtype type_to_dtype(); + +} // end namespace perspective + +namespace std { +template <> +struct hash> { + typedef std::pair + argument_type; + typedef std::size_t result_type; + + result_type + operator()(argument_type const& s) const { + result_type const h1(std::hash()(s.first)); + result_type const h2(std::hash()(s.second)); + return h1 ^ (h2 << 1); + } +}; + +void string_to_lower(string& str); + +} // end namespace std diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/binding.h b/rust/perspective-server/cpp/perspective/src/include/perspective/binding.h new file mode 100644 index 0000000..742d5cd --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/binding.h @@ -0,0 +1,476 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#pragma clang diagnostic ignored "-Wdeprecated-declarations" +#if defined(PSP_ENABLE_WASM) || defined(PSP_ENABLE_PYTHON) + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef PSP_ENABLE_WASM +#include +typedef std::codecvt_utf8 utf8convert_type; +typedef std::codecvt_utf8_utf16 utf16convert_type; +#endif + +namespace perspective { + +/** + * @brief the `binding` namespace contains methods which ingest, return, or + * otherwise manipulate values in Perspective's binding language. + * + * For Emscripten WASM builds, the type is defined as `emscripten::val`, + * shadowed using our `t_val` construct. + * + * For Python, the type is `py::object`. + * + * Templated functions in this namespace should be explicitly instantiated with + * the correct object type for the binding language. + * + */ +namespace binding { + + /****************************************************************************** + * + * Utility + */ + + template + std::vector vecFromArray(T& arr); + + template + bool has_value(T val); + + /****************************************************************************** + * + * Manipulate scalar values + */ + + /** + * @brief Converts a `t_scalar` to a value in the binding language. + * + * @tparam T + * @param scalar + */ + template + T scalar_to(const t_tscalar& scalar); + + /** + * @brief namespace `arraybuffer` contains utilities for writing data in the + * Apache arrow format. + * + * Implementations of these methods access underlying heap memory and create + * contiguous blocks of data which conform to the arrow schema; these + * methods should not be used to parse `.arrow` files or access + * arrow-formatted data in memory. Instead, arrow data parsing and + * manipulation should be implemented in the binding language, using + * appropriate libraries. + */ + namespace arraybuffer { + + template + void vecFromTypedArray( + const T& typedArray, + void* data, + std::int32_t length, + const char* destType = nullptr + ); + + template + void fill_col_valid(T dcol, std::shared_ptr col); + + template + void fill_col_dict(T dictvec, std::shared_ptr col); + + } // namespace arraybuffer + + /** + * @brief Retrieve and cast a `t_scalar` value. + * + * @tparam F + * @tparam F + * @param t + * @return T + */ + template + T get_scalar(t_tscalar& t); + + /** + * DataAccessor + * + * parses and converts input data into a canonical format for + * interfacing with Perspective. + */ + + // Name parsing + template + std::vector get_column_names(T data, std::int32_t format); + + // Type inferrence for fill_col and data_types + template + t_dtype infer_type(T x, U date_validator); + + template + t_dtype get_data_type( + T data, std::int32_t format, std::string name, U date_validator + ); + + template + std::vector get_data_types( + T data, + std::int32_t format, + std::vector names, + U date_validator + ); + + /** + * @brief Given a data accessor and a pointer to a column, iterate through + * the data and fill the column. + */ + template + void _fill_col_numeric( + T accessor, + t_data_table& tbl, + std::shared_ptr col, + const std::string& name, + std::int32_t cidx, + t_dtype type, + bool is_update + ); + + template + void _fill_col_int64( + T accessor, + std::shared_ptr col, + const std::string& name, + std::int32_t cidx, + t_dtype type, + bool is_update + ); + + template + void _fill_col_time( + T accessor, + std::shared_ptr col, + const std::string& name, + std::int32_t cidx, + t_dtype type, + bool is_update + ); + + template + void _fill_col_date( + T accessor, + std::shared_ptr col, + const std::string& name, + std::int32_t cidx, + t_dtype type, + bool is_update + ); + + template + void _fill_col_bool( + T accessor, + std::shared_ptr col, + const std::string& name, + std::int32_t cidx, + t_dtype type, + bool is_update + ); + + template + void _fill_col_string( + T accessor, + std::shared_ptr col, + const std::string& name, + std::int32_t cidx, + t_dtype type, + bool is_update + ); + + /** + * @brief Change a value at a given index inside the column. + */ + template + void set_column_nth(std::shared_ptr col, t_uindex idx, T value); + + /** + * @brief Utility function for accessing columns and adding data. + * + * @tparam T + * @param accessor + * @param tbl + * @param col + * @param name + * @param cidx + * @param type + * @param is_update + */ + template + void _fill_data_helper( + T accessor, + t_data_table& tbl, + std::shared_ptr col, + const std::string& name, + std::int32_t cidx, + t_dtype type, + bool is_update + ); + + /** + * @brief Given a table, iterate through each column and fill it with data. + * + * @tparam T + * @param tbl + * @param accessor + * @param input_schema + * @param is_update + */ + template + void _fill_data( + t_data_table& tbl, + T accessor, + const t_schema& input_schema, + const std::string& index, + std::uint32_t offset, + std::uint32_t limit, + bool is_update + ); + + /** + * @brief Create and populate a table. + * + * @tparam + * @param pool + * @param gnode + * @param accessor + * @param limit + * @param index + * @param is_update + * @param is_arrow + * @return std::shared_ptr + */ + template + std::shared_ptr
    make_table( + T table, + T accessor, + std::uint32_t limit, + const std::string& index, + t_op op, + bool is_update, + bool is_arrow, + bool is_csv, + t_uindex port_id + ); + + /****************************************************************************** + * + * View API + */ + + /** + * @brief is the filter term and operand combination valid? + * + * If the operand is null/not null then it is always valid. + * Is the filter term is a valid date? Otherwise, make sure + * the filter term is not null/undefined. + * + * @tparam T + * @param column_type + * @param date_parser + * @param filter_term + * @param filter_operator + * @return true + * @return false + */ + template + bool is_valid_filter( + t_dtype column_type, + T date_parser, + t_filter_op filter_operator, + T filter_term + ); + + /** + * @brief Create a filter by parsing the filter term from the binding + * language. + * + * @tparam T + * @param column_type + * @param date_parser + * @param column_name + * @param filter_op_str + * @param filter_term + * @return std::tuple> + */ + template + std::tuple> + make_filter_term( + t_dtype column_type, + T date_parser, + const std::string& column_name, + const std::string& filter_op_str, + T filter_term + ); + /** + * @brief Create a shared pointer to a `t_view_config` object from the + * binding language's `view_config` object. + * + * @tparam T + * @param schema + * @param date_parser + * @param config + * @return t_config + */ + template + std::shared_ptr make_view_config( + const t_gnode& gnode, + std::shared_ptr schema, + T date_parser, + T config + ); + + /** + * @brief Create a new view. + * + * Zero-sided views have no pivots or aggregates applied. + * + * Views are backed by an underlying `t_ctx_*` object, represented by the + * `CTX_T` template. + * + * One-sided views have one or more `row_pivots` applied. + * + * Two sided views have one or more `row_pivots` and `column_pivots` + * applied, or they have one or more `column_pivots` applied without any row + * pivots, hence the term `column_only`. + * + * @tparam T + * @param table + * @param name + * @param separator + * @param config + * @param date_parser + * @return std::shared_ptr> + */ + template + std::shared_ptr> make_view( + std::shared_ptr
    table, + const std::string& name, + const std::string& separator, + T view_config, + T date_parser + ); + + /** + * @brief Create a new context of type `CTX_T`, which will be one of 3 + * types: + * + * `t_ctx0`, `t_ctx1`, `t_ctx2`. + * + * + * Contexts contain the underlying aggregates, sort specifications, filter + * terms, and other metadata allowing for data manipulation and view + * creation. + * + * @return std::shared_ptr + */ + template + std::shared_ptr make_context( + std::shared_ptr
    table, + std::shared_ptr schema, + std::shared_ptr view_config, + const std::string& name + ); + + /** + * @brief Given a table and a list of expressions, validate the expressions + * and return a `t_validated_expression_map` object. + * + * @tparam T + * @param table + * @param j_expressions + * @return t_validated_expression_map + */ + template + t_validated_expression_map validate_expressions( + std::shared_ptr
    table, + const std::vector>& j_expressions + ); + + /** + * @brief Get a slice of data for a single column, serialized to t_val. + * + * @tparam + * @param table + * @param colname + * @return t_val + */ + template + T get_column_data( + std::shared_ptr table, const std::string& colname + ); + + /** + * @brief Get the t_data_slice object, which contains an underlying slice of + * data and metadata required to interact with it. + * + * @param view + * @param start_row + * @param end_row + * @param start_col + * @param end_col + */ + template + std::shared_ptr> get_data_slice( + std::shared_ptr> view, + std::uint32_t start_row, + std::uint32_t end_row, + std::uint32_t start_col, + std::uint32_t end_col + ); + + /** + * @brief Retrieve a single value from the data slice and serialize it to an + * output type that interfaces with the binding language. + * + * @param view + * @param start_row + * @param end_row + * @param start_col + * @param end_col + * @return val + */ + template + T get_from_data_slice( + std::shared_ptr> data_slice, + t_uindex ridx, + t_uindex cidx + ); + +} // end namespace binding +} // end namespace perspective + +#endif diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/column.h b/rust/perspective-server/cpp/perspective/src/include/perspective/column.h new file mode 100644 index 0000000..907c56f --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/column.h @@ -0,0 +1,492 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +/* +TODO - +1. Implement implicit typepunning based on cardinality. +2. No pointers should be returned from columns. Only +accessors! +3. Add get_nth for strings +*/ + +namespace perspective { + +class t_column; + +#ifdef PSP_COLUMN_VERIFY +#define COLUMN_CHECK_ACCESS(idx) \ + PSP_VERBOSE_ASSERT((idx) <= m_size, "Invalid column access") +#define COLUMN_CHECK_VALUES() verify() +#define COLUMN_CHECK_STRCOL() \ + PSP_VERBOSE_ASSERT(m_isvlen, "Expected to use string column") +#else +#define COLUMN_CHECK_ACCESS(idx) +#define COLUMN_CHECK_VALUES() +#define COLUMN_CHECK_STRCOL() +#endif + +class PERSPECTIVE_EXPORT t_column { +public: +#ifdef PSP_DBG_MALLOC + PSP_NEW_DELETE(t_column) +#endif + t_column(); + t_column(const t_column_recipe& recipe); + t_column(t_dtype dtype, bool missing_enabled, const t_lstore_recipe& a); + t_column( + t_dtype dtype, + bool missing_enabled, + const t_lstore_recipe& a, + t_uindex row_capacity + ); + ~t_column(); + + void column_copy_helper(const t_column& other); + + t_column(const t_column& c); + t_column& operator=(const t_column&); + + void init(); + + template + void push_back(DATA_T elem); + + template + void push_back(DATA_T elem, t_status status); + + t_dtype get_dtype() const; + + // Increases size by one and returns reference + // to new element + template + T* extend(); + + template + T* extend(t_uindex idx); + + void extend_dtype(t_uindex idx); + + // idx is in bytes + template + T* get(t_uindex idx); + + // idx is in bytes + template + const T* get(t_uindex idx) const; + + // idx is in items + template + T* get_nth(t_uindex idx); + + // idx is in items + template + const T* get_nth(t_uindex idx) const; + + // Returns a raw pointer to the beginning of the typed data array. + // Use for sequential/batched access to avoid per-element get_nth calls. + template + const T* get_nth_base() const; + + template + T* get_nth_base(); + + // idx is in items + const t_status* get_nth_status(t_uindex idx) const; + + // idx is in items + template + void set_nth(t_uindex idx, T v); + + template + void set_nth(t_uindex idx, T v, t_status status); + + void set_valid(t_uindex idx, bool valid); + + // Mark `[offset, offset + len)` as `STATUS_VALID` in one pass. Bulk + // equivalent of a `set_valid(i, true)` loop, used for the null-free + // chunk fast path in the Arrow loader. + void set_valid_range(t_uindex offset, t_uindex len); + + void set_status(t_uindex idx, t_status status); + + void set_size(t_uindex size); + + void reserve(t_uindex size); + + // object storage + template + void object_copied(t_uindex ptr) const; + void notify_object_copied(t_uindex idx) const; + + template + void object_cleared(t_uindex ptr) const; + void notify_object_cleared(t_uindex idx) const; + + const t_lstore& data_lstore() const; + + t_uindex size() const; + + t_uindex get_vlenidx() const; + + const char* unintern_c(t_uindex idx) const; + + // Internal apis + + t_lstore* _get_data_lstore(); + + t_vocab* _get_vocab(); + + t_tscalar get_scalar(t_uindex idx) const; + void set_scalar(t_uindex idx, t_tscalar value); + + bool is_status_enabled() const; + + bool is_valid(t_uindex idx) const; + + bool is_cleared(t_uindex idx) const; + + bool is_vlen() const; + + void append(const t_column& other); + + void clear(); + void clear_objects() const; + + template + void fill(VEC_T& vec, const t_uindex* bidx, const t_uindex* eidx) const; + + void pprint() const; + + template + void set_nth_body(t_uindex idx, DATA_T elem, t_status status); + + t_column_recipe get_recipe() const; + + // vocabulary must not contain empty string + // indices should be > 0 + // scalars will be implicitly understood to be of dtype str + template + void set_vocabulary(const VOCAB_T& vocab, size_t total_size = 0); + + void copy_vocabulary(const t_column* other); + + void pprint_vocabulary() const; + + template + void raw_fill(DATA_T v); + + std::shared_ptr clone() const; + + std::shared_ptr clone(const t_mask& mask) const; + + /** + * @brief Deep-copy the contents of `other` into this column, preserving + * *this* column's backing store (e.g. `BACKING_STORE_DISK`). Unlike + * `clone()`, which inherits the source column's backing store, this fills + * an already-initialized destination column in place. + * + * @param other + */ + void copy_from(const t_column& other); + + // Ensure this column's backing stores (data, status, string vocab) are + // resident, restoring any the residency manager evicted and stamping LRU + // recency. No-op for resident / non-disk columns. Called once when the column + // is fetched (`t_data_table::get_column`), so per-element accessors stay + // check-free; safe because eviction only happens at request safepoints. + void ensure_resident(); + + void valid_raw_fill(); + void invalid_raw_fill(); + + template + void copy_helper( + const t_column* other, + const std::vector& indices, + t_uindex offset + ); + + void copy( + const t_column* other, + const std::vector& indices, + t_uindex offset + ); + + void clear(t_uindex idx); + void clear(t_uindex idx, t_status status); + + void unset(t_uindex idx); + + void verify() const; + void verify_size() const; + void verify_size(t_uindex idx) const; + + t_uindex get_interned(const std::string& s); + t_uindex get_interned(const char* s); + void _rebuild_map(); + + void borrow_vocabulary(const t_column& o); + +private: + t_dtype m_dtype; + bool m_init; + bool m_isvlen; + + // For varlen columns this + // contains t_uindex encoded ids + std::shared_ptr m_data; + + std::shared_ptr m_vocab; + + // Missing value support + std::shared_ptr m_status; + + t_uindex m_size; + + bool m_status_enabled; + + bool m_from_recipe; + + std::uint32_t m_elemsize; +}; + +template <> +PERSPECTIVE_EXPORT void t_column::push_back(const char* elem); + +template <> +PERSPECTIVE_EXPORT void t_column::push_back(char* elem); + +template <> +PERSPECTIVE_EXPORT void t_column::push_back(std::string elem); + +template <> +PERSPECTIVE_EXPORT void t_column::push_back(t_tscalar elem); + +template <> +PERSPECTIVE_EXPORT void +t_column::set_nth(t_uindex idx, const char* elem); + +template <> +PERSPECTIVE_EXPORT void +t_column::set_nth(t_uindex idx, std::string elem); + +template <> +PERSPECTIVE_EXPORT void +t_column::set_nth(t_uindex idx, const char* elem, t_status status); + +template <> +PERSPECTIVE_EXPORT void +t_column::set_nth(t_uindex idx, std::string elem, t_status status); + +template <> +PERSPECTIVE_EXPORT const char* t_column::get_nth(t_uindex idx +) const; + +template +T* +t_column::get(t_uindex idx) { + return m_data->get(idx); +} + +template +const T* +t_column::get(t_uindex idx) const { + return m_data->get(idx); +} + +template +T* +t_column::get_nth(t_uindex idx) { + COLUMN_CHECK_ACCESS(idx); + return m_data->get_nth(idx); +} + +template +const T* +t_column::get_nth(t_uindex idx) const { + COLUMN_CHECK_ACCESS(idx); + return m_data->get_nth(idx); +} + +template +const T* +t_column::get_nth_base() const { + return m_data->get_nth(0); +} + +template +T* +t_column::get_nth_base() { + return m_data->get_nth(0); +} + +template +T* +t_column::extend() { + return extend(1); +} + +template +T* +t_column::extend(t_uindex idx) { + T* rv = m_data->extend(idx); + m_size += idx; + return rv; +} + +template +void +t_column::push_back(DATA_T elem) { + m_data->push_back(elem); + ++m_size; +} + +template +void +t_column::push_back(DATA_T elem, t_status status) { + PSP_VERBOSE_ASSERT(is_status_enabled(), "Validity not enabled for column"); + m_data->push_back(elem); + m_status->push_back(status); + ++m_size; +} + +// idx is in items + +template +void +t_column::set_nth(t_uindex idx, T v) { + COLUMN_CHECK_ACCESS(idx); + m_data->set_nth(idx, v); + + if (is_status_enabled()) { + m_status->set_nth(idx, STATUS_VALID); + } +} + +template +void +t_column::set_nth(t_uindex idx, T v, t_status status) { + COLUMN_CHECK_ACCESS(idx); + m_data->set_nth(idx, v); + + if (is_status_enabled()) { + m_status->set_nth(idx, status); + } +} + +template <> +void t_column::fill>( + std::vector& vec, const t_uindex* bidx, const t_uindex* eidx +) const; + +template +void +t_column::fill(VEC_T& vec, const t_uindex* bidx, const t_uindex* eidx) const { + + PSP_VERBOSE_ASSERT(eidx - bidx > 0, "Invalid pointers passed in"); + + for (t_uindex idx = 0, loop_end = eidx - bidx; idx < loop_end; ++idx) + + { + vec[idx] = *(get_nth(*(bidx + idx))); + } +} + +template +void +t_column::set_nth_body(t_uindex idx, DATA_T elem, t_status status) { + COLUMN_CHECK_ACCESS(idx); + PSP_VERBOSE_ASSERT(m_dtype == DTYPE_STR, "Setting non string column"); + t_uindex interned = m_vocab->get_interned(elem); + m_data->set_nth(idx, interned); + + if (is_status_enabled()) { + m_status->set_nth(idx, status); + } +} + +// object storage, specialize only for std::uint64_t +template <> +void t_column::object_copied(t_uindex ptr) const; + +template <> +void t_column::object_cleared(t_uindex ptr) const; + +template +void +t_column::raw_fill(DATA_T v) { + m_data->raw_fill(v); +} + +template +void +t_column::set_vocabulary(const VOCAB_T& vocab, size_t total_size) { + if (total_size) { + m_vocab->reserve(total_size, vocab.size() + 1); + } + + for (const auto& kv : vocab) { + m_vocab->get_interned(kv.first.get_char_ptr()); + } +} + +template <> +void t_column::copy_helper( + const t_column* other, const std::vector& indices, t_uindex offset +); + +template +void +t_column::copy_helper( + const t_column* other, const std::vector& indices, t_uindex offset +) { + + t_uindex eidx = + std::min(other->size(), static_cast(indices.size())); + reserve(eidx + offset); + + const DATA_T* o_base = other->get_nth(0); + DATA_T* base = get_nth(0); + + for (t_uindex idx = 0; idx < eidx; ++idx) { + base[idx + offset] = o_base[indices[idx]]; + } + + if (is_status_enabled() && other->is_status_enabled()) { + for (t_uindex idx = 0; idx < eidx; ++idx) { + set_status(idx + offset, *other->get_nth_status(indices[idx])); + } + } + COLUMN_CHECK_VALUES(); +} + +inline void +scol_set(t_column* c, t_uindex row_idx, const char* s) { + c->set_nth(row_idx, s); +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/comparators.h b/rust/perspective-server/cpp/perspective/src/include/perspective/comparators.h new file mode 100644 index 0000000..125807f --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/comparators.h @@ -0,0 +1,47 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +template +struct PERSPECTIVE_EXPORT t_comparator { + inline bool + operator()(DATA_T a, DATA_T b) const { + std::less cmp; + return cmp(a, b); + } +}; + +template +struct PERSPECTIVE_EXPORT t_filter_comparator { + bool + operator()(DATA_T c1, DATA_T c2) const { + return c1 < c2; + } + + bool + operator()(const char* c1, const char* c2) const { + bool rv = strcmp(c1, c2) <= 0; + return rv; + } +}; + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/compat.h b/rust/perspective-server/cpp/perspective/src/include/perspective/compat.h new file mode 100644 index 0000000..c11c34c --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/compat.h @@ -0,0 +1,61 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#ifndef WIN32 +#include +#endif + +namespace perspective { + +t_uindex file_size(t_handle h); +void close_file(t_handle h); +void flush_mapping(void* base, t_uindex len); +void rmfile(const std::string& fname); + +struct t_rfmapping { + t_rfmapping(); + t_rfmapping(t_handle fd, void* base, t_uindex size); + ~t_rfmapping(); + + t_handle m_fd; + void* m_base; + t_uindex m_size; + + PSP_NON_COPYABLE(t_rfmapping); +}; + +void map_file_read(const std::string& fname, t_rfmapping& out); +void map_file_write(const std::string& fname, t_uindex size, t_rfmapping& out); + +void set_thread_name(std::thread& thr, const std::string& name); +void set_thread_name(const std::string& name); + +void launch_proc(const std::string& cmdline); + +std::string cwd(); + +PERSPECTIVE_EXPORT std::int64_t get_page_size(); +PERSPECTIVE_EXPORT std::int64_t psp_curtime(); +PERSPECTIVE_EXPORT std::int64_t psp_curmem(); + +PERSPECTIVE_EXPORT void* psp_dbg_malloc(size_t size); +PERSPECTIVE_EXPORT void psp_dbg_free(void* mem); + +PERSPECTIVE_EXPORT void* psp_page_aligned_malloc(std::int64_t size); +PERSPECTIVE_EXPORT void psp_page_aligned_free(void* mem); + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/computed_expression.h b/rust/perspective-server/cpp/perspective/src/include/perspective/computed_expression.h new file mode 100644 index 0000000..3e9e096 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/computed_expression.h @@ -0,0 +1,266 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// a header that includes exprtk and overload definitions for `t_tscalar` so +// it can be used inside exprtk. +#include + +namespace perspective { + +/** + * @brief A thin container for an error message and position (start and end + * row/column) from Exprtk. + */ +struct PERSPECTIVE_EXPORT t_expression_error { + std::string m_error_message; + t_index m_line; + t_index m_column; +}; + +class PERSPECTIVE_EXPORT t_computed_expression; + +// Per-expression compiled-expression cache (compile-once / rebind-per-call). +// Defined in computed_expression.cpp and held by `t_computed_expression` via a +// `mutable unique_ptr` so the compiled exprtk AST and the storage its variable +// and function nodes point at stay heap-pinned across calls. +struct t_computed_expression_cache; + +class PERSPECTIVE_EXPORT t_computed_expression_parser { +public: + t_computed_expression_parser(); + + /** + * @brief Given expression strings, validate the expression's dtype and + * return a shared pointer to a new `t_computed_expression`. This method + * will abort() if an input column is invalid or the expression cannot be + * parsed. + * + * @param expression_alias an alias for the expression, which will become + * the name of the new expression column on the table. + * @param expression_string the expression, as the user typed it. + * @param parsed_expression_string the expression after having been + * parsed through the Perspective engine to replace column names, add + * intern(), etc. + * @param column_ids A map of column IDs to column names, which is used to + * properly access column names from the symbol table. + * @param schema + * @return std::shared_ptr + */ + std::shared_ptr precompute( + const std::string& expression_alias, + const std::string& expression_string, + const std::string& parsed_expression_string, + const std::vector>& column_ids, + const std::shared_ptr& source_table, + const t_gstate::t_mapping& pkey_map, + const std::shared_ptr& schema, + t_expression_vocab& vocab, + t_regex_mapping& regex_mapping + ) const; + + /** + * @brief Returns the dtype of the given expression, or `DTYPE_NONE` + * if the expression is invalid. Unlike `precompute`, this method + * implements additional checks for column validity and is guaranteed + * to return a value, even if the expression cannot be parsed. If the + * expression has an error in it, this function will return `DTYPE_NONE` + * and the `error_string` will have an error message set. + * + * @param expression_alias an alias for the expression, which will become + * the name of the new expression column on the table. + * @param expression_string the expression, as the user typed it. + * @param parsed_expression_string the expression after having been + * parsed through the Perspective engine to replace column names, add + * intern(), etc. + * @param column_ids A map of column IDs to column names, which is used to + * properly access column names from the symbol table. + * @param schema + * @param error_string + * @return t_dtype + */ + t_dtype get_dtype( + const std::string& expression_alias, + const std::string& expression_string, + const std::string& parsed_expression_string, + const std::vector>& column_ids, + const std::shared_ptr& source_table, + const t_gstate::t_mapping& pkey_map, + const t_schema& schema, + t_expression_error& error, + t_expression_vocab& vocab, + t_regex_mapping& regex_mapping + ) const; + + std::shared_ptr> m_parser; + + // Static computed functions have no state + static computed_function::bucket BUCKET_FN; + static computed_function::hour_of_day HOUR_OF_DAY_FN; + static computed_function::percent_of PERCENT_OF_FN; + static computed_function::inrange_fn INRANGE_FN; + static computed_function::min_fn MIN_FN; + static computed_function::max_fn MAX_FN; + static computed_function::coalesce COALESCE_FN; + static computed_function::contains CONTAINS_FN; + static computed_function::diff3 diff3; + static computed_function::norm3 norm3; + static computed_function::cross_product3 cross_product3; + static computed_function::dot_product3 dot_product3; + static computed_function::length LENGTH_FN; + static computed_function::is_null IS_NULL_FN; + static computed_function::is_not_null IS_NOT_NULL_FN; + static computed_function::to_integer TO_INTEGER_FN; + static computed_function::to_float TO_FLOAT_FN; + static computed_function::to_boolean TO_BOOLEAN_FN; + static computed_function::make_date MAKE_DATE_FN; + static computed_function::make_datetime MAKE_DATETIME_FN; + static computed_function::random RANDOM_FN; + + // constants for True and False as DTYPE_BOOL scalars + static t_tscalar TRUE_SCALAR; + static t_tscalar FALSE_SCALAR; +}; + +/** + * @brief Contains the metadata for a single expression and the methods which + * will compute the expression's output. + */ +class PERSPECTIVE_EXPORT t_computed_expression { +public: + PSP_NON_COPYABLE(t_computed_expression); + + t_computed_expression( + std::string expression_alias, + std::string expression_string, + std::string parsed_expression_string, + const std::vector>& column_ids, + t_dtype dtype + ); + + // Out-of-line: `m_cache` is a unique_ptr to an incomplete type, so the + // destructor must be defined where that type is complete (the .cpp). + ~t_computed_expression(); + + void compute( + const std::shared_ptr& source_table, + const t_gstate::t_mapping& pkey_map, + const std::shared_ptr& destination_table, + t_expression_vocab& vocab, + t_regex_mapping& regex_mapping + ) const; + + const std::string& get_expression_alias() const; + const std::string& get_expression_string() const; + const std::string& get_parsed_expression_string() const; + const std::vector>& + get_column_ids() const; + t_dtype get_dtype() const; + +private: + std::string m_expression_alias; + std::string m_expression_string; + std::string m_parsed_expression_string; + t_computed_expression_parser m_computed_expression_parser; + std::vector> m_column_ids; + t_dtype m_dtype; + + // Lazily-built compiled-expression cache (see compute()). `mutable` because + // compute() is const; `unique_ptr` keeps the cache heap-pinned so exprtk's + // bound variable (T*) and function (ifunction*) pointers stay valid across + // calls. Invalidated + rebuilt if an input column's dtype is promoted. + mutable std::unique_ptr m_cache; +}; + +/** + * @brief a `t_schema`-like container for validated expression results that + * offers fast lookups. + */ +struct PERSPECTIVE_EXPORT t_validated_expression_map { + t_validated_expression_map(); + + void add_expression( + const std::string& expression_alias, const std::string& type_string + ); + void + add_error(const std::string& expression_alias, t_expression_error& error); + + std::map get_expression_schema() const; + std::map get_expression_errors() const; + + std::map m_expression_schema; + std::map m_expression_errors; +}; + +/** + * @brief Store instances of each computed function for use in + * t_computed_expression::compute, t_computed_expression::precompute, + * and t_computed_expression::get_dtype. When a new computed function + * definition is added, it needs to be added here as well. + */ +struct PERSPECTIVE_EXPORT t_computed_function_store { + PSP_NON_COPYABLE(t_computed_function_store); + + t_computed_function_store( + t_expression_vocab& vocab, + t_regex_mapping& regex_mapping, + bool is_type_validator, + const std::shared_ptr& source_table, + const t_gstate::t_mapping& pkey_map, + t_uindex& row_idx + ); + + void register_computed_functions(exprtk::symbol_table& sym_table + ); + + /** + * @brief Clear any intermediate state that may be used by functions, such + * as mappings that must be cleared per-invocation. + */ + void clear_computed_function_state(); + + // Member functions are instances that must be initialized per-method call, + // as they have references to a `t_expression_vocab`. + computed_function::day_of_week m_day_of_week_fn; + computed_function::month_of_year m_month_of_year_fn; + computed_function::intern m_intern_fn; + computed_function::concat m_concat_fn; + computed_function::order m_order_fn; + computed_function::upper m_upper_fn; + computed_function::lower m_lower_fn; + computed_function::to_string m_to_string_fn; + computed_function::match m_match_fn; + computed_function::match_all m_match_all_fn; + computed_function::search m_search_fn; + computed_function::indexof m_indexof_fn; + computed_function::substring m_substring_fn; + computed_function::replace m_replace_fn; + computed_function::replace_all m_replace_all_fn; + computed_function::index m_index_fn; + computed_function::col m_col_fn; + computed_function::vlookup m_vlookup_fn; +}; + +} // end namespace perspective \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/computed_function.h b/rust/perspective-server/cpp/perspective/src/include/perspective/computed_function.h new file mode 100644 index 0000000..f4768db --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/computed_function.h @@ -0,0 +1,439 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +namespace computed_function { + + typedef typename exprtk::igeneric_function::parameter_list_t + t_parameter_list; + typedef typename exprtk::igeneric_function::generic_type + t_generic_type; + typedef typename t_generic_type::scalar_view t_scalar_view; + typedef typename t_generic_type::vector_view t_vector_view; + typedef typename t_generic_type::string_view t_string_view; + +// A regex function that caches its parsed regex objects. +#define REGEX_FUNCTION_HEADER(NAME) \ + struct NAME final : public exprtk::igeneric_function { \ + NAME(t_regex_mapping& regex_mapping); \ + ~NAME(); \ + t_tscalar operator()(t_parameter_list parameters) override; \ + t_regex_mapping& m_regex_mapping; \ + }; + +// A regex function that returns a string stored in the expression vocab. +#define REGEX_STRING_FUNCTION_HEADER(NAME) \ + struct NAME final : public exprtk::igeneric_function { \ + NAME( \ + t_expression_vocab& expression_vocab, \ + t_regex_mapping& regex_mapping, \ + bool is_type_validator \ + ); \ + ~NAME(); \ + t_tscalar operator()(t_parameter_list parameters) override; \ + t_expression_vocab& m_expression_vocab; \ + t_regex_mapping& m_regex_mapping; \ + bool m_is_type_validator; \ + }; + +// A function that returns a new string that is not part of the original +// input column. To prevent the new strings from going out of scope and causing +// memory errors, store the strings in the expression_vocab of the gnode +// that is the parent of the view. String functions MUST NOT BE STATIC; +// because Exprtk is the caller of the function (using operator()), the only +// place we can provide a reference to the vocab is through the constructor. +// As string functions are constructed per-invocation of compute/precompute/ +// get_dtype, and because the gnode is guaranteed to be valid for all of +// those invocations, we can store a reference to the vocab. +#define STRING_FUNCTION_HEADER(NAME) \ + struct NAME final : public exprtk::igeneric_function { \ + NAME(t_expression_vocab& expression_vocab, bool is_type_validator); \ + ~NAME(); \ + t_tscalar operator()(t_parameter_list parameters) override; \ + t_expression_vocab& m_expression_vocab; \ + t_tscalar m_sentinel; \ + bool m_is_type_validator; \ + }; + + // TODO PSP_NON_COPYABLE all functions + + // Place string literals into `expression_vocab` so they do not go out + // of scope and remain valid for the lifetime of the table. + STRING_FUNCTION_HEADER(intern) + + // Concat any number of columns and string literals together. + STRING_FUNCTION_HEADER(concat) + + // Uppercase a string, storing intermediates in `expression_vocab` + STRING_FUNCTION_HEADER(upper) + + // Lowercase a string + STRING_FUNCTION_HEADER(lower) + + /** + * @brief Given a string column and 1...n string parameters, generate a + * numeric column that will act as a custom sort order for the string + * column: + * + * category = ['A', 'B', 'C', 'D'] + * order("Category", 'B', 'C', 'A') => [2, 0, 1, 3] + * + * This allows string sort order to be overridden from the default + * alphabetical sort, which is useful for categories or string timestamps. + * Strings in the column not provided to `order()` will by default appear at + * the end. + */ + struct order final : public exprtk::igeneric_function { + order(bool is_type_validator); + ~order(); + + t_tscalar operator()(t_parameter_list parameters) override; + void clear_order_map(); + + tsl::hopscotch_map m_order_map; + double m_order_idx; + bool m_is_type_validator; + t_tscalar m_sentinel; + }; + + /** + * @brief Given a string column and a non-regex string literal, check + * whether each row in the string column contains the string literal. + */ + struct contains final : public exprtk::igeneric_function { + contains(); + ~contains(); + t_tscalar operator()(t_parameter_list parameters) override; + }; + + /** + * @brief match(string, pattern) => True if the string or a substring + * partially matches pattern, and False otherwise. + */ + REGEX_FUNCTION_HEADER(match) + + /** + * @brief match_all(string, pattern) => True if the string fully matches + * pattern, and False otherwise. + */ + REGEX_FUNCTION_HEADER(match_all) + + /** + * @brief search(string, pattern) => Returns the substring in the first + * capturing group that matches pattern. If the regex does not have any + * capturing groups, fails type checking and returns null. + */ + REGEX_STRING_FUNCTION_HEADER(search) + + /** + * @brief indexof(string, pattern) => the start index of the first match, + * partial or full, inside string for pattern, or null if the string + * does not match the pattern at all. + */ + REGEX_FUNCTION_HEADER(indexof) + + /** + * @brief substr(string, start_idx, end_idx) => the substring of string + * at start_idx (inclusive) and end_idx (exclusive). If end_idx is not + * provided, defaults to string.length(). Equivalent to the string slice + * operator (str[bidx:eidx]) in Python. If bidx > eidx, returns null. + */ + STRING_FUNCTION_HEADER(substring) + + /** + * @brief replace(string, replace_str, pattern) => Replaces the first match + * of pattern inside string with replace_str, or returns the original + * string if no replacements were made. + */ + REGEX_STRING_FUNCTION_HEADER(replace) + + /** + * @brief replace_all(string, replace_str, pattern) => Replaces all matches + * of pattern inside string with replace_str, or returns the original + * string if no replacements were made. + */ + REGEX_STRING_FUNCTION_HEADER(replace_all) + +#define FUNCTION_HEADER(NAME) \ + struct NAME final : public exprtk::igeneric_function { \ + NAME(); \ + ~NAME(); \ + t_tscalar operator()(t_parameter_list parameters) override; \ + }; + + // Length of the string + FUNCTION_HEADER(length) + + // NOTE: `index`/`col`/`vlookup` hold `m_source_table` and `m_row_idx` by + // reference, not by value, so a single compiled expression can be re-pointed + // at a different source table / row on each call without recompiling (see + // `t_computed_expression::compute`). The referents must outlive the function + // object: the compile-once cache owns stable slots, and the throwaway + // precompute/get_dtype stores bind to their local args for their own scope. + struct index final : public exprtk::igeneric_function { + index( + const t_pkey_mapping& pkey_map, + const std::shared_ptr& source_table, + t_uindex& row_idx + ); + ~index(); + t_tscalar operator()(t_parameter_list parameters) override; + + private: + const t_pkey_mapping& m_pkey_map; + const std::shared_ptr& m_source_table; + t_uindex& m_row_idx; + }; + + struct col final : public exprtk::igeneric_function { + col(t_expression_vocab& expression_vocab, + bool is_type_validator, + const std::shared_ptr& source_table, + t_uindex& row_idx); + ~col(); + t_tscalar operator()(t_parameter_list parameters) override; + + private: + t_expression_vocab& m_expression_vocab; + bool m_is_type_validator; + const std::shared_ptr& m_source_table; + t_uindex& m_row_idx; + }; + + struct vlookup final : public exprtk::igeneric_function { + vlookup( + t_expression_vocab& expression_vocab, + bool is_type_validator, + const std::shared_ptr& source_table, + t_uindex& row_idx + ); + ~vlookup(); + t_tscalar operator()(t_parameter_list parameters) override; + + private: + t_expression_vocab& m_expression_vocab; + bool m_is_type_validator; + const std::shared_ptr& m_source_table; + t_uindex& m_row_idx; + }; + + /** + * @brief Return the hour of the day the date/datetime belongs to. + */ + FUNCTION_HEADER(hour_of_day) + + // Day of Week/Month of Year write strings directly, and use custom strings + // so they are sorted by day/month and *not* alphabetically + + extern const std::string days_of_week[7]; + extern const std::string months_of_year[12]; + + // Return the day of the week the date/datetime belongs to + STRING_FUNCTION_HEADER(day_of_week) + + // Return the month of the year the date/datetime belongs to + STRING_FUNCTION_HEADER(month_of_year) + + enum t_date_bucket_unit { + SECONDS, + MINUTES, + HOURS, + DAYS, + WEEKS, + MONTHS, + YEARS + }; + + /** + * @brief Bucket the input by the unit: + * + * - If the input is a date or a datetime, the unit is one of the following: + * [s]econds, [m]inutes, [h]ours, [D]ays, [W]eeks, [M]onths, [Y]ears. + * + * - If the input is a number, the unit is also a number. + * + * Any other inputs are invalid. + */ + struct bucket final : public exprtk::igeneric_function { + bucket(); + ~bucket(); + + t_tscalar operator()(t_parameter_list parameters) final; + + // faster unit lookups, since we are calling this lookup in a tight + // loop. + static tsl::hopscotch_map UNIT_MAP; + }; + + void _second_bucket(t_tscalar& val, t_tscalar& rval, t_uindex multiplicity); + void _minute_bucket(t_tscalar& val, t_tscalar& rval, t_uindex multiplicity); + void _hour_bucket(t_tscalar& val, t_tscalar& rval, t_uindex multiplicity); + void _day_bucket(t_tscalar& val, t_tscalar& rval); + void _week_bucket(t_tscalar& val, t_tscalar& rval); + void _month_bucket(t_tscalar& val, t_tscalar& rval, t_uindex multiplicity); + void _year_bucket(t_tscalar& val, t_tscalar& rval, t_uindex multiplicity); + + /** + * @brief Returns the current datetime. Will be recalculated on view + * creation and table update. + * + * @return t_tscalar + */ + t_tscalar now(); + + /** + * @brief Returns the current date. Will be recalculated on view creation + * and table update. + * + * @return t_tscalar + */ + t_tscalar today(); + + /** + * @brief inrange(range_low, value, range_high) returns whether value + * is inside of the range (inclusive of range_low and range_high). + */ + FUNCTION_HEADER(inrange_fn) + + /** + * @brief Get the minimum of all the inputs. + */ + FUNCTION_HEADER(min_fn) + + /** + * @brief Get the maximum of all the inputs. + */ + FUNCTION_HEADER(max_fn) + + /** + * @brief Return the first non-null argument. Variadic; all numeric + * inputs are promoted to FLOAT64. Non-numeric inputs must share an + * exact dtype. + */ + FUNCTION_HEADER(coalesce) + + /** + * @brief Get the cross product of two vec3s + */ + FUNCTION_HEADER(diff3) + + /** + * @brief Get the cross product of two vec3s + */ + FUNCTION_HEADER(norm3) + + /** + * @brief Get the cross product of two vec3s + */ + FUNCTION_HEADER(cross_product3) + + /** + * @brief Get the dot product of two vec3s + */ + FUNCTION_HEADER(dot_product3) + + /** + * @brief Get a as percent of b. + * + */ + FUNCTION_HEADER(percent_of) + + /** + * @brief Whether the input is null. + * + */ + FUNCTION_HEADER(is_null) + + /** + * @brief Whether the input is not null. + * + */ + FUNCTION_HEADER(is_not_null) + + /** + * @brief Convert a column or scalar of any type to a string. + * + */ + STRING_FUNCTION_HEADER(to_string) + + /** + * @brief Convert a column or scalar to an integer, or null if the value is + * not parsable as an integer. In the WASM runtime, the integer is 32-bit + * and will return none if the result under/over flows. + */ + FUNCTION_HEADER(to_integer) + + /** + * @brief Convert a column or scalar to a float, or null if the value is not + * parsable as an float. + */ + FUNCTION_HEADER(to_float) + + /** + * @brief Given a Year, Month (1-12), and Day, create a new date value. + * Because we also extensively use `date.h` which exports its symbols under + * `date`, this function is given a name that does not conflict (even though + * it is registered using "date"). + */ + FUNCTION_HEADER(make_date) + + /** + * @brief Convert a column or scalar to a boolean, which returns True if the + * value is truthy or False otherwise. + * + * boolean(1) + * boolean(null) + */ + FUNCTION_HEADER(to_boolean) + + /** + * @brief Given a POSIX timestamp of milliseconds since epoch, create a + * new datetime value. + */ + FUNCTION_HEADER(make_datetime) + + /** + * @brief Return a random float between 0.0 and 1.0, inclusive. + */ + struct random : public exprtk::igeneric_function { + random(); + ~random(); + + t_tscalar operator()(t_parameter_list parameters); + + // faster unit lookups, since we are calling this lookup in a tight + // loop. + static std::default_random_engine RANDOM_ENGINE; + static std::uniform_real_distribution DISTRIBUTION; + }; + +} // end namespace computed_function +} // end namespace perspective \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/config.h b/rust/perspective-server/cpp/perspective/src/include/perspective/config.h new file mode 100644 index 0000000..9ec4f6d --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/config.h @@ -0,0 +1,254 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +/** + * @brief `t_config` contains metadata for the `View` and `t_ctx*` structures, + * containing specifications for how pivots, columns, filters, and sorts should + * be constructed. + * + */ +class PERSPECTIVE_EXPORT t_config { +public: + /** + * @brief Construct a new config for a `t_ctxunit` object. + * + * @param detail_columns the columns to be displayed in the context + */ + t_config(const std::vector& detail_columns); + + /** + * @brief Construct a new config for a `t_ctx0` object. + * + * @param detail_columns the columns to be displayed in the context + * @param combiner + * @param fterms specifications for filtering down the context + */ + t_config( + const std::vector& detail_columns, + const std::vector& fterms, + t_filter_op combiner, + const std::vector>& expressions + ); + + /** + * @brief Construct a new config for a `t_ctx1` object, which has 1 or more + * `row_pivot`s applied. + * + * @param row_pivots + * @param aggregates + * @param combiner + * @param fterms + */ + t_config( + const std::vector& row_pivots, + const std::vector& aggregates, + const std::vector& fterms, + t_filter_op combiner, + const std::vector>& expressions + ); + + /** + * @brief Construct a new config for a `t_ctx2` object, which has 1 or more + * `row_pivot`s and 1 or more `col_pivot`s applied. + * + * @param row_pivots + * @param col_pivots + * @param aggregates + * @param totals + * @param combiner + * @param fterms + * @param column_only + */ + t_config( + const std::vector& row_pivots, + const std::vector& col_pivots, + const std::vector& aggregates, + const t_totals totals, + const std::vector& fterms, + t_filter_op combiner, + const std::vector>& expressions, + bool column_only + ); + + // An empty config, used for the unit context. + t_config(); + + // Constructors used for C++ tests, not exposed to other parts of the engine + t_config( + const std::vector& row_pivots, + const std::vector& col_pivots, + const std::vector& aggregates + ); + + t_config( + const std::vector& row_pivots, + const std::vector& col_pivots, + const std::vector& aggregates, + const t_totals totals, + t_filter_op combiner, + const std::vector& fterms + ); + + t_config( + const std::vector& row_pivots, + const std::vector& aggregates + ); + + t_config( + const std::vector& row_pivots, + const std::vector& aggregates + ); + + t_config(const std::vector& row_pivots, const t_aggspec& agg); + + /** + * @brief For each column in the config's `detail_columns` (i.e. visible + * columns), add it to the internal map tracking column indices. + * + * @param detail_columns + */ + void setup(const std::vector& detail_columns); + + void setup( + const std::vector& detail_columns, + const std::vector& sort_pivot, + const std::vector& sort_pivot_by + ); + + /** + * @brief A t_config is trivial if it does not have any pivots, sorts, + * filter terms, or expressions. This allows a context_zero to + * skip creating a traversal and simply read from its gnode state for + * a performance boost. + * + * @return true + * @return false + */ + bool is_trivial_config() const; + + t_index get_colidx(const std::string& colname) const; + + std::string repr() const; + + t_uindex get_num_aggregates() const; + + t_uindex get_num_columns() const; + + std::string col_at(t_uindex idx) const; + + bool has_pkey_agg() const; + + std::string get_totals_string() const; + + std::string get_sort_by(const std::string& pivot) const; + + bool validate_colidx(t_index idx) const; + + std::vector get_column_names() const; + t_uindex get_num_rpivots() const; + t_uindex get_num_cpivots() const; + bool is_column_only() const; + + std::vector get_pivots() const; + const std::vector& get_row_pivots() const; + const std::vector& get_column_pivots() const; + const std::vector& get_aggregates() const; + + std::vector> get_sortby_pairs() const; + const std::vector& get_sortspecs() const; + const std::vector& get_col_sortspecs() const; + + bool has_filters() const; + + const std::vector& get_fterms() const; + + std::vector> get_expressions() const; + + t_totals get_totals() const; + + t_filter_op get_combiner() const; + + std::string get_parent_pkey_column() const; + + std::string get_child_pkey_column() const; + + const std::string& get_grouping_label_column() const; + + std::string unity_get_column_name(t_uindex idx) const; + std::string unity_get_column_display_name(t_uindex idx) const; + t_fmode get_fmode() const; + + inline const std::string& + get_grand_agg_str() const { + return m_grand_agg_str; + } + + // The backing store for any persistent storage created by the context built + // from this config (currently the expression `m_master` table). Inherited + // from the parent `Table`'s backing store. Defaults to memory. + inline void + set_backing_store(t_backing_store backing_store) { + m_backing_store = backing_store; + } + + inline t_backing_store + get_backing_store() const { + return m_backing_store; + } + +protected: + void populate_sortby(const std::vector& pivots); + +private: + // Set at initialization and accessible through a public API. + std::vector m_detail_columns; + std::vector m_row_pivots; + std::vector m_col_pivots; + std::vector m_aggregates; + std::map m_sortby; + std::vector m_sortspecs; + std::vector m_col_sortspecs; + std::vector m_fterms; + std::vector> m_expressions; + t_filter_op m_combiner; + bool m_column_only; + + // A trivial config exists if there are no pivots, sorts, filters, or + // expression columns. + bool m_is_trivial_config; + + // Internal + t_totals m_totals; + std::map m_detail_colmap; + std::string m_parent_pkey_column; + std::string m_child_pkey_column; + std::string m_grouping_label_column; + std::string m_grand_agg_str; + t_fmode m_fmode; + bool m_has_pkey_agg; + t_backing_store m_backing_store = BACKING_STORE_MEMORY; +}; + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/context_base.h b/rust/perspective-server/cpp/perspective/src/include/perspective/context_base.h new file mode 100644 index 0000000..22cd218 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/context_base.h @@ -0,0 +1,319 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +template +class t_ctx_common { +public: + t_ctx_common(CTX_T* ctx) : m_ctx(ctx) {} + + t_slice + get_data(const t_range& rng, const std::vector& fvec) const { + return m_ctx->unity_get_data(rng, fvec); + } + +private: + PSP_NON_COPYABLE(t_ctx_common); + CTX_T* m_ctx; +}; + +template +class t_ctxbase { +public: + typedef DERIVED_T t_derived; + + t_ctxbase(); + static std::shared_ptr + build(const t_schema& schema, const t_config& config); + t_ctxbase(const t_schema& schema, const t_config& config); + + void set_name(const std::string& name); + std::string get_name() const; + std::int64_t get_ptr() const; + void set_state(std::shared_ptr gstate); + const t_config& get_config() const; + t_config& get_config(); + std::vector get_pivots() const; + t_schema get_schema() const; + + bool get_feature_state(t_ctx_feature feature) const; + + // Backwards compatibility only + bool get_alerts_enabled() const; + bool get_deltas_enabled() const; + + bool failed() const; + + t_ctx_common + common() { + return t_ctx_common(this); + } + + void unity_populate_slice_row( + t_slice& s, const std::vector& fvec, t_uindex idx + ) const; + void unity_populate_slice_column( + t_slice& s, const std::vector& fvec, t_uindex idx + ) const; + + t_slice + unity_get_data(const t_range& rng, const std::vector& fvec) const; + + std::vector get_data() const; + +protected: + t_schema m_schema; + t_config m_config; + bool m_rows_changed; + bool m_columns_changed; + std::string m_name; + std::shared_ptr m_gstate; + bool m_init; + std::vector m_features; +}; + +template +std::shared_ptr +t_ctxbase::build(const t_schema& schema, const t_config& config) { + auto rv = std::make_shared(schema, config); + rv->init(); + return rv; +} + +template +t_ctxbase::t_ctxbase() : + m_rows_changed(true), + m_columns_changed(true), + m_init(false) { + m_features = std::vector(CTX_FEAT_LAST_FEATURE); + m_features[CTX_FEAT_ENABLED] = true; +} + +template +t_ctxbase::t_ctxbase(const t_schema& schema, const t_config& config) + + : + m_schema(schema), + m_config(config), + m_rows_changed(true), + m_columns_changed(true), + m_init(false) { + m_features = std::vector(CTX_FEAT_LAST_FEATURE); + m_features[CTX_FEAT_ENABLED] = true; +} + +template +void +t_ctxbase::set_name(const std::string& name) { + m_name = name; +} + +template +std::string +t_ctxbase::get_name() const { + return m_name; +} + +template +std::int64_t +t_ctxbase::get_ptr() const { + return reinterpret_cast(this); +} + +template +void +t_ctxbase::set_state(std::shared_ptr gstate) { + m_gstate = gstate; +} + +template +t_config& +t_ctxbase::get_config() { + return m_config; +} + +template +const t_config& +t_ctxbase::get_config() const { + return m_config; +} + +template +std::vector +t_ctxbase::get_pivots() const { + return m_config.get_pivots(); +} + +template +t_schema +t_ctxbase::get_schema() const { + return m_schema; +} + +template +bool +t_ctxbase::get_alerts_enabled() const { + return m_features[CTX_FEAT_ALERT]; +} + +template +bool +t_ctxbase::get_deltas_enabled() const { + return m_features[CTX_FEAT_DELTA]; +} + +template +bool +t_ctxbase::failed() const { + return false; +} + +template +bool +t_ctxbase::get_feature_state(t_ctx_feature feature) const { + return m_features[feature]; +} + +template +void +t_ctxbase::unity_populate_slice_column( + t_slice& s, const std::vector& fvec, t_uindex idx +) const { + for (auto f : fvec) { + switch (f) { + case FETCH_COLUMN_DEPTH: { + s.column_depth().push_back( + reinterpret_cast(this) + ->unity_get_column_depth(idx) + ); + } break; + case FETCH_COLUMN_PATHS: { + s.column_paths().push_back( + reinterpret_cast(this) + ->unity_get_column_path(idx) + ); + } break; + case FETCH_COLUMN_INDICES: { + s.column_indices().push_back(idx); + } break; + case FETCH_IS_COLUMN_EXPANDED: { + s.is_column_expanded().push_back( + reinterpret_cast(this) + ->unity_get_column_expanded(idx) + ); + } break; + default: { + } + } + } +} + +template +void +t_ctxbase::unity_populate_slice_row( + t_slice& s, const std::vector& fvec, t_uindex idx +) const { + auto cptr = reinterpret_cast(this); + for (auto f : fvec) { + switch (f) { + case FETCH_ROW_PATHS: { + s.row_paths().push_back(cptr->unity_get_row_path(idx)); + } break; + case FETCH_ROW_INDICES: { + s.row_indices().push_back(idx); + } break; + case FETCH_ROW_DATA_SLICE: { + s.row_data().push_back(cptr->unity_get_row_data(idx)); + } break; + case FETCH_IS_ROW_EXPANDED: { + s.is_row_expanded().push_back(cptr->unity_get_row_expanded(idx) + ); + } break; + case FETCH_ROW_DEPTH: { + s.row_depth().push_back(cptr->unity_get_row_depth(idx)); + } break; + default: { + } + } + } +} + +template +t_slice +t_ctxbase::unity_get_data( + const t_range& rng, const std::vector& fvec +) const { + auto cptr = reinterpret_cast(this); + t_uindex row_count = cptr->get_row_count(); + t_uindex bridx, eridx; + + switch (rng.get_mode()) { + case RANGE_ROW: { + bridx = rng.bridx(); + eridx = rng.eridx(); + } break; + case RANGE_ALL: { + bridx = 0; + eridx = row_count; + } break; + default: { + std::cout << "Unexpected mode encountered => " << rng.get_mode() + << "\n"; + return t_slice(); + } + } + + if (bridx > eridx || bridx >= row_count || eridx > row_count) { + return t_slice(); + } + + t_slice rv; + + for (t_uindex idx = bridx; idx < eridx; ++idx) { + cptr->unity_populate_slice_row(rv, fvec, idx); + } + + t_uindex bcidx = 0; + t_uindex ecidx = cptr->unity_get_column_count(); + + for (t_uindex idx = bcidx; idx < ecidx; ++idx) { + cptr->unity_populate_slice_column(rv, fvec, idx); + } + + return rv; +} + +template +std::vector +t_ctxbase::get_data() const { + auto cptr = reinterpret_cast(this); + return cptr->get_data( + 0, cptr->get_row_count(), 0, cptr->get_column_count() + ); +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/context_common_decls.h b/rust/perspective-server/cpp/perspective/src/include/perspective/context_common_decls.h new file mode 100644 index 0000000..f566ff5 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/context_common_decls.h @@ -0,0 +1,147 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +perspective::t_index get_row_count() const; + +perspective::t_index get_column_count() const; + +std::vector get_data( + t_index start_row, t_index end_row, t_index start_col, t_index end_col +) const; + +std::vector get_data(const std::vector& rows) const; + +void sort_by(const std::vector& sortby); + +void reset_sortby(); + +// will only work on empty contexts. `is_registration` is `true` when this +// call is part of `_register_context` (no subscriber yet) and `false` when +// it is the first update to a previously-empty table. When `true`, per-row +// delta tracking is skipped to avoid O(N) allocations no observer can see. +void notify(const t_data_table& flattened, bool is_registration); + +void notify( + const t_data_table& flattened, + const t_data_table& delta, + const t_data_table& prev, + const t_data_table& current, + const t_data_table& transitions, + const t_data_table& existed +); + +void step_begin(); + +void step_end(); + +std::string repr() const; + +void init(); + +void reset(bool reset_expressions = true); + +t_index sidedness() const; + +bool get_deltas_enabled() const; + +void set_alerts_enabled(bool enabled_state); + +void set_deltas_enabled(bool enabled_state); + +void set_feature_state(t_ctx_feature feature, bool state); + +std::vector +get_pkeys(const std::vector>& cells) const; + +t_stepdelta get_step_delta(t_index bidx, t_index eidx); + +t_rowdelta get_row_delta(); + +std::vector get_rows_changed(); + +std::vector get_cell_delta(t_index bidx, t_index eidx) const; + +void clear_deltas(); + +void reset_step_state(); + +void disable(); + +void enable(); + +std::vector get_trees(); + +bool has_deltas() const; + +void pprint() const; + +t_dtype get_column_dtype(t_uindex idx) const; + +std::shared_ptr get_table() const; + +/** + * @brief Given a column name return whether it is an expression column. + * Because expression columns cannot overwrite real columns, a column cannot + * be both an expression and a "real" column. + * + * @param colname + * @return true + * @return false + */ +bool is_expression_column(const std::string& colname) const; + +t_uindex num_expressions() const; + +std::shared_ptr get_expression_tables() const; + +// Given shared pointers to data tables from the gnode, use them to +// compute the results of expression columns. +void compute_expressions( + const std::shared_ptr& master, + const t_gstate::t_mapping& pkey_map, + t_expression_vocab& expression_vocab, + t_regex_mapping& regex_mapping +); + +void compute_expressions( + const std::shared_ptr& master, + const t_gstate::t_mapping& pkey_map, + const std::shared_ptr& flattened, + const std::shared_ptr& delta, + const std::shared_ptr& prev, + const std::shared_ptr& current, + const std::shared_ptr& transitions, + const std::shared_ptr& existed, + t_expression_vocab& expression_vocab, + t_regex_mapping& regex_mapping +); + +// Unity api +std::vector unity_get_row_data(t_uindex idx) const; +std::vector unity_get_column_data(t_uindex idx) const; +std::vector unity_get_row_path(t_uindex idx) const; +std::vector unity_get_column_path(t_uindex idx) const; +t_uindex unity_get_row_depth(t_uindex ridx) const; +t_uindex unity_get_column_depth(t_uindex cidx) const; +std::vector unity_get_column_names() const; +std::vector unity_get_column_display_names() const; +std::string unity_get_column_name(t_uindex idx) const; +std::string unity_get_column_display_name(t_uindex idx) const; +t_uindex unity_get_column_count() const; +t_uindex unity_get_row_count() const; +t_data_table unity_get_table() const; +bool unity_get_row_expanded(t_uindex idx) const; +bool unity_get_column_expanded(t_uindex idx) const; +void unity_init_load_step_end(); +// TODO + +// add unity api for tables \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/context_grouped_pkey.h b/rust/perspective-server/cpp/perspective/src/include/perspective/context_grouped_pkey.h new file mode 100644 index 0000000..a3fa08d --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/context_grouped_pkey.h @@ -0,0 +1,86 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +class PERSPECTIVE_EXPORT t_ctx_grouped_pkey + : public t_ctxbase { +public: + t_ctx_grouped_pkey(); + t_ctx_grouped_pkey(const t_schema& schema, const t_config& config); + + ~t_ctx_grouped_pkey(); + +#include + + t_index open(t_header header, t_index idx); + t_index open(t_index idx); + + t_index close(t_index idx); + std::vector get_aggregates() const; + std::vector get_row_path(t_index idx) const; + std::vector get_expansion_state() const; + void set_expansion_state(const std::vector& paths); + t_tscalar get_tree_value(t_index idx) const; + t_stree* _get_tree(); + std::vector + get_flattened_tree(t_index idx, t_depth stop_depth); + std::shared_ptr get_traversal() const; + + void set_depth(t_depth depth); + + void expand_path(const std::vector& path); + + // aggregates should be presized to be same size + // as agg_indices + void + get_aggregates_for_sorting(t_uindex nidx, const std::vector& agg_indices, std::vector& aggregates, t_ctx2*) + const; + + using t_ctxbase::get_data; + +private: + void rebuild(); + + t_tscalar get_value_from_gstate( + const std::string& colname, const t_tscalar& pkey + ) const; + + std::shared_ptr m_traversal; + std::shared_ptr m_tree; + std::vector m_sortby; + t_symtable m_symtable; + bool m_has_label; + t_depth m_depth; + bool m_depth_set; + std::shared_ptr m_expression_tables; +}; + +typedef std::shared_ptr t_ctx_grouped_pkey_sptr; +typedef t_ctx_grouped_pkey t_ctxg; +typedef std::shared_ptr t_ctxg_sptr; +typedef std::vector t_ctx_grouped_pkey_svec; + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/context_handle.h b/rust/perspective-server/cpp/perspective/src/include/perspective/context_handle.h new file mode 100644 index 0000000..10dc96f --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/context_handle.h @@ -0,0 +1,40 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include + +namespace perspective { + +struct t_ctx_handle { + t_ctx_handle(); + t_ctx_handle(void* ctx, t_ctx_type ctx_type); + + std::string get_type_descr() const; + t_ctx_type + get_type() const { + return m_ctx_type; + } + + template + _T* + get() const { + return static_cast<_T*>(m_ctx); + } + + t_ctx_type m_ctx_type; + void* m_ctx; +}; +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/context_one.h b/rust/perspective-server/cpp/perspective/src/include/perspective/context_one.h new file mode 100644 index 0000000..6adf080 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/context_one.h @@ -0,0 +1,69 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +class PERSPECTIVE_EXPORT t_ctx1 : public t_ctxbase { +public: + t_ctx1(); + + t_ctx1(const t_schema& schema, const t_config& config); + + ~t_ctx1(); + +#include + + t_index open(t_header header, t_index idx); + t_index open(t_index idx); + t_index close(t_index idx); + + t_aggspec get_aggregate(t_uindex idx) const; + t_tscalar get_aggregate_name(t_uindex idx) const; + std::vector get_aggregates() const; + std::vector get_row_path(t_index idx) const; + void set_depth(t_depth depth); + void set_leaves_only(bool enabled); + void set_total_only(bool enabled); + + t_index get_row_idx(const std::vector& path) const; + + t_depth get_trav_depth(t_index idx) const; + + std::pair get_min_max(const std::string& colname + ) const; + + using t_ctxbase::get_data; + +private: + std::shared_ptr m_traversal; + std::shared_ptr m_tree; + std::vector m_sortby; + std::shared_ptr m_expression_tables; + t_depth m_depth; + bool m_depth_set; + bool m_leaves_only = false; + bool m_total_only = false; +}; + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/context_two.h b/rust/perspective-server/cpp/perspective/src/include/perspective/context_two.h new file mode 100644 index 0000000..b93d308 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/context_two.h @@ -0,0 +1,106 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +class PERSPECTIVE_EXPORT t_ctx2 : public t_ctxbase { +public: +#include + t_ctx2(); + + t_ctx2(const t_schema& schema, const t_config& config); + + ~t_ctx2(); + + t_index open(t_header header, t_index idx); + t_index close(t_header header, t_index idx); + + t_totals get_totals() const; + std::vector get_ctraversal_indices() const; + t_uindex get_num_view_columns() const; + + std::vector get_row_path(t_index idx) const; + std::vector get_row_path(const t_tvnode& node) const; + + std::vector get_column_path(t_index idx) const; + std::vector get_column_path(const t_tvnode& node) const; + std::vector get_column_path_userspace(t_index idx) const; + + std::vector get_aggregates() const; + t_tscalar get_aggregate_name(t_uindex idx) const; + + void column_sort_by(const std::vector& sortby); + + void set_depth(t_header header, t_depth depth); + void set_leaves_only(bool enabled); + void set_total_only(bool enabled); + + std::pair get_min_max(const std::string& colname + ) const; + + using t_ctxbase::get_data; + +protected: + std::vector + resolve_cells(const std::vector>& cells + ) const; + + std::vector + resolve_cells(t_uindex srow, t_uindex erow, t_uindex scol, t_uindex ecol + ) const; + + std::shared_ptr rtree(); + std::shared_ptr rtree() const; + + std::shared_ptr ctree(); + std::shared_ptr ctree() const; + + t_uindex is_rtree_idx(t_uindex idx) const; + t_uindex is_ctree_idx(t_uindex idx) const; + + t_index translate_column_index(t_index idx) const; + + t_uindex get_num_trees() const; + + t_uindex calc_translated_colidx(t_uindex n_aggs, t_uindex cidx) const; + +private: + std::shared_ptr m_rtraversal; + std::shared_ptr m_ctraversal; + std::vector m_sortby; + bool m_rows_changed; + std::vector> m_trees; + std::vector m_column_sortby; + t_depth m_row_depth; + bool m_row_depth_set; + t_depth m_column_depth; + bool m_column_depth_set; + std::shared_ptr m_expression_tables; + bool m_leaves_only = false; + bool m_total_only = false; +}; + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/context_unit.h b/rust/perspective-server/cpp/perspective/src/include/perspective/context_unit.h new file mode 100644 index 0000000..74c8bb1 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/context_unit.h @@ -0,0 +1,170 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace perspective { + +/** + * @brief A context that does not maintain its own traversal, instead + * reading directly from the underlying master table of the context's + * gnode state. + * + * This context can be created when the table does not have an explicit index + * set, as the order of rows in the master table will be exactly the same as + * the primary key order (using PSP_PKEY). If the table has an explicit index, + * a context's traversal contains the sorted order of primary keys and thus + * the order in which the table is meant to be read. + * + * Additionally, to create a unit context, the context must have no pivots, + * sorts, filters, or expressions applied. It can have any number of + * columns in any order. See implementations in the binding language to see + * how a unit context is created. + * + * Benchmarking shows a 5-10x improvement in View construction time compared + * to a regular ctx_0. + */ +class PERSPECTIVE_EXPORT t_ctxunit : public t_ctxbase { +public: + t_ctxunit(); + + t_ctxunit(const t_schema& schema, const t_config& config); + + ~t_ctxunit(); + + perspective::t_index get_row_count() const; + + perspective::t_index get_column_count() const; + + std::pair get_min_max(const std::string& colname + ) const; + + using t_ctxbase::get_data; + + std::vector get_data( + t_index start_row, t_index end_row, t_index start_col, t_index end_col + ) const; + + std::vector get_data(const std::vector& rows) const; + + std::vector get_data(const std::vector& pkeys) const; + + // will only work on empty contexts. `is_registration` is `true` when + // this call is part of `_register_context` (no subscriber yet) and + // `false` when it is the first update to a previously-empty table. + // When `true`, per-row delta tracking is skipped to avoid O(N) + // allocations no observer can see. + void notify(const t_data_table& flattened, bool is_registration); + + void notify( + const t_data_table& flattened, + const t_data_table& delta, + const t_data_table& prev, + const t_data_table& current, + const t_data_table& transitions, + const t_data_table& existed + ); + + void step_begin(); + + void step_end(); + + std::string repr() const; + + void init(); + + void reset(); + + t_index sidedness() const; + + bool get_deltas_enabled() const; + void set_deltas_enabled(bool enabled_state); + + std::vector + get_pkeys(const std::vector>& cells) const; + + t_stepdelta get_step_delta(t_index bidx, t_index eidx); + + t_rowdelta get_row_delta(); + + std::vector get_rows_changed(); + + void clear_deltas(); + + void reset_step_state(); + + void disable(); + + void enable(); + + bool has_deltas() const; + + void pprint() const; + + t_dtype get_column_dtype(t_uindex idx) const; + + std::shared_ptr get_table() const; + + t_tscalar get_column_name(t_index idx); + + std::vector get_column_names() const; + + const tsl::hopscotch_set& get_delta_pkeys() const; + + // Unity api + std::vector unity_get_row_data(t_uindex idx) const; + std::vector unity_get_row_path(t_uindex idx) const; + std::vector unity_get_column_path(t_uindex idx) const; + t_uindex unity_get_row_depth(t_uindex ridx) const; + t_uindex unity_get_column_depth(t_uindex cidx) const; + std::vector unity_get_column_names() const; + std::vector unity_get_column_display_names() const; + std::string unity_get_column_name(t_uindex idx) const; + std::string unity_get_column_display_name(t_uindex idx) const; + t_uindex unity_get_column_count() const; + t_uindex unity_get_row_count() const; + bool unity_get_row_expanded(t_uindex idx) const; + bool unity_get_column_expanded(t_uindex idx) const; + +protected: + void add_delta_pkey(t_tscalar pkey); + + void add_row(t_tscalar pkey, t_uindex idx); + + void update_row(t_tscalar pkey); + + void delete_row(t_tscalar pkey); + +private: + /** + * The unit context does not contain `m_traversal`, as it does not need to + * keep track of a subset of the `gnode_state`'s master table or perform + * translations between row/column indices and primary keys. Instead, data + * is read from the master table using row/column indices that map directly + * to the master table's data. + */ + tsl::hopscotch_set m_delta_pkeys; + + t_symtable m_symtable; + bool m_has_delta; +}; + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/context_zero.h b/rust/perspective-server/cpp/perspective/src/include/perspective/context_zero.h new file mode 100644 index 0000000..768cfd9 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/context_zero.h @@ -0,0 +1,113 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +class t_data_table; + +class PERSPECTIVE_EXPORT t_ctx0 : public t_ctxbase { +public: + t_ctx0(); + + t_ctx0(const t_schema& schema, const t_config& config); + + ~t_ctx0(); +#include + + t_tscalar get_column_name(t_index idx); + + std::vector get_column_names() const; + + const tsl::hopscotch_set& get_delta_pkeys() const; + + void sort_by(); + std::vector get_sort_by() const; + + std::pair get_min_max(const std::string& colname + ) const; + + using t_ctxbase::get_data; + +protected: + std::vector + get_all_pkeys(const std::vector>& cells + ) const; + + /** + * @brief During a call to `notify` when the master table is being updated + * with data for the first time (prev, curr, and transitions tables are + * empty), take all added rows in the traversal and store them in + * `m_deltas`. + * + * @param flattened + */ + void calc_step_delta(const t_data_table& flattened); + + /** + * @brief During a call to `notify` when the master table has data, + * calculate the deltas - both changed and added cells, and write them + * to `m_deltas`. + * + * @param flattened + * @param prev + * @param curr + * @param transitions + */ + void calc_step_delta( + const t_data_table& flattened, + const t_data_table& prev, + const t_data_table& curr, + const t_data_table& transitions + ); + + void add_delta_pkey(t_tscalar pkey); + + /** + * @brief Read the specified column using the gnode's mapping - if the + * column is an expression column, uses the expression master table, + * otherwise use the gstate master table. + * + * @param colname + * @param pkeys + * @param out_data + */ + void read_column_from_gstate( + const std::string& colname, + const std::vector& pkeys, + std::vector& out_data + ) const; + +private: + std::shared_ptr m_traversal; + std::shared_ptr m_deltas; + tsl::hopscotch_set m_delta_pkeys; + std::shared_ptr m_expression_tables; + t_symtable m_symtable; + bool m_has_delta; +}; + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/data.h b/rust/perspective-server/cpp/perspective/src/include/perspective/data.h new file mode 100644 index 0000000..e980e61 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/data.h @@ -0,0 +1,30 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include + +namespace perspective { + +struct PERSPECTIVE_EXPORT t_data { + t_data(); + t_data(const std::vector& data); + + const std::vector& data() const; + std::vector& data(); + std::vector m_data; +}; + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/data_accessor.h b/rust/perspective-server/cpp/perspective/src/include/perspective/data_accessor.h new file mode 100644 index 0000000..5a9b2a4 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/data_accessor.h @@ -0,0 +1,26 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include + +namespace perspective { +/** + * @brief `t_data_accessor` allows for value retrieval from multiple data + * formats (row, column, etc.) without copying the underlying dataset. + * + * For WASM builds, `t_data_accessor` is replaced by t_val through a `typedef`. + * + * TODO: implement `t_data_accessor` for python build + */ +class PERSPECTIVE_EXPORT t_data_accessor {}; +} // namespace perspective \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/data_slice.h b/rust/perspective-server/cpp/perspective/src/include/perspective/data_slice.h new file mode 100644 index 0000000..ec26384 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/data_slice.h @@ -0,0 +1,144 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { +/** + * @class t_data_slice + * + * @brief t_data_slice contains a slice of the View's underlying data + * with the metadata required to correctly parse it. It offers a unified + * get(row_index, col_index) API that is extensible and does not require + * additional parsing in the binding language. This makes implementing data + * serialization easy, as one simply writes each row and each column inside it + * sequentially. + * + * + * - m_view: a reference to the view from which we output data + * - m_slice: a reference to a vector of t_tscalar objects containing data + * - m_column_names: a reference to a vector of string column names from the + * view. + * - m_column_indices: an optional reference to a vector of t_uindex column + * indices, which we use for column-pivoted views. + * + */ +template +class PERSPECTIVE_EXPORT t_data_slice { +public: + t_data_slice( + std::shared_ptr ctx, + t_uindex start_row, + t_uindex end_row, + t_uindex start_col, + t_uindex end_col, + t_uindex row_offset, + t_uindex col_offset, + const std::vector& slice, + const std::vector>& column_names + ); + + /** + * @brief Construct a new data slice, with a vector of row indices on which + * to access the underlying data. + * + * @tparam CTX_T + * @param ctx + * @param slice + * @param row_indices + */ + t_data_slice( + std::shared_ptr ctx, + t_uindex start_row, + t_uindex end_row, + t_uindex start_col, + t_uindex end_col, + t_uindex row_offset, + t_uindex col_offset, + const std::vector& slice, + const std::vector>& column_names, + const std::vector& column_indices + ); + + ~t_data_slice(); + + /** + * @brief Returns the t_tscalar at the declared indices in the data slice, + * or an invalid t_tscalar if the indices should be skipped. + * + * @param ridx row index into the slice + * @param cidx column index into the slice + * + * @return t_tscalar a valid scalar containing the underlying data, or a new + * t_tscalar initialized with an invalid flag. + */ + t_tscalar get(t_uindex ridx, t_uindex cidx) const; + + std::vector get_pkeys(t_uindex ridx, t_uindex cidx) const; + + /** + * @brief Returns the row path, which maps a specific piece of data to the + * row and column that it belongs to. + * + * @param ridx the row index into the slice + * @return std::vector + */ + std::vector get_row_path(t_uindex ridx) const; + + std::vector get_column_slice(t_uindex cidx) const; + + // Getters + std::shared_ptr get_context() const; + const std::vector& get_slice() const; + const std::vector>& get_column_names() const; + const std::vector& get_column_indices() const; + t_get_data_extents get_data_extents() const; + t_uindex get_stride() const; + t_uindex num_rows() const; + t_uindex get_row_offset() const; + t_uindex get_col_offset() const; + bool is_column_only() const; + +private: + /** + * @brief Calculates the index into the underlying data slice for the + * row and the column. + * + * @param ridx + * @param cidx + * @return t_uindex + */ + t_uindex get_slice_idx(t_uindex ridx, t_uindex cidx) const; + + std::shared_ptr m_ctx; + t_uindex m_start_row; + t_uindex m_end_row; + t_uindex m_start_col; + t_uindex m_end_col; + t_uindex m_row_offset; + t_uindex m_col_offset; + t_uindex m_stride; + std::vector m_slice; + std::vector> m_column_names; + std::vector m_column_indices; +}; +} // end namespace perspective \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/data_table.h b/rust/perspective-server/cpp/perspective/src/include/perspective/data_table.h new file mode 100644 index 0000000..f200767 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/data_table.h @@ -0,0 +1,583 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +template +struct t_rowpack { + DATA_T m_pkey; + bool m_pkey_is_valid; + t_index m_idx; + t_op m_op; +}; + +struct t_flatten_record { + t_uindex m_store_idx; + t_uindex m_bidx; + t_uindex m_eidx; +}; + +class t_data_table; + +class PERSPECTIVE_EXPORT t_tabular {}; + +/** + * @brief `t_data_table` is the underlying structure used to store and access + * tabluar data within Perspective. + * + * Do not directly construct or use `t_data_table` - use the `Table` class + * instead. + * + */ +class PERSPECTIVE_EXPORT t_data_table { +public: +#ifdef PSP_DBG_MALLOC + PSP_NEW_DELETE(t_data_table) +#endif + PSP_NON_COPYABLE(t_data_table); + + t_data_table(t_schema s, t_uindex init_cap = DEFAULT_EMPTY_CAPACITY); + + // Only use in tests, it inits the table unlike other constructors + t_data_table( + const t_schema& s, const std::vector>& v + ); + + t_data_table( + std::string name, + std::string dirname, + t_schema s, + t_uindex init_cap, + t_backing_store backing_store + ); + ~t_data_table(); + + /** + * @brief Initialize the `t_data_table`. If `make_columns` is True (the + * default option), construct and initialize the `t_column`s for the + * table. + */ + void init(bool make_columns = true); + + const std::string& name() const; + + t_uindex num_columns() const; + t_uindex num_rows() const; + + const t_schema& get_schema() const; + + t_uindex size() const; + t_uindex get_capacity() const; + t_dtype get_dtype(const std::string& colname) const; + + std::shared_ptr get_column(std::string_view colname); + + std::shared_ptr get_column_safe(std::string_view colname); + + std::shared_ptr get_column(std::string_view colname) const; + + std::shared_ptr get_column_safe(std::string_view colname) const; + + std::shared_ptr get_const_column(std::string_view colname + ) const; + + std::shared_ptr + get_const_column_safe(std::string_view colname) const; + + std::shared_ptr get_const_column(t_uindex idx) const; + + std::shared_ptr get_const_column_safe(t_uindex idx) const; + + // Only increment capacity + void reserve(t_uindex capacity); + + // Increment capacity and size + void extend(t_uindex nelems); + + void set_size(t_uindex size); + + /** + * @brief Only set `m_size` for the table without setting it for columns. + * + * @param size + */ + void set_table_size(t_uindex size); + + t_column* _get_column(std::string_view colname); + const t_column* _get_const_column(std::string_view colname) const; + const t_column* _get_const_column_safe(std::string_view colname) const; + const t_column* _get_const_column(t_uindex idx) const; + + std::shared_ptr flatten() const; + + bool is_pkey_table() const; + bool is_same_shape(t_data_table& tbl) const; + + void pprint() const; + void pprint(t_uindex nrows, std::ostream* os = 0) const; + void pprint(const std::string& fname) const; + void pprint(const std::vector& vec) const; + + void append(const t_data_table& other); + + void clear(); + void reset(); + + t_mask + filter_cpp(t_filter_op combiner, const std::vector& fterms_) const; + t_data_table* clone_(const t_mask& mask) const; + std::shared_ptr clone(const t_mask& mask) const; + std::shared_ptr clone() const; + + /** + * @brief Given `other_table`, return a new `t_data_table` that references + * both the columns of the current table and `table` without making any + * copies of the underlying data. + * + * If a column is present in both the current table and `other_table`, the + * column from the current table takes precedence. This method is designed + * for quick, temporary copies of a `t_data_table` that can be used and + * disposed of, which is why it returns a stack-allocated table instead + * of a heap-allocated pointer to a table. + * + * @param table + * @return t_data_table + */ + std::shared_ptr + join(const std::shared_ptr& other_table) const; + + /** + * @brief Create a new `t_data_table` from the specified schema. For each + * column in the schema, retrieve a shared pointer to the current instance's + * column, and set it in the new `t_data_table` to be returned. + * + * @return std::shared_ptr + */ + std::shared_ptr borrow(const std::vector& columns + ) const; + + t_column* clone_column( + const std::string& existing_col, const std::string& new_colname + ); + + std::vector get_const_columns() const; + std::vector get_columns(); + + void set_column(t_uindex idx, std::shared_ptr col); + void set_column(const std::string& name, std::shared_ptr col); + + std::shared_ptr add_column_sptr( + const std::string& cname, t_dtype dtype, bool status_enabled + ); + + t_column* + add_column(const std::string& cname, t_dtype dtype, bool status_enabled); + + void promote_column( + std::string_view col_name, + t_dtype new_dtype, + std::int32_t iter_limit, + bool fill + ); + + std::shared_ptr + make_column(const std::string& colname, t_dtype dtype, bool status_enabled); + + /** + * @brief Given a column name, clear the underlying column but do not + * mutate the `t_data_table` instance's `m_schema` and `m_columns`. Use + * `t_data_table::reindex` with a vector of column names signifying + * dropped columns that can be cleared by mutating `m_schema` and + * `m_columns`. + * + * @param name + */ + void drop_column(const std::string& name); + + /** + * @brief Given a vector of column names marking dropped columns, + * mutate the instance's `m_schema` and `m_columns` to ensure that column + * indices belonging to deleted columns are dropped, and that new column + * indices are assigned sequentially. + * + * @param dropped_columns + */ + void reindex(const std::vector dropped_columns); + + void verify() const; + void set_capacity(t_uindex idx); + + std::vector get_scalvec() const; + std::shared_ptr operator[](const std::string& name); + +protected: + template + void flatten_body(FLATTENED_T flattened) const; + + template + void flatten_helper_1(FLATTENED_T flattened) const; + + template + void flatten_helper_2( + ROWPACK_VEC_T& sorted, + std::vector& fltrecs, + const t_column* scol, + t_column* dcol + ) const; + std::string repr() const; + +private: + // Residency chokepoint: ensure column `idx` is resident before it is handed + // out. Disk-backed tables only (one predicted-not-taken branch otherwise); + // every disk-column access fetches the column by name/index first and caches + // it (the schema hash-lookup forbids per-row re-fetch), so this runs once per + // column per operation — keeping per-element accessors check-free while + // preserving fine-grained, per-column lazy restore. + inline void + ensure_col_resident(t_uindex idx) const { + if (m_backing_store == BACKING_STORE_DISK) { + m_columns[idx]->ensure_resident(); + } + } + + std::string m_name; + std::string m_dirname; + t_schema m_schema; + t_uindex m_size; + t_uindex m_capacity; + t_backing_store m_backing_store; + bool m_init; + std::vector> m_columns; +}; + +PERSPECTIVE_EXPORT bool +operator==(const t_data_table& lhs, const t_data_table& rhs); + +template +void +t_data_table::flatten_body(FLATTENED_T flattened) const { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + PSP_VERBOSE_ASSERT(is_pkey_table(), "Not a pkeyed table"); + + t_dtype pkey_dtype = get_const_column("psp_pkey")->get_dtype(); + switch (pkey_dtype) { + case DTYPE_INT64: { + flatten_helper_1(flattened); + } break; + case DTYPE_INT32: { + flatten_helper_1(flattened); + } break; + case DTYPE_INT16: { + flatten_helper_1(flattened); + } break; + case DTYPE_INT8: { + flatten_helper_1(flattened); + } break; + case DTYPE_UINT64: { + flatten_helper_1(flattened); + } break; + case DTYPE_UINT32: { + flatten_helper_1(flattened); + } break; + case DTYPE_UINT16: { + flatten_helper_1(flattened); + } break; + case DTYPE_UINT8: { + flatten_helper_1(flattened); + } break; + case DTYPE_TIME: { + flatten_helper_1(flattened); + } break; + case DTYPE_DATE: { + flatten_helper_1(flattened); + } break; + case DTYPE_STR: { + flatten_helper_1(flattened); + } break; + case DTYPE_FLOAT64: { + flatten_helper_1(flattened); + } break; + case DTYPE_FLOAT32: { + flatten_helper_1(flattened); + } break; + default: { + std::stringstream ss; + ss << "Unsupported type `" << get_dtype_descr(pkey_dtype) + << "` for `index`." + << "\n"; + PSP_COMPLAIN_AND_ABORT(ss.str()); + } + } + + return; +} + +template +void +t_data_table::flatten_helper_2( + ROWPACK_VEC_T& sorted, + std::vector& fltrecs, + const t_column* scol, + t_column* dcol +) const { + for (const auto& rec : fltrecs) { + bool added = false; + t_index fragidx = 0; + t_status status = STATUS_INVALID; + for (t_index spanidx = rec.m_eidx - 1; spanidx >= t_index(rec.m_bidx); + --spanidx) { + const auto& sort_rec = sorted[spanidx]; + fragidx = sort_rec.m_idx; + status = *(scol->get_nth_status(fragidx)); + if (status != STATUS_INVALID) { + added = true; + break; + } + } + + if (added) { + dcol->set_nth( + rec.m_store_idx, *(scol->get_nth(fragidx)), status + ); + } + } +} + +template +void +t_data_table::flatten_helper_1(FLATTENED_T flattened) const { + + t_uindex frags_size = size(); + + PSP_VERBOSE_ASSERT(is_same_shape(*flattened), "Misaligned shaped found"); + + if (frags_size == 0) { + return; + } + + std::vector s_columns; + std::vector d_columns; + + for (const auto& colname : m_schema.m_columns) { + if (colname != "psp_pkey" && colname != "psp_op") { + s_columns.push_back(_get_const_column(colname)); + d_columns.push_back(flattened->_get_column(colname)); + } + } + + const t_column* s_pkey_col = _get_const_column("psp_pkey"); + const t_column* s_op_col = _get_const_column("psp_op"); + + t_column* d_pkey_col = flattened->_get_column("psp_pkey"); + t_column* d_op_col = flattened->_get_column("psp_op"); + + typedef std::vector> t_rpvec; + + std::vector> sorted(frags_size); + for (t_uindex fragidx = 0; fragidx < frags_size; ++fragidx) { + sorted[fragidx].m_pkey = *(s_pkey_col->get_nth(fragidx)); + sorted[fragidx].m_pkey_is_valid = s_pkey_col->is_valid(fragidx); + sorted[fragidx].m_op = + static_cast(*(s_op_col->get_nth(fragidx))); + sorted[fragidx].m_idx = fragidx; + } + + struct t_packcomp { + bool + operator()(const t_rowpack& a, const t_rowpack& b) + const { + return a.m_pkey < b.m_pkey + || (!(b.m_pkey < a.m_pkey) && a.m_idx < b.m_idx); + } + }; + + t_packcomp cmp; + std::sort(sorted.begin(), sorted.end(), cmp); + + std::vector edges; + edges.push_back(0); + + for (t_index idx = 1, loop_end = sorted.size(); idx < loop_end; ++idx) { + if ((sorted[idx].m_pkey_is_valid != sorted[idx - 1].m_pkey_is_valid) + || (sorted[idx].m_pkey != sorted[idx - 1].m_pkey)) { + edges.push_back(idx); + } + } + + flattened->reserve(size()); + + std::vector fltrecs; + + t_uindex store_idx = 0; + + for (t_index fidx = 0, loop_end = edges.size(); fidx < loop_end; ++fidx) { + t_index bidx = edges[fidx]; + bool edge_bool = fidx == static_cast(edges.size() - 1); + t_index eidx = edge_bool ? sorted.size() : edges[fidx + 1]; + + bool delete_encountered = false; + + for (t_index spanidx = bidx; spanidx < eidx; ++spanidx) { + if (sorted[spanidx].m_op == OP_DELETE) { + bidx = spanidx; + delete_encountered = true; + } + } + + const auto& sort_rec = sorted[bidx]; + if (delete_encountered) { + d_pkey_col->push_back( + sort_rec.m_pkey, + sort_rec.m_pkey_is_valid ? t_status::STATUS_VALID + : t_status::STATUS_INVALID + ); + std::uint8_t op8 = OP_DELETE; + d_op_col->push_back(op8); + ++store_idx; + } + + if (!delete_encountered || (bidx + 1 != eidx)) { + t_flatten_record rec; + rec.m_store_idx = store_idx; + rec.m_bidx = bidx; + rec.m_eidx = eidx; + fltrecs.push_back(rec); + + d_pkey_col->push_back( + sort_rec.m_pkey, + sort_rec.m_pkey_is_valid ? t_status::STATUS_VALID + : t_status::STATUS_INVALID + ); + + std::uint8_t op8 = OP_INSERT; + d_op_col->push_back(op8); + ++store_idx; + } + } + + flattened->set_size(store_idx); + t_uindex ndata_cols = d_columns.size(); + + parallel_for( + int(ndata_cols), + [&s_columns, &sorted, &d_columns, &fltrecs, this](int colidx) { + auto scol = s_columns[colidx]; + auto dcol = d_columns[colidx]; + + switch (scol->get_dtype()) { + case DTYPE_INT64: { + this->flatten_helper_2( + sorted, fltrecs, scol, dcol + ); + } break; + case DTYPE_INT32: { + this->flatten_helper_2( + sorted, fltrecs, scol, dcol + ); + } break; + case DTYPE_INT16: { + this->flatten_helper_2( + sorted, fltrecs, scol, dcol + ); + } break; + case DTYPE_INT8: { + this->flatten_helper_2( + sorted, fltrecs, scol, dcol + ); + } break; + case DTYPE_UINT64: { + this->flatten_helper_2( + sorted, fltrecs, scol, dcol + ); + } break; + case DTYPE_UINT32: { + this->flatten_helper_2( + sorted, fltrecs, scol, dcol + ); + } break; + case DTYPE_UINT16: { + this->flatten_helper_2( + sorted, fltrecs, scol, dcol + ); + } break; + case DTYPE_UINT8: { + this->flatten_helper_2( + sorted, fltrecs, scol, dcol + ); + } break; + case DTYPE_FLOAT64: { + this->flatten_helper_2( + sorted, fltrecs, scol, dcol + ); + } break; + case DTYPE_FLOAT32: { + this->flatten_helper_2( + sorted, fltrecs, scol, dcol + ); + } break; + case DTYPE_BOOL: { + this->flatten_helper_2( + sorted, fltrecs, scol, dcol + ); + } break; + case DTYPE_TIME: { + this->flatten_helper_2( + sorted, fltrecs, scol, dcol + ); + } break; + case DTYPE_DATE: { + this->flatten_helper_2( + sorted, fltrecs, scol, dcol + ); + } break; + case DTYPE_STR: { + this->flatten_helper_2( + sorted, fltrecs, scol, dcol + ); + } break; + case DTYPE_OBJECT: + default: { + PSP_COMPLAIN_AND_ABORT("Unsupported column dtype"); + } + } + } + ); + + parallel_for( + int(m_schema.get_num_columns()), + [&flattened, this](int colidx) { + const auto& colname = this->m_schema.m_columns[colidx]; + auto col = _get_const_column(colname); + if (col->get_dtype() == DTYPE_STR) { + flattened->get_column(colname)->copy_vocabulary(col); + } + } + ); + + d_op_col->valid_raw_fill(); +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/date.h b/rust/perspective-server/cpp/perspective/src/include/perspective/date.h new file mode 100644 index 0000000..d96624a --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/date.h @@ -0,0 +1,148 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { +static const std::int32_t CUMULATIVE_DAYS[2][13] = { + {0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365 + } /* Normal years. */, + {0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366} /* Leap + years. + */ +}; + +// In terms of (non-tm based) inputs/outputs, t_date +// represents an instant in time as: +// A year() since year 0AD. +// A month() in the range [1..12]. +// A day() of the month in the range [1..31]. +class PERSPECTIVE_EXPORT t_date { +public: + static const std::int32_t YEAR_MASK = 0xFFFF0000; + static const std::int32_t MONTH_MASK = 0x0000FF00; + static const std::int32_t DAY_MASK = 0x000000FF; + + static const std::int32_t YEAR_SHIFT = 16; + static const std::int32_t MONTH_SHIFT = 8; + static const std::int32_t DAY_SHIFT = 0; + +public: + typedef std::uint32_t t_rawtype; + + t_date(); + + /** + * @brief Construct a new t_date from year, month, and day as integers. + * + * @param year an integer representing the year + * @param month an integer from 0 - 11 representing the month + * @param day an integer representing the day + */ + t_date(std::int16_t year, std::int8_t month, std::int8_t day); + + void + set_year_month_day(std::int16_t year, std::int8_t month, std::int8_t day); + + void set_year(std::int16_t year); + void set_month(std::int8_t month); + void set_day(std::int8_t day); + + explicit t_date(std::uint32_t raw_val); + + std::uint32_t raw_value() const; + + // Index such that + // a.consecutive_day_idx()-b.consecutive_day_idx() is + // number of days a is after b. + //(Start point is unspecified, may not be stable and + // only works for dates after 1900AD.) + std::int32_t consecutive_day_idx() const; + + /** + * @brief Milliseconds since the Unix epoch at *UTC* midnight of this + * calendar date. This is the serialization convention for `date` values + * (matching Arrow's `date32` day arithmetic) — a `t_date` is a timezone- + * agnostic calendar day, so the host timezone must never be consulted. + * + * @return std::int64_t + */ + std::int64_t as_epoch_ms() const; + + /** + * @brief Inverse of `as_epoch_ms`: the UTC calendar date containing + * epoch `ms`. + * + * @return t_date + */ + static t_date from_epoch_ms(std::int64_t ms); + + friend bool operator<(const t_date& a, const t_date& b); + friend bool operator<=(const t_date& a, const t_date& b); + friend bool operator>(const t_date& a, const t_date& b); + friend bool operator>=(const t_date& a, const t_date& b); + friend bool operator==(const t_date& a, const t_date& b); + friend bool operator!=(const t_date& a, const t_date& b); + + /** + * @brief Returns the year stored in the `t_date`. + * + * @return std::int32_t + */ + std::int32_t year() const; + + /** + * @brief Returns the month stored in the `t_date` as an integer from + * 0 - 11. + * + * @return std::int32_t + */ + std::int32_t month() const; + + /** + * @brief Returns the day stored in the `t_date`. + * + * @return std::int32_t + */ + std::int32_t day() const; + + std::string str() const; + friend inline size_t hash_value(const t_date& d); + + void set_psp_date(t_uindex dt); + +private: + t_rawtype m_storage; +}; + +t_date from_consecutive_day_idx(std::int32_t idx); + +inline size_t +hash_value(const t_date& d) { + boost::hash hasher; + return hasher(d.m_storage); +} +} // end namespace perspective + +namespace std { +std::ostream& operator<<(std::ostream& os, const perspective::t_date& t); +} diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/date_parser.h b/rust/perspective-server/cpp/perspective/src/include/perspective/date_parser.h new file mode 100644 index 0000000..e7cb7d7 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/date_parser.h @@ -0,0 +1,31 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include + +namespace perspective { + +class PERSPECTIVE_EXPORT t_date_parser { +public: + t_date_parser(); + + bool is_valid(std::string const& datestring); + +private: + static const std::string VALID_FORMATS[12]; +}; +} // end namespace perspective \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/debug_helpers.h b/rust/perspective-server/cpp/perspective/src/include/perspective/debug_helpers.h new file mode 100644 index 0000000..378aa15 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/debug_helpers.h @@ -0,0 +1,47 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include + +namespace perspective { + +struct t_debug_helper { +#ifdef PSP_DEBUG_HELPER + void* + operator new(size_t size) { + return PSP_MALLOC(size); + } + + void + operator delete(void* mem) { + if (mem) { + PSP_FREE(mem); + } + } + + void* + operator new[](size_t size) { + return PSP_MALLOC(size); + } + + void + operator delete[](void* p) { + if (p) { + PSP_FREE(p); + } + } +#endif // end PSP_DEBUG_HELPER +}; + +} // end namespace perspective \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/defaults.h b/rust/perspective-server/cpp/perspective/src/include/perspective/defaults.h new file mode 100644 index 0000000..025378b --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/defaults.h @@ -0,0 +1,49 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#ifndef WIN32 +#include +#include +#endif + +namespace perspective { +#ifdef WIN32 +const t_fflag PSP_DEFAULT_FFLAGS = GENERIC_READ | GENERIC_WRITE; +const t_fflag PSP_DEFAULT_FMODE = FILE_SHARE_READ; +const t_fflag PSP_DEFAULT_CREATION_DISPOSITION = CREATE_ALWAYS; +const t_fflag PSP_DEFAULT_MPROT = PAGE_READWRITE; +const t_fflag PSP_DEFAULT_MFLAGS = FILE_MAP_READ | FILE_MAP_WRITE; + +const t_fflag PSP_DEFAULT_SHARED_RO_FFLAGS = GENERIC_READ; +const t_fflag PSP_DEFAULT_SHARED_RO_FMODE = FILE_SHARE_READ | FILE_SHARE_WRITE; +const t_fflag PSP_DEFAULT_SHARED_RO_CREATION_DISPOSITION = OPEN_ALWAYS; +const t_fflag PSP_DEFAULT_SHARED_RO_MPROT = PAGE_READONLY; +const t_fflag PSP_DEFAULT_SHARED_RO_MFLAGS = FILE_MAP_READ; +#else +const t_fflag PSP_DEFAULT_FFLAGS = O_RDWR | O_TRUNC | O_CREAT; +const t_fflag PSP_DEFAULT_FMODE = + S_IRUSR | S_IWUSR | S_IROTH | S_IRGRP | S_IWGRP; +const t_fflag PSP_DEFAULT_CREATION_DISPOSITION = 0; +const t_fflag PSP_DEFAULT_MPROT = PROT_WRITE | PROT_READ; +const t_fflag PSP_DEFAULT_MFLAGS = MAP_SHARED; + +const t_fflag PSP_DEFAULT_SHARED_RO_FFLAGS = O_RDONLY | O_CREAT; +const t_fflag PSP_DEFAULT_SHARED_RO_FMODE = S_IRUSR; +const t_fflag PSP_DEFAULT_SHARED_RO_CREATION_DISPOSITION = 0; +const t_fflag PSP_DEFAULT_SHARED_RO_MPROT = PROT_READ; +const t_fflag PSP_DEFAULT_SHARED_RO_MFLAGS = MAP_SHARED; +#endif +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/dense_nodes.h b/rust/perspective-server/cpp/perspective/src/include/perspective/dense_nodes.h new file mode 100644 index 0000000..b76f72e --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/dense_nodes.h @@ -0,0 +1,46 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include + +namespace perspective { + +struct PERSPECTIVE_EXPORT t_dense_tnode { + t_uindex m_idx; + t_uindex m_pidx; + t_uindex m_fcidx; + t_uindex m_nchild; + t_uindex m_flidx; + t_uindex m_nleaves; +}; + +PERSPECTIVE_EXPORT void fill_dense_tnode( + t_dense_tnode* node, + t_uindex idx, + t_uindex pidx, + t_uindex fcidx, + t_uindex nchild, + t_uindex flidx, + t_uindex nleaves +); + +} // end namespace perspective + +namespace std { +PERSPECTIVE_EXPORT std::ostream& +operator<<(std::ostream& os, const perspective::t_dense_tnode& s); +} diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/dense_tree.h b/rust/perspective-server/cpp/perspective/src/include/perspective/dense_tree.h new file mode 100644 index 0000000..7c4336a --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/dense_tree.h @@ -0,0 +1,100 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Pass filter in and store the filter on the tree +// as a shared ptr + +namespace perspective { + +class t_filter; + +class t_dtree { +public: + typedef t_dense_tnode t_tnode; + typedef std::vector t_tnodevec; + typedef std::shared_ptr t_dssptr; + + t_dtree( + t_dssptr ds, + const std::vector& pivots, + const std::vector>& sortby_colvec + ); + t_dtree( + std::string dirname, + t_dssptr ds, + const std::vector& pivots, + t_backing_store backing_store, + const std::vector>& sortby_colvec + ); + + void init(); + std::string repr() const; + std::string leaves_colname() const; + std::string nodes_colname() const; + std::string values_colname(const std::string& tbl_colname) const; + void check_pivot(const t_filter& f, t_uindex level); + void pivot(const t_filter& f, t_uindex level); + + t_uindex size() const; + + t_tscalar + _get_value(const t_filter& filter, t_index nidx, bool sort_value) const; + t_tscalar get_value(const t_filter& filter, t_index nidx) const; + t_tscalar get_sortby_value(const t_filter& filter, t_index nidx) const; + + t_uindex get_depth(t_index idx) const; + std::pair get_level_markers(t_uindex idx) const; + const t_tnode* get_node_ptr(t_index idx) const; + t_uindex last_level() const; + std::pair get_span_index(t_index idx) const; + void pprint(const t_filter& filter) const; + const t_column* get_leaf_cptr() const; + + t_bfs_iter bfs() const; + t_dfs_iter dfs() const; + t_index get_parent(t_index idx) const; + const std::vector& get_pivots() const; + void get_child_indices(t_index idx, std::vector& v) const; + +private: + std::string m_dirname; + t_uindex m_levels_pivoted; + t_dssptr m_ds; + std::vector> m_levels; + std::vector m_pivots; + t_column m_leaves; + t_tnodevec m_nodes; + std::vector m_values; + t_uindex m_nidx; + t_backing_store m_backing_store; + bool m_init; + std::vector m_sortby_dpthcol; + std::vector> m_sortby_colvec; + std::map m_sortby_columns; + std::vector m_has_sortby; +}; + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/dense_tree_context.h b/rust/perspective-server/cpp/perspective/src/include/perspective/dense_tree_context.h new file mode 100644 index 0000000..dad375d --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/dense_tree_context.h @@ -0,0 +1,63 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +class PERSPECTIVE_EXPORT t_dtree_ctx { +public: + t_dtree_ctx( + std::shared_ptr strands, + std::shared_ptr strand_deltas, + const t_dtree& tree, + const std::vector& aggspecs + ); + void init(); + const t_data_table& get_aggtable() const; + const t_dtree& get_tree() const; + const std::vector& get_aggspecs() const; + const t_aggspec& get_aggspec(const std::string& aggname) const; + void pprint(const t_filter& fltr) const; + + std::pair get_leaf_iterators(t_index idx + ) const; + + std::shared_ptr get_pkey_col() const; + std::shared_ptr get_strand_count_col() const; + std::shared_ptr get_strands() const; + std::shared_ptr get_strand_deltas() const; + void pprint_strands() const; + void pprint_strands_tree() const; + +protected: + void build_aggregates(); + t_uindex get_num_aggcols() const; + +private: + std::shared_ptr m_strands; + std::shared_ptr m_strand_deltas; + const t_dtree& m_tree; + std::vector m_aggspecs; + std::shared_ptr m_aggregates; + bool m_init; + std::map m_aggspecmap; +}; + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/dependency.h b/rust/perspective-server/cpp/perspective/src/include/perspective/dependency.h new file mode 100644 index 0000000..b904df4 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/dependency.h @@ -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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include + +namespace perspective { + +struct PERSPECTIVE_EXPORT t_dep_recipe { + t_dep_recipe() {} + std::string m_name; + std::string m_disp_name; + t_deptype m_type; + t_tscalar m_imm; + t_dtype m_dtype; +}; + +class PERSPECTIVE_EXPORT t_dep { +public: + t_dep(const t_dep_recipe& v); + t_dep(const std::string& name, t_deptype type); + + t_dep( + std::string name, std::string disp_name, t_deptype type, t_dtype dtype + ); + + t_dep(t_tscalar imm); + + const std::string& name() const; + const std::string& disp_name() const; + + t_deptype type() const; + t_tscalar imm() const; + t_dtype dtype() const; + t_dep_recipe get_recipe() const; + +private: + std::string m_name; + std::string m_disp_name; + t_deptype m_type; + t_tscalar m_imm; + t_dtype m_dtype; +}; + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/emscripten_api_utils.h b/rust/perspective-server/cpp/perspective/src/include/perspective/emscripten_api_utils.h new file mode 100644 index 0000000..9216809 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/emscripten_api_utils.h @@ -0,0 +1,32 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include + +template +struct js_array_type; + +template <> +struct js_array_type { + static constexpr auto name = "Float32Array"; +}; + +template <> +struct js_array_type { + static constexpr auto name = "Int8Array"; +}; + +template <> +struct js_array_type { + static constexpr auto name = "Uint8Array"; +}; \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/env_vars.h b/rust/perspective-server/cpp/perspective/src/include/perspective/env_vars.h new file mode 100644 index 0000000..b4e9031 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/env_vars.h @@ -0,0 +1,173 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include + +namespace perspective { + +struct PERSPECTIVE_EXPORT t_env { + static inline bool + log_time() { + static const bool rv = std::getenv("PSP_LOG_TIME") != 0; + return rv; + } + + static inline bool + log_storage_resize() { + static const bool rv = std::getenv("PSP_LOG_STORAGE_RESIZE") != 0; + return rv; + } + + static inline bool + log_schema_gnode_flattened() { + static const bool rv = + std::getenv("PSP_LOG_SCHEMA_GNODE_FLATTENED") != 0; + return rv; + } + + static inline bool + log_data_pool_send() { + static const bool rv = std::getenv("PSP_LOG_DATA_POOL_SEND") != 0; + return rv; + } + + static inline bool + log_time_gnode_process() { + static const bool rv = std::getenv("PSP_LOG_TIME_GNODE_PROCESS") != 0; + return rv; + } + + static inline bool + log_time_ctx_notify() { + static const bool rv = std::getenv("PSP_LOG_TIME_CTX_NOTIFY") != 0; + return rv; + } + + static inline bool + log_data_gnode_flattened() { + static const bool rv = std::getenv("PSP_LOG_DATA_GNODE_FLATTENED") != 0; + return rv; + } + + static inline bool + log_data_gnode_delta() { + static const bool rv = std::getenv("PSP_LOG_DATA_GNODE_DELTA") != 0; + return rv; + } + + static inline bool + log_data_gnode_prev() { + static const bool rv = std::getenv("PSP_LOG_DATA_GNODE_PREV") != 0; + return rv; + } + + static inline bool + log_data_gnode_current() { + static const bool rv = std::getenv("PSP_LOG_DATA_GNODE_CURRENT") != 0; + return rv; + } + + static inline bool + log_data_gnode_transitions() { + static const bool rv = + std::getenv("PSP_LOG_DATA_GNODE_TRANSITIONS") != 0; + return rv; + } + + static inline bool + log_data_gnode_existed() { + static const bool rv = std::getenv("PSP_LOG_DATA_GNODE_EXISTED") != 0; + return rv; + } + + static inline bool + log_data_nsparse_strands() { + static const bool rv = std::getenv("PSP_LOG_DATA_NSPARSE_STRANDS") != 0; + return rv; + } + + static inline bool + log_data_nsparse_strand_deltas() { + static const bool rv = + std::getenv("PSP_LOG_DATA_NSPARSE_STRAND_DELTAS") != 0; + return rv; + } + + static inline bool + log_data_nsparse_dtree() { + static const bool rv = std::getenv("PSP_LOG_DATA_NSPARSE_DTREE") != 0; + return rv; + } + + static inline bool + log_data_nsparse_dctx() { + static const bool rv = std::getenv("PSP_LOG_DATA_NSPARSE_DCTX") != 0; + return rv; + } + + static inline bool + log_data_nsparse_stree_prev() { + static const bool rv = + std::getenv("PSP_LOG_DATA_NSPARSE_STREE_PREV") != 0; + return rv; + } + + static inline bool + log_data_nsparse_stree_after() { + static const bool rv = + std::getenv("PSP_LOG_DATA_NSPARSE_STREE_AFTER") != 0; + return rv; + } + + static inline bool + log_progress() { + static const bool rv = std::getenv("PSP_LOG_PROGRESS") != 0; + return rv; + } + + static inline bool + show_svg_browser() { + static const bool rv = std::getenv("PSP_SHOW_SVG_BROWSER") != 0; + return rv; + } + + static inline bool + backout_nveq_ft() { + static const bool rv = std::getenv("PSP_BACKOUT_NVEQ_FT") != 0; + return rv; + } + + static inline bool + backout_invalid_neq_ft() { + static const bool rv = std::getenv("PSP_BACKOUT_INVALID_NEQ_FT") != 0; + return rv; + } + + static inline bool + backout_force_current_row() { + static const bool rv = + std::getenv("PSP_BACKOUT_FORCE_CURRENT_ROW") != 0; + return rv; + } + + static inline bool + backout_eq_invalid_invalid() { + static const bool rv = + std::getenv("PSP_BACKOUT_EQ_INVALID_INVALID") != 0; + return rv; + } +}; + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/exception.h b/rust/perspective-server/cpp/perspective/src/include/perspective/exception.h new file mode 100644 index 0000000..598aced --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/exception.h @@ -0,0 +1,43 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include + +namespace perspective { + +class PERSPECTIVE_EXPORT PerspectiveException : public std::exception { +public: + explicit PerspectiveException(const char* m) : message{m} {} + [[nodiscard]] + const char* + what() const noexcept override { + return message.c_str(); + } + +private: + std::string message; +}; + +class PERSPECTIVE_EXPORT PerspectiveViewNotFoundException + : public std::exception { +public: + explicit PerspectiveViewNotFoundException() {} + + [[nodiscard]] + const char* + what() const noexcept override { + return "View not found"; + } +}; + +} // namespace perspective \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/exports.h b/rust/perspective-server/cpp/perspective/src/include/perspective/exports.h new file mode 100644 index 0000000..fcbaaf2 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/exports.h @@ -0,0 +1,42 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once + +#ifdef WIN32 +#ifdef PERSPECTIVE_EXPORTS +#define PERSPECTIVE_EXPORT __declspec(dllexport) +#else +#define PERSPECTIVE_EXPORT __declspec(dllimport) +#endif +#else +#define PERSPECTIVE_EXPORT __attribute__((visibility("default"))) +#endif + +#ifdef WIN32 +#ifdef PERSPECTIVE_EXPORTS +#ifdef PSP_MPROTECT +#define PERSPECTIVE_MPROTECT_EXPORT __declspec(dllexport, align(4096)) +#else +#define PERSPECTIVE_MPROTECT_EXPORT __declspec(dllexport) +#endif // mprotect + +#else +#ifdef PSP_MPROTECT +#define PERSPECTIVE_MPROTECT_EXPORT __declspec(dllimport, align(4096)) +#else +#define PERSPECTIVE_MPROTECT_EXPORT __declspec(dllimport) +#endif // mprotect +#endif // perspective exports +#else +#define PERSPECTIVE_MPROTECT_EXPORT __attribute__((visibility("default"))) +#endif // win32 diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/expression_tables.h b/rust/perspective-server/cpp/perspective/src/include/perspective/expression_tables.h new file mode 100644 index 0000000..0ff88af --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/expression_tables.h @@ -0,0 +1,73 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include + +namespace perspective { + +/** + * @brief Store expression tables for each context - by separating expression + * columns from the main tables managed by the context, we ensure that cleaning + * up a context will also clean up its expression columns and not leak memory + * after the lifetime of a context. + */ +struct t_expression_tables { + + PSP_NON_COPYABLE(t_expression_tables); + + t_expression_tables( + const std::vector>& expressions, + t_backing_store backing_store = BACKING_STORE_MEMORY + ); + + /** + * @brief Reserve space on each transitional table - reserve is important + * because it also reserves space on each underlying column, whereas + * `set_capacity` only affects the table, and can lead to a situation where + * the table's capacity and the capacity of underlying columns grow out + * of sync, which causes memory errors. + * + * @param size + */ + void reserve_transitional_table_size(t_uindex size) const; + + void set_transitional_table_size(t_uindex size) const; + + void clear_transitional_tables() const; + + // Calculate the `t_transitions` value for each row. + void calculate_transitions(const std::shared_ptr& existed); + + void set_flattened(const std::shared_ptr& flattened) const; + + void reset() const; + + t_data_table* get_table() const; + + // master table is calculated from t_gstate's master table + std::shared_ptr m_master; + + // flattened, prev, current, delta, transitions calculated from the + // tables stored on the gnode's output ports. + std::shared_ptr m_flattened; + std::shared_ptr m_prev; + std::shared_ptr m_current; + std::shared_ptr m_delta; + std::shared_ptr m_transitions; +}; + +} // end namespace perspective \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/expression_vocab.h b/rust/perspective-server/cpp/perspective/src/include/perspective/expression_vocab.h new file mode 100644 index 0000000..15f0f42 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/expression_vocab.h @@ -0,0 +1,70 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include + +namespace perspective { +class PERSPECTIVE_EXPORT t_expression_vocab { +public: + PSP_NON_COPYABLE(t_expression_vocab); + + t_expression_vocab(); + + /** + * @brief Given a const char* to a string, intern it into the current + * vocab page, and return the pointer to the string that has been + * interned into the vocab. The returned pointer is guaranteed to be + * valid for the lifetime of the `t_expression_vocab` instance. + * + * @param str + * @return const char* + */ + const char* intern(const char* str); + const char* intern(const std::string& str); + + void clear(); + + /** + * @brief Returns the empty string owned by the vocab, which will be valid + * as long as the vocab is alive. + * + * @return const char* + */ + const char* get_empty_string() const; + + void pprint() const; + +private: + void allocate_new_vocab(); + + std::vector m_vocabs; + + // The number of strings to store in each page of the vocab. + // t_vocab::reserve(byte_length, num_strings) takes both the bytelength + // of all strings to be reserved, as well as the # of strings to reserve. + // Assume that strings are up to 64 bytes, and whenever we reach + // EXPRESSION_VOCAB_CAPACITY strings OR go over + // EXPRESSION_VOCAB_CAPACITY * 64 bytes, allocate a new page. + // TODO: this leaves edge cases where we allocate new pages too eagerly, + // or we aren't using the allocated space as efficiently as possible. + std::size_t m_max_vocab_size; + + std::size_t m_current_vocab_size; + + // An empty string for validation functions to use. + std::string m_empty_string; +}; + +} // end namespace perspective \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/exprtk.h b/rust/perspective-server/cpp/perspective/src/include/perspective/exprtk.h new file mode 100644 index 0000000..ae248a7 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/exprtk.h @@ -0,0 +1,1916 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once + +#include + +namespace exprtk { + +using perspective::mknone; +using perspective::mktscalar; +using perspective::t_tscalar; + +namespace details { + namespace numeric { + namespace details { + + /** + * Allows for tag dispatching so that the overloads in this header + * are called for t_tscalar operations. + */ + struct t_tscalar_type_tag; + + template + inline T const_pi_impl(t_tscalar_type_tag); + template + inline T const_e_impl(t_tscalar_type_tag); + + template + inline std::int32_t to_int32_impl(const T& v, t_tscalar_type_tag); + + template + inline std::int64_t to_int64_impl(const T& v, t_tscalar_type_tag); + + template + inline std::uint64_t to_uint64_impl(const T& v, t_tscalar_type_tag); + +#define define_unary_function_impl_header(FunctionName) \ + template \ + inline T FunctionName##_impl(const T v, t_tscalar_type_tag); + // clang-format off + define_unary_function_impl_header(abs ) + define_unary_function_impl_header(acos ) + define_unary_function_impl_header(acosh) + define_unary_function_impl_header(asin ) + define_unary_function_impl_header(asinh) + define_unary_function_impl_header(atan ) + define_unary_function_impl_header(atanh) + define_unary_function_impl_header(ceil ) + define_unary_function_impl_header(cos ) + define_unary_function_impl_header(cosh ) + define_unary_function_impl_header(exp ) + define_unary_function_impl_header(expm1) + define_unary_function_impl_header(floor) + define_unary_function_impl_header(log ) + define_unary_function_impl_header(log10) + define_unary_function_impl_header(log2 ) + define_unary_function_impl_header(log1p) + define_unary_function_impl_header(neg ) + define_unary_function_impl_header(pos ) + define_unary_function_impl_header(round) + define_unary_function_impl_header(sin ) + define_unary_function_impl_header(sinc ) + define_unary_function_impl_header(sinh ) + define_unary_function_impl_header(sqrt ) + define_unary_function_impl_header(tan ) + define_unary_function_impl_header(tanh ) + define_unary_function_impl_header(cot ) + define_unary_function_impl_header(sec ) + define_unary_function_impl_header(csc ) + define_unary_function_impl_header(r2d ) + define_unary_function_impl_header(d2r ) + define_unary_function_impl_header(d2g ) + define_unary_function_impl_header(g2d ) + define_unary_function_impl_header(notl ) + define_unary_function_impl_header(sgn ) + define_unary_function_impl_header(erf ) + define_unary_function_impl_header(erfc ) + define_unary_function_impl_header(ncdf ) + define_unary_function_impl_header(frac ) + define_unary_function_impl_header(trunc) + // clang-format on + +#undef define_unary_function_impl_header + +#define define_binary_function_impl_header(FunctionName) \ + template \ + inline T FunctionName##_impl(const T v0, const T v1, t_tscalar_type_tag); + + // except reserved words: and, or, xor + // clang-format off + define_binary_function_impl_header(min ) + define_binary_function_impl_header(max ) + define_binary_function_impl_header(equal ) + define_binary_function_impl_header(nequal ) + define_binary_function_impl_header(modulus) + define_binary_function_impl_header(pow ) + define_binary_function_impl_header(logn ) + define_binary_function_impl_header(root ) + define_binary_function_impl_header(roundn ) + define_binary_function_impl_header(hypot ) + define_binary_function_impl_header(atan2 ) + define_binary_function_impl_header(shr ) + define_binary_function_impl_header(shl ) + define_binary_function_impl_header(nand ) + define_binary_function_impl_header(nor ) + define_binary_function_impl_header(xnor ) + // clang-format on + +#undef define_binary_function_impl_header + + template + inline T + and_impl(const T v0, const T v1, t_tscalar_type_tag); + + template + inline T or_impl(const T v0, const T v1, t_tscalar_type_tag); + + template + inline T xor_impl(const T v0, const T v1, t_tscalar_type_tag); + + template + inline bool is_integer_impl(const T& v, t_tscalar_type_tag); + + // #if (defined(_MSC_VER) && (_MSC_VER >= 1900)) || + // !defined(_MSC_VER) + // #define exprtk_define_erf(TT,impl) \ + // inline TT erf_impl(TT v) { return impl(v); } \ + + // exprtk_define_erf( float,::erff) + // exprtk_define_erf( double,::erf ) + // exprtk_define_erf(long double,::erfl) + // #undef exprtk_define_erf + // #endif + + // #if (defined(_MSC_VER) && (_MSC_VER >= 1900)) || + // !defined(_MSC_VER) + // #define exprtk_define_erfc(TT,impl) \ + // inline TT erfc_impl(TT v) { return impl(v); } \ + + // exprtk_define_erfc( float,::erfcf) + // exprtk_define_erfc( double,::erfc ) + // exprtk_define_erfc(long double,::erfcl) + // #undef exprtk_define_erfc + // #endif + + } // end namespace details + } // end namespace numeric + + inline bool is_true(const t_tscalar& v); + + inline bool is_false(const t_tscalar& v); + + template + inline bool string_to_real( + Iterator& itr_external, + const Iterator end, + t_tscalar& t, + numeric::details::t_tscalar_type_tag + ); + +} // end namespace details +} // end namespace exprtk + +// exprtk needs to be imported after the type tags have been declared. +// #define exprtk_enable_debugging + +// for some reason, break/continue will break expression.value() when it is +// called, but enabling this ifdef creates a litany of compiler warnings +// about misleading indentation inside exprtk.hpp. Considering that the UI +// will not allow while/continue to be used, this should be ok. +// #define exprtk_disable_break_continue + +#define exprtk_disable_return_statement +#define exprtk_disable_rtl_io_file +#include + +namespace exprtk { +namespace details { + namespace numeric { + namespace details { + +#define UNARY_STD_FUNCTION_BODY(FUNC) \ + t_tscalar rval; \ + rval.clear(); \ + rval.m_type = perspective::t_dtype::DTYPE_FLOAT64; \ + if (!v.is_numeric()) { \ + rval.m_status = perspective::t_status::STATUS_CLEAR; \ + } \ + if (!v.is_valid()) \ + return rval; \ + rval.set(static_cast(std::FUNC(v.to_double()))); \ + return rval; + +/** + * @brief a function that returns none for all types besides float. + * + */ +#define UNARY_STD_INT_FUNCTION_BODY(FUNC) \ + t_tscalar rval; \ + rval.clear(); \ + rval.m_type = perspective::t_dtype::DTYPE_FLOAT64; \ + if (!v.is_numeric()) { \ + rval.m_status = perspective::t_status::STATUS_CLEAR; \ + } \ + if (!v.is_valid()) \ + return rval; \ + switch (v.get_dtype()) { \ + case perspective::t_dtype::DTYPE_FLOAT64: { \ + rval.set(static_cast(std::FUNC(v.get()))); \ + } break; \ + case perspective::t_dtype::DTYPE_FLOAT32: { \ + rval.set(static_cast(std::FUNC(v.get()))); \ + } break; \ + default: \ + return rval; \ + } \ + return rval; + + struct t_tscalar_type_tag {}; + + template <> + struct number_type { + typedef t_tscalar_type_tag type; + number_type() {} + }; + + template <> + inline t_tscalar + const_pi_impl(t_tscalar_type_tag) { + t_tscalar rval; + rval.set(constant::pi); + return rval; + }; + + template <> + inline t_tscalar + const_e_impl(t_tscalar_type_tag) { + t_tscalar rval; + rval.set(constant::e); + return rval; + }; + + template <> + struct epsilon_type { + static inline t_tscalar + value() { + t_tscalar rval; + rval.set(0.0000000001); + return rval; + } + }; + + inline bool + is_nan_impl(const t_tscalar& v, t_tscalar_type_tag) { + return v.is_nan(); + } + + template <> + inline std::int32_t + to_int32_impl(const t_tscalar& v, t_tscalar_type_tag) { + if (!v.is_valid()) { + return std::numeric_limits::quiet_NaN(); + } + switch (v.get_dtype()) { + case perspective::t_dtype::DTYPE_INT64: + return static_cast(v.get()); + case perspective::t_dtype::DTYPE_INT32: + return static_cast(v.get()); + case perspective::t_dtype::DTYPE_INT16: + return static_cast(v.get()); + case perspective::t_dtype::DTYPE_INT8: + return static_cast(v.get()); + case perspective::t_dtype::DTYPE_UINT64: + return static_cast(v.get() + ); + case perspective::t_dtype::DTYPE_UINT32: + return static_cast(v.get() + ); + case perspective::t_dtype::DTYPE_UINT16: + return static_cast(v.get() + ); + case perspective::t_dtype::DTYPE_UINT8: + return static_cast(v.get()); + case perspective::t_dtype::DTYPE_FLOAT64: + return static_cast(v.get()); + case perspective::t_dtype::DTYPE_FLOAT32: + return static_cast(v.get()); + default: + return std::numeric_limits::quiet_NaN(); + } + } + + template <> + inline std::int64_t + to_int64_impl(const t_tscalar& v, t_tscalar_type_tag) { + if (!v.is_valid()) { + return std::numeric_limits::quiet_NaN(); + } + switch (v.get_dtype()) { + case perspective::t_dtype::DTYPE_INT64: + return static_cast(v.get()); + case perspective::t_dtype::DTYPE_INT32: + return static_cast(v.get()); + case perspective::t_dtype::DTYPE_INT16: + return static_cast(v.get()); + case perspective::t_dtype::DTYPE_INT8: + return static_cast(v.get()); + case perspective::t_dtype::DTYPE_UINT64: + return static_cast(v.get() + ); + case perspective::t_dtype::DTYPE_UINT32: + return static_cast(v.get() + ); + case perspective::t_dtype::DTYPE_UINT16: + return static_cast(v.get() + ); + case perspective::t_dtype::DTYPE_UINT8: + return static_cast(v.get()); + case perspective::t_dtype::DTYPE_FLOAT64: + return static_cast(v.get()); + case perspective::t_dtype::DTYPE_FLOAT32: + return static_cast(v.get()); + default: + return std::numeric_limits::quiet_NaN(); + } + } + + template <> + inline std::uint64_t + to_uint64_impl(const t_tscalar& v, t_tscalar_type_tag) { + if (!v.is_valid()) { + return std::numeric_limits::quiet_NaN(); + } + switch (v.get_dtype()) { + case perspective::t_dtype::DTYPE_INT64: + return static_cast(v.get() + ); + case perspective::t_dtype::DTYPE_INT32: + return static_cast(v.get() + ); + case perspective::t_dtype::DTYPE_INT16: + return static_cast(v.get() + ); + case perspective::t_dtype::DTYPE_INT8: + return static_cast(v.get()); + case perspective::t_dtype::DTYPE_UINT64: + return static_cast(v.get() + ); + case perspective::t_dtype::DTYPE_UINT32: + return static_cast(v.get() + ); + case perspective::t_dtype::DTYPE_UINT16: + return static_cast(v.get() + ); + case perspective::t_dtype::DTYPE_UINT8: + return static_cast(v.get() + ); + case perspective::t_dtype::DTYPE_FLOAT64: + return static_cast(v.get()); + case perspective::t_dtype::DTYPE_FLOAT32: + return static_cast(v.get()); + default: + return std::numeric_limits::quiet_NaN(); + } + } + + /****************************************************************************** + * + * Unary functions + */ + template <> + inline t_tscalar + abs_impl(const t_tscalar v, t_tscalar_type_tag) { + UNARY_STD_INT_FUNCTION_BODY(abs) + } + template <> + inline t_tscalar + acos_impl(const t_tscalar v, t_tscalar_type_tag) { + UNARY_STD_INT_FUNCTION_BODY(acos) + } + template <> + inline t_tscalar + acosh_impl(const t_tscalar v, t_tscalar_type_tag) { + UNARY_STD_INT_FUNCTION_BODY(acosh) + } + template <> + inline t_tscalar + asin_impl(const t_tscalar v, t_tscalar_type_tag) { + UNARY_STD_INT_FUNCTION_BODY(asin) + } + template <> + inline t_tscalar + asinh_impl(const t_tscalar v, t_tscalar_type_tag) { + UNARY_STD_INT_FUNCTION_BODY(asinh) + } + template <> + inline t_tscalar + atan_impl(const t_tscalar v, t_tscalar_type_tag) { + UNARY_STD_INT_FUNCTION_BODY(atan) + } + template <> + inline t_tscalar + atanh_impl(const t_tscalar v, t_tscalar_type_tag) { + UNARY_STD_INT_FUNCTION_BODY(atanh) + } + template <> + inline t_tscalar + ceil_impl(const t_tscalar v, t_tscalar_type_tag) { + UNARY_STD_FUNCTION_BODY(ceil) + } + template <> + inline t_tscalar + cos_impl(const t_tscalar v, t_tscalar_type_tag) { + UNARY_STD_INT_FUNCTION_BODY(cos) + } + template <> + inline t_tscalar + cosh_impl(const t_tscalar v, t_tscalar_type_tag) { + UNARY_STD_INT_FUNCTION_BODY(cosh) + } + template <> + inline t_tscalar + exp_impl(const t_tscalar v, t_tscalar_type_tag) { + UNARY_STD_FUNCTION_BODY(exp) + } + template <> + inline t_tscalar + expm1_impl(const t_tscalar v, t_tscalar_type_tag) { + UNARY_STD_FUNCTION_BODY(expm1) + } + template <> + inline t_tscalar + floor_impl(const t_tscalar v, t_tscalar_type_tag) { + UNARY_STD_FUNCTION_BODY(floor) + } + template <> + inline t_tscalar + log_impl(const t_tscalar v, t_tscalar_type_tag) { + UNARY_STD_FUNCTION_BODY(log) + } + template <> + inline t_tscalar + log10_impl(const t_tscalar v, t_tscalar_type_tag) { + UNARY_STD_FUNCTION_BODY(log10) + } + template <> + inline t_tscalar + log2_impl(const t_tscalar v, t_tscalar_type_tag) { + UNARY_STD_FUNCTION_BODY(log2) + } + template <> + inline t_tscalar + log1p_impl(const t_tscalar v, t_tscalar_type_tag) { + UNARY_STD_FUNCTION_BODY(log1p) + } + template <> + inline t_tscalar + round_impl(const t_tscalar v, t_tscalar_type_tag) { + UNARY_STD_FUNCTION_BODY(round) + } + + template <> + inline t_tscalar + neg_impl(const t_tscalar v, t_tscalar_type_tag) { + return -v; + } + template <> + inline t_tscalar + pos_impl(const t_tscalar v, t_tscalar_type_tag) { + return +v; + } + + template <> + inline t_tscalar + sgn_impl(const t_tscalar v, t_tscalar_type_tag) { + t_tscalar rval; + rval.clear(); + rval.m_type = perspective::t_dtype::DTYPE_INT32; + + if (!v.is_numeric()) { + rval.m_status = perspective::t_status::STATUS_CLEAR; + } + + if (!v.is_valid()) { + return rval; + } + + switch (v.get_dtype()) { + case perspective::t_dtype::DTYPE_INT64: { + std::int64_t val = v.get(); + if (val == 0) { + rval.set(static_cast(val)); + } else { + rval.set( + static_cast((0 < val) - (val < 0)) + ); + } + } break; + case perspective::t_dtype::DTYPE_INT32: { + std::int32_t val = v.get(); + if (val == 0) { + rval.set(val); + } else { + rval.set((0 < val) - (val < 0)); + } + } break; + case perspective::t_dtype::DTYPE_INT16: { + std::int16_t val = v.get(); + if (val == 0) { + rval.set(static_cast(val)); + } else { + rval.set( + static_cast((0 < val) - (val < 0)) + ); + } + } break; + case perspective::t_dtype::DTYPE_INT8: { + std::int8_t val = v.get(); + if (val == 0) { + rval.set(static_cast(val)); + } else { + rval.set( + static_cast((0 < val) - (val < 0)) + ); + } + } break; + case perspective::t_dtype::DTYPE_UINT64: { + std::uint64_t val = v.get(); + if (val == 0) { + rval.set(static_cast(val)); + } else { + rval.set( + static_cast((0 < val) - (val < 0)) + ); + } + } break; + case perspective::t_dtype::DTYPE_UINT32: { + std::uint32_t val = v.get(); + if (val == 0) { + rval.set(static_cast(val)); + } else { + rval.set( + static_cast((0 < val) - (val < 0)) + ); + } + } break; + case perspective::t_dtype::DTYPE_UINT16: { + std::uint16_t val = v.get(); + if (val == 0) { + rval.set(static_cast(val)); + } else { + rval.set( + static_cast((0 < val) - (val < 0)) + ); + } + } break; + case perspective::t_dtype::DTYPE_UINT8: { + std::uint8_t val = v.get(); + if (val == 0) { + rval.set(static_cast(val)); + } else { + rval.set( + static_cast((0 < val) - (val < 0)) + ); + } + } break; + case perspective::t_dtype::DTYPE_FLOAT64: { + double val = v.get(); + if (val == 0) { + rval.set(static_cast(val)); + } else { + rval.set( + static_cast((0 < val) - (val < 0)) + ); + } + } break; + case perspective::t_dtype::DTYPE_FLOAT32: { + float val = v.get(); + if (val == 0) { + rval.set(static_cast(val)); + } else { + rval.set( + static_cast((0 < val) - (val < 0)) + ); + } + } break; + default: + return rval; + } + + return rval; + } + + template <> + inline t_tscalar + sin_impl(const t_tscalar v, t_tscalar_type_tag) { + UNARY_STD_INT_FUNCTION_BODY(sin) + } + + template <> + inline t_tscalar + sinc_impl(const t_tscalar v, t_tscalar_type_tag) { + t_tscalar rval; + rval.clear(); + rval.m_type = perspective::t_dtype::DTYPE_FLOAT64; + + if (!v.is_numeric()) { + rval.m_status = perspective::t_status::STATUS_CLEAR; + } + + if (!v.is_valid()) { + return rval; + } + + double x = v.to_double(); + + if (x == 0) { + rval.set(static_cast(1.0)); + } else { + rval.set(std::sin(x) / x); + } + + return rval; + } + template <> + inline t_tscalar + sinh_impl(const t_tscalar v, t_tscalar_type_tag) { + UNARY_STD_INT_FUNCTION_BODY(sinh) + } + template <> + inline t_tscalar + sqrt_impl(const t_tscalar v, t_tscalar_type_tag) { + UNARY_STD_FUNCTION_BODY(sqrt) + } + template <> + inline t_tscalar + tan_impl(const t_tscalar v, t_tscalar_type_tag) { + UNARY_STD_INT_FUNCTION_BODY(tan) + } + template <> + inline t_tscalar + tanh_impl(const t_tscalar v, t_tscalar_type_tag) { + UNARY_STD_INT_FUNCTION_BODY(tanh) + } + + template <> + inline t_tscalar + cot_impl(const t_tscalar v, t_tscalar_type_tag) { + t_tscalar rval; + rval.clear(); + rval.m_type = perspective::t_dtype::DTYPE_FLOAT64; + + if (!v.is_numeric()) { + rval.m_status = perspective::t_status::STATUS_CLEAR; + } + + if (!v.is_valid()) { + return rval; + } + + rval.set(static_cast(1.00 / std::tan(v.to_double()))); + + return rval; + } + + template <> + inline t_tscalar + sec_impl(const t_tscalar v, t_tscalar_type_tag) { + return mknone(); + } + template <> + inline t_tscalar + csc_impl(const t_tscalar v, t_tscalar_type_tag) { + return mknone(); + } + + template <> + inline t_tscalar + r2d_impl(const t_tscalar v, t_tscalar_type_tag) { + t_tscalar rval; + rval.clear(); + rval.m_type = perspective::t_dtype::DTYPE_FLOAT64; + + if (!v.is_numeric()) { + rval.m_status = perspective::t_status::STATUS_CLEAR; + } + + if (!v.is_valid()) { + return rval; + } + + // radians to degrees + rval.set( + v.to_double() + * 57.29577951308232087679815481410517033240547246656443 + ); + return rval; + } + + template <> + inline t_tscalar + d2r_impl(const t_tscalar v, t_tscalar_type_tag) { + t_tscalar rval; + rval.clear(); + rval.m_type = perspective::t_dtype::DTYPE_FLOAT64; + + if (!v.is_numeric()) { + rval.m_status = perspective::t_status::STATUS_CLEAR; + } + + if (!v.is_valid()) { + return rval; + } + + // degrees to radians + rval.set( + v.to_double() + * 0.01745329251994329576923690768488612713442871888542 + ); + return rval; + } + + template <> + inline t_tscalar + d2g_impl(const t_tscalar v, t_tscalar_type_tag) { + t_tscalar rval; + rval.clear(); + rval.m_type = perspective::t_dtype::DTYPE_FLOAT64; + + if (!v.is_numeric()) { + rval.m_status = perspective::t_status::STATUS_CLEAR; + } + + if (!v.is_valid()) { + return rval; + } + + // degrees to gradians + rval.set(v.to_double() * (10.0 / 9.0)); + return rval; + } + + template <> + inline t_tscalar + g2d_impl(const t_tscalar v, t_tscalar_type_tag) { + t_tscalar rval; + rval.clear(); + rval.m_type = perspective::t_dtype::DTYPE_FLOAT64; + + if (!v.is_numeric()) { + rval.m_status = perspective::t_status::STATUS_CLEAR; + } + + if (!v.is_valid()) { + return rval; + } + + // gradians to degrees + rval.set(v.to_double() * (9.0 / 10.0)); + return rval; + } + template <> + inline t_tscalar + notl_impl(const t_tscalar v, t_tscalar_type_tag) { + return mknone(); + } + + template <> + inline t_tscalar + frac_impl(const t_tscalar v, t_tscalar_type_tag) { + t_tscalar rval; + rval.clear(); + rval.m_type = perspective::t_dtype::DTYPE_FLOAT64; + + if (!v.is_numeric()) { + rval.m_status = perspective::t_status::STATUS_CLEAR; + } + + if (!v.is_valid()) { + return rval; + } + + double intpart; + + switch (v.get_dtype()) { + case perspective::t_dtype::DTYPE_INT64: + case perspective::t_dtype::DTYPE_INT32: + case perspective::t_dtype::DTYPE_INT16: + case perspective::t_dtype::DTYPE_INT8: + case perspective::t_dtype::DTYPE_UINT64: + case perspective::t_dtype::DTYPE_UINT32: + case perspective::t_dtype::DTYPE_UINT16: + case perspective::t_dtype::DTYPE_UINT8: { + rval.set(static_cast(0)); + }; break; + case perspective::t_dtype::DTYPE_FLOAT64: + case perspective::t_dtype::DTYPE_FLOAT32: { + rval.set(std::modf(v.to_double(), &intpart)); + } break; + default: + return rval; + } + + return rval; + } + + template <> + inline t_tscalar + trunc_impl(const t_tscalar v, t_tscalar_type_tag) { + t_tscalar rval; + rval.clear(); + rval.m_type = perspective::t_dtype::DTYPE_INT64; + + if (!v.is_numeric()) { + rval.m_status = perspective::t_status::STATUS_CLEAR; + } + + if (!v.is_valid()) { + return rval; + } + + rval.set(static_cast(v.to_double())); + + return rval; + } + + template <> + inline t_tscalar + erf_impl(const t_tscalar v, t_tscalar_type_tag) { + UNARY_STD_INT_FUNCTION_BODY(erf) + } + template <> + inline t_tscalar + erfc_impl(const t_tscalar v, t_tscalar_type_tag) { + UNARY_STD_INT_FUNCTION_BODY(erfc) + } + template <> + inline t_tscalar + ncdf_impl(const t_tscalar v, t_tscalar_type_tag) { + return mknone(); + } + + /****************************************************************************** + * + * Binary functions + */ + template <> + inline t_tscalar + min_impl( + const t_tscalar v0, const t_tscalar v1, t_tscalar_type_tag + ) { + t_tscalar rval; + rval.m_type = perspective::t_dtype::DTYPE_FLOAT64; + + if (!v0.is_valid() || !v1.is_valid() || v0.is_none() + || v1.is_none()) { + rval.m_status = perspective::t_status::STATUS_INVALID; + return rval; + } + + double x = v0.to_double(); + double y = v1.to_double(); + + rval.set(x < y ? x : y); + return rval; + } + + template <> + inline t_tscalar + max_impl( + const t_tscalar v0, const t_tscalar v1, t_tscalar_type_tag + ) { + t_tscalar rval; + rval.m_type = perspective::t_dtype::DTYPE_FLOAT64; + + if (!v0.is_valid() || !v1.is_valid() || v0.is_none() + || v1.is_none()) { + rval.m_status = perspective::t_status::STATUS_INVALID; + return rval; + } + + double x = v0.to_double(); + double y = v1.to_double(); + + rval.set(x > y ? x : y); + return rval; + } + + template <> + inline t_tscalar + equal_impl( + const t_tscalar v0, const t_tscalar v1, t_tscalar_type_tag + ) { + t_tscalar rval; + rval.m_type = perspective::t_dtype::DTYPE_BOOL; + + if (!v0.is_valid() || !v1.is_valid() || v0.is_none() + || v1.is_none()) { + rval.m_status = perspective::t_status::STATUS_INVALID; + return rval; + } + + rval.set(v0 == v1); + return rval; + } + template <> + inline t_tscalar + nequal_impl( + const t_tscalar v0, const t_tscalar v1, t_tscalar_type_tag + ) { + t_tscalar rval; + rval.m_type = perspective::t_dtype::DTYPE_BOOL; + + if (!v0.is_valid() || !v1.is_valid() || v0.is_none() + || v1.is_none()) { + rval.m_status = perspective::t_status::STATUS_INVALID; + return rval; + } + + rval.set(v0 != v1); + return rval; + } + + template <> + inline t_tscalar + modulus_impl( + const t_tscalar v0, const t_tscalar v1, t_tscalar_type_tag + ) { + return v0 % v1; + } + + template <> + inline t_tscalar + pow_impl( + const t_tscalar v0, const t_tscalar v1, t_tscalar_type_tag + ) { + t_tscalar rval; + rval.clear(); + rval.m_type = perspective::t_dtype::DTYPE_FLOAT64; + + if (!v1.is_numeric() || !v1.is_numeric()) { + rval.m_status = perspective::t_status::STATUS_CLEAR; + } + + if (!v0.is_valid() || !v1.is_valid()) { + return rval; + } + + rval.set(std::pow(v0.to_double(), v1.to_double())); + + return rval; + } + + template <> + inline t_tscalar + logn_impl( + const t_tscalar v0, const t_tscalar v1, t_tscalar_type_tag + ) { + t_tscalar rval; + rval.clear(); + rval.m_type = perspective::t_dtype::DTYPE_FLOAT64; + + if (!v1.is_numeric() || !v1.is_numeric()) { + rval.m_status = perspective::t_status::STATUS_CLEAR; + } + + if (!v0.is_valid() || !v1.is_valid()) { + return rval; + } + + double base = v1.to_double(); + + if (base < 0) { + return rval; + } + + double result = std::log(v0.to_double()) / std::log(base); + + rval.set(result); + return rval; + } + + template <> + inline t_tscalar + root_impl( + const t_tscalar v0, const t_tscalar v1, t_tscalar_type_tag + ) { + t_tscalar rval; + rval.clear(); + rval.m_type = perspective::t_dtype::DTYPE_FLOAT64; + + if (!v1.is_numeric() || !v1.is_numeric()) { + rval.m_status = perspective::t_status::STATUS_CLEAR; + } + + if (!v0.is_valid() || !v1.is_valid()) { + return rval; + } + + double x = v0.to_double(); + std::int64_t y = static_cast(v1.to_double()); + + if (y < 0 || (x < 0 && y % 2 == 0)) { + return mknone(); + } + + double result = + std::pow(v0.to_double(), (1.0 / v1.to_double())); + + rval.set(result); + return rval; + } + + template <> + inline t_tscalar + roundn_impl( + const t_tscalar v0, const t_tscalar v1, t_tscalar_type_tag + ) { + return mknone(); + } + template <> + inline t_tscalar + hypot_impl( + const t_tscalar v0, const t_tscalar v1, t_tscalar_type_tag + ) { + return mknone(); + } + template <> + inline t_tscalar + atan2_impl( + const t_tscalar v0, const t_tscalar v1, t_tscalar_type_tag + ) { + return mknone(); + } + template <> + inline t_tscalar + shr_impl( + const t_tscalar v0, const t_tscalar v1, t_tscalar_type_tag + ) { + return mknone(); + } + template <> + inline t_tscalar + shl_impl( + const t_tscalar v0, const t_tscalar v1, t_tscalar_type_tag + ) { + return mknone(); + } + + template <> + inline t_tscalar + and_impl( + const t_tscalar v0, const t_tscalar v1, t_tscalar_type_tag + ) { + t_tscalar rval; + rval.set(v0.as_bool() && v1.as_bool()); + return rval; + } + + template <> + inline t_tscalar + or_impl( + const t_tscalar v0, const t_tscalar v1, t_tscalar_type_tag + ) { + t_tscalar rval; + rval.set(v0.as_bool() || v1.as_bool()); + return rval; + } + + template <> + inline t_tscalar + xor_impl( + const t_tscalar v0, const t_tscalar v1, t_tscalar_type_tag + ) { + t_tscalar rval; + rval.set(!v0.as_bool() != !v1.as_bool()); + return rval; + } + + template <> + inline t_tscalar + nand_impl( + const t_tscalar v0, const t_tscalar v1, t_tscalar_type_tag + ) { + t_tscalar rval; + rval.set(!(v0.as_bool() && v1.as_bool())); + return rval; + } + + template <> + inline t_tscalar + nor_impl( + const t_tscalar v0, const t_tscalar v1, t_tscalar_type_tag + ) { + t_tscalar rval; + rval.set(!(v0.as_bool() || v1.as_bool())); + return rval; + } + + template <> + inline t_tscalar + xnor_impl( + const t_tscalar v0, const t_tscalar v1, t_tscalar_type_tag + ) { + t_tscalar rval; + rval.set(v0.as_bool() == v1.as_bool()); + return rval; + } + + template <> + inline bool + is_integer_impl(const t_tscalar& v, t_tscalar_type_tag) { + switch (v.get_dtype()) { + case perspective::t_dtype::DTYPE_INT64: + case perspective::t_dtype::DTYPE_INT32: + case perspective::t_dtype::DTYPE_INT16: + case perspective::t_dtype::DTYPE_INT8: + case perspective::t_dtype::DTYPE_UINT64: + case perspective::t_dtype::DTYPE_UINT32: + case perspective::t_dtype::DTYPE_UINT16: + case perspective::t_dtype::DTYPE_UINT8: + return true; + case perspective::t_dtype::DTYPE_FLOAT64: + case perspective::t_dtype::DTYPE_FLOAT32: + return false; + default: + return false; + } + }; + +#undef UNARY_STD_FUNCTION_BODY +#undef UNARY_STD_INT_FUNCTION_BODY + + /** + * @brief Get the result of applying the given operator to + * two scalars. Explicitly specialize the template here so + * we return scalars of DTYPE_BOOL instead of the default + * DTYPE_FLOAT64 from the function-style cast. + * + * @tparam + * @param operation + * @param arg0 + * @param arg1 + * @return t_tscalar + */ + template <> + inline t_tscalar + process_impl( + const operator_type operation, + const t_tscalar arg0, + const t_tscalar arg1 + ) { + // use the scalar type tag to dispatch to the right fns + t_tscalar_type_tag scalar_type_tag; + + switch (operation) { + case e_add: + return (arg0 + arg1); + case e_sub: + return (arg0 - arg1); + case e_mul: + return (arg0 * arg1); + case e_div: + return (arg0 / arg1); + case e_mod: + return modulus_impl( + arg0, arg1, scalar_type_tag + ); + case e_pow: + return pow_impl(arg0, arg1, scalar_type_tag); + case e_atan2: + return atan2_impl( + arg0, arg1, scalar_type_tag + ); + case e_min: + return min_impl(arg0, arg1, scalar_type_tag); + case e_max: + return max_impl(arg0, arg1, scalar_type_tag); + case e_logn: + return logn_impl( + arg0, arg1, scalar_type_tag + ); + case e_lt: { + perspective::t_tscalar rval; + rval.set(arg0 < arg1); + return rval; + }; + case e_lte: { + perspective::t_tscalar rval; + rval.set(arg0 <= arg1); + return rval; + }; + case e_eq: { + perspective::t_tscalar rval; + rval.set(std::equal_to()(arg0, arg1)); + return rval; + }; + case e_ne: { + perspective::t_tscalar rval; + rval.set(std::not_equal_to()(arg0, arg1)); + return rval; + }; + case e_gte: { + perspective::t_tscalar rval; + rval.set(arg0 >= arg1); + return rval; + }; + case e_gt: { + perspective::t_tscalar rval; + rval.set(arg0 > arg1); + return rval; + }; + case e_and: + return and_impl(arg0, arg1, scalar_type_tag); + case e_nand: + return nand_impl( + arg0, arg1, scalar_type_tag + ); + case e_or: + return or_impl(arg0, arg1, scalar_type_tag); + case e_nor: + return nor_impl(arg0, arg1, scalar_type_tag); + case e_xor: + return xor_impl(arg0, arg1, scalar_type_tag); + case e_xnor: + return xnor_impl( + arg0, arg1, scalar_type_tag + ); + case e_root: + return root_impl( + arg0, arg1, scalar_type_tag + ); + case e_roundn: + return roundn_impl( + arg0, arg1, scalar_type_tag + ); + case e_equal: + return equal_impl(arg0, arg1, scalar_type_tag); + case e_nequal: + return nequal_impl(arg0, arg1, scalar_type_tag); + case e_hypot: + return hypot_impl( + arg0, arg1, scalar_type_tag + ); + case e_shr: + return shr_impl(arg0, arg1, scalar_type_tag); + case e_shl: + return shl_impl(arg0, arg1, scalar_type_tag); + default: + // return scalar of DTYPE_NONE + return std::numeric_limits::quiet_NaN(); + } + } + + } // end namespace details + } // end namespace numeric + + /** + * Override the comparison operators to return a `t_tscalar` of DTYPE_BOOL + * instead of the default DTYPE_FLOAT64 from the function-style cast. + */ + template <> + struct lt_op : public opr_base { + typedef typename opr_base::Type Type; + + static inline t_tscalar + process(Type t1, Type t2) { + t_tscalar rval; + rval.set(t1 < t2); + return rval; + } + + static inline t_tscalar + process(const std::string& t1, const std::string& t2) { + t_tscalar rval; + rval.set(t1 < t2); + return rval; + } + + static inline typename expression_node::node_type + type() { + return expression_node::e_lt; + } + static inline details::operator_type + operation() { + return details::e_lt; + } + }; + + template <> + struct lte_op : public opr_base { + typedef typename opr_base::Type Type; + + static inline t_tscalar + process(Type t1, Type t2) { + t_tscalar rval; + rval.set(t1 <= t2); + return rval; + } + static inline t_tscalar + process(const std::string& t1, const std::string& t2) { + t_tscalar rval; + rval.set(t1 <= t2); + return rval; + } + static inline typename expression_node::node_type + type() { + return expression_node::e_lte; + } + static inline details::operator_type + operation() { + return details::e_lte; + } + }; + + template <> + struct gt_op : public opr_base { + typedef typename opr_base::Type Type; + + static inline t_tscalar + process(Type t1, Type t2) { + t_tscalar rval; + rval.set(t1 > t2); + return rval; + } + + static inline t_tscalar + process(const std::string& t1, const std::string& t2) { + t_tscalar rval; + rval.set(t1 > t2); + return rval; + } + + static inline typename expression_node::node_type + type() { + return expression_node::e_gt; + } + static inline details::operator_type + operation() { + return details::e_gt; + } + }; + + template <> + struct gte_op : public opr_base { + typedef typename opr_base::Type Type; + + static inline t_tscalar + process(Type t1, Type t2) { + t_tscalar rval; + rval.set(t1 >= t2); + return rval; + } + + static inline t_tscalar + process(const std::string& t1, const std::string& t2) { + t_tscalar rval; + rval.set(t1 >= t2); + return rval; + } + + static inline typename expression_node::node_type + type() { + return expression_node::e_gte; + } + static inline details::operator_type + operation() { + return details::e_gte; + } + }; + + template <> + struct eq_op : public opr_base { + typedef typename opr_base::Type Type; + static inline t_tscalar + process(Type t1, Type t2) { + t_tscalar rval; + rval.set(std::equal_to()(t1, t2)); + return rval; + } + static inline t_tscalar + process(const std::string& t1, const std::string& t2) { + t_tscalar rval; + rval.set(t1 == t2); + return rval; + } + static inline typename expression_node::node_type + type() { + return expression_node::e_eq; + } + static inline details::operator_type + operation() { + return details::e_eq; + } + }; + + template <> + struct equal_op : public opr_base { + typedef typename opr_base::Type Type; + + static inline t_tscalar + process(Type t1, Type t2) { + t_tscalar rval; + rval.set(std::equal_to()(t1, t2)); + return rval; + } + static inline t_tscalar + process(const std::string& t1, const std::string& t2) { + t_tscalar rval; + rval.set(t1 == t2); + return rval; + } + static inline typename expression_node::node_type + type() { + return expression_node::e_eq; + } + static inline details::operator_type + operation() { + return details::e_equal; + } + }; + + template <> + struct ne_op : public opr_base { + typedef typename opr_base::Type Type; + + static inline t_tscalar + process(Type t1, Type t2) { + t_tscalar rval; + rval.set(std::not_equal_to()(t1, t2)); + return rval; + } + static inline t_tscalar + process(const std::string& t1, const std::string& t2) { + t_tscalar rval; + rval.set(t1 != t2); + return rval; + } + static inline typename expression_node::node_type + type() { + return expression_node::e_ne; + } + static inline details::operator_type + operation() { + return details::e_ne; + } + }; + + template <> + struct and_op : public opr_base { + typedef typename opr_base::Type Type; + + static inline t_tscalar + process(Type t1, Type t2) { + t_tscalar rval; + rval.set(is_true(t1) && is_true(t2)); + return rval; + } + static inline typename expression_node::node_type + type() { + return expression_node::e_and; + } + static inline details::operator_type + operation() { + return details::e_and; + } + }; + + template <> + struct nand_op : public opr_base { + typedef typename opr_base::Type Type; + + static inline t_tscalar + process(Type t1, Type t2) { + t_tscalar rval; + rval.set(!(is_true(t1) && is_true(t2))); + return rval; + } + static inline typename expression_node::node_type + type() { + return expression_node::e_nand; + } + static inline details::operator_type + operation() { + return details::e_nand; + } + }; + + template <> + struct or_op : public opr_base { + typedef typename opr_base::Type Type; + + static inline t_tscalar + process(Type t1, Type t2) { + t_tscalar rval; + rval.set(is_true(t1) || is_true(t2)); + return rval; + } + static inline typename expression_node::node_type + type() { + return expression_node::e_or; + } + static inline details::operator_type + operation() { + return details::e_or; + } + }; + + template <> + struct nor_op : public opr_base { + typedef typename opr_base::Type Type; + + static inline t_tscalar + process(Type t1, Type t2) { + t_tscalar rval; + rval.set(!(is_true(t1) || is_true(t2))); + return rval; + } + static inline typename expression_node::node_type + type() { + return expression_node::e_nor; + } + static inline details::operator_type + operation() { + return details::e_nor; + } + }; + + template <> + struct xor_op : public opr_base { + typedef typename opr_base::Type Type; + + static inline t_tscalar + process(Type t1, Type t2) { + return numeric::xor_opr(t1, t2); + } + + static inline typename expression_node::node_type + type() { + return expression_node::e_nor; + } + static inline details::operator_type + operation() { + return details::e_xor; + } + }; + + template <> + struct xnor_op : public opr_base { + typedef typename opr_base::Type Type; + + static inline t_tscalar + process(Type t1, Type t2) { + return numeric::xnor_opr(t1, t2); + } + static inline typename expression_node::node_type + type() { + return expression_node::e_nor; + } + static inline details::operator_type + operation() { + return details::e_xnor; + } + }; + + /** + * We don't use ExprTk std::string literals (strings are passed into + * intern() automatically), so disable the string literal comparison + * operations. The only time we use string literals explicitly are in + * the parameter of the `bucket` operation, but that doesn't use these + * comparison operators. + */ + template <> + struct in_op : public opr_base { + typedef typename opr_base::Type Type; + + static inline t_tscalar + process(const t_tscalar&, const t_tscalar&) { + return std::numeric_limits::quiet_NaN(); + } + static inline t_tscalar + process(const std::string& t1, const std::string& t2) { + return std::numeric_limits::quiet_NaN(); + } + static inline typename expression_node::node_type + type() { + return expression_node::e_in; + } + static inline details::operator_type + operation() { + return details::e_in; + } + }; + + template <> + struct like_op : public opr_base { + typedef typename opr_base::Type Type; + + static inline t_tscalar + process(const t_tscalar&, const t_tscalar&) { + return std::numeric_limits::quiet_NaN(); + } + static inline t_tscalar + process(const std::string& t1, const std::string& t2) { + return std::numeric_limits::quiet_NaN(); + } + static inline typename expression_node::node_type + type() { + return expression_node::e_like; + } + static inline details::operator_type + operation() { + return details::e_like; + } + }; + + template <> + struct ilike_op : public opr_base { + typedef typename opr_base::Type Type; + + static inline t_tscalar + process(const t_tscalar&, const t_tscalar&) { + return std::numeric_limits::quiet_NaN(); + } + static inline t_tscalar + process(const std::string& t1, const std::string& t2) { + return std::numeric_limits::quiet_NaN(); + } + static inline typename expression_node::node_type + type() { + return expression_node::e_ilike; + } + static inline details::operator_type + operation() { + return details::e_ilike; + } + }; + + template <> + struct inrange_op : public opr_base { + typedef typename opr_base::Type Type; + + static inline t_tscalar + process(const t_tscalar& t0, const t_tscalar& t1, const t_tscalar& t2) { + t_tscalar rval; + rval.set((t0 <= t1) && (t1 <= t2)); + return rval; + } + static inline t_tscalar + process( + const std::string& t0, const std::string& t1, const std::string& t2 + ) { + t_tscalar rval; + rval.set((t0 <= t1) && (t1 <= t2)); + return rval; + } + static inline typename expression_node::node_type + type() { + return expression_node::e_inranges; + } + static inline details::operator_type + operation() { + return details::e_inrange; + } + }; + + /** + * @brief mand(a, b, c, ...) returns true if all arguments evaluate to + * true, else false. All parameters should be of DTYPE_BOOL, or the + * function will return a scalar of DTYPE_NONE indicating an invalid + * operation. + */ + template <> + struct vararg_mand_op : public opr_base { + typedef typename opr_base::Type Type; + + template < + typename Type, + typename Allocator, + template + class Sequence> + static inline t_tscalar + process(const Sequence& arg_list) { + t_tscalar rval = mktscalar(false); + + // Originally, there was specific implementations for up to 5 args + // that did not use a for loop, but since we do type-checking + // centralize all the logic here instead. + for (std::size_t i = 0; i < arg_list.size(); ++i) { + t_tscalar val = value(arg_list[i]); + + // If scalar is invalid or not a bool, return bool but + // with STATUS_CLEAR to poison the type-checker. + if (!val.is_valid() + || val.get_dtype() != perspective::t_dtype::DTYPE_BOOL) { + rval.m_status = perspective::t_status::STATUS_CLEAR; + return rval; + } + + // if value is equal to False, return False + if (std::equal_to()(rval, val)) { + return rval; + } + } + + // True if all values are true + rval.set(true); + return rval; + } + }; + + // /** + // * @brief mor(a, b, c, ...) returns True if any parameter evalutes to + // * True, and False otherwise. All parameters should be scalars of + // * `DTYPE_BOOL`. + // * + // * @tparam T + // */ + template <> + struct vararg_mor_op : public opr_base { + typedef typename opr_base::Type Type; + + template < + typename Type, + typename Allocator, + template + class Sequence> + static inline t_tscalar + process(const Sequence& arg_list) { + t_tscalar rval = mktscalar(false); + + // Originally, there was specific implementations for up to 5 args + // that did not use a for loop, but since we do type-checking + // centralize all the logic here instead. + for (std::size_t i = 0; i < arg_list.size(); ++i) { + t_tscalar val = value(arg_list[i]); + + // If scalar is invalid or not a bool, return bool but + // with STATUS_CLEAR to poison the type-checker. + if (!val.is_valid() + || val.get_dtype() != perspective::t_dtype::DTYPE_BOOL) { + rval.m_status = perspective::t_status::STATUS_CLEAR; + return rval; + } + + // if any value is True, return True + if (std::not_equal_to()(rval, val)) { + rval.set(true); + return rval; + } + } + + // False if all values are false + return rval; + } + }; + + /** + * @brief Evaluates the truthiness of an expression node that returns a + * `t_tscalar`. The `t_tscalar` returned must be of type bool. + * + * @tparam + * @param node + * @return true + * @return false + */ + template <> + inline bool + is_true(const expression_node* node) { + return std::not_equal_to()(mktscalar(false), node->value()); + } + + template <> + inline bool + is_true(const std::pair*, bool>& node) { + return std::not_equal_to()( + mktscalar(false), node.first->value() + ); + } + + template <> + inline bool + is_false(const expression_node* node) { + return std::equal_to()(mktscalar(false), node->value()); + } + + template <> + inline bool + is_false(const std::pair*, bool>& node) { + return std::equal_to()( + mktscalar(false), node.first->value() + ); + } + + /** + * @brief Evaluates the truthiness of a single scalar value. + * + * @param v + * @return true + * @return false + */ + inline bool + is_true(const t_tscalar& v) { + return v.as_bool(); + } + + inline bool + is_false(const t_tscalar& v) { + return !is_true(v); + }; + + /****************************************************************************** + * + * String -> t_tscalar + */ + + /** + * @brief Parse an infinity value and set `t` to infinity as a DTYPE_FLOAT64 + * scalar. + * + * @tparam Iterator + * @param itr + * @param end + * @param t + * @param negative + * @return true + * @return false + */ + template + static inline bool + parse_inf(Iterator& itr, const Iterator end, t_tscalar& t, bool negative) { + static const char_t inf_uc[] = "INFINITY"; + static const char_t inf_lc[] = "infinity"; + static const std::size_t inf_length = 8; + + const std::size_t length = + static_cast(std::distance(itr, end)); + + if ((3 != length) && (inf_length != length)) { + return false; + } + + char_cptr inf_itr = ('i' == (*itr)) ? inf_lc : inf_uc; + + while (end != itr) { + if (*inf_itr == static_cast(*itr)) { + ++itr; + ++inf_itr; + continue; + } else { + return false; + } + } + + negative ? t.set(-std::numeric_limits::infinity()) + : t.set(std::numeric_limits::infinity()); + t.m_type = perspective::t_dtype::DTYPE_FLOAT64; + + return true; + } + + template + inline bool + valid_exponent(const int exponent, numeric::details::t_tscalar_type_tag) { + using namespace details::numeric; + return (numeric_info::min_exp <= exponent) + && (exponent <= numeric_info::max_exp); + } + + /** + * @brief Parse a string to a real number, setting `t` to the parsed value + * and giving it DTYPE_FLOAT64. Returns true if the parse succeeded, and + * false otherwise. + * + * @tparam Iterator + * @param itr_external + * @param end + * @param t + * @return true + * @return false + */ + template + inline bool + string_to_real( + Iterator& itr_external, + const Iterator end, + t_tscalar& t, + numeric::details::t_tscalar_type_tag + ) { + bool parsed = string_to_real( + itr_external, + end, + t.m_data.m_float64, + numeric::details::real_type_tag() + ); + + if (parsed) { + t.m_type = perspective::DTYPE_FLOAT64; + t.m_status = perspective::STATUS_VALID; + } + + return parsed; + } + +} // end namespace details +} // end namespace exprtk diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/extract_aggregate.h b/rust/perspective-server/cpp/perspective/src/include/perspective/extract_aggregate.h new file mode 100644 index 0000000..22fdccb --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/extract_aggregate.h @@ -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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include + +namespace perspective { + +t_tscalar extract_aggregate( + const t_aggspec& aggspec, + const t_column* aggcol, + t_uindex ridx, + t_index pridx +); +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/filter.h b/rust/perspective-server/cpp/perspective/src/include/perspective/filter.h new file mode 100644 index 0000000..b54d642 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/filter.h @@ -0,0 +1,212 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +// Filter operators +template class OP_T> +struct t_operator_base { + inline bool + operator()(DATA_T value, DATA_T threshold_value) { + OP_T cmp; + return cmp(value, threshold_value); + } + + inline bool + operator()(const char* value, const char* threshold_value) { + t_const_char_comparator cmp; + return cmp(value, threshold_value); + } +}; + +template +using t_operator_lt = t_operator_base; + +template +using t_operator_lteq = t_operator_base; + +template +using t_operator_gt = t_operator_base; + +template +using t_operator_gteq = t_operator_base; + +template +using t_operator_ne = t_operator_base; + +template +using t_operator_eq = t_operator_base; + +template +struct t_operator_in { + inline bool + operator()(const DATA_T& value, const std::set& threshold_values) { + return threshold_values.find(value) != threshold_values.end(); + } +}; + +template +struct t_operator_not_in { + inline bool + operator()(const DATA_T& value, const std::set& threshold_values) { + return threshold_values.find(value) == threshold_values.end(); + } +}; + +template +struct t_operator_begins_with { + inline bool + operator()(DATA_T value, DATA_T threshold_value) { + return false; + } +}; + +template +struct t_operator_ends_with { + inline bool + operator()(DATA_T value, DATA_T threshold_value) { + return false; + } +}; + +template +struct t_operator_contains { + inline bool + operator()(DATA_T value, DATA_T threshold_value) { + return false; + } +}; + +template <> +struct t_operator_begins_with { + inline bool + operator()(const char* value, const char* threshold_value) { + t_uindex t_vlen = strlen(value); + t_uindex t_tlen = strlen(threshold_value); + return t_vlen < t_tlen ? false + : strncmp(value, threshold_value, t_tlen) == 0; + } +}; + +template <> +struct t_operator_ends_with { + inline bool + operator()(const char* value, const char* threshold_value) { + t_uindex t_vlen = strlen(value); + t_uindex t_tlen = strlen(threshold_value); + + return t_vlen < t_tlen + ? false + : strncmp(value + t_vlen - t_tlen, threshold_value, t_tlen) == 0; + } +}; + +template <> +struct t_operator_contains { + inline bool + operator()(const char* value, const char* threshold_value) { + return strstr(value, threshold_value) != 0; + } +}; + +struct PERSPECTIVE_EXPORT t_fterm { + t_fterm(); + + t_fterm( + std::string colname, + t_filter_op op, + t_tscalar threshold, + const std::vector& bag + ); + + t_fterm( + std::string colname, + t_filter_op op, + t_tscalar threshold, + const std::vector& bag, + bool negated, + bool is_primary + ); + + inline bool + operator()(t_tscalar s) const { + bool rv; + switch (m_op) { + case FILTER_OP_NOT_IN: { + rv = std::find(m_bag.begin(), m_bag.end(), s) == m_bag.end(); + } break; + case FILTER_OP_IN: { + rv = std::find(m_bag.begin(), m_bag.end(), s) != m_bag.end(); + } break; + default: { + rv = s.cmp(m_op, m_threshold); + } break; + } + + return m_negated ? (!rv) : rv; + } + + std::string get_expr() const; + + void coerce_numeric(t_dtype dtype); + + std::string m_colname; + t_filter_op m_op; + t_tscalar m_threshold; + std::vector m_bag; + bool m_negated; + bool m_is_primary; + bool m_use_interned; +}; + +class PERSPECTIVE_EXPORT t_filter { +public: + t_filter(); + t_filter(const std::vector& columns); + + t_filter( + const std::vector& columns, t_uindex bidx, t_uindex eidx + ); + + t_filter(const std::vector& columns, t_uindex mask_size); + t_filter(const t_mask& mask); + + t_uindex num_cols() const; + bool has_filter() const; + const std::vector& columns() const; + t_select_mode mode() const; + t_uindex bidx() const; + t_uindex eidx() const; + t_maskcsptr cmask() const; + t_masksptr mask() const; + t_uindex count() const; + +private: + t_select_mode m_mode; + t_uindex m_bidx; + t_uindex m_eidx; + std::vector m_columns; + t_masksptr m_mask; +}; + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/filter_utils.h b/rust/perspective-server/cpp/perspective/src/include/perspective/filter_utils.h new file mode 100644 index 0000000..2956d68 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/filter_utils.h @@ -0,0 +1,35 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include + +namespace perspective { + +inline t_mask +filter_table_for_config(const t_data_table& tbl, const t_config& config) { + + switch (config.get_fmode()) { + case FMODE_SIMPLE_CLAUSES: { + return tbl.filter_cpp(config.get_combiner(), config.get_fterms()); + } break; + default: { + } + } + + return t_mask(tbl.size()); +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/first.h b/rust/perspective-server/cpp/perspective/src/include/perspective/first.h new file mode 100644 index 0000000..90a0f41 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/first.h @@ -0,0 +1,69 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#if !defined(__linux__) && !defined(__APPLE__) && !defined(WIN32) +// default to linux +#define __linux__ +#endif + +#pragma once + +#ifdef WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif // NOMINMAX +#endif // WIN32 + +#ifdef PSP_VERIFY +#define PSP_STORAGE_VERIFY +#define PSP_COLUMN_VERIFY +#define PSP_TABLE_VERIFY +#define PSP_GNODE_VERIFY +#define PSP_TABLE_TRACE +// #define PSP_DEBUG_HELPER +// #define PSP_MPROTECT +// #define PSP_DBG_MALLOC +#endif + +// Remove once we are c++11 everywhere +#define NPY_NO_DEPRECATED_API NPY_1_7_API_VERSION +#define _GLIBCXX_USE_NANOSLEEP 1 +#include +#include + +#ifndef PSP_DEBUG_HELPER +#define PSP_MALLOC malloc +#define PSP_FREE free +#endif + +#ifdef PSP_DBG_MALLOC +#define PSP_MALLOC psp_dbg_malloc +#define PSP_FREE psp_dbg_free +#endif + +#ifdef PSP_MPROTECT +#define PSP_MALLOC psp_page_aligned_malloc +#define PSP_FREE psp_page_aligned_free +#endif + +#ifdef PSP_ENABLE_PYTHON +// #include +// #include +// namespace py = pybind11; + +// Define object serialization type +#define PSP_OBJECT_TYPE std::uint64_t + +#else +// TODO javascript +#define PSP_OBJECT_TYPE std::uint64_t +#endif diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/flat_traversal.h b/rust/perspective-server/cpp/perspective/src/include/perspective/flat_traversal.h new file mode 100644 index 0000000..1615a50 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/flat_traversal.h @@ -0,0 +1,171 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +class PERSPECTIVE_EXPORT t_ftrav { + +public: + t_ftrav(); + + void init(); + + std::vector + get_all_pkeys(const std::vector>& cells + ) const; + + std::vector + get_pkeys(const std::vector>& cells) const; + + std::vector get_pkeys() const; + std::vector get_pkeys(t_index begin_row, t_index end_row) const; + std::vector get_pkeys(const std::vector& rows) const; + + t_tscalar get_pkey(t_index idx) const; + + void fill_sort_elem( + const t_gstate& gstate, + const t_data_table& expression_master_table, + const t_config& config, + t_tscalar pkey, + t_mselem& out_elem + ); + + // sorting over a whole row whose data we already have, so we don't need + // the expression table separately. + void fill_sort_elem( + const t_gstate& gstate, + const t_config& config, + const std::vector& row, + t_mselem& out_elem + ) const; + + void sort_by( + const t_gstate& gstate, + const t_data_table& expression_master_table, + const t_config& config, + const std::vector& sortby + ); + + t_index size() const; + + void get_row_indices( + const tsl::hopscotch_set& pkeys, + tsl::hopscotch_map& out_map + ) const; + + void get_row_indices( + t_index bidx, + t_index eidx, + const tsl::hopscotch_set& pkeys, + tsl::hopscotch_map& out_map + ) const; + + std::vector + get_row_indices(const tsl::hopscotch_set& pkeys) const; + + void reset(); + + void check_size(); + + bool validate_cells(const std::vector>& cells + ) const; + + void step_begin(); + + void step_end(); + + void add_row( + const t_gstate& gstate, + const t_data_table& expression_master_table, + const t_config& config, + t_tscalar pkey + ); + + void update_row( + const t_gstate& gstate, + const t_data_table& expression_master_table, + const t_config& config, + t_tscalar pkey + ); + + void delete_row(t_tscalar pkey); + + std::vector get_sort_by() const; + bool empty_sort_by() const; + + // Fast-path initialization for unsorted traversals: append pkeys + // directly to `m_index` in one shot, skipping the `m_new_elems` + // hopscotch_map round-trip and the `new_rows` intermediate vector + // that a normal `add_row` / `step_end` pair would incur. Only valid + // when `empty_sort_by()` is true and the traversal is otherwise empty + // (i.e. during initial registration of a ctx0 without a sort). + // + // Call `bulk_load_reserve(n)` first, then `bulk_load_append(pkey)` + // once per pkey, then `bulk_load_finalize()` to sort by pkey (the + // implicit order used by `cmp_mselem` with an empty sort spec) and + // build `m_pkeyidx`. After finalize, `step_end` short-circuits + // because `m_step_inserts` remains 0. + void bulk_load_reserve(t_uindex n); + void bulk_load_append(t_tscalar pkey); + void bulk_load_finalize(); + + void reset_step_state(); + + t_uindex lower_bound_row_idx( + const t_gstate& gstate, + const t_config& config, + const std::vector& row + ) const; + + t_index get_row_idx(t_tscalar pkey) const; + +private: + t_tscalar get_from_gstate( + const t_gstate& gstate, + const t_data_table& expression_master_table, + const std::string& colname, + t_tscalar pkey + ) const; + + t_index m_step_deletes; + t_index m_step_inserts; + + // map primary keys to row indices + tsl::hopscotch_map m_pkeyidx; + + // map primary keys to sort items + tsl::hopscotch_map m_new_elems; + + std::vector m_sortby; + std::shared_ptr> m_index; + t_symtable m_symtable; +}; + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/flatten.h b/rust/perspective-server/cpp/perspective/src/include/perspective/flatten.h new file mode 100644 index 0000000..d646d5b --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/flatten.h @@ -0,0 +1,20 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once + +namespace perspective { + +class t_flattener { + static void op(); +}; +} // namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/fragments.h b/rust/perspective-server/cpp/perspective/src/include/perspective/fragments.h new file mode 100644 index 0000000..cb3e66d --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/fragments.h @@ -0,0 +1,19 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include + +namespace perspective { +class t_dfragments {}; + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/get_data_extents.h b/rust/perspective-server/cpp/perspective/src/include/perspective/get_data_extents.h new file mode 100644 index 0000000..0178b44 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/get_data_extents.h @@ -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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once + +#include +#include +#include + +namespace perspective { + +struct t_get_data_extents { + t_index m_srow; + t_index m_erow; + t_index m_scol; + t_index m_ecol; +}; + +t_get_data_extents sanitize_get_data_extents( + t_index nrows, + t_index ncols, + t_index start_row, + t_index end_row, + t_index start_col, + t_index end_col +); +} // namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/gnode.h b/rust/perspective-server/cpp/perspective/src/include/perspective/gnode.h new file mode 100644 index 0000000..772b58e --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/gnode.h @@ -0,0 +1,662 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef PSP_PARALLEL_FOR +#include +#include +#endif + +namespace perspective { + +PERSPECTIVE_EXPORT t_tscalar +calc_delta(t_value_transition trans, t_tscalar oval, t_tscalar nval); + +PERSPECTIVE_EXPORT t_tscalar +calc_newer(t_value_transition trans, t_tscalar oval, t_tscalar nval); + +PERSPECTIVE_EXPORT t_tscalar calc_negate(t_tscalar val); + +class t_ctxunit; +class t_ctx0; +class t_ctx1; +class t_ctx2; +class t_ctx_grouped_pkey; + +#ifdef PSP_GNODE_VERIFY +#define PSP_GNODE_VERIFY_TABLE(X) (X)->verify() +#else +#define PSP_GNODE_VERIFY_TABLE(X) +#endif + +/** + * @brief The struct returned from `_process_table`, which contains a + * pointer to the flattened and processed `t_data_table`, and a boolean showing + * whether the user's `on_update` callbacks should be called, i.e. whether the + * update contained new data or not. + * + * Given that `_process_table` may be called multiple times, as is `_process`, + * `t_update_task` will accumulate the values of `m_should_notify_userspace` + * over multiple calls and only consider an update a no-op if all values of + * `m_should_notify_userspace` are false. + */ +struct PERSPECTIVE_EXPORT t_process_table_result { + std::shared_ptr m_flattened_data_table; + bool m_should_notify_userspace; +}; +class PERSPECTIVE_EXPORT t_gnode { +public: + /** + * @brief Construct a new `t_gnode`. A `t_gnode` manages the accumulated + * internal state of a `Table` - it handles updates, calculates the + * transition state between each `update()` call, and manages/notifies + * contexts (`View`s) created from the `Table`. + * + * A `t_gnode` is created with two `t_schema`s: + * + * `input_schema`: the canonical `t_schema` for the `Table`, which cannot + * be mutated after creation. This schema contains the `psp_pkey` and + * `psp_op` columns, which are used internally. + * + * `output_schema`: the `t_schema` that contains all columns provided + * by the dataset, excluding `psp_pkey` and `psp_op`. + * + * @param input_schema + * @param output_schema + */ + t_gnode( + t_schema input_schema, + t_schema output_schema, + t_backing_store backing_store = BACKING_STORE_MEMORY + ); + ~t_gnode(); + + void init(); + void reset(); + + /** + * @brief Send a t_data_table with a schema that matches the gnode's + * input schema to the input port at `port_id`. + * + * @param port_id + * @param fragments + */ + void send(t_uindex port_id, const t_data_table& fragments); + + /** + * @brief Bulk-initialize an empty gnode directly from `data_table`, + * bypassing the input port, `flatten()`, and `update_master_table()` + * pipeline. + * + * The caller must guarantee that (a) the gnode's gstate is empty, + * (b) all rows are `OP_INSERT`, and (c) `psp_pkey` contains no + * duplicates (e.g. an implicit row-index primary key). Columns of + * `data_table` are aliased into the master table instead of being + * copied. + * + * @param data_table + */ + void init_bulk(const std::shared_ptr& data_table); + + /** + * @brief Given a port_id, call `process_table` on the port's data table, + * reconciling all queued calls to `update` and `remove` on that port. + * Returns a boolean indicating whether the update was valid and whether + * contexts were notified. + * + * @param port_id + */ + bool process(t_uindex port_id); + + /** + * @brief Create a new input port, store it in `m_input_ports`, and + * return the integer ID that references the new port. + * + * @return t_uindex + */ + t_uindex make_input_port(); + + /** + * @brief Given a port ID, remove the input port that belongs to that + * input ID and clean up its associated table. + * + * @param port_id + */ + void remove_input_port(t_uindex port_id); + + /** + * @brief Given a new context, register it with the gnode, compute and + * add its expression columns. + * + * @param name + * @param type + * @param ptr + */ + void _register_context( + const std::string& name, t_ctx_type type, std::uintptr_t ptr + ); + + /** + * @brief Remove a context by name from the gnode, and remove its + * computed expression columns. + * + * @param name + */ + void _unregister_context(const std::string& name); + + const t_data_table* get_table() const; + t_data_table* get_table(); + + std::shared_ptr get_table_sptr() const; + std::shared_ptr get_pkeyed_table() const; + + t_data_table* _get_otable(t_uindex port_id); + t_data_table* _get_itable(t_uindex port_id); + + t_schema get_output_schema() const; + const t_schema& get_state_input_schema() const; + + t_uindex num_input_ports() const; + t_uindex num_output_ports() const; + + std::vector get_pivots() const; + std::vector get_trees(); + + void set_id(t_uindex id); + t_uindex get_id() const; + + void release_inputs(); + void release_outputs(); + + std::vector get_registered_contexts() const; + std::vector get_contexts_last_updated() const; + + void clear_input_ports(); + void clear_output_ports(); + + bool has_pkey(t_tscalar pkey) const; + + std::vector + get_row_data_pkeys(const std::vector& pkeys) const; + std::vector has_pkeys(const std::vector& pkeys) const; + std::vector get_pkeys() const; + + void set_pool_cleanup(std::function cleanup); + bool was_updated() const; + void clear_updated(); + + t_uindex mapping_size() const; + + // helper function for JS interface + void promote_column(const std::string& name, t_dtype new_type); + + // Gnode will steal a reference to the context + void register_context( + const std::string& name, const std::shared_ptr& ctx + ); + void register_context( + const std::string& name, const std::shared_ptr& ctx + ); + void register_context( + const std::string& name, const std::shared_ptr& ctx + ); + void register_context( + const std::string& name, const std::shared_ptr& ctx + ); + void register_context( + const std::string& name, const std::shared_ptr& ctx + ); + + void pprint() const; + std::string repr() const; + + std::shared_ptr get_expression_vocab() const; + std::shared_ptr get_expression_regex_mapping() const; + + const t_gstate::t_mapping& get_pkey_map() const; + +#ifdef PSP_PARALLEL_FOR + void set_lock(std::shared_mutex* lock); +#endif + +protected: + /** + * @brief Given `tbl`, notify each registered context with `tbl`. + * + * @param tbl + */ + void _update_contexts_from_state(std::shared_ptr tbl); + + /** + * @brief Notify a single registered `ctx` with `tbl`. + * + * @tparam CTX_T + * @param ctx + * @param tbl + * @param flattened + * @param delta + * @param prev + * @param current + * @param transitions + * @param changed_rows + */ + template + void update_context_from_state( + CTX_T* ctx, + const std::string& name, + std::shared_ptr flattened, + bool is_registration + ); + + /** + * @brief Provide the registered `t_ctx*` with a pointer to this gnode's + * `m_gstate` object. `t_ctx*` are assumed to access/mutate this state + * object arbitrarily at runtime. + * + * @tparam CTX_T + * @param ptr + */ + template + void set_ctx_state(void* ptr); + + bool have_context(const std::string& name) const; + void notify_contexts(std::shared_ptr flattened); + + template + void notify_context( + std::shared_ptr flattened, + const t_ctx_handle& ctxh, + const std::string& name + ); + + /** + * @brief Given the process state, create a `t_mask` bitset set to true for + * all rows in `flattened`, UNLESS the row is an `OP_DELETE`. + * + * Mutates the `t_process_state` object that is passed in. + * + * @param process_state + */ + t_mask _process_mask_existed_rows(t_process_state& process_state); + + /** + * @brief Given a flattened column, the master column from `m_gstate`, and + * all transitional columns containing metadata, process and calculate + * transitional values. + * + * @tparam DATA_T + * @param fcolumn + * @param scolumn + * @param dcolumn + * @param pcolumn + * @param ccolumn + * @param tcolumn + * @param process_state + */ + template + void _process_column( + const t_column* fcolumn, + const t_column* scolumn, + t_column* dcolumn, + t_column* pcolumn, + t_column* ccolumn, + t_column* tcolumn, + const t_process_state& process_state + ); + + /** + * @brief Calculate the transition state for a single cell, which depends + * on whether the cell is/was valid, existed, or is new. + * + * @param prev_existed + * @param row_pre_existed + * @param exists + * @param prev_valid + * @param cur_valid + * @param prev_cur_eq + * @param prev_pkey_eq + * @return t_value_transition + */ + t_value_transition calc_transition( + bool prev_existed, + bool row_pre_existed, + bool exists, + bool prev_valid, + bool cur_valid, + bool prev_cur_eq, + bool prev_pkey_eq + ); + + /****************************************************************************** + * + * Expression Column Operations + */ + + /** + * @brief Compute all expressions on each registered context using the + * flattened table. This method is called on the first update applied + * on an empty gstate master table. + */ + void + _compute_expressions(const std::shared_ptr& flattened_masked); + + /** + * @brief Compute all expressions on each registered context using all + * data and transition tables. This method is called on all subsequent + * updates applied after the first update. + */ + void _compute_expressions( + const std::shared_ptr& master, + const std::shared_ptr& flattened + ); + +private: + /** + * @brief Process the input data table by flattening it, calculating + * transitional values, and returning a new masked version. + * + * @return t_process_table_result + */ + t_process_table_result _process_table(t_uindex port_id); + + t_gnode_processing_mode m_mode; + t_gnode_type m_gnode_type; + + // A `t_schema` containing all columns, including internal metadata columns. + t_schema m_input_schema; + + // A `t_schema` containing all columns (excluding internal columns). + t_schema m_output_schema; + + // A vector of `t_schema`s for each transitional `t_data_table`. + std::vector m_transitional_schemas; + + bool m_init; + t_uindex m_id; + + // Input ports mapped by integer id + tsl::ordered_map> m_input_ports; + + // Input port IDs are sequential, starting from 0 + t_uindex m_last_input_port_id; + + // Output ports stored sequentially in a vector, keyed by the + // `t_gnode_port` enum. + std::vector> m_oports; + tsl::ordered_map m_contexts; + std::shared_ptr m_gstate; + t_backing_store m_backing_store; + + std::chrono::high_resolution_clock::time_point m_epoch; + std::function m_pool_cleanup; + bool m_was_updated; + + std::shared_ptr m_expression_vocab; + std::shared_ptr m_expression_regex_mapping; + +#ifdef PSP_PARALLEL_FOR + std::shared_mutex* m_lock; +#endif +}; + +/** + * @brief Given a t_data_table and a context handler, construct the t_tables + * relating to delta calculation and notify the context with the constructed + * tables. + * + * @tparam CTX_T + * @param flattened + * @param ctxh + */ +template +void +t_gnode::notify_context( + std::shared_ptr flattened, + const t_ctx_handle& ctxh, + const std::string& name +) { + CTX_T* ctx = ctxh.get(); + + // Tables from the gnode which do not have the expressions applied yet + std::shared_ptr delta = m_oports[PSP_PORT_DELTA]->get_table(); + std::shared_ptr prev = m_oports[PSP_PORT_PREV]->get_table(); + std::shared_ptr current = + m_oports[PSP_PORT_CURRENT]->get_table(); + std::shared_ptr transitions = + m_oports[PSP_PORT_TRANSITIONS]->get_table(); + + // Existed is special - it will never have any expression columns and can + // be used as-is from the gnode. + const t_data_table& existed = *(m_oports[PSP_PORT_EXISTED]->get_table()); + + ctx->step_begin(); + + if (ctx->num_expressions() > 0) { + // Join expression tables on the context with gnode tables and pass + // those into the context so there is no distinction between expression + // and real columns for the context. + std::shared_ptr ctx_expression_tables = + ctx->get_expression_tables(); + + auto joined_flattened = + flattened->join(ctx_expression_tables->m_flattened); + auto joined_delta = delta->join(ctx_expression_tables->m_delta); + auto joined_prev = prev->join(ctx_expression_tables->m_prev); + auto joined_current = current->join(ctx_expression_tables->m_current); + auto joined_transitions = + transitions->join(ctx_expression_tables->m_transitions); + + // pass the tables as const references - the destructors for all of the + // joined tables will be called after this function finishes executing, + // as the contexts do not retain a reference to these tables. + ctx->notify( + *joined_flattened, + *joined_delta, + *joined_prev, + *joined_current, + *joined_transitions, + existed + ); + } else { + ctx->notify(*flattened, *delta, *prev, *current, *transitions, existed); + } + + ctx->step_end(); +} + +/** + * @brief Given a flattened `t_data_table`, update the context with the table. + * + * Called with the context is initialized with a table. + * + * @tparam CTX_T the template type + * @param ctx a pointer to a `t_context` object + * @param flattened the flattened `t_data_table` containing data for the context + */ +template +void +t_gnode::update_context_from_state( + CTX_T* ctx, + const std::string& name, + std::shared_ptr flattened, + bool is_registration +) { + PSP_TRACE_SENTINEL(); + PSP_VERBOSE_ASSERT(m_init, "touching uninited object"); + PSP_VERBOSE_ASSERT( + m_mode == NODE_PROCESSING_SIMPLE_DATAFLOW, + "Only simple dataflows supported currently" + ) + + if (flattened->size() == 0) { + return; + } + + ctx->step_begin(); + + // This method is called in two places: + // + // 1. when a new context is created and it needs to get the current state + // of the gstate master table in order to calculate aggregates, etc. + // `is_registration` is `true` in this case — no subscriber can observe + // deltas populated during this initial notify, so we skip populating + // `m_delta_pkeys` to save O(N) allocations. + // + // 2. when a table created from schema (0 rows) gets data and now needs + // to update its registered contexts with the new data. `is_registration` + // is `false` here — a subscriber may have attached between context + // creation and the first update, and expects to see all rows as deltas. + if (ctx->num_expressions() > 0) { + // If the context has expression columns, it has already been computed + // in `process_table` and we can join the "real" and expression columns + // together and pass it to the context. + std::shared_ptr ctx_expression_tables = + ctx->get_expression_tables(); + std::shared_ptr joined_flattened = + flattened->join(ctx_expression_tables->m_flattened); + ctx->notify(*joined_flattened, is_registration); + } else { + // Just use the table from the gnode + ctx->notify(*flattened, is_registration); + } + + ctx->step_end(); +} + +template <> +void t_gnode::_process_column( + const t_column* fcolumn, + const t_column* scolumn, + t_column* dcolumn, + t_column* pcolumn, + t_column* ccolumn, + t_column* tcolumn, + const t_process_state& process_state +); + +template +void +t_gnode::_process_column( + const t_column* fcolumn, + const t_column* scolumn, + t_column* dcolumn, + t_column* pcolumn, + t_column* ccolumn, + t_column* tcolumn, + const t_process_state& process_state +) { + for (t_uindex idx = 0, loop_end = fcolumn->size(); idx < loop_end; ++idx) { + std::uint8_t op_ = process_state.m_op_base[idx]; + t_op op = static_cast(op_); + t_uindex added_count = process_state.m_added_offset[idx]; + + const t_rlookup& rlookup = process_state.m_lookup[idx]; + bool row_pre_existed = rlookup.m_exists; + auto prev_pkey_eq = process_state.m_prev_pkey_eq_vec[idx]; + + switch (op) { + case OP_INSERT: { + row_pre_existed = row_pre_existed && !prev_pkey_eq; + + DATA_T prev_value; + memset(&prev_value, 0, sizeof(DATA_T)); + bool prev_valid = false; + + DATA_T cur_value = *(fcolumn->get_nth(idx)); + bool cur_valid = fcolumn->is_valid(idx); + + if (row_pre_existed) { + prev_value = *(scolumn->get_nth(rlookup.m_idx)); + prev_valid = scolumn->is_valid(rlookup.m_idx); + } + + bool exists = cur_valid; + bool prev_existed = row_pre_existed && prev_valid; + bool prev_cur_eq = prev_value == cur_value; + + auto trans = calc_transition( + prev_existed, + row_pre_existed, + exists, + prev_valid, + cur_valid, + prev_cur_eq, + prev_pkey_eq + ); + + dcolumn->set_nth( + added_count, cur_valid ? cur_value - prev_value : DATA_T(0) + ); + dcolumn->set_valid(added_count, true); + + pcolumn->set_nth(added_count, prev_value); + pcolumn->set_valid(added_count, prev_valid); + + ccolumn->set_nth( + added_count, cur_valid ? cur_value : prev_value + ); + ccolumn->set_valid( + added_count, cur_valid ? cur_valid : prev_valid + ); + + tcolumn->set_nth(idx, trans); + } break; + case OP_DELETE: { + if (row_pre_existed) { + DATA_T prev_value = + *(scolumn->get_nth(rlookup.m_idx)); + bool prev_valid = scolumn->is_valid(rlookup.m_idx); + + pcolumn->set_nth(added_count, prev_value); + pcolumn->set_valid(added_count, prev_valid); + + ccolumn->set_nth(added_count, prev_value); + ccolumn->set_valid(added_count, prev_valid); + + SUPPRESS_WARNINGS_VC(4146) + dcolumn->set_nth(added_count, -prev_value); + RESTORE_WARNINGS_VC() + dcolumn->set_valid(added_count, true); + + tcolumn->set_nth( + added_count, VALUE_TRANSITION_NEQ_TDF + ); + } + } break; + default: { + PSP_COMPLAIN_AND_ABORT("Unknown OP"); + } + } + } +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/gnode_state.h b/rust/perspective-server/cpp/perspective/src/include/perspective/gnode_state.h new file mode 100644 index 0000000..148ee9b --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/gnode_state.h @@ -0,0 +1,339 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +std::pair +get_vec_min_max(const std::vector& vec); + +class PERSPECTIVE_EXPORT t_gstate { +public: + /** + * @brief A mapping of `t_tscalar` primary keys to `t_uindex` row indices. + */ + typedef tsl::hopscotch_map t_mapping; + + typedef tsl::hopscotch_set t_free_items; + + /** + * @brief Construct a new `t_gstate`, which manages the canonical state of + * the `t_gnode` and associated `Table`. + * + * Contexts use `t_gstate` to read out columns for serialization, + * calculate aggregates, and look up primary keys - the `t_gstate` will + * contain the *latest* state of the dataset managed by Perspective, + * after updates/removes/etc. are applied. + * + * @param input_schema + * @param output_schema + */ + t_gstate( + t_schema input_schema, + t_schema output_schema, + t_backing_store backing_store = BACKING_STORE_MEMORY + ); + + ~t_gstate(); + + void init(); + + /** + * @brief Look up a primary key in the `t_gstate`'s mapping of primary + * keys to row numbers. + * + * @param pkey + * @return t_rlookup a struct containing row index `m_idx` and `m_exists`, + * whether `pkey` exists at that row index. If `pkey` is not found, + * `m_idx` is 0 and `m_exists` is false. + */ + t_rlookup lookup(t_tscalar pkey) const; + + /** + * @brief If the master table has 0 rows, fill it using `flattened`. + * + * Aliases `flattened`'s column `shared_ptr`s into the master table instead + * of memcpy-cloning them; callers that still hold references to + * `flattened` must not mutate it after this call. + * + * @param flattened + */ + void fill_master_table(const std::shared_ptr& flattened); + + /** + * @brief Update the master `t_data_table` with the flattened and masked + * `t_data_table` after an `update` has been called and fully processed + * by `t_gnode::_process_table`. + * + * @param flattened + */ + void update_master_table(const std::shared_ptr& flattened); + + /** + * @brief Bulk-initialize the master table directly from `source`, bypassing + * the flatten/merge pipeline. + * + * Requires the master table to be empty, all rows in `source` to be + * `OP_INSERT`, and the `psp_pkey` column of `source` to contain no + * duplicates. Column `shared_ptr`s are aliased into the master table. + * + * @param source + */ + void init_from_table(const std::shared_ptr& source); + + /** + * @brief Given a column in the master data table and the corresponding + * column in the `flattened` data table, fill the master column with data + * from the flattened column. + * + * @param master_column + * @param flattened_column + */ + void update_master_column( + t_column* master_column, + const t_column* flattened_column, + const t_column* op_column, + const std::vector& master_table_indexes, + t_uindex num_rows + ); + + // Operations that use the gnode state's internal `m_mapping` of + // primary keys to row indices in order to access subsets of data from + // a column in a given `t_data_table`. Because these methods are used + // extensively in the context objects, and because expression columns are + // stored in tables on the context, these methods receive a pointer to + // a `t_data_table` - usually a pointer to the gnode state's master + // table or the expression table from the context. + + void resolve_pkeys( + const std::vector& pkeys, + std::vector& out_row_indices + ) const; + + t_tscalar read_by_pkey( + const t_data_table& table, const std::string& colname, t_tscalar& pkey + ) const; + + void read_column( + const t_data_table& table, + const std::string& colname, + const std::vector& pkeys, + std::vector& out_data + ) const; + + void read_column( + const t_data_table& table, + const std::string& colname, + const std::vector& pkeys, + std::vector& out_data + ) const; + + void read_column( + const t_data_table& table, + const std::string& colname, + const std::vector& pkeys, + std::vector& out_data, + bool include_nones + ) const; + + void read_column( + const t_data_table& table, + const std::string& colname, + t_uindex start_idx, + t_uindex end_idx, + std::vector& out_data + ) const; + + void read_column( + const t_data_table& table, + const std::string& colname, + const std::vector& row_indices, + std::vector& out_data + ) const; + + // Also called extensively in contexts during aggregate calculation + + bool apply( + const t_data_table& table, + const std::string& colname, + const std::vector& pkeys, + t_tscalar& value, + const std::function& fn + ) const; + + /** + * @brief Reduce the column's values at the specified primary keys, and + * return a single, reduced value. + * + * @tparam FN_T + * @param pkeys + * @param colname + * @param fn + * @return FN_T::result_type + */ + template + typename FN_T::result_type reduce( + const t_data_table& table, + const std::string& colname, + const std::vector& pkeys, + FN_T fn + ) const; + + bool is_unique( + const t_data_table& table, + const std::string& colname, + const std::vector& pkeys, + t_tscalar& value + ) const; + + /** + * @brief Returns the scalar value at column `colname` with primary key + * `pkey`. + * + * @param pkey + * @param colname + * @return t_tscalar + */ + t_tscalar + get(const t_data_table& table, const std::string& colname, t_tscalar pkey + ) const; + + /** + * @brief Get the scalar value for `pkey` in `colname`. + * + * @param pkey + * @param colname + * @return t_tscalar + */ + t_tscalar get_value( + const t_data_table& table, + const std::string& colname, + const t_tscalar& pkey + ) const; + + /** + * @brief Return the number of rows on the master `t_data_table`. + * + * @return t_uindex + */ + t_uindex num_rows() const; + + /** + * @brief Return the number of columns on the master `t_data_table`. + * + * @return t_uindex + */ + t_uindex num_columns() const; + + /** + * @brief Returns the size of the underlying primary key map. + * + * @return t_uindex + */ + t_uindex mapping_size() const; + + /** + * @brief Resets the gnode state and its master `t_data_table` and + * mapping. + * + */ + void reset(); + + // Getters + std::shared_ptr get_table() const; + std::shared_ptr get_pkeyed_table() const; + std::shared_ptr get_pkeyed_table( + const t_schema& schema, const std::shared_ptr& table + ) const; + + const t_schema& get_input_schema() const; + const t_schema& get_output_schema() const; + const t_mapping& get_pkey_map() const; + + std::vector + get_row_data_pkeys(const std::vector& pkeys) const; + + void pprint() const; + +protected: + /** + * @brief If the pkey exists in the state, return its row index. Otherwise, + * add a new row to the mapping and return its index. + * + * @param pkey + * @return t_uindex + */ + t_uindex lookup_or_create(const t_tscalar& pkey); + + /** + * @brief Clear the value at `pkey` for every column in the table. + * + * @param pkey + */ + void erase(const t_tscalar& pkey); + + /** + * @brief Generate a `t_mask` bitset set to true for every value in the + * underlying `m_mapping`. + * + * @return t_mask + */ + t_mask get_cpp_mask() const; + + void _mark_deleted(t_uindex idx); + bool has_pkey(t_tscalar pkey) const; + t_dtype get_pkey_dtype() const; + +private: + // Unused methods + std::vector get_pkeys_idx(const std::vector& pkeys + ) const; + std::vector has_pkeys(const std::vector& pkeys) const; + std::vector get_pkeys() const; + + t_schema m_input_schema; // pkeyed + t_schema m_output_schema; // tblschema + t_backing_store m_backing_store; + + bool m_init; + std::shared_ptr m_table; + t_mapping m_mapping; + t_free_items m_free; + t_symtable m_symtable; + std::shared_ptr m_pkcol; + std::shared_ptr m_opcol; +}; + +template +typename FN_T::result_type +t_gstate::reduce( + const t_data_table& table, + const std::string& colname, + const std::vector& pkeys, + FN_T fn +) const { + std::vector data; + read_column(table, colname, pkeys, data); + return fn(data); +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/heap_instruments.h b/rust/perspective-server/cpp/perspective/src/include/perspective/heap_instruments.h new file mode 100644 index 0000000..d90df7b --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/heap_instruments.h @@ -0,0 +1,59 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#include +#include +#include +#include + +// Don't track our own memory usage +template +class UnderlyingAllocator : std::allocator { +public: + using value_type = T; + + UnderlyingAllocator() = default; + + template + UnderlyingAllocator(const UnderlyingAllocator& /*unused*/) noexcept {} + + T* + allocate(std::size_t n) { + if (n > std::numeric_limits::max() / sizeof(T)) { + throw std::bad_alloc(); + } + void* ptr = emscripten_builtin_malloc(n * sizeof(T)); + if (ptr == nullptr) { + throw std::bad_alloc(); + } + return static_cast(ptr); + } + + // Deallocate memory + void + deallocate(T* ptr, std::size_t /*unused*/) noexcept { + emscripten_builtin_free(ptr); + } + + // Equality comparison (required for allocators) + template + bool + operator==(const UnderlyingAllocator& /*unused*/) const noexcept { + return true; + } + + template + bool + operator!=(const UnderlyingAllocator& other) const noexcept { + return !(*this == other); + } +}; diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/index.h b/rust/perspective-server/cpp/perspective/src/include/perspective/index.h new file mode 100644 index 0000000..be44e4d --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/index.h @@ -0,0 +1,21 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once + +namespace perspective { + +enum t_index_mode { INDEX_MODE_IMPLICIT, INDEX_MODE_BITMAP }; + +class t_index {}; + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/init.h b/rust/perspective-server/cpp/perspective/src/include/perspective/init.h new file mode 100644 index 0000000..f87eb38 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/init.h @@ -0,0 +1,19 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include + +namespace perspective { +void perspective_init(); +void perspective_finalize(); +} // namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/iterator.h b/rust/perspective-server/cpp/perspective/src/include/perspective/iterator.h new file mode 100644 index 0000000..a79a5ac --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/iterator.h @@ -0,0 +1,150 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include + +namespace perspective { + +template +class t_iter : public std::iterator< + std::random_access_iterator_tag, + DATA_T, + std::ptrdiff_t, + DATA_T*, + DATA_T&> { +public: + t_iter(DATA_T* ptr = nullptr) { m_ptr = ptr; } + +#ifndef WIN32 + t_iter(const t_iter& other) = default; + t_iter& operator=(const t_iter& other) = default; +#endif + + ~t_iter() {} + + t_iter& + operator=(DATA_T* ptr) { + m_ptr = ptr; + return (*this); + } + + operator bool() const { + if (m_ptr) { + return true; + } else { + return false; + } + } + + bool + operator==(const t_iter& other) const { + return (m_ptr == other.get_cptr()); + } + + bool + operator!=(const t_iter& other) const { + return (m_ptr != other.get_cptr()); + } + + t_iter& + operator+=(const std::ptrdiff_t& movement) { + m_ptr += movement; + return (*this); + } + + t_iter& + operator-=(const std::ptrdiff_t& movement) { + m_ptr -= movement; + return (*this); + } + + t_iter& + operator++() { + ++m_ptr; + return (*this); + } + + t_iter& + operator--() { + --m_ptr; + return (*this); + } + + t_iter + operator++(int) { + auto temp(*this); + ++m_ptr; + return temp; + } + + t_iter + operator--(int) { + auto temp(*this); + --m_ptr; + return temp; + } + + t_iter + operator+(const std::ptrdiff_t& movement) { + auto oldPtr = m_ptr; + m_ptr += movement; + auto temp(*this); + m_ptr = oldPtr; + return temp; + } + + t_iter + operator-(const std::ptrdiff_t& movement) { + auto oldPtr = m_ptr; + m_ptr -= movement; + auto temp(*this); + m_ptr = oldPtr; + return temp; + } + + std::ptrdiff_t + operator-(const t_iter& other) { + return std::distance(other.get_ptr(), this->get_ptr()); + } + + DATA_T& + operator*() { + return *m_ptr; + } + + const DATA_T& + operator*() const { + return *m_ptr; + } + + DATA_T* + operator->() { + return m_ptr; + } + + DATA_T* + get_ptr() const { + return m_ptr; + } + + const DATA_T* + get_cptr() const { + return m_ptr; + } + +protected: + DATA_T* m_ptr; +}; +} // namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/join_engine.h b/rust/perspective-server/cpp/perspective/src/include/perspective/join_engine.h new file mode 100644 index 0000000..064d05b --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/join_engine.h @@ -0,0 +1,101 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once + +#include "perspective/exports.h" +#include "perspective/raw_types.h" +#include "perspective/scalar.h" +#include "perspective/schema.h" +#include "perspective/table.h" +#include +#include +#include +#include +#include +#include + +namespace perspective::server { + +struct JoinDef { + std::string left_table_id; + std::string right_table_id; + std::string on_column; + std::string right_on_column; + proto::JoinType join_type; +}; + +struct MakeJoinResult { + std::shared_ptr
    table; + std::string error; + + bool + ok() const { + return error.empty(); + } +}; + +struct JoinCache { + tsl::hopscotch_map> right_index; + std::vector> right_entries; + bool valid = false; +}; + +class PERSPECTIVE_EXPORT JoinEngine { +public: + using t_id = std::string; + + void register_join( + const t_id& join_table_id, + const t_id& left_table_id, + const t_id& right_table_id, + const std::string& on_column, + const std::string& right_on_column, + proto::JoinType join_type + ); + + void unregister_join(const t_id& join_table_id); + bool is_join_table(const t_id& id) const; + std::vector get_dependent_join_tables(const t_id& source_table_id + ) const; + const JoinDef& get_join_def(const t_id& join_table_id) const; + + MakeJoinResult make_join_table( + const std::string& on_column, + const std::string& right_on_column, + proto::JoinType join_type, + const std::shared_ptr
    & left_table, + const std::shared_ptr
    & right_table + ); + + void recompute( + const t_id& join_table_id, + const std::shared_ptr
    & left_table, + const std::shared_ptr
    & right_table, + const std::shared_ptr
    & join_table, + bool left_changed = true, + bool right_changed = true + ); + +private: + void build_right_index( + JoinCache& cache, + const std::shared_ptr
    & right_table, + const std::string& on_column + ); + + tsl::hopscotch_map m_join_defs; + tsl::hopscotch_map m_caches; + std::multimap m_table_to_join_tables; +}; + +} // namespace perspective::server diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/kernel_engine.h b/rust/perspective-server/cpp/perspective/src/include/perspective/kernel_engine.h new file mode 100644 index 0000000..ddcb2dd --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/kernel_engine.h @@ -0,0 +1,63 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include + +#ifdef PSP_ENABLE_WASM +#include +#include +typedef emscripten::val t_kernel; +namespace em = emscripten; +#else +typedef perspective::t_str t_kernel; +#endif + +namespace perspective { + +class t_kernel_evaluator { +public: + t_kernel_evaluator(); + template + T reduce(const t_kernel& fn, t_uindex lvl_depth, std::vector data); + +private: + std::vector m_kernels; +}; + +#ifdef PSP_ENABLE_WASM +template +T +t_kernel_evaluator::reduce( + const t_kernel& fn, t_uindex lvl_depth, std::vector data +) { + auto arr = em::val(em::typed_memory_view(data.size(), data.data())); + return fn(arr, em::val(lvl_depth)).as(); +} + +#else +template +T +t_kernel_evaluator::reduce( + const t_kernel& fn, t_uindex lvl_depth, std::vector data +) { + PSP_COMPLAIN_AND_ABORT("Not implemented"); + return T(); +} +#endif + +t_kernel_evaluator* get_evaluator(); + +} // namespace perspective \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/last.h b/rust/perspective-server/cpp/perspective/src/include/perspective/last.h new file mode 100644 index 0000000..5d1354e --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/last.h @@ -0,0 +1,33 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once + +#ifdef PSP_ENABLE_PYTHON + +/* + Due to a very silly #define in python, this file needs + to come after any other perspective includes, which + themselves have to precede any arrow includes. + + The preprocessor is fun. + + https://bugs.python.org/issue24643 +*/ + +#ifdef WIN32 +#if _MSC_VER >= 1900 +#undef timezone +#endif +#endif // win32 + +#endif diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/mask.h b/rust/perspective-server/cpp/perspective/src/include/perspective/mask.h new file mode 100644 index 0000000..c22c90c --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/mask.h @@ -0,0 +1,81 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include + +namespace perspective { + +class t_mask_iterator; + +class PERSPECTIVE_EXPORT t_mask { + typedef boost::dynamic_bitset<>::size_type t_msize; + +public: + t_mask(); + t_mask(t_uindex size); + + t_mask(const t_simple_bitmask& m); + + ~t_mask(); + + void clear(); + t_uindex count() const; + bool get(t_uindex idx) const; + void set(t_uindex idx, bool v); + void set(t_uindex idx); + + t_mask& operator&=(const t_mask& b); + t_mask& operator|=(const t_mask& b); + t_mask& operator^=(const t_mask& b); + t_mask& operator-=(const t_mask& b); + + t_uindex find_first() const; + t_uindex find_next(t_uindex pos) const; + static const t_uindex m_npos = boost::dynamic_bitset<>::npos; + ; + t_uindex size() const; + void pprint() const; + +private: + boost::dynamic_bitset<> m_bitmap; +}; + +typedef std::shared_ptr t_masksptr; +typedef std::shared_ptr t_maskcsptr; + +class PERSPECTIVE_EXPORT t_mask_iterator { +public: + t_mask_iterator(); + t_mask_iterator(t_maskcsptr m); + ~t_mask_iterator(); + bool has_next() const; + t_uindex next(); + t_uindex size() const; + t_uindex count() const; + + static const t_uindex m_end = t_mask::m_npos; + +private: + t_maskcsptr m_mask; + t_uindex m_pos; +}; + +} // end namespace perspective + +namespace std { +std::ostream& operator<<(std::ostream& os, const perspective::t_mask& mask); +} diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/multi_sort.h b/rust/perspective-server/cpp/perspective/src/include/perspective/multi_sort.h new file mode 100644 index 0000000..be546d1 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/multi_sort.h @@ -0,0 +1,311 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +// Inline fixed-capacity vector for sort rows with small-buffer optimization. +// Uses a stack-allocated array for <= SBO_CAPACITY elements, falling back to +// heap allocation for larger sizes. All access is branchless via pointer +// indirection; the inline-vs-heap decision is made once at reserve() time. +struct PERSPECTIVE_EXPORT t_sortrow_vec { + static constexpr std::size_t SBO_CAPACITY = 8; + + t_sortrow_vec() : m_ptr(m_data.data()), m_alloc(nullptr), m_size(0) {} + + t_sortrow_vec(const std::vector& v) + : m_alloc(nullptr), m_size(v.size()) { + if (m_size > SBO_CAPACITY) { + m_alloc = new t_tscalar[m_size]; + m_ptr = m_alloc; + } else { + m_ptr = m_data.data(); + } + for (std::size_t i = 0; i < m_size; ++i) { + m_ptr[i] = v[i]; + } + } + + ~t_sortrow_vec() { delete[] m_alloc; } + + t_sortrow_vec(const t_sortrow_vec& o) + : m_data(o.m_data), m_alloc(nullptr), m_size(o.m_size) { + if (o.m_alloc) { + m_alloc = new t_tscalar[m_size]; + std::copy(o.m_ptr, o.m_ptr + m_size, m_alloc); + m_ptr = m_alloc; + } else { + m_ptr = m_data.data(); + } + } + + t_sortrow_vec& operator=(const t_sortrow_vec& o) { + if (this != &o) { + delete[] m_alloc; + m_alloc = nullptr; + m_size = o.m_size; + if (o.m_alloc) { + m_alloc = new t_tscalar[m_size]; + std::copy(o.m_ptr, o.m_ptr + m_size, m_alloc); + m_ptr = m_alloc; + } else { + m_data = o.m_data; + m_ptr = m_data.data(); + } + } + return *this; + } + + t_sortrow_vec(t_sortrow_vec&& o) noexcept + : m_data(o.m_data), m_alloc(o.m_alloc), m_size(o.m_size) { + if (m_alloc) { + m_ptr = m_alloc; + } else { + m_ptr = m_data.data(); + } + o.m_alloc = nullptr; + o.m_size = 0; + o.m_ptr = o.m_data.data(); + } + + t_sortrow_vec& operator=(t_sortrow_vec&& o) noexcept { + if (this != &o) { + delete[] m_alloc; + m_data = o.m_data; + m_alloc = o.m_alloc; + m_size = o.m_size; + if (m_alloc) { + m_ptr = m_alloc; + } else { + m_ptr = m_data.data(); + } + o.m_alloc = nullptr; + o.m_size = 0; + o.m_ptr = o.m_data.data(); + } + return *this; + } + + void reserve(std::size_t capacity) { + if (capacity > SBO_CAPACITY && !m_alloc) { + m_alloc = new t_tscalar[capacity]; + m_ptr = m_alloc; + } + } + + void push_back(const t_tscalar& v) { m_ptr[m_size++] = v; } + + const t_tscalar& operator[](std::size_t i) const { return m_ptr[i]; } + t_tscalar& operator[](std::size_t i) { return m_ptr[i]; } + + std::size_t size() const { return m_size; } + + const t_tscalar* begin() const { return m_ptr; } + const t_tscalar* end() const { return m_ptr + m_size; } + + std::array m_data; + t_tscalar* m_ptr; + t_tscalar* m_alloc; + std::uint8_t m_size; +}; + +struct PERSPECTIVE_EXPORT t_mselem { + t_mselem(); + t_mselem(const std::vector& row); + t_mselem(const std::vector& row, t_uindex order); + t_mselem(const t_tscalar& pkey, const std::vector& row); + t_mselem(const t_mselem& other) = default; + t_mselem(t_mselem&& other) noexcept = default; + t_mselem& operator=(const t_mselem& other) = default; + t_mselem& operator=(t_mselem&& other) noexcept = default; + + t_sortrow_vec m_row; + t_tscalar m_pkey; + t_uindex m_order; + bool m_deleted; + bool m_updated; +}; + +} // end namespace perspective + +namespace std { + +inline std::ostream& +operator<<(std::ostream& os, const perspective::t_sortrow_vec& v) { + os << "["; + for (std::size_t i = 0; i < v.size(); ++i) { + if (i > 0) { + os << ", "; + } + os << v[i]; + } + os << "]"; + return os; +} + +inline std::ostream& +operator<<(std::ostream& os, const perspective::t_mselem& t) { + os << "mse " << t.m_pkey << " row => " << t.m_row << " deleted => " + << t.m_deleted << " order => " << t.m_order << ">"; + return os; +} + +} // end namespace std + +namespace perspective { + +inline void +swap(t_mselem& a, t_mselem& b) { + t_mselem c(std::move(a)); + a = std::move(b); + b = std::move(c); +} + +struct PERSPECTIVE_EXPORT t_minmax_idx { + t_minmax_idx(t_index mn, t_index mx); + + t_index m_min; + t_index m_max; +}; + +// Given a vector return the indices of the +// minimum and maximum elements in it. +PERSPECTIVE_EXPORT t_minmax_idx +get_minmax_idx(const std::vector& vec, t_sorttype stype); + +PERSPECTIVE_EXPORT double to_double(const t_tscalar& c); + +struct PERSPECTIVE_EXPORT t_nancmp { + t_nancmp(); + + bool m_active; + t_cmp_op m_cmpval; +}; + +PERSPECTIVE_EXPORT t_nancmp +nan_compare(t_sorttype order, const t_tscalar& a, const t_tscalar& b); + +inline PERSPECTIVE_EXPORT bool +cmp_mselem( + const t_mselem& a, + const t_mselem& b, + const std::vector& sort_order +) { + typedef std::pair dpair; + + if (a.m_row.size() != b.m_row.size() + || a.m_row.size() != sort_order.size()) { + std::cout << "ERROR detected in MultiSort." + << "\n"; + return false; + } + + t_tscalar first_pkey = a.m_pkey; + t_tscalar second_pkey = b.m_pkey; + + for (int idx = 0, loop_end = sort_order.size(); idx < loop_end; ++idx) { + const t_tscalar& first = a.m_row[idx]; + const t_tscalar& second = b.m_row[idx]; + + t_sorttype order = sort_order[idx]; + + if (first.is_floating_point() || second.is_floating_point()) { + t_nancmp nancmp = nan_compare(order, first, second); + + if (nancmp.m_active) { + switch (nancmp.m_cmpval) { + case CMP_OP_LT: { + return true; + } break; + case CMP_OP_GT: { + return false; + } break; + case CMP_OP_EQ: + default: { + continue; + } break; + } + } + } + + if (first == second) { + continue; + } + + switch (order) { + case SORTTYPE_ASCENDING: { + return (first < second); + } break; + case SORTTYPE_DESCENDING: { + return (first > second); + } break; + case SORTTYPE_ASCENDING_ABS: { + double val_a = first.to_double(); + double val_b = second.to_double(); + return dpair(std::abs(val_a), first_pkey) + < dpair(std::abs(val_b), second_pkey); + } break; + case SORTTYPE_DESCENDING_ABS: { + double val_a = first.to_double(); + double val_b = second.to_double(); + return dpair(std::abs(val_a), first_pkey) + > dpair(std::abs(val_b), second_pkey); + } break; + case SORTTYPE_NONE: { + return first_pkey < second_pkey; + } + } + } + + if (a.m_order != b.m_order) { + return a.m_order < b.m_order; + } + + return first_pkey < second_pkey; +} + +inline PERSPECTIVE_EXPORT bool +cmp_mselem( + const t_mselem* a, + const t_mselem* b, + const std::vector& sort_order +) { + return cmp_mselem(*a, *b, sort_order); +} + +// Helper for sorting taking multiple sort specifications +// into account +struct PERSPECTIVE_EXPORT t_multisorter { + t_multisorter(const std::vector& order); + + t_multisorter( + std::shared_ptr> elems, + const std::vector& order + ); + + bool operator()(const t_mselem& a, const t_mselem& b) const; + + bool operator()(t_index a, t_index b) const; + + std::vector m_sort_order; + std::shared_ptr> m_elems; +}; + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/node_processor.h b/rust/perspective-server/cpp/perspective/src/include/perspective/node_processor.h new file mode 100644 index 0000000..4d39106 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/node_processor.h @@ -0,0 +1,162 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +template +struct t_pivot_processor { + typedef t_chunk_value_span t_spans; + typedef std::vector t_spanvec; + typedef std::vector t_spanvvec; + typedef std::map> + t_map; + + // For now we dont do any inter node + // parallelism. this should be trivial + // to fix in the future. + t_uindex operator()( + const t_column* data, + std::vector* nodes, + t_column* values, + t_column* leaves, + t_uindex nbidx, + t_uindex neidx, + const t_mask* mask + ); +}; + +template +t_uindex +t_pivot_processor::operator()( + const t_column* data, + std::vector* nodes, + t_column* values, + + t_column* leaves, + t_uindex nbidx, + t_uindex neidx, + const t_mask* mask +) { + + t_lstore lcopy(leaves->data_lstore(), t_lstore_tmp_init_tag()); + + // add accessor api and move these to that + t_uindex* leaves_ptr = leaves->get_nth(0); + t_uindex* lcopy_ptr = lcopy.get_nth(0); + t_uindex lvl_nidx = neidx; + t_uindex offset = 0; + + for (t_uindex nidx = nbidx; nidx < neidx; ++nidx) { + t_dense_tnode* pnode = &nodes->at(nidx); + t_uindex cbidx = pnode->m_flidx; + t_uindex ceidx = pnode->m_flidx + pnode->m_nleaves; + t_uindex parent_idx = pnode->m_idx; + t_spanvvec spanvec; + { + t_spanvec spans; + partition(data, leaves, cbidx, ceidx, spans); + spanvec.push_back(spans); + } + + // map value to number of rows with value + t_map globcount((t_comparator())); + + for (t_index idx = 0, loop_end = spanvec.size(); idx < loop_end; + ++idx) { + const t_spanvec& sp = spanvec[idx]; + for (t_uindex spidx = 0, sp_loop_end = sp.size(); + spidx < sp_loop_end; + ++spidx) { + const t_spans& vsp = sp[spidx]; + auto miter = globcount.find(vsp.m_value); + if (miter == globcount.end()) { + globcount[vsp.m_value] = vsp.m_eidx - vsp.m_bidx; + } else { + miter->second = miter->second + vsp.m_eidx - vsp.m_bidx; + } + } + } + + // map value to leaf offset + t_map globcursor((t_comparator())); + + for (typename t_map::const_iterator miter = globcount.begin(), + loop_end = globcount.end(); + miter != loop_end; + ++miter) { + globcursor[miter->first] = offset; + offset += miter->second; + } + + auto running_cursor = globcursor; + for (t_index idx = 0, loop_end = spanvec.size(); idx < loop_end; + ++idx) { + const t_spanvec& sp = spanvec[idx]; + for (t_index spidx = 0, sp_loop_end = sp.size(); + spidx < sp_loop_end; + ++spidx) { + const auto& cvs = sp[spidx]; + t_uindex voff = running_cursor[cvs.m_value]; + memcpy( + lcopy_ptr + voff, + leaves_ptr + cvs.m_bidx, + sizeof(t_uindex) * (cvs.m_eidx - cvs.m_bidx) + ); + + running_cursor[cvs.m_value] = voff + cvs.m_eidx - cvs.m_bidx; + } + } + + // Update current node + pnode->m_fcidx = lvl_nidx; + pnode->m_nchild = globcount.size(); + + for (typename t_map::const_iterator miter = globcursor.begin(), + loop_end = globcursor.end(); + miter != loop_end; + ++miter) { + nodes->push_back( + {lvl_nidx, + parent_idx, + 0, + 0, + miter->second, + globcount[miter->first]} + ); + lvl_nidx += 1; + values->push_back(miter->first); + } + } + + t_lstore* llstore = leaves->_get_data_lstore(); + + memcpy(leaves_ptr, lcopy_ptr, llstore->size()); + + return lvl_nidx; +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/node_processor_types.h b/rust/perspective-server/cpp/perspective/src/include/perspective/node_processor_types.h new file mode 100644 index 0000000..b78708c --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/node_processor_types.h @@ -0,0 +1,47 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include + +namespace perspective { + +template +struct t_chunk_value_span { + DATA_T m_value; + t_uindex m_bidx; + t_uindex m_eidx; +}; + +template +void +fill_chunk_value_span( + t_chunk_value_span& cvs, DATA_T value, t_uindex bidx, t_uindex eidx +) { + cvs.m_value = value; + cvs.m_bidx = bidx; + cvs.m_eidx = eidx; +} + +template +void +fill_chunk_value_span( + t_chunk_value_span* cvs, DATA_T value, t_uindex bidx, t_uindex eidx +) { + cvs->m_value = value; + cvs->m_bidx = bidx; + cvs->m_eidx = eidx; +} +} // namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/none.h b/rust/perspective-server/cpp/perspective/src/include/perspective/none.h new file mode 100644 index 0000000..84083ca --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/none.h @@ -0,0 +1,79 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include + +namespace perspective { +struct PERSPECTIVE_EXPORT t_none { + t_none(); + + template + bool operator==(const DATA_T&) const; + bool operator==(const t_none&) const; + + template + bool operator<(const DATA_T&) const; + bool operator<(const t_none&) const; + + template + bool operator>(const DATA_T&) const; + bool operator>(const t_none&) const; + + template + bool operator>=(const DATA_T&) const; + bool operator>=(const t_none&) const; + + template + bool operator<=(const DATA_T&) const; + bool operator<=(const t_none&) const; +}; + +template +bool +t_none::operator==(const DATA_T&) const { + return false; +} + +template +bool +t_none::operator<(const DATA_T&) const { + return true; +} + +template +bool +t_none::operator<=(const DATA_T&) const { + return true; +} + +template +bool +t_none::operator>(const DATA_T&) const { + return false; +} + +template +bool +t_none::operator>=(const DATA_T&) const { + return false; +} + +size_t hash_value(const t_none& none); + +} // end namespace perspective + +namespace std { +std::ostream& operator<<(std::ostream& os, const perspective::t_none& dt); +} diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/opfs.h b/rust/perspective-server/cpp/perspective/src/include/perspective/opfs.h new file mode 100644 index 0000000..282c58d --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/opfs.h @@ -0,0 +1,43 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once + +// ┌─────────────────────────────────────────────────────────────────────────┐ +// │ Custom OPFS bridge (WASM, path 3) │ +// │ │ +// │ On WASM, a `BACKING_STORE_DISK` column's bytes live in a resident `malloc`│ +// │ buffer (`m_base`). The residency manager flushes that buffer to / reloads │ +// │ it from the browser's OPFS through these three imports, implemented in JS │ +// │ by `perspective-server.poly.ts` using `FileSystemSyncAccessHandle`. │ +// │ │ +// │ The OPFS *open* (`createSyncAccessHandle`) is async, so these imports are │ +// │ JSPI-**suspending**: they may ONLY be called from a `WebAssembly.promising`│ +// │ export — i.e. the residency safepoint / pre-request restore entry points, │ +// │ never from the synchronous `psp_handle_request`. This keeps the engine's │ +// │ request path synchronous while avoiding SharedArrayBuffer/COOP-COEP. │ +// │ │ +// │ `name` is the column's OPFS file key (`m_fname`); `data`/`len` is the │ +// │ resident buffer. Return value is bytes transferred, or negative on error. │ +// └─────────────────────────────────────────────────────────────────────────┘ + +#if defined(PSP_ENABLE_WASM) && defined(PSP_WASM_OPFS_PERSIST) + +extern "C" { +int psp_opfs_store(const char* name, const void* data, int len); +int psp_opfs_load(const char* name, void* data, int len); +void psp_opfs_remove(const char* name); +} + +#define PSP_HAS_OPFS_BRIDGE 1 + +#endif diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/parallel_for.h b/rust/perspective-server/cpp/perspective/src/include/perspective/parallel_for.h new file mode 100644 index 0000000..8ce5188 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/parallel_for.h @@ -0,0 +1,54 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once + +#ifdef PSP_PARALLEL_FOR +#include "base.h" +#include +#include +#include +#else +#include "raw_types.h" +#endif + +namespace perspective { + +template +void +parallel_for(int num_tasks, FUNCTION&& func) { +#ifdef PSP_PARALLEL_FOR + std::exception_ptr e; + std::mutex e_mtx; + const auto rethrow_wrapper = [&](int64_t task) { + try { + return func(task); + } catch (...) { + std::lock_guard lg(e_mtx); + e = std::current_exception(); + } + }; + auto status = arrow::internal::ParallelFor(num_tasks, rethrow_wrapper); + if (!status.ok()) { + PSP_COMPLAIN_AND_ABORT("ParallelFor failed"); + } + if (e) { + std::rethrow_exception(e); + } +#else + for (t_uindex aggnum = 0; aggnum < num_tasks; ++aggnum) { + func(aggnum); + } +#endif +} + +} // namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/partition.h b/rust/perspective-server/cpp/perspective/src/include/perspective/partition.h new file mode 100644 index 0000000..a0ef329 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/partition.h @@ -0,0 +1,145 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include +#include +#include + +/* +TODO improvements + +1. Remove allocations in partition using one of the +following +approaches + + a. Direct mapping tid offset into global buffer + b. TLS + c. ...? +*/ + +namespace perspective { + +template +struct t_argsort_cmp { + t_argsort_cmp(const DATA_T* base) : m_base(base) {} + + inline bool + operator()(t_uindex a, t_uindex b) { + return (*(m_base + a)) < (*(m_base + b)); + } + + const DATA_T* m_base; +}; + +template +inline void +argsort(const DATA_T* b, std::vector& output) { + // Output should be the same size is erange-brange + for (t_index i = 0, loop_end = output.size(); i != loop_end; ++i) { + output[i] = i; + } + t_argsort_cmp cmp(const_cast(b)); + std::sort(output.begin(), output.end(), cmp); +} + +inline void +partition( + const t_column* PSP_RESTRICT data_, + t_column* PSP_RESTRICT leaves_, + t_uindex bidx, + t_uindex eidx, + std::vector>& out_spans +) { + t_uindex* leaves = leaves_->get_nth(0); + typedef t_chunk_value_span t_cvs; + t_uindex nelems = eidx - bidx; + switch (nelems) { + case 0: + break; + case 1: { + out_spans.push_back(t_cvs()); + t_cvs& c = out_spans[0]; + fill_chunk_value_span( + c, data_->get_scalar(leaves[bidx]), bidx, eidx + ); + } break; + default: { + std::vector buf(nelems); + for (t_uindex idx = 0; idx < nelems; ++idx) { + buf[idx] = data_->get_scalar(leaves[bidx + idx]); + } + + std::vector order(nelems); + + t_tscalar* buf_addr = &buf[0]; + argsort(buf_addr, order); + std::vector temp_leaves(nelems); + for (t_uindex j = 0; j < nelems; ++j) { + temp_leaves[j] = leaves[bidx + order[j]]; + } + + std::vector sdata(nelems); + std::vector edges; + auto old_value = buf[order[0]]; + + sdata[0] = old_value; + for (t_uindex i = 0; i < nelems; ++i) { + auto new_value = buf[order[i]]; + if (old_value != new_value) { + sdata[i] = new_value; + edges.push_back(i); + } + old_value = new_value; + } + + if (edges.empty()) { + out_spans.push_back(t_cvs()); + t_cvs& c = out_spans.back(); + fill_chunk_value_span(c, sdata[0], bidx, eidx); + } else { + t_tscalar value; + std::vector boundaries; + boundaries.push_back(0); + boundaries.insert(boundaries.end(), edges.begin(), edges.end()); + boundaries.push_back(order.size()); + + for (t_uindex i = 0, loop_end = boundaries.size() - 1; + i < loop_end; + ++i) { + t_uindex begin = boundaries[i]; + t_uindex end = boundaries[i + 1]; + value = sdata[begin]; + t_uindex num_new_bytes = sizeof(t_uindex) * (end - begin); + memcpy( + leaves + begin + bidx, + &temp_leaves[begin], + num_new_bytes + ); + out_spans.push_back(t_cvs()); + t_cvs& cvs = out_spans.back(); + fill_chunk_value_span( + cvs, value, bidx + begin, bidx + end + ); + } + } + } + + break; + } +} + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/path.h b/rust/perspective-server/cpp/perspective/src/include/perspective/path.h new file mode 100644 index 0000000..e51ef6f --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/path.h @@ -0,0 +1,30 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include + +namespace perspective { + +struct PERSPECTIVE_EXPORT t_path { + t_path(); + t_path(const std::vector& path); + + const std::vector& path() const; + std::vector& path(); + std::vector m_path; +}; + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/pivot.h b/rust/perspective-server/cpp/perspective/src/include/perspective/pivot.h new file mode 100644 index 0000000..aae9818 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/pivot.h @@ -0,0 +1,48 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once + +#include +#include +#include +#include + +namespace perspective { + +struct PERSPECTIVE_EXPORT t_pivot_recipe { + t_pivot_recipe() {} + std::string m_colname; + std::string m_name; + t_pivot_mode m_mode; +}; + +class PERSPECTIVE_EXPORT t_pivot { +public: + t_pivot(const t_pivot_recipe& r); + t_pivot(const std::string& colname); + t_pivot(const std::string& colname, t_pivot_mode mode); + + const std::string& name() const; + const std::string& colname() const; + + t_pivot_mode mode() const; + + t_pivot_recipe get_recipe() const; + +private: + std::string m_colname; + std::string m_name; + t_pivot_mode m_mode; +}; + +} // namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/pool.h b/rust/perspective-server/cpp/perspective/src/include/perspective/pool.h new file mode 100644 index 0000000..209857d --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/pool.h @@ -0,0 +1,119 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include +#include +#ifdef PSP_PARALLEL_FOR +#include +#include +#endif + +namespace perspective { + +struct PERSPECTIVE_EXPORT t_updctx { + t_updctx(); + t_updctx(t_uindex gnode_id, std::string ctx); + + t_uindex m_gnode_id; + std::string m_ctx; +}; + +class t_update_task; + +class PERSPECTIVE_EXPORT t_pool { + friend class t_update_task; + using t_ctx_id = std::pair; + +public: + PSP_NON_COPYABLE(t_pool); + + t_pool(); + t_uindex register_gnode(t_gnode* node); + + // #if defined PSP_ENABLE_WASM || defined PSP_ENABLE_PYTHON + // void set_update_delegate(t_val ud); + // #endif + + void register_context( + t_uindex gnode_id, + const std::string& name, + t_ctx_type type, + std::uintptr_t ptr + ); + +#ifdef PSP_PARALLEL_FOR + std::shared_mutex* get_lock() const; +#endif + + /** + * @brief Call the binding language's `update_callback` method, + * set at initialize time. + * + * @param port_id + */ + void notify_userspace(t_uindex port_id); + + ~t_pool(); + + void unregister_gnode(t_uindex idx); + + void unregister_context(t_uindex gnode_id, const std::string& name); + + void send(t_uindex gnode_id, t_uindex port_id, const t_data_table& table); + + void _process( + std::optional> callback = + std::nullopt + ); + + void init(); + void stop(); + void set_sleep(t_uindex ms); + std::vector get_trees(); + + bool get_data_remaining() const; + std::vector get_contexts_last_updated(); + std::string repr() const; + + void pprint_registered() const; + + t_uindex epoch() const; + void inc_epoch(); + std::vector get_gnodes_last_updated(); + t_gnode* get_gnode(t_uindex idx); + +protected: + // Unused methods + std::vector + get_row_data_pkeys(t_uindex gnode_id, const std::vector& pkeys); + + // Following three functions + // use the python api + bool validate_gnode_id(t_uindex gnode_id) const; + +private: +#ifdef PSP_PARALLEL_FOR + std::shared_mutex* m_lock; +#endif + std::vector m_gnodes; + std::atomic_flag m_run; + std::atomic m_data_remaining; + std::atomic m_sleep; + std::atomic m_epoch; +}; + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/port.h b/rust/perspective-server/cpp/perspective/src/include/perspective/port.h new file mode 100644 index 0000000..73c8a83 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/port.h @@ -0,0 +1,51 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include + +namespace perspective { + +enum t_port_mode { + PORT_MODE_RAW, // no pkeys in incoming + PORT_MODE_PKEYED, // pkeys and op present +}; + +class PERSPECTIVE_EXPORT t_port { +public: + t_port(t_port_mode mode, t_schema schema); + ~t_port(); + void init(); + std::shared_ptr get_table(); + void set_table(std::shared_ptr table); + + // append to existing table + void send(const std::shared_ptr& table); + void send(const t_data_table& table); + + t_schema get_schema() const; + + void release(); + void release_or_clear(); + void clear(); + +private: + // t_port_mode m_mode; + t_schema m_schema; + bool m_init; + std::shared_ptr m_table; + t_uindex m_prevsize; +}; + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/portable.h b/rust/perspective-server/cpp/perspective/src/include/perspective/portable.h new file mode 100644 index 0000000..f647943 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/portable.h @@ -0,0 +1,55 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include + +// standalone build, no ASGUtils +#if defined(__GNUC__) +#define __ALWAYS_INLINE__ __attribute__((always_inline)) +#ifndef NOINLINE +#define NOINLINE __attribute__((noinline)) +#endif // NOINLINE + +#ifndef NORETURN +#define NORETURN __attribute__((__noreturn__)) void +#endif // NORETURN + +#define UNUSED __attribute__((unused)) +#define PRAGMA_GCC(X_) _Pragma(#X_) +#define PRAGMA_VC(X_) +#else // __GNUC__ + +#define __ALWAYS_INLINE__ __forceinline + +// TODO: Resolve collision on NOINLINE and NORETURN with fxalib +#ifndef NOINLINE +#define NOINLINE __declspec(noinline) +#endif + +#ifndef NORETURN +#define NORETURN __declspec(noreturn) void +#endif + +#define UNUSED +#define PRAGMA_GCC(X_) +#define PRAGMA_VC(X_) __pragma(X_) + +#endif // else + +#define SUPPRESS_WARNINGS_GCC(X_) \ + PRAGMA_GCC(GCC diagnostic push) PRAGMA_GCC(GCC diagnostic ignored #X_) +#define RESTORE_WARNINGS_GCC() PRAGMA_GCC(GCC diagnostic pop) + +#define SUPPRESS_WARNINGS_VC(X_) \ + PRAGMA_VC(warning(push)) PRAGMA_VC(warning(disable : X_)) +#define RESTORE_WARNINGS_VC() PRAGMA_VC(warning(pop)) diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/process_state.h b/rust/perspective-server/cpp/perspective/src/include/perspective/process_state.h new file mode 100644 index 0000000..b36f2ab --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/process_state.h @@ -0,0 +1,65 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include + +namespace perspective { + +/** + * @brief Manages the intermediate data structures and transitional + * `t_data_table`s associated with a single call to `t_gnode::_process_table`. + */ +struct t_process_state { + t_process_state(); + + /** + * @brief Clear each transitional `t_data_table`, i.e. all tables except + * `flattened` and `state`. + */ + void clear_transitional_data_tables() const; + + /** + * @brief Reserve `size` elements for each transitional table in the state. + * + * @param size + */ + void reserve_transitional_data_tables(t_uindex size) const; + + /** + * @brief For each transitional table in the state, set its size to `size`. + * + * @param size + */ + void set_size_transitional_data_tables(t_uindex size) const; + + std::shared_ptr m_state_data_table; + std::shared_ptr m_flattened_data_table; + std::shared_ptr m_delta_data_table; + std::shared_ptr m_prev_data_table; + std::shared_ptr m_current_data_table; + std::shared_ptr m_transitions_data_table; + std::shared_ptr m_existed_data_table; + + std::vector m_lookup; + std::vector m_col_translation; + std::vector m_added_offset; + std::vector m_prev_pkey_eq_vec; + + std::uint8_t* m_op_base; +}; + +} // end namespace perspective \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/pyutils.h b/rust/perspective-server/cpp/perspective/src/include/perspective/pyutils.h new file mode 100644 index 0000000..3a8e5c7 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/pyutils.h @@ -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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include + +#ifdef PSP_PARALLEL_FOR +#include +#include +#endif + +// #ifdef PSP_ENABLE_PYTHON +// #include +// #endif + +namespace perspective { + +#ifdef PSP_PARALLEL_FOR +// Use this when you want to acquire the reader lock without unlocking the GIL. +#define PSP_GIL_READ_LOCK(X) + +#define PSP_GIL_WRITE_LOCK(X) + +#define PSP_READ_LOCK(X) std::shared_lock _lock(X); +#define PSP_WRITE_LOCK(X) std::unique_lock _lock(X); +#else +#define PSP_GIL_READ_LOCK(X) +#define PSP_GIL_WRITE_LOCK(X) +#define PSP_READ_LOCK(X) +#define PSP_WRITE_LOCK(X) +#endif + +#ifdef PSP_ENABLE_PYTHON +class PERSPECTIVE_EXPORT PerspectiveGILUnlock { +public: + PerspectiveGILUnlock(); + ~PerspectiveGILUnlock(); + +private: + // PyThreadState* m_thread_state; +}; + +#define PSP_GIL_UNLOCK() +#else +#define PSP_GIL_UNLOCK() +#endif + +} // namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/raii.h b/rust/perspective-server/cpp/perspective/src/include/perspective/raii.h new file mode 100644 index 0000000..289eb3e --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/raii.h @@ -0,0 +1,45 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include + +namespace perspective { + +#ifndef PSP_ENABLE_WASM +class t_file_handle { + t_handle m_value; + +public: + t_file_handle(t_handle value); + ~t_file_handle(); + t_handle value() const; + bool valid() const; + void release(); +}; +#endif + +class t_mmap_handle { + void* m_value; + t_uindex m_len; + +public: + t_mmap_handle(void* value, t_uindex len); + ~t_mmap_handle(); + void* value(); + t_uindex len() const; + bool valid(); +}; + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/range.h b/rust/perspective-server/cpp/perspective/src/include/perspective/range.h new file mode 100644 index 0000000..7a0fc81 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/range.h @@ -0,0 +1,63 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include + +namespace perspective { + +class PERSPECTIVE_EXPORT t_range { +public: + t_range(t_uindex bridx, t_uindex eridx); + + t_range(t_uindex bridx, t_uindex eridx, t_uindex bcidx, t_uindex ecidx); + + t_range(); // select all + + t_range( + const std::vector& brpath, + const std::vector& erpath + ); + + t_range( + const std::vector& brpath, + const std::vector& erpath, + const std::vector& bcpath, + const std::vector& ecpath + ); + + t_range(const std::string& expr_name); + + t_uindex bridx() const; + t_uindex eridx() const; + t_uindex bcidx() const; + t_uindex ecidx() const; + t_range_mode get_mode() const; + +private: + t_uindex m_bridx; + t_uindex m_eridx; + t_uindex m_bcidx; + t_uindex m_ecidx; + std::vector m_brpath; + std::vector m_erpath; + std::vector m_bcpath; + std::vector m_ecpath; + std::string m_expr_name; + t_range_mode m_mode; +}; + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/raw_types.h b/rust/perspective-server/cpp/perspective/src/include/perspective/raw_types.h new file mode 100644 index 0000000..cb888b5 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/raw_types.h @@ -0,0 +1,159 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +// Cannot include anything else in +// perspective apart from first.h +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +enum t_status : std::uint8_t { STATUS_INVALID, STATUS_VALID, STATUS_CLEAR }; + +class t_date; +class t_time; + +enum t_dtype { + DTYPE_NONE, + DTYPE_INT64, + DTYPE_INT32, + DTYPE_INT16, + DTYPE_INT8, + DTYPE_UINT64, + DTYPE_UINT32, + DTYPE_UINT16, + DTYPE_UINT8, + DTYPE_FLOAT64, + DTYPE_FLOAT32, + DTYPE_BOOL, + DTYPE_TIME, + DTYPE_DATE, + DTYPE_ENUM, + DTYPE_OID, + DTYPE_OBJECT, + DTYPE_F64PAIR, + DTYPE_USER_FIXED, + DTYPE_STR, + DTYPE_USER_VLEN, + DTYPE_LAST_VLEN, + DTYPE_LAST +}; + +#ifdef __wasm32__ +typedef std::uint32_t t_uindex; +typedef std::int32_t t_index; +#else +typedef std::uint64_t t_uindex; +typedef std::int64_t t_index; +#endif + +#ifdef WIN32 +typedef std::uint32_t t_fflag; +typedef void* t_handle; +#else +typedef std::int32_t t_fflag; +typedef std::int32_t t_handle; +#endif + +typedef std::uint32_t t_depth; + +struct t_extent_pair { + t_uindex m_begin; + t_uindex m_end; +}; + +struct t_column_static_ctx { + std::string m_colname; + t_dtype m_dtype; +}; + +struct t_column_dynamic_ctx { + void* m_base; + std::uint8_t* m_status; + t_extent_pair* m_extents_base; + unsigned char* m_vlendata_base; + void* m_opaque; +}; + +struct t_table_static_ctx { + std::vector m_columns; + + inline std::vector + get_colnames() const { + std::vector rval; + for (const auto& c : m_columns) { + rval.push_back(c.m_colname); + } + return rval; + } +}; + +template +struct t_accumulation_type { + using type = T; +}; + +template <> +struct t_accumulation_type { + using type = std::int64_t; +}; + +template <> +struct t_accumulation_type { + using type = std::int64_t; +}; + +template <> +struct t_accumulation_type { + using type = std::int64_t; +}; + +template <> +struct t_accumulation_type { + using type = std::int64_t; +}; +template <> +struct t_accumulation_type { + using type = std::int64_t; +}; +template <> +struct t_accumulation_type { + using type = std::int64_t; +}; +template <> +struct t_accumulation_type { + using type = std::int64_t; +}; + +template <> +struct t_accumulation_type { + using type = double; +}; + +template <> +struct t_accumulation_type { + using type = std::int64_t; +}; + +struct t_tscalar; + +typedef tsl::hopscotch_map t_pkey_mapping; + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/regex.h b/rust/perspective-server/cpp/perspective/src/include/perspective/regex.h new file mode 100644 index 0000000..fef5721 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/regex.h @@ -0,0 +1,45 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once + +#include +#include +#include +#include +#include + +namespace perspective { + +/** + * @brief Caches RE2 objects by pattern string. + */ +struct PERSPECTIVE_EXPORT t_regex_mapping { + + /** + * @brief Given a regex pattern string, store it in the map if it does not + * exist already, and return a raw pointer to the stored RE2 object which + * will be valid as long as the mapping is alive. + * + * @param pattern + * @return RE2& + */ + RE2* intern(const std::string& pattern); + + void clear(); + + // Store pointers to RE2 objects as the default copy assignment operator + // for RE2 is disabled. + tsl::hopscotch_map> m_regex_map; +}; + +} // end namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/residency.h b/rust/perspective-server/cpp/perspective/src/include/perspective/residency.h new file mode 100644 index 0000000..696369f --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/residency.h @@ -0,0 +1,90 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace perspective { + +class t_lstore; + +// Hot-path flags read by `t_lstore::ensure_resident()`. Plain globals so the +// common (residency-disabled) case is a single predicted-not-taken branch with +// no indirection. Owned/updated by `t_residency_manager`. +PERSPECTIVE_EXPORT extern bool g_residency_active; +PERSPECTIVE_EXPORT extern std::uint64_t g_residency_tick; + +/** + * @brief Bounds the resident (in linear-memory) footprint of `BACKING_STORE_DISK` + * column buffers, evicting the coldest ones to their backing files when over a + * configured byte budget and transparently restoring them on next access. + * + * This is what turns on-disk backing into actual *memory relief* on WASM, where + * there is no demand paging: an evicted store's `m_base` buffer is freed (its + * data flushed to the backing file) and re-read on demand. Eviction runs only at + * request *safepoints* (between requests, when no live raw column pointer + * exists — see the safepoint audit), and restoration is lazy via + * `ensure_resident()` on every `t_lstore` data accessor. + * + * Disabled by default (zero overhead). Enabled by setting the `PSP_MEMORY_BUDGET` + * environment variable (bytes), re-read at each safepoint. + */ +class PERSPECTIVE_EXPORT t_residency_manager { +public: + static t_residency_manager& inst(); + + // Disk-backed stores register on `init()` and unregister on destruction. + void register_store(t_lstore* store); + void unregister_store(t_lstore* store); + + std::size_t prepare(); + const char* victim_fname(std::size_t i) const; + void commit(); + void safepoint(); + + // Total bytes currently resident across registered disk-backed stores. + std::size_t resident_bytes(); + + bool active() const { return g_residency_active; } + std::size_t budget() const { return m_budget; } + std::uint64_t evictions() const { return m_evictions; } + std::uint64_t restores() const { return m_restores; } + + // Serializes lazy restores (which may occur from parallel-for workers within + // a request) against each other and the safepoint eviction pass. + std::mutex& mutex() { return m_mutex; } + void note_restore() { ++m_restores; } + +private: + t_residency_manager() = default; + void refresh_config(); + + std::size_t m_budget = 0; + std::uint64_t m_tick = 0; + std::uint64_t m_evictions = 0; + std::uint64_t m_restores = 0; + std::unordered_set m_stores; + std::vector m_pending; + std::vector m_pending_fnames; + std::mutex m_mutex; + std::mutex m_safepoint_mutex; +}; + +} // namespace perspective diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/rlookup.h b/rust/perspective-server/cpp/perspective/src/include/perspective/rlookup.h new file mode 100644 index 0000000..93dd10f --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/rlookup.h @@ -0,0 +1,27 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include + +namespace perspective { + +struct PERSPECTIVE_EXPORT t_rlookup { + t_rlookup(); + t_rlookup(t_uindex idx, bool exists); + ~t_rlookup(); + t_uindex m_idx; + bool m_exists; +}; + +} // end namespace perspective \ No newline at end of file diff --git a/rust/perspective-server/cpp/perspective/src/include/perspective/scalar.h b/rust/perspective-server/cpp/perspective/src/include/perspective/scalar.h new file mode 100644 index 0000000..8c2dd73 --- /dev/null +++ b/rust/perspective-server/cpp/perspective/src/include/perspective/scalar.h @@ -0,0 +1,490 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include //std::hash + +namespace perspective { + +template

    + + +