From 5277fb88ab648cdc48aaa89a3a43157d76c5f05d Mon Sep 17 00:00:00 2001 From: Donghao Ren Date: Wed, 4 Mar 2026 13:57:30 -0800 Subject: [PATCH] feat: instances view Co-Authored-By: Dominik Moritz <589034+domoritz@users.noreply.github.com> --- README.md | 8 +- package-lock.json | 47 +- package.json | 3 +- packages/backend/embedding_atlas/options.py | 10 +- packages/docs/.vitepress/config.mts | 1 - .../docs/.vitepress/theme/ExampleItem.vue | 1 - packages/docs/develop.md | 2 - packages/docs/examples/examples.data.ts | 16 +- packages/docs/overview.md | 1 - packages/docs/table.md | 106 ---- packages/embedding-atlas/package.json | 1 - packages/embedding-atlas/src/index.ts | 4 +- packages/embedding-atlas/src/react.ts | 4 +- packages/embedding-atlas/src/table.ts | 3 - packages/embedding-atlas/src/viewer.ts | 2 +- packages/embedding-atlas/svelte/Table.svelte | 26 - packages/embedding-atlas/svelte/index.d.ts | 3 +- packages/embedding-atlas/svelte/index.js | 4 +- packages/embedding-atlas/vite.config.js | 1 - packages/examples/src/App.svelte | 3 - .../examples/src/react/EmbeddingAtlas.tsx | 25 +- .../src/svelte/EmbeddingAtlasExample.svelte | 25 +- .../examples/src/svelte/TableExample.svelte | 27 - packages/table/.gitignore | 24 - packages/table/index.html | 12 - packages/table/package.json | 44 -- .../AdditionalHeaderContentExample.svelte | 16 - packages/table/src/demo/App.svelte | 232 --------- .../table/src/demo/CustomCellExample.svelte | 13 - packages/table/src/demo/main.ts | 6 - packages/table/src/lib/StyleWrapper.svelte | 166 ------- packages/table/src/lib/Table.svelte | 299 ------------ packages/table/src/lib/api/config.ts | 7 - packages/table/src/lib/api/custom-cells.ts | 41 -- packages/table/src/lib/api/custom-headers.ts | 42 -- packages/table/src/lib/api/style.ts | 34 -- .../table/src/lib/context/config.svelte.ts | 109 ----- .../table/src/lib/context/context.svelte.ts | 72 --- .../src/lib/context/coordinator.svelte.ts | 16 - .../src/lib/context/custom-cells.svelte.ts | 26 - .../src/lib/context/custom-headers.svelte.ts | 27 - .../table/src/lib/context/style.svelte.ts | 21 - .../controllers/ColResizeController.svelte.ts | 56 --- .../HorizontalScrollbarController.svelte.ts | 54 -- .../lib/controllers/TableController.svelte.ts | 462 ------------------ .../TablePortalController.svelte.ts | 89 ---- .../VerticalScrollbarController.svelte.ts | 90 ---- packages/table/src/lib/index.ts | 150 ------ packages/table/src/lib/model/Schema.svelte.ts | 32 -- .../table/src/lib/model/TableModel.svelte.ts | 209 -------- .../src/lib/modifiers/overscroll.svelte.ts | 27 - .../src/lib/mosaic-clients/NumRowsClient.ts | 29 -- .../src/lib/mosaic-clients/RowsClient.ts | 112 ----- packages/table/src/lib/types/index.ts | 8 - packages/table/src/lib/util.ts | 20 - .../src/lib/views/HorizontalScrollbar.svelte | 90 ---- .../table/src/lib/views/RowBackground.svelte | 84 ---- .../src/lib/views/VerticalScrollbar.svelte | 131 ----- .../table/src/lib/views/cells/Cell.svelte | 100 ---- .../src/lib/views/cells/CellContent.svelte | 124 ----- .../src/lib/views/cells/RowNumber.svelte | 31 -- .../cells/cell-contents/BigIntContent.svelte | 30 -- .../cell-contents/CustomCellContents.svelte | 45 -- .../cells/cell-contents/ImageContent.svelte | 97 ---- .../cells/cell-contents/LinkContent.svelte | 18 - .../cells/cell-contents/NumberContent.svelte | 35 -- .../cells/cell-contents/TextContent.svelte | 41 -- .../views/headers/CustomHeaderContents.svelte | 42 -- .../table/src/lib/views/headers/Header.svelte | 100 ---- .../src/lib/views/headers/HeaderRow.svelte | 146 ------ .../src/lib/views/headers/HeaderTitle.svelte | 23 - .../src/lib/views/headers/Resizer.svelte | 59 --- .../lib/views/headers/RowNumberTitle.svelte | 15 - .../src/lib/views/headers/SortButtons.svelte | 74 --- .../src/lib/views/shared/Dropdown.svelte | 79 --- .../src/lib/views/shared/TablePortal.svelte | 84 ---- packages/table/svelte.config.js | 8 - packages/table/svelte/Table.svelte | 26 - packages/table/svelte/index.d.ts | 5 - packages/table/svelte/index.js | 5 - packages/table/tsconfig.json | 26 - packages/table/vite.config.ts | 25 - packages/utils/src/equals.ts | 28 ++ packages/viewer/package.json | 2 +- packages/viewer/src/EmbeddingAtlas.svelte | 61 ++- packages/viewer/src/api.ts | 14 +- .../src/app/components/MessagesView.svelte | 2 +- packages/viewer/src/app/logging.ts | 2 + packages/viewer/src/app/mcp_server.ts | 2 + .../viewer/src/app/reconnecting_websocket.ts | 2 + packages/viewer/src/assets/chart-cards.svg | 6 + packages/viewer/src/assets/chart-table.svg | 15 + packages/viewer/src/assets/chart_icons.ts | 4 + packages/viewer/src/assets/icons.ts | 6 + .../src/charts/basic/ContentViewer.svelte | 4 +- .../src/charts/basic/CountPlotBar.svelte | 1 + .../viewer/src/charts/basic/Markdown.svelte | 2 +- .../viewer/src/charts/builder/Builder.svelte | 7 +- .../src/charts/builder/builder_description.ts | 11 +- packages/viewer/src/charts/chart.ts | 11 +- packages/viewer/src/charts/chart_types.ts | 101 +++- packages/viewer/src/charts/default_charts.ts | 11 +- .../src/charts/embedding/Embedding.svelte | 28 +- .../src/charts/embedding/Tooltip.svelte | 2 +- .../viewer/src/charts/instances/Cards.svelte | 64 +++ .../src/charts/instances/Instances.svelte | 393 +++++++++++++++ .../charts/instances/SortOrderControl.svelte | 54 ++ .../viewer/src/charts/instances/Table.svelte | 208 ++++++++ packages/viewer/src/charts/instances/query.ts | 28 ++ packages/viewer/src/charts/instances/types.ts | 42 ++ .../viewer/src/charts/spec/layer_helper.ts | 2 + packages/viewer/src/charts/spec/runtime.ts | 16 +- packages/viewer/src/charts/table/Table.svelte | 67 --- .../viewer/src/charts/table/table_theme.ts | 34 -- packages/viewer/src/charts/table/types.ts | 7 - packages/viewer/src/index.ts | 1 + packages/viewer/src/layouts/LayoutView.svelte | 6 +- .../viewer/src/layouts/dashboard/placement.ts | 2 +- .../viewer/src/layouts/list/ListLayout.svelte | 18 +- .../src/layouts/list/ListLayoutOptions.svelte | 22 +- packages/viewer/src/layouts/list/types.ts | 6 + .../viewer/src/model_context/model_context.ts | 45 ++ .../src/renderers/ContentRenderer.svelte | 30 +- .../viewer/src/renderers/ImageOptions.svelte | 23 + .../renderers/LiquidTemplateOptions.svelte | 15 + packages/viewer/src/renderers/image.ts | 31 -- packages/viewer/src/renderers/index.ts | 80 --- packages/viewer/src/renderers/json.ts | 36 -- packages/viewer/src/renderers/markdown.ts | 31 -- packages/viewer/src/renderers/messages.ts | 114 ++--- .../viewer/src/renderers/renderer_types.ts | 185 +++++++ .../viewer/src/renderers/renderer_utils.ts | 53 ++ packages/viewer/src/renderers/types.ts | 42 ++ packages/viewer/src/renderers/url.ts | 24 - packages/viewer/src/schemas.ts | 9 +- packages/viewer/src/utils/database.ts | 10 + packages/viewer/src/utils/html_template.ts | 30 ++ packages/viewer/src/utils/sanitize.ts | 15 + packages/viewer/src/utils/screenshot.ts | 2 + .../viewer/src/views/ColumnStylePicker.svelte | 2 +- .../src/views/ColumnStylePickerRow.svelte | 35 +- .../viewer/src/views/SearchResultList.svelte | 2 +- .../viewer/src/views/TooltipContent.svelte | 39 +- .../viewer/src/widgets/ActionButton.svelte | 18 +- scripts/build.sh | 4 - 145 files changed, 1726 insertions(+), 4852 deletions(-) delete mode 100644 packages/docs/table.md delete mode 100644 packages/embedding-atlas/src/table.ts delete mode 100644 packages/embedding-atlas/svelte/Table.svelte delete mode 100644 packages/examples/src/svelte/TableExample.svelte delete mode 100644 packages/table/.gitignore delete mode 100644 packages/table/index.html delete mode 100644 packages/table/package.json delete mode 100644 packages/table/src/demo/AdditionalHeaderContentExample.svelte delete mode 100644 packages/table/src/demo/App.svelte delete mode 100644 packages/table/src/demo/CustomCellExample.svelte delete mode 100644 packages/table/src/demo/main.ts delete mode 100644 packages/table/src/lib/StyleWrapper.svelte delete mode 100644 packages/table/src/lib/Table.svelte delete mode 100644 packages/table/src/lib/api/config.ts delete mode 100644 packages/table/src/lib/api/custom-cells.ts delete mode 100644 packages/table/src/lib/api/custom-headers.ts delete mode 100644 packages/table/src/lib/api/style.ts delete mode 100644 packages/table/src/lib/context/config.svelte.ts delete mode 100644 packages/table/src/lib/context/context.svelte.ts delete mode 100644 packages/table/src/lib/context/coordinator.svelte.ts delete mode 100644 packages/table/src/lib/context/custom-cells.svelte.ts delete mode 100644 packages/table/src/lib/context/custom-headers.svelte.ts delete mode 100644 packages/table/src/lib/context/style.svelte.ts delete mode 100644 packages/table/src/lib/controllers/ColResizeController.svelte.ts delete mode 100644 packages/table/src/lib/controllers/HorizontalScrollbarController.svelte.ts delete mode 100644 packages/table/src/lib/controllers/TableController.svelte.ts delete mode 100644 packages/table/src/lib/controllers/TablePortalController.svelte.ts delete mode 100644 packages/table/src/lib/controllers/VerticalScrollbarController.svelte.ts delete mode 100644 packages/table/src/lib/index.ts delete mode 100644 packages/table/src/lib/model/Schema.svelte.ts delete mode 100644 packages/table/src/lib/model/TableModel.svelte.ts delete mode 100644 packages/table/src/lib/modifiers/overscroll.svelte.ts delete mode 100644 packages/table/src/lib/mosaic-clients/NumRowsClient.ts delete mode 100644 packages/table/src/lib/mosaic-clients/RowsClient.ts delete mode 100644 packages/table/src/lib/types/index.ts delete mode 100644 packages/table/src/lib/util.ts delete mode 100644 packages/table/src/lib/views/HorizontalScrollbar.svelte delete mode 100644 packages/table/src/lib/views/RowBackground.svelte delete mode 100644 packages/table/src/lib/views/VerticalScrollbar.svelte delete mode 100644 packages/table/src/lib/views/cells/Cell.svelte delete mode 100644 packages/table/src/lib/views/cells/CellContent.svelte delete mode 100644 packages/table/src/lib/views/cells/RowNumber.svelte delete mode 100644 packages/table/src/lib/views/cells/cell-contents/BigIntContent.svelte delete mode 100644 packages/table/src/lib/views/cells/cell-contents/CustomCellContents.svelte delete mode 100644 packages/table/src/lib/views/cells/cell-contents/ImageContent.svelte delete mode 100644 packages/table/src/lib/views/cells/cell-contents/LinkContent.svelte delete mode 100644 packages/table/src/lib/views/cells/cell-contents/NumberContent.svelte delete mode 100644 packages/table/src/lib/views/cells/cell-contents/TextContent.svelte delete mode 100644 packages/table/src/lib/views/headers/CustomHeaderContents.svelte delete mode 100644 packages/table/src/lib/views/headers/Header.svelte delete mode 100644 packages/table/src/lib/views/headers/HeaderRow.svelte delete mode 100644 packages/table/src/lib/views/headers/HeaderTitle.svelte delete mode 100644 packages/table/src/lib/views/headers/Resizer.svelte delete mode 100644 packages/table/src/lib/views/headers/RowNumberTitle.svelte delete mode 100644 packages/table/src/lib/views/headers/SortButtons.svelte delete mode 100644 packages/table/src/lib/views/shared/Dropdown.svelte delete mode 100644 packages/table/src/lib/views/shared/TablePortal.svelte delete mode 100644 packages/table/svelte.config.js delete mode 100644 packages/table/svelte/Table.svelte delete mode 100644 packages/table/svelte/index.d.ts delete mode 100644 packages/table/svelte/index.js delete mode 100644 packages/table/tsconfig.json delete mode 100644 packages/table/vite.config.ts create mode 100644 packages/viewer/src/assets/chart-cards.svg create mode 100644 packages/viewer/src/assets/chart-table.svg create mode 100644 packages/viewer/src/charts/instances/Cards.svelte create mode 100644 packages/viewer/src/charts/instances/Instances.svelte create mode 100644 packages/viewer/src/charts/instances/SortOrderControl.svelte create mode 100644 packages/viewer/src/charts/instances/Table.svelte create mode 100644 packages/viewer/src/charts/instances/query.ts create mode 100644 packages/viewer/src/charts/instances/types.ts delete mode 100644 packages/viewer/src/charts/table/Table.svelte delete mode 100644 packages/viewer/src/charts/table/table_theme.ts delete mode 100644 packages/viewer/src/charts/table/types.ts create mode 100644 packages/viewer/src/renderers/ImageOptions.svelte create mode 100644 packages/viewer/src/renderers/LiquidTemplateOptions.svelte delete mode 100644 packages/viewer/src/renderers/image.ts delete mode 100644 packages/viewer/src/renderers/index.ts delete mode 100644 packages/viewer/src/renderers/json.ts delete mode 100644 packages/viewer/src/renderers/markdown.ts create mode 100644 packages/viewer/src/renderers/renderer_types.ts create mode 100644 packages/viewer/src/renderers/renderer_utils.ts create mode 100644 packages/viewer/src/renderers/types.ts delete mode 100644 packages/viewer/src/renderers/url.ts create mode 100644 packages/viewer/src/utils/html_template.ts create mode 100644 packages/viewer/src/utils/sanitize.ts diff --git a/README.md b/README.md index c3216e5f..fb4eab34 100644 --- a/README.md +++ b/README.md @@ -61,13 +61,13 @@ npm install embedding-atlas ``` ```js -import { EmbeddingAtlas, EmbeddingView, Table } from "embedding-atlas"; +import { EmbeddingAtlas, EmbeddingView } from "embedding-atlas"; // or with React: -import { EmbeddingAtlas, EmbeddingView, Table } from "embedding-atlas/react"; +import { EmbeddingAtlas, EmbeddingView } from "embedding-atlas/react"; // or Svelte: -import { EmbeddingAtlas, EmbeddingView, Table } from "embedding-atlas/svelte"; +import { EmbeddingAtlas, EmbeddingView } from "embedding-atlas/svelte"; ``` For more information, please visit . @@ -110,8 +110,6 @@ Frontend: - `packages/component`: The `EmbeddingView` and `EmbeddingViewMosaic` components. -- `packages/table`: The `Table` component. - - `packages/viewer`: The frontend application for visualizing embedding and other columns. It also provides the `EmbeddingAtlas` component that can be embedded in other applications. - `packages/density-clustering`: The density clustering algorithm, written in Rust. diff --git a/package-lock.json b/package-lock.json index fd5857f8..f4546d80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "packages/utils", "packages/component", "packages/viewer", - "packages/table", "packages/umap-wasm", "packages/density-clustering", "packages/embedding-atlas", @@ -660,10 +659,6 @@ "resolved": "packages/examples", "link": true }, - "node_modules/@embedding-atlas/table": { - "resolved": "packages/table", - "link": true - }, "node_modules/@embedding-atlas/umap-wasm": { "resolved": "packages/umap-wasm", "link": true @@ -3614,13 +3609,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/lodash": { - "version": "4.17.20", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", - "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/mark.js": { "version": "8.11.12", "resolved": "https://registry.npmjs.org/@types/mark.js/-/mark.js-8.11.12.tgz", @@ -6433,6 +6421,37 @@ "uc.micro": "^2.0.0" } }, + "node_modules/liquidjs": { + "version": "10.24.0", + "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.24.0.tgz", + "integrity": "sha512-TAUNAdgwaAXjjcUFuYVJm9kOVH7zc0mTKxsG9t9Lu4qdWjB2BEblyVIYpjWcmJLMGgiYqnGNJjpNMHx0gp/46A==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^10.0.0" + }, + "bin": { + "liquid": "bin/liquid.js", + "liquidjs": "bin/liquid.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/liquidjs" + } + }, + "node_modules/liquidjs/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/local-pkg": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", @@ -9917,7 +9936,6 @@ "license": "MIT", "devDependencies": { "@embedding-atlas/component": "*", - "@embedding-atlas/table": "*", "@embedding-atlas/umap-wasm": "*", "@embedding-atlas/viewer": "*", "svelte-check": "^4.3.1", @@ -9973,6 +9991,7 @@ "packages/table": { "name": "@embedding-atlas/table", "version": "0.0.0", + "extraneous": true, "devDependencies": { "@types/lodash": "^4.17.20", "lodash": "^4.17.21", @@ -10019,7 +10038,6 @@ "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.38.5", "@embedding-atlas/component": "*", - "@embedding-atlas/table": "*", "@embedding-atlas/umap-wasm": "*", "@embedding-atlas/utils": "*", "@floating-ui/dom": "^1.7.4", @@ -10039,6 +10057,7 @@ "flexsearch": "^0.8.205", "html-to-image": "^1.11.13", "json-schema": "^0.4.0", + "liquidjs": "^10.24.0", "mark.js": "^8.11.1", "marked": "^17.0.0", "nanoid": "^5.1.5", diff --git a/package.json b/package.json index d0a9ffea..859c9ddb 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,6 @@ "packages/utils", "packages/component", "packages/viewer", - "packages/table", "packages/umap-wasm", "packages/density-clustering", "packages/embedding-atlas", @@ -16,7 +15,7 @@ "scripts": { "build": "./scripts/build.sh", "test": "npm run test -w @embedding-atlas/density-clustering -w @embedding-atlas/umap-wasm -w @embedding-atlas/utils", - "check": "npm run check -w @embedding-atlas/component -w @embedding-atlas/table -w @embedding-atlas/viewer -w @embedding-atlas/embedding-atlas -w @embedding-atlas/examples -w @embedding-atlas/backend", + "check": "npm run check -w @embedding-atlas/component -w @embedding-atlas/viewer -w @embedding-atlas/embedding-atlas -w @embedding-atlas/examples -w @embedding-atlas/backend", "check-format": "prettier -c . && uvx ruff check && uvx ruff format --check --exclude '*.ipynb'" }, "devDependencies": { diff --git a/packages/backend/embedding_atlas/options.py b/packages/backend/embedding_atlas/options.py index 92a7c727..45202d36 100644 --- a/packages/backend/embedding_atlas/options.py +++ b/packages/backend/embedding_atlas/options.py @@ -50,6 +50,9 @@ class EmbeddingAtlasOptions(TypedDict, total=False): show_embedding: Whether to display the embedding view when the widget opens. + + initial_state: + The initial Embedding Atlas state. """ table: str | None @@ -68,6 +71,8 @@ class EmbeddingAtlasOptions(TypedDict, total=False): show_charts: bool | None show_embedding: bool | None + initial_state: dict | None + def make_embedding_atlas_props(**options: Unpack[EmbeddingAtlasOptions]) -> dict: """ @@ -111,6 +116,9 @@ def set_prop(key: str, value): set_prop("embeddingViewLabels", options.get("labels")) set_prop("embeddingViewConfig.autoLabelStopWords", options.get("stop_words")) + # Initial state + set_prop("initialState", options.get("initial_state")) + # Layout set_prop("initialState.layoutStates.list.showTable", options.get("show_table")) set_prop("initialState.layoutStates.list.showCharts", options.get("show_charts")) @@ -118,6 +126,4 @@ def set_prop(key: str, value): "initialState.layoutStates.list.showEmbedding", options.get("show_embedding") ) - set_prop("initialState.version", "0.0.0") - return props diff --git a/packages/docs/.vitepress/config.mts b/packages/docs/.vitepress/config.mts index fd0d60c6..a02e0996 100644 --- a/packages/docs/.vitepress/config.mts +++ b/packages/docs/.vitepress/config.mts @@ -44,7 +44,6 @@ export default defineConfig({ { text: "Component Library", items: [ - { text: "Table", link: "/table" }, { text: "EmbeddingView", link: "/embedding-view" }, { text: "EmbeddingViewMosaic", link: "/embedding-view-mosaic" }, { text: "EmbeddingAtlas", link: "/embedding-atlas" }, diff --git a/packages/docs/.vitepress/theme/ExampleItem.vue b/packages/docs/.vitepress/theme/ExampleItem.vue index b1a12fa1..847bbcac 100644 --- a/packages/docs/.vitepress/theme/ExampleItem.vue +++ b/packages/docs/.vitepress/theme/ExampleItem.vue @@ -42,7 +42,6 @@ const href = computed( .example-item:hover { color: var(--vp-c-indigo-1); - transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); border-color: var(--vp-c-brand-1); } diff --git a/packages/docs/develop.md b/packages/docs/develop.md index e0c0085a..cfe49943 100644 --- a/packages/docs/develop.md +++ b/packages/docs/develop.md @@ -8,8 +8,6 @@ Frontend: - `packages/component`: The `EmbeddingView` and `EmbeddingViewMosaic` components. -- `packages/table`: The `Table` component. - - `packages/viewer`: The frontend application for visualizing embedding and other columns. It also provides the `EmbeddingAtlas` component that can be embedded in other applications. - `packages/density-clustering`: The density clustering algorithm, written in Rust. diff --git a/packages/docs/examples/examples.data.ts b/packages/docs/examples/examples.data.ts index b7874dd9..9dbda828 100644 --- a/packages/docs/examples/examples.data.ts +++ b/packages/docs/examples/examples.data.ts @@ -54,7 +54,7 @@ const examples: Record = { { type: "encoding.normalize", attribute: "y", layer: [0, 1], options: ["x"] }, ], }, - 5: { type: "table", title: "Table", columns: ["title", "country", "province", "description", "points", "price", "variety", "designation", "projection_x", "projection_y", "neighbors"] }, + 5: { type: "instances", title: "Table" }, 6: { type: "count-plot", title: "country", data: { field: "country" } }, 7: { type: "count-plot", title: "province", data: { field: "province" } }, 8: { @@ -99,7 +99,7 @@ const examples: Record = { charts: { 1: { type: "embedding", title: "Embedding", data: { x: "projection_x", y: "projection_y", text: "Abstract", category: "Conference" } }, 2: { type: "predicates", title: "SQL Predicates" }, - 3: { type: "table", title: "Table", columns: ["Year", "Conference", "Title", "Abstract", "Link", "PaperType", "Award", "AuthorNames_Deduped", "AuthorAffiliation", "AuthorKeywords", "AminerCitationCount", "CitationCount_CrossRef", "PubsCited_CrossRef", "projection_x", "projection_y", "neighbors"] }, + 3: { type: "instances", title: "Table" }, 4: { title: "Year", layers: [ @@ -171,7 +171,7 @@ const examples: Record = { charts: { 1: { type: "embedding", title: "Embedding", data: { x: "projection_x", y: "projection_y", text: "question", category: "subject_name" } }, 2: { title: "topic_name", type: "count-plot", data: { field: "topic_name" } }, - 3: { type: "table", title: "Table", columns: ["id", "question", "opa", "opb", "opc", "opd", "cop", "choice_type", "exp", "subject_name", "topic_name", "projection_x", "projection_y", "neighbors"] }, + 3: { type: "instances", title: "Table" }, 4: { type: "count-plot", title: "subject_name", data: { field: "subject_name" } }, }, layout: "dashboard", @@ -204,7 +204,7 @@ const examples: Record = { version: "0.15.0", charts: { 1: { type: "embedding", title: "Embedding", data: { x: "projection_x", y: "projection_y", text: "question", category: "discipline" } }, - 2: { type: "table", title: "Table", columns: ["uuid", "question", "options", "answer", "answer_letter", "discipline", "field", "subfield", "difficulty", "is_calculation", "projection_x", "projection_y", "neighbors"] }, + 2: { type: "instances", title: "Table" }, 3: { type: "count-plot", title: "discipline", data: { field: "discipline" } }, 4: { type: "count-plot", title: "field", data: { field: "field" } }, 5: { type: "count-plot", title: "subfield", data: { field: "subfield" } }, @@ -256,7 +256,7 @@ const examples: Record = { { type: "scale.type", channel: "y" }, ], }, - 2: { type: "table", title: "Table", columns: ["Title", "US Gross", "Worldwide Gross", "US DVD Sales", "Production Budget", "Release Date", "MPAA Rating", "Running Time min", "Distributor", "Source", "Major Genre", "Creative Type", "Director", "Rotten Tomatoes Rating", "IMDB Rating", "IMDB Votes"] }, + 2: { type: "instances", title: "Table" }, 3: { title: "US Gross", layers: [ @@ -368,7 +368,7 @@ const examples: Record = { version: "0.15.0", charts: { 1: { type: "predicates", title: "SQL Predicates" }, - 2: { type: "table", title: "Table", columns: ["image", "question", "choices", "answer", "hint", "task", "grade", "subject", "topic", "category", "skill", "lecture", "solution"] }, + 2: { type: "instances", title: "Table" }, 3: { title: "grade, topic", plotSize: { height: 350 }, @@ -415,7 +415,7 @@ const examples: Record = { { type: "encoding.normalize", attribute: "y", layer: [0, 1], options: ["x"] }, ], }, - 2: { type: "table", title: "Table", columns: ["age", "workclass", "fnlwgt", "education", "education.num", "marital.status", "occupation", "relationship", "race", "sex", "capital.gain", "capital.loss", "hours.per.week", "native.country", "income"] }, + 2: { type: "instances", title: "Table" }, 3: { title: "Sex", type: "count-plot", data: { field: "sex" } }, 4: { type: "count-plot", title: "Workclass", data: { field: "workclass" } }, 5: { title: "Age (CDF) by Income", layers: [{ mark: "line", filter: "$filter", encoding: { x: { aggregate: "ecdf-value", field: "age" }, y: { aggregate: "ecdf-rank" }, color: { field: "education" } } }], selection: { brush: { encoding: "x" } }, widgets: [{ type: "scale.type", channel: "x" }] }, @@ -479,7 +479,7 @@ const examples: Record = { { type: "encoding.normalize", attribute: "y", layer: [0, 1], options: ["x"] }, ], }, - 2: { type: "table", title: "Table", columns: ["MedInc", "HouseAge", "AveRooms", "AveBedrms", "Population", "AveOccup", "Latitude", "Longitude", "MedHouseVal"] }, + 2: { type: "instances", title: "Table" }, 3: { title: "MedInc", layers: [ diff --git a/packages/docs/overview.md b/packages/docs/overview.md index d729c733..368e7320 100644 --- a/packages/docs/overview.md +++ b/packages/docs/overview.md @@ -15,7 +15,6 @@ Embedding Atlas is released as two packages: - All of these approaches allow you to compute embeddings (with custom models) and projections. - An npm package `embedding-atlas` that exposes the user interface components as API so you can use them in your own applications. Below are the exposed components: - - [Table](./table.md) - [EmbeddingView](./embedding-view.md) - [EmbeddingViewMosaic](./embedding-view-mosaic.md) - [EmbeddingAtlas](./embedding-atlas.md) diff --git a/packages/docs/table.md b/packages/docs/table.md deleted file mode 100644 index 8985ea65..00000000 --- a/packages/docs/table.md +++ /dev/null @@ -1,106 +0,0 @@ -# Table - -The `embedding-atlas` package contains a table component for showing data frames from Mosaic. - -```bash -npm install embedding-atlas -``` - -To use the React wrapper: - -```js -import { Table } from "embedding-atlas/react"; - - -``` - -To use the Svelte wrapper: - -```js -import { Table } from "embedding-atlas/svelte"; - -
-``` - -## API - -The Table component accepts a number of required and optional properties. - - - -## Custom Cells - -To use custom cell rendering, first create a class for the custom cell renderer: - -```ts -interface CustomCellProps { - value: any; - rowData: any; -} - -class CustomCellRenderer { - constructor(target, props: CustomCellProps) { - // Create the cell component and mount it to the target element. - } - update(props: CustomCellProps) { - // Update the component with new props. - } - destroy() { - // Destroy the component. - } -} -``` - -Then specify the `customCells` property to the component for the desired column: - -```svelte - -``` - -## Additional Header Contents - -Similar to custom cells, to add additional header content, first create a class for the additional header content: - -```ts -interface AdditionalHeaderContentProps { - column: string; -} - -class AdditionalHeaderContentRenderer { - constructor(target, props: AdditionalHeaderContentProps) { - // Create the cell component and mount it to the target element. - } - update(props: AdditionalHeaderContentProps) { - // Update the component with new props. - } - destroy() { - // Destroy the component. - } -} -``` - -Then specify the `additionalHeaderContents` property to the component for the desired column: - -```svelte - -``` diff --git a/packages/embedding-atlas/package.json b/packages/embedding-atlas/package.json index 1b6fe300..166fccd2 100644 --- a/packages/embedding-atlas/package.json +++ b/packages/embedding-atlas/package.json @@ -77,7 +77,6 @@ "devDependencies": { "@embedding-atlas/component": "*", "@embedding-atlas/viewer": "*", - "@embedding-atlas/table": "*", "@embedding-atlas/umap-wasm": "*", "tslib": "^2.8.1", "typescript": "^5.9.2", diff --git a/packages/embedding-atlas/src/index.ts b/packages/embedding-atlas/src/index.ts index 44698354..e87ee013 100644 --- a/packages/embedding-atlas/src/index.ts +++ b/packages/embedding-atlas/src/index.ts @@ -1,8 +1,6 @@ // Copyright (c) 2025 Apple Inc. Licensed under MIT License. export * from "./component.js"; -export * from "./table.js"; -export * from "./viewer.js"; - export * from "./density_clustering.js"; export * from "./umap.js"; +export * from "./viewer.js"; diff --git a/packages/embedding-atlas/src/react.ts b/packages/embedding-atlas/src/react.ts index 8ab1bbc1..0dfa959a 100644 --- a/packages/embedding-atlas/src/react.ts +++ b/packages/embedding-atlas/src/react.ts @@ -8,7 +8,6 @@ import { type EmbeddingViewMosaicProps, type EmbeddingViewProps, } from "./component.js"; -import { Table as TableComponent, type TableProps } from "./table.js"; import { EmbeddingAtlas as EmbeddingAtlasComponent, type EmbeddingAtlasProps } from "./viewer.js"; function makeReactWrapper( @@ -42,7 +41,6 @@ const EmbeddingAtlas = makeReactWrapper(EmbeddingAtlasCompo const EmbeddingView = makeReactWrapper(EmbeddingViewComponent); const EmbeddingViewMosaic = makeReactWrapper(EmbeddingViewMosaicComponent); -const Table = makeReactWrapper(TableComponent); export * from "./index.js"; -export { EmbeddingAtlas, EmbeddingView, EmbeddingViewMosaic, Table }; +export { EmbeddingAtlas, EmbeddingView, EmbeddingViewMosaic }; diff --git a/packages/embedding-atlas/src/table.ts b/packages/embedding-atlas/src/table.ts deleted file mode 100644 index 93ba9f57..00000000 --- a/packages/embedding-atlas/src/table.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -export { Table, type TableProps } from "@embedding-atlas/table"; diff --git a/packages/embedding-atlas/src/viewer.ts b/packages/embedding-atlas/src/viewer.ts index cc8a5653..a38110f5 100644 --- a/packages/embedding-atlas/src/viewer.ts +++ b/packages/embedding-atlas/src/viewer.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 Apple Inc. Licensed under MIT License. -export { defaultCharts, EmbeddingAtlas } from "@embedding-atlas/viewer"; +export { defaultCharts, EmbeddingAtlas, registerRenderer } from "@embedding-atlas/viewer"; export type { BuiltinChartSpec, Cache, diff --git a/packages/embedding-atlas/svelte/Table.svelte b/packages/embedding-atlas/svelte/Table.svelte deleted file mode 100644 index 81dbf12e..00000000 --- a/packages/embedding-atlas/svelte/Table.svelte +++ /dev/null @@ -1,26 +0,0 @@ - - - -
diff --git a/packages/embedding-atlas/svelte/index.d.ts b/packages/embedding-atlas/svelte/index.d.ts index e13015fd..13185008 100644 --- a/packages/embedding-atlas/svelte/index.d.ts +++ b/packages/embedding-atlas/svelte/index.d.ts @@ -3,7 +3,6 @@ import EmbeddingAtlas from "./EmbeddingAtlas.svelte"; import EmbeddingView from "./EmbeddingView.svelte"; import EmbeddingViewMosaic from "./EmbeddingViewMosaic.svelte"; -import Table from "./Table.svelte"; export * from "../dist/index.js"; -export { EmbeddingAtlas, EmbeddingView, EmbeddingViewMosaic, Table }; +export { EmbeddingAtlas, EmbeddingView, EmbeddingViewMosaic }; diff --git a/packages/embedding-atlas/svelte/index.js b/packages/embedding-atlas/svelte/index.js index 44c0372e..ac75e815 100644 --- a/packages/embedding-atlas/svelte/index.js +++ b/packages/embedding-atlas/svelte/index.js @@ -3,9 +3,7 @@ import EmbeddingView from "./EmbeddingView.svelte"; import EmbeddingViewMosaic from "./EmbeddingViewMosaic.svelte"; -import Table from "./Table.svelte"; - import EmbeddingAtlas from "./EmbeddingAtlas.svelte"; export * from "../dist/index.js"; -export { EmbeddingAtlas, EmbeddingView, EmbeddingViewMosaic, Table }; +export { EmbeddingAtlas, EmbeddingView, EmbeddingViewMosaic }; diff --git a/packages/embedding-atlas/vite.config.js b/packages/embedding-atlas/vite.config.js index 26a37b60..745b0639 100644 --- a/packages/embedding-atlas/vite.config.js +++ b/packages/embedding-atlas/vite.config.js @@ -21,7 +21,6 @@ export default defineConfig({ rollupTypes: true, bundledPackages: [ "@embedding-atlas/component", - "@embedding-atlas/table", "@embedding-atlas/viewer", "@embedding-atlas/umap-wasm", "@embedding-atlas/density-clustering", diff --git a/packages/examples/src/App.svelte b/packages/examples/src/App.svelte index 3d6e3ed4..33b7f2d9 100644 --- a/packages/examples/src/App.svelte +++ b/packages/examples/src/App.svelte @@ -10,7 +10,6 @@ import EmbeddingViewExample from "./svelte/EmbeddingViewExample.svelte"; import EmbeddingViewMosaicExample from "./svelte/EmbeddingViewMosaicExample.svelte"; import FindClustersExample from "./svelte/FindClustersExample.svelte"; - import TableExample from "./svelte/TableExample.svelte"; import ReactEmbeddingAtlasExample from "./react/EmbeddingAtlas.js"; import ReactEmbeddingViewExample from "./react/EmbeddingView.js"; @@ -24,7 +23,6 @@ "/svelte/embedding-atlas": EmbeddingAtlasExample, "/svelte/embedding-view": EmbeddingViewExample, "/svelte/embedding-view-mosaic": EmbeddingViewMosaicExample, - "/svelte/table": TableExample, "/svelte/find-clusters": FindClustersExample, "/react/embedding-view": reactWrap(ReactEmbeddingViewExample), "/react/embedding-atlas": reactWrap(ReactEmbeddingAtlasExample), @@ -37,7 +35,6 @@ { title: "EmbeddingView", href: "/svelte/embedding-view" }, { title: "EmbeddingViewMosaic", href: "/svelte/embedding-view-mosaic" }, { title: "EmbeddingAtlas", href: "/svelte/embedding-atlas" }, - { title: "Table", href: "/svelte/table" }, { title: "findClusters", href: "/svelte/find-clusters" }, ], }, diff --git a/packages/examples/src/react/EmbeddingAtlas.tsx b/packages/examples/src/react/EmbeddingAtlas.tsx index a8996499..722f36f2 100644 --- a/packages/examples/src/react/EmbeddingAtlas.tsx +++ b/packages/examples/src/react/EmbeddingAtlas.tsx @@ -4,13 +4,31 @@ import { Coordinator, wasmConnector } from "@uwdata/mosaic-core"; import * as React from "react"; import { useEffect, useState } from "react"; -import { EmbeddingAtlas } from "embedding-atlas/react"; +import { EmbeddingAtlas, registerRenderer } from "embedding-atlas/react"; import { createSampleDataTable } from "../sample_datasets.js"; export default function Component() { let [coordinator, _] = useState(() => new Coordinator()); let [ready, setReady] = useState(false); + class TagsRenderer { + node: HTMLElement; + + constructor(node: HTMLElement, props: { value: string }) { + this.node = node; + this.update(props); + } + + update(props: { value: string }) { + let el = document.createElement("span"); + el.innerText = props.value.toString(); + el.style = "border: 1px solid #ccc; border-radius: 2px; padding: 2px 4px"; + this.node.replaceChildren(el); + } + } + + registerRenderer({ name: "custom_renderer", label: "Custom Renderer", renderer: TagsRenderer }); + useEffect(() => { async function initialize() { const wasm = await wasmConnector(); @@ -32,6 +50,11 @@ export default function Component() { text: "text", projection: { x: "x", y: "y" }, }} + initialState={{ + columnStyles: { + var_many_category: { renderer: "custom_renderer" }, + }, + }} /> ); diff --git a/packages/examples/src/svelte/EmbeddingAtlasExample.svelte b/packages/examples/src/svelte/EmbeddingAtlasExample.svelte index 9473e3c3..cc97094c 100644 --- a/packages/examples/src/svelte/EmbeddingAtlasExample.svelte +++ b/packages/examples/src/svelte/EmbeddingAtlasExample.svelte @@ -2,11 +2,29 @@ - -{#await initialized} - Initializing dataset... -{:then} -
-
- -{/await} diff --git a/packages/table/.gitignore b/packages/table/.gitignore deleted file mode 100644 index 294b3857..00000000 --- a/packages/table/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -node_modules - -# Output -.output -.vercel -.netlify -.wrangler -/.svelte-kit -/build -/dist - -# OS -.DS_Store -Thumbs.db - -# Env -.env -.env.* -!.env.example -!.env.test - -# Vite -vite.config.js.timestamp-* -vite.config.ts.timestamp-* diff --git a/packages/table/index.html b/packages/table/index.html deleted file mode 100644 index edfffeb7..00000000 --- a/packages/table/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Demo - - -
- - - diff --git a/packages/table/package.json b/packages/table/package.json deleted file mode 100644 index d335cad7..00000000 --- a/packages/table/package.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "@embedding-atlas/table", - "version": "0.0.0", - "type": "module", - "private": true, - "scripts": { - "dev": "vite", - "build": "vite build", - "check": "svelte-check", - "package": "npm run build && publint", - "preview": "vite preview" - }, - "files": [ - "dist", - "svelte" - ], - "svelte": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "./svelte": { - "types": "./svelte/index.d.ts", - "svelte": "./svelte/index.js", - "default": "./svelte/index.js" - } - }, - "peerDependencies": { - "@uwdata/mosaic-core": ">=0.19.0", - "@uwdata/mosaic-sql": ">=0.19.0", - "svelte": "^5.0.0" - }, - "devDependencies": { - "@types/lodash": "^4.17.20", - "publint": "^0.3.12", - "svelte": "^5.37.3", - "svelte-check": "^4.3.1", - "typescript": "^5.9.2", - "vite": "^7.0.6", - "lodash": "^4.17.21" - } -} diff --git a/packages/table/src/demo/AdditionalHeaderContentExample.svelte b/packages/table/src/demo/AdditionalHeaderContentExample.svelte deleted file mode 100644 index f5984ef5..00000000 --- a/packages/table/src/demo/AdditionalHeaderContentExample.svelte +++ /dev/null @@ -1,16 +0,0 @@ - - - -
Additional Header Content: {column}
- - diff --git a/packages/table/src/demo/App.svelte b/packages/table/src/demo/App.svelte deleted file mode 100644 index 21eb355c..00000000 --- a/packages/table/src/demo/App.svelte +++ /dev/null @@ -1,232 +0,0 @@ - - - -
-
- - - - - - - - - -
-
- {#if ready} -
{ - console.log("clicked row:", rowId); - }} - highlightedRows={highlightedRows} - highlightHoveredRow={true} - /> - {/if} - - - - diff --git a/packages/table/src/demo/CustomCellExample.svelte b/packages/table/src/demo/CustomCellExample.svelte deleted file mode 100644 index 31ad27b7..00000000 --- a/packages/table/src/demo/CustomCellExample.svelte +++ /dev/null @@ -1,13 +0,0 @@ - - - -
{JSON.stringify(rowData)}
- - diff --git a/packages/table/src/demo/main.ts b/packages/table/src/demo/main.ts deleted file mode 100644 index d0bb0587..00000000 --- a/packages/table/src/demo/main.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -import { mount } from "svelte"; -import App from "./App.svelte"; - -const app = mount(App, { target: document.getElementById("app")! }); diff --git a/packages/table/src/lib/StyleWrapper.svelte b/packages/table/src/lib/StyleWrapper.svelte deleted file mode 100644 index acb044e8..00000000 --- a/packages/table/src/lib/StyleWrapper.svelte +++ /dev/null @@ -1,166 +0,0 @@ - - - -
- {@render children()} -
- - diff --git a/packages/table/src/lib/Table.svelte b/packages/table/src/lib/Table.svelte deleted file mode 100644 index 4d5182ee..00000000 --- a/packages/table/src/lib/Table.svelte +++ /dev/null @@ -1,299 +0,0 @@ - - - - -
- -
- {#if controller.isReady} -
- - {#key updateKey} - {#each renderVisibleRows as row (row)} - {#each renderVisibleCols as col (col)} - - {/each} - {/each} - {#each model.renderableRows as row (row)} - - {/each} - {/key} -
- - - {/if} -
-
-
- - diff --git a/packages/table/src/lib/api/config.ts b/packages/table/src/lib/api/config.ts deleted file mode 100644 index faacd907..00000000 --- a/packages/table/src/lib/api/config.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -import { OID } from "../model/TableModel.svelte.js"; - -export function rowNumber() { - return OID; -} diff --git a/packages/table/src/lib/api/custom-cells.ts b/packages/table/src/lib/api/custom-cells.ts deleted file mode 100644 index 8fb507f2..00000000 --- a/packages/table/src/lib/api/custom-cells.ts +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -import type { Component } from "svelte"; -import { createClassComponent } from "svelte/legacy"; - -export type CustomCellsConfig = { [col: string]: CustomCell }; - -export interface CustomCellProps { - value: any; - rowData: { [col: string]: any }; -} - -type CustomCellClass = new ( - node: HTMLElement, - props: CustomCellProps, -) => { update?: (props: CustomCellProps) => void; destroy?: () => void }; - -export type CustomCell = - | { - class: CustomCellClass; - props?: CustomCellProps; - } - | CustomCellClass; - -export function createCustomCellClass(Component: Component): any { - return class { - private component: any; - - constructor(target: HTMLDivElement, props: Props) { - this.component = createClassComponent({ component: Component, target: target, props: props }); - } - - update(props: Partial) { - this.component.$set(props); - } - - destroy() { - this.component.$destroy(); - } - }; -} diff --git a/packages/table/src/lib/api/custom-headers.ts b/packages/table/src/lib/api/custom-headers.ts deleted file mode 100644 index d43ecf1d..00000000 --- a/packages/table/src/lib/api/custom-headers.ts +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -import type { Component } from "svelte"; -import { createClassComponent } from "svelte/legacy"; - -export type AdditionalHeaderContentsConfig = { [col: string]: AdditionalHeaderContent }; - -export interface AdditionalHeaderContentProps { - column: string; -} - -type AdditionalHeaderContentClass = new ( - node: HTMLElement, - props: AdditionalHeaderContentProps, -) => { update?: (props: AdditionalHeaderContentProps) => void; destroy?: () => void }; - -export type AdditionalHeaderContent = - | { - class: AdditionalHeaderContentClass; - props?: AdditionalHeaderContentProps; - } - | AdditionalHeaderContentClass; - -export function createAdditionalHeaderContentClass( - Component: Component, -): any { - return class { - private component: any; - - constructor(target: HTMLDivElement, props: Props) { - this.component = createClassComponent({ component: Component, target: target, props: props }); - } - - update(props: Partial) { - this.component.$set(props); - } - - destroy() { - this.component.$destroy(); - } - }; -} diff --git a/packages/table/src/lib/api/style.ts b/packages/table/src/lib/api/style.ts deleted file mode 100644 index ab41fa46..00000000 --- a/packages/table/src/lib/api/style.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -export interface ThemeConfig { - primaryTextColor?: string; - secondaryTextColor?: string; - tertiaryTextColor?: string; - fontFamily?: string; - fontSize?: string; - primaryBackgroundColor?: string; - secondaryBackgroundColor?: string; - tertiaryBackgroundColor?: string; - hoverBackgroundColor?: string; - headerFontFamily?: string; - headerFontSize?: string; - cellFontFamily?: string; - cellFontSize?: string; - scrollbarBackgroundColor?: string; - scrollbarPillColor?: string; - scrollbarLabelBackgroundColor?: string; - shadow?: string; - outlineColor?: string; - dimmedRowColor?: string; - rowScrollToColor?: string; - rowHoverColor?: string; -} - -export type Theme = ThemeConfig & { - dark?: ThemeConfig; - light?: ThemeConfig; -}; - -export function resolveTheme(theme: Theme, colorScheme: "light" | "dark"): ThemeConfig { - return { ...theme, ...(theme[colorScheme] != null ? theme[colorScheme] : {}) }; -} diff --git a/packages/table/src/lib/context/config.svelte.ts b/packages/table/src/lib/context/config.svelte.ts deleted file mode 100644 index cb5f364d..00000000 --- a/packages/table/src/lib/context/config.svelte.ts +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -import { getContext, setContext } from "svelte"; - -export interface Config { - headerHeight: number | null; // null will use auto height - columnConfigs: ColumnConfigs; - onColumnConfigsChange: ColumnConfigChangeCallback; - minColumnWidths: ColumnWidthsConfig; - rowRenderBatchSize: number; - minFetchSize: number; - renderWindowOffset: number; - verticalScrollbarPillHeight: number; - verticalScrollbarWidth: number; - horizontalScrollbarHeight: number; - lineHeight: number; - textMaxLines: number; - betweenRowPadding: number; - betweenColPadding: number; - scrollOverflowValue: number; - onRowClick: RowClickCallback | null; - highlightedRows: Set | null; - firstColLeftPadding: number; - showRowNumber: boolean | null; - onShowRowNumberChange: ((showRowNumber: boolean) => void) | null; - highlightHoveredRow: boolean; - - get rowHeight(): number; - - DEFAULT_TEXT_MAX_LINES: number; - DEFAULT_LINE_HEIGHT: number; - DEFAULT_ROW_NUMBER_COL_WIDTH: number; -} - -export type ColumnWidthsConfig = { - [column: string]: number | null; -}; - -export type ColumnTitlesConfig = { - [column: string]: string; -}; - -export type ColumnResizeCallback = (column: string, newWidth: number) => void; - -export type ColumnConfig = { - title?: string; - width?: number; - hidden?: boolean; -}; - -export type ColumnConfigs = { - [column: string]: ColumnConfig; -}; - -export type ColumnConfigChangeCallback = (column: string, newConfigs: ColumnConfigs) => void; - -export type RowClickCallback = (rowId: string) => void; - -export const DEFAULT_CONFIG = { - headerHeight: null, - columnConfigs: {}, - onColumnConfigsChange: () => {}, - minColumnWidths: {}, - rowRenderBatchSize: 4, - minFetchSize: 1, - renderWindowOffset: 400, - verticalScrollbarPillHeight: 4, - verticalScrollbarWidth: 24, - horizontalScrollbarHeight: 16, - lineHeight: 20, - textMaxLines: 3, - betweenRowPadding: 8, - betweenColPadding: 24, - scrollOverflowValue: 1000000, // keep this below chrome's maximum translate value - onRowClick: null, - highlightedRows: null, - firstColLeftPadding: 8, - showRowNumber: true, - onShowRowNumberChange: () => {}, - highlightHoveredRow: false, - - get rowHeight() { - return this.textMaxLines * this.lineHeight + this.betweenRowPadding; - }, - - DEFAULT_TEXT_MAX_LINES: 3, - DEFAULT_LINE_HEIGHT: 20, - DEFAULT_ROW_NUMBER_COL_WIDTH: 60, -}; - -export class ConfigState { - config: Config = $state(DEFAULT_CONFIG); -} - -const CONFIG_KEY = Symbol("config"); - -export class ConfigContext { - public static initialize() { - setContext(CONFIG_KEY, new ConfigState()); - } - - public static get config(): Config { - const configState: ConfigState = getContext(CONFIG_KEY); - if (configState == null) { - throw new Error("config context not yet set"); - } - return configState.config; - } -} diff --git a/packages/table/src/lib/context/context.svelte.ts b/packages/table/src/lib/context/context.svelte.ts deleted file mode 100644 index f5d17cdd..00000000 --- a/packages/table/src/lib/context/context.svelte.ts +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -import { getContext, setContext } from "svelte"; -import { HorizontalScrollbarController } from "../controllers/HorizontalScrollbarController.svelte"; -import { TableController } from "../controllers/TableController.svelte"; -import { TablePortalController } from "../controllers/TablePortalController.svelte"; -import { VerticalScrollbarController } from "../controllers/VerticalScrollbarController.svelte"; -import { Schema } from "../model/Schema.svelte"; -import { TableModel } from "../model/TableModel.svelte"; -import { OverscrollModifier } from "../modifiers/overscroll.svelte"; - -const SCHEMA_KEY = Symbol("schema"); -const MODEL_KEY = Symbol("model"); -const CONTROLLER_KEY = Symbol("controller"); -const VERTICAL_SCROLLBAR_CONTROLLER_KEY = Symbol("vertical-scrollbar-controller"); -const HORIZONTAL_SCROLLBAR_CONTROLLER_KEY = Symbol("horizontal-scrollbar-controller"); -const TABLE_PORTAL_CONTROLLER_KEY = Symbol("table-portal-controller"); -const OVERSCROLL_MODIFIER_KEY = Symbol("overscroll-modifier"); - -export class Context { - public static initialize() { - const schema = new Schema(); - const model = new TableModel(schema); - const controller = new TableController(model, schema); - const verticalScrollbarController = new VerticalScrollbarController({ - tableModel: model, - tableController: controller, - }); - const horizontalScrollbarContainer = new HorizontalScrollbarController({ - tableModel: model, - tableController: controller, - }); - const tablePortalController = new TablePortalController(controller); - const overscrollModifier = new OverscrollModifier(controller); - - setContext(SCHEMA_KEY, schema); - setContext(MODEL_KEY, model); - setContext(CONTROLLER_KEY, controller); - setContext(VERTICAL_SCROLLBAR_CONTROLLER_KEY, verticalScrollbarController); - setContext(HORIZONTAL_SCROLLBAR_CONTROLLER_KEY, horizontalScrollbarContainer); - setContext(TABLE_PORTAL_CONTROLLER_KEY, tablePortalController); - setContext(OVERSCROLL_MODIFIER_KEY, overscrollModifier); - } - - public static get schema(): Schema { - return getContext(SCHEMA_KEY); - } - - public static get model(): TableModel { - return getContext(MODEL_KEY); - } - - public static get controller(): TableController { - return getContext(CONTROLLER_KEY); - } - - public static get verticalScrollbarController(): VerticalScrollbarController { - return getContext(VERTICAL_SCROLLBAR_CONTROLLER_KEY); - } - - public static get horizontalScrollbarController(): HorizontalScrollbarController { - return getContext(HORIZONTAL_SCROLLBAR_CONTROLLER_KEY); - } - - public static get tablePortalController(): TablePortalController { - return getContext(TABLE_PORTAL_CONTROLLER_KEY); - } - - public static get overscrollModifier(): OverscrollModifier { - return getContext(OVERSCROLL_MODIFIER_KEY); - } -} diff --git a/packages/table/src/lib/context/coordinator.svelte.ts b/packages/table/src/lib/context/coordinator.svelte.ts deleted file mode 100644 index b9cfd369..00000000 --- a/packages/table/src/lib/context/coordinator.svelte.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -import { coordinator, type Coordinator } from "@uwdata/mosaic-core"; -import { getContext, setContext } from "svelte"; - -const COORDINATOR_KEY = Symbol("mosaic-coordinator"); - -export class CoordinatorContext { - public static get coordinator(): Coordinator { - return getContext(COORDINATOR_KEY) ?? coordinator(); - } - - public static set coordinator(coordinator: Coordinator | null) { - setContext(COORDINATOR_KEY, coordinator); - } -} diff --git a/packages/table/src/lib/context/custom-cells.svelte.ts b/packages/table/src/lib/context/custom-cells.svelte.ts deleted file mode 100644 index 238ec785..00000000 --- a/packages/table/src/lib/context/custom-cells.svelte.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -import { getContext, setContext } from "svelte"; -import type { CustomCellsConfig } from "../api/custom-cells.js"; - -export class CustomCellsState { - config: CustomCellsConfig = $state({}); -} - -const CUSTOM_CELLS_KEY = Symbol("custom-cells"); - -export class CustomCellsContext { - public static initialize() { - setContext(CUSTOM_CELLS_KEY, new CustomCellsState()); - } - - public static set config(value: CustomCellsConfig) { - const customCellsState: CustomCellsState = getContext(CUSTOM_CELLS_KEY); - customCellsState.config = value; - } - - public static get config(): CustomCellsConfig { - const customCellsState: CustomCellsState = getContext(CUSTOM_CELLS_KEY); - return customCellsState.config; - } -} diff --git a/packages/table/src/lib/context/custom-headers.svelte.ts b/packages/table/src/lib/context/custom-headers.svelte.ts deleted file mode 100644 index a6e9a26f..00000000 --- a/packages/table/src/lib/context/custom-headers.svelte.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -import { getContext, setContext } from "svelte"; - -import type { AdditionalHeaderContentsConfig } from "../api/custom-headers.js"; - -export class CustomHeadersState { - config: AdditionalHeaderContentsConfig = $state({}); -} - -const CUSTOM_HEADERS_KEY = Symbol("custom-headers"); - -export class CustomHeadersContext { - public static initialize() { - setContext(CUSTOM_HEADERS_KEY, new CustomHeadersState()); - } - - public static set config(value: AdditionalHeaderContentsConfig) { - const CustomHeadersState: CustomHeadersState = getContext(CUSTOM_HEADERS_KEY); - CustomHeadersState.config = value; - } - - public static get config(): AdditionalHeaderContentsConfig { - const customHeadersState: CustomHeadersState = getContext(CUSTOM_HEADERS_KEY); - return customHeadersState.config; - } -} diff --git a/packages/table/src/lib/context/style.svelte.ts b/packages/table/src/lib/context/style.svelte.ts deleted file mode 100644 index a1fd2a4a..00000000 --- a/packages/table/src/lib/context/style.svelte.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -import { getContext, setContext } from "svelte"; -import type { Theme } from "../api/style.js"; - -export class StyleState { - colorScheme: "light" | "dark" | null = $state(null); - theme: Theme = $state({}); -} - -const STYLE_KEY = Symbol("style"); - -export class StyleContext { - public static initialize() { - setContext(STYLE_KEY, new StyleState()); - } - - public static get style(): StyleState { - return getContext(STYLE_KEY); - } -} diff --git a/packages/table/src/lib/controllers/ColResizeController.svelte.ts b/packages/table/src/lib/controllers/ColResizeController.svelte.ts deleted file mode 100644 index 0da230e1..00000000 --- a/packages/table/src/lib/controllers/ColResizeController.svelte.ts +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -import { ConfigContext, type Config } from "../context/config.svelte.js"; -import type { TableModel } from "../model/TableModel.svelte.js"; -import type { TableController } from "./TableController.svelte.js"; - -export interface ColResizeControllerProps { - tableModel: TableModel; - tableController: TableController; - col: string; -} - -export class ColResizeController { - tableModel: TableModel; - tableController: TableController; - col: string; - config: Config; - - isDragging: boolean = false; - startDragX: number | null = 0; - - constructor({ tableModel, tableController, col }: ColResizeControllerProps) { - this.tableModel = tableModel; - this.tableController = tableController; - this.col = col; - this.config = ConfigContext.config; - } - - public handlePointerDown = (e: PointerEvent) => { - e.preventDefault(); // prevents selection - // @ts-ignore - e.target.setPointerCapture(e.pointerId); - this.isDragging = true; - this.startDragX = e.offsetX; - }; - - public handlePointerMove = (e: PointerEvent) => { - if (this.isDragging && this.startDragX !== null) { - const dragDiff = e.offsetX - this.startDragX; - const prevColWidth = this.tableModel.colWidths[this.col]; - const newColWidth = Math.max(0, Math.round(prevColWidth + dragDiff)); - if (!this.config.columnConfigs[this.col]) { - this.config.columnConfigs[this.col] = {}; - } - this.config.columnConfigs[this.col].width = newColWidth; - this.config.onColumnConfigsChange(this.col, $state.snapshot(this.config.columnConfigs)); - } - }; - - public handlePointerUp = (e: PointerEvent) => { - // @ts-ignore - e.target.releasePointerCapture(e.pointerId); - this.isDragging = false; - this.startDragX = null; - }; -} diff --git a/packages/table/src/lib/controllers/HorizontalScrollbarController.svelte.ts b/packages/table/src/lib/controllers/HorizontalScrollbarController.svelte.ts deleted file mode 100644 index 3bcf198b..00000000 --- a/packages/table/src/lib/controllers/HorizontalScrollbarController.svelte.ts +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -import type { TableModel } from "../model/TableModel.svelte.js"; -import type { TableController } from "./TableController.svelte.js"; - -export interface HorizontalScrollbarControllerProps { - tableModel: TableModel; - tableController: TableController; -} - -export class HorizontalScrollbarController { - tableModel: TableModel; - tableController: TableController; - - margin: number = 2; - isDragging: boolean = false; - lastDragX: number | null = 0; - - public elementWidth: number = $state(0); - - public scrollbarWidth: number = $derived(this.elementWidth - this.margin * 2); - public pillWidth: number = $derived.by(() => { - return (this.tableController.viewWidth / this.tableModel.colsRightmostPosition) * this.scrollbarWidth; - }); - public pillLeft: number = $derived.by(() => { - return (-this.tableController.xScroll / this.tableModel.colsRightmostPosition) * this.scrollbarWidth; - }); - - constructor({ tableModel, tableController }: HorizontalScrollbarControllerProps) { - this.tableModel = tableModel; - this.tableController = tableController; - } - - public handlePointerDown = (e: PointerEvent) => { - e.preventDefault(); // prevents selection - // @ts-ignore - e.target.setPointerCapture(e.pointerId); - this.isDragging = true; - this.lastDragX = e.offsetX; - }; - - public handlePointerMove = (e: PointerEvent) => { - if (this.isDragging && this.lastDragX !== null) { - this.tableController.scroll({ deltaX: e.offsetX - this.lastDragX, deltaY: 0 }); - } - }; - - public handlePointerUp = (e: PointerEvent) => { - // @ts-ignore - e.target.releasePointerCapture(e.pointerId); - this.isDragging = false; - this.lastDragX = null; - }; -} diff --git a/packages/table/src/lib/controllers/TableController.svelte.ts b/packages/table/src/lib/controllers/TableController.svelte.ts deleted file mode 100644 index b1f0b5a5..00000000 --- a/packages/table/src/lib/controllers/TableController.svelte.ts +++ /dev/null @@ -1,462 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -import { Coordinator, Selection } from "@uwdata/mosaic-core"; -import { column, eq, literal, Query } from "@uwdata/mosaic-sql"; -import * as _ from "lodash"; - -import { ConfigContext, type Config } from "../context/config.svelte.js"; -import { CoordinatorContext } from "../context/coordinator.svelte.js"; -import type { Schema } from "../model/Schema.svelte.js"; -import { TableModel, type Cell, type ColWidths, type TableData } from "../model/TableModel.svelte.js"; -import { NumRowsClient } from "../mosaic-clients/NumRowsClient.js"; -import { OID, RowsClient } from "../mosaic-clients/RowsClient.js"; -import type { Sort } from "../types/index.js"; - -interface Props { - tableName: string; - rowKey: string; - columns: string[]; - filterBy: Selection | null; -} - -export class TableController { - model: TableModel; - schema: Schema; - config: Config; - coordinator: Coordinator = $derived(CoordinatorContext.coordinator); - - filterBy: Selection | null = null; - - rowsClient: RowsClient | null = null; - numRowsClient: NumRowsClient | null = null; - rowKeyColumn: string | null = null; - - public element: HTMLElement | null = $state(null); - public viewHeight: number = $state(0); - public viewWidth: number = $state(0); - public yScroll: number = $state(0); - public xScroll: number = $state(0); - public isFetching: boolean = $state(false); - public isJumping: boolean = $state(false); - public sort: Sort | null = $state(null); - public isReady: boolean = $state(false); - public updateKey: number = $state(0); // increment when the table should refresh rendering - public isStale: boolean = $state(false); - public flashedRowId: string | null = $state(null); - public hoveredRowId: string | null = $state(null); - - public rowsOnScreen = $derived(Math.ceil(this.viewHeight / ConfigContext.config.rowHeight)); - public renderWindowOffset = $derived(this.isJumping ? 0 : ConfigContext.config.renderWindowOffset); - public firstVisibleRowOID: number | null = $derived.by(() => { - if (this.model.renderableRows.length === 0) { - return null; - } - const visibleRows = this.model.renderableRows.filter((row) => { - const screenY = this.model.rowPositions[row] + this.yScroll; - return screenY + this.model.rowHeights[row] > 0 && screenY < this.viewHeight; - }); - - if (visibleRows.length === 0) { - return null; - } - const firstVisibleRow = visibleRows[0]; - return this.model.data[firstVisibleRow][OID]; - }); - public offset = $derived.by(() => { - return Math.max(0, Math.floor(-this.yScroll / ConfigContext.config.rowHeight)); - }); - - onFetchResolveBegin: Function | null = null; - onFetchResolveEnd: Function | null = null; - - constructor(model: TableModel, schema: Schema) { - this.model = model; - this.schema = schema; - this.config = ConfigContext.config; - } - - handleFilterBy = () => { - if (!this.rowsClient) { - return; - } - this.rowsClient.offset = 0; - this.rowsClient.limit = this.rowsOnScreen; - this.isJumping = true; - this.markStale(); - }; - - updateData = (newData: any) => { - if (!this.model || !this.rowKeyColumn) { - return; - } - if (this.onFetchResolveBegin) { - this.onFetchResolveBegin(); - this.onFetchResolveBegin = null; - } - - const arr = newData.toArray(); - const toAdd: TableData = {}; - for (const d of arr) { - const rowKey = d[this.rowKeyColumn]; - toAdd[rowKey] = d; - } - - this.model.data = { - ...this.model.data, - ...toAdd, - }; - - if (this.onFetchResolveEnd) { - this.onFetchResolveEnd(); - this.onFetchResolveEnd = null; - } - this.isFetching = false; - }; - - public initialize({ tableName, rowKey, columns, filterBy }: Props) { - this.model.columns = columns; - this.model.rowKeyColumn = rowKey; - this.rowKeyColumn = rowKey; - - if (filterBy) { - this.filterBy = filterBy; - this.filterBy.addEventListener("value", this.handleFilterBy); - } - - if (!this.rowKeyColumn) { - throw new Error("rowkey cannot be null"); - } - - let requiredColumns = columns.includes(this.rowKeyColumn) ? columns : [...columns, this.rowKeyColumn]; - this.rowsClient = new RowsClient( - tableName, - requiredColumns, - filterBy, - (data) => { - this.updateData(data); - }, - (columnInfo) => { - this.schema.columnInfo = columnInfo; - this.computeColWidths(tableName, columns); - this.isReady = true; - }, - ); - this.coordinator.connect(this.rowsClient); - this.numRowsClient = new NumRowsClient(tableName, filterBy, (numRows) => { - if (!this.model) { - return; - } - this.model.numRows = numRows; - }); - this.coordinator.connect(this.numRowsClient); - - $effect(() => { - if (!this.rowsClient || this.isFetching || !this.isReady) { - return; - } - - const renderWindowTop = -this.renderWindowOffset; - const renderWindowBottom = this.viewHeight + this.renderWindowOffset; - - const maxRowScreenY = this.model.maxRowPosition + this.yScroll + this.config.rowHeight; - const minRowScreenY = this.model.minRowPosition + this.yScroll; - - // TODO: do we need to throttle this? - // if we've scrolled past all rows, then do a clean fetch - if ( - (minRowScreenY < 0 && maxRowScreenY < 0) || - (minRowScreenY > this.viewHeight && maxRowScreenY > this.viewHeight) - ) { - const rowsToFetch = this.rowsOnScreen; - this.isFetching = true; - this.rowsClient.fetchRows(this.offset, rowsToFetch); - } else { - // otherwise, fetch from existing rows - if (maxRowScreenY < renderWindowBottom) { - const rowsToFetch = _.clamp( - Math.ceil((renderWindowBottom - maxRowScreenY) / this.config.rowHeight), - this.config.minFetchSize, - this.rowsOnScreen, - ); - if (rowsToFetch > 0 && this.model.maxRowOID !== this.model.numRows) { - this.isFetching = true; - this.rowsClient.fetchRows(this.model.maxRowOID, rowsToFetch); - } - } - - const minRowScreenY = this.model.minRowPosition + this.yScroll; - if (minRowScreenY > renderWindowTop && this.model.minRowOID !== 1) { - const rowsToFetch = _.clamp( - Math.ceil((minRowScreenY - renderWindowTop) / this.config.rowHeight), - this.config.minFetchSize, - this.rowsOnScreen, - ); - if (rowsToFetch > 0) { - this.isFetching = true; - this.rowsClient.fetchRows(Math.max(0, this.model.minRowOID - 1 - rowsToFetch), rowsToFetch); - } - } - } - - // snapshot renderable rows, as we are going to mutate data. - const renderableRows = $state.snapshot(this.model.renderableRows); - - // collapse rows that are above the viewport - let k = 0; - while (this.model.rowPositions[renderableRows[k]] + this.yScroll + this.model.rowHeights[renderableRows[k]] < 0) { - // the yScroll needs to adjust to compensate for rows before the current scroll being collapsed - this.yScroll += this.model.collapseRow(renderableRows[k]); - k += 1; - } - - // collapse rows that are below the viewport - let l = renderableRows.length - 1; - while (this.model.rowPositions[renderableRows[l]] + this.yScroll > this.viewHeight) { - this.model.collapseRow(renderableRows[l]); - l -= 1; - } - - // remove rows that are above the viewport - let i = 0; - while ( - this.model.rowPositions[renderableRows[i]] + this.yScroll + this.model.rowHeights[renderableRows[i]] < - renderWindowTop - ) { - this.model.deleteRow(renderableRows[i]); - i += 1; - } - - // remove rows that are below the viewport - let j = renderableRows.length - 1; - while (this.model.rowPositions[renderableRows[j]] + this.yScroll > renderWindowBottom) { - this.model.deleteRow(renderableRows[j]); - j -= 1; - } - }); - } - - public teardown() { - if (this.filterBy) { - this.filterBy.removeEventListener("value", this.handleFilterBy); - } - } - - public cellIsVisible(cell: Cell): boolean { - const { x, y } = this.model.getPosition(cell); - const { width, height } = this.model.getDimensions(cell); - - const screenX = x + this.xScroll; - const screenY = y + this.yScroll; - return screenX + width >= 0 && screenX <= this.viewWidth && screenY + height >= 0 && screenY <= this.viewHeight; - } - - public rowIsVisible(row: string): boolean { - const y = this.model.rowPositions[row]; - const height = this.model.rowHeights[row]; - const screenY = y + this.yScroll; - return screenY + height >= 0 && screenY <= this.viewHeight; - } - - public rowStillExists(row: string): boolean { - return this.model.data[row] != null; - } - - public colIsVisible(col: string): boolean { - const x = this.model.colPositions[col]; - const width = this.model.colWidths[col]; - const screenX = x + this.xScroll; - return screenX + width >= 0 && screenX <= this.viewWidth; - } - - public scroll({ deltaX, deltaY }: { deltaX: number; deltaY: number }) { - if (Math.abs(deltaY) > Math.abs(deltaX)) { - const newYScroll = this.yScroll - deltaY; - if (this.model.zeroRowPosition + newYScroll > 0) { - this.yScroll = -this.model.zeroRowPosition; - } else if (this.model.finalRowPosition + newYScroll < 0) { - this.yScroll = -this.model.finalRowPosition; - } else { - this.yScroll = newYScroll; - } - } else { - const newXScroll = this.xScroll - deltaX; - if (-newXScroll < 0) { - this.xScroll = 0; - } else if (-newXScroll > Math.max(this.model.colsRightmostPosition, this.viewWidth) - this.viewWidth) { - this.xScroll = -Math.max(this.model.colsRightmostPosition, this.viewWidth) + this.viewWidth; - } else { - this.xScroll = newXScroll; - } - } - } - - public handleWheel = (e: WheelEvent) => { - e.preventDefault(); - this.isJumping = false; // scroll is not a jumping operation. - this.scroll({ deltaX: e.deltaX, deltaY: e.deltaY }); - }; - - public jumpToOffset(offset: number) { - if (!this.rowsClient) { - return; - } - this.isFetching = true; - const rowsToFetch = this.rowsOnScreen; - const oldOnFetchResolveEnd = this.onFetchResolveEnd; - this.onFetchResolveEnd = () => { - if (oldOnFetchResolveEnd) { - oldOnFetchResolveEnd(); - } - this.yScroll = -(offset * this.config.rowHeight); - }; - this.markStale(); - this.rowsClient.fetchRows(offset, rowsToFetch); - } - - public handleSort = (sort: Sort | null) => { - if (!this.rowsClient) { - return; - } - - this.sort = sort; - this.rowsClient.sort = sort; - this.resetRows(); - }; - - private resetRows() { - this.model.reset(); - this.yScroll = 0; - } - - private flashRow(rowId: string) { - this.flashedRowId = rowId; - setTimeout(() => { - this.flashedRowId = null; - }, 400); - } - - async scrollToRow(rowKey: string, animate: boolean = true) { - if (!this.rowsClient) { - return; - } - this.isFetching = true; - const query = Query.with({ - original: this.rowsClient - .query(this.rowsClient.filterBy?.predicate(this.rowsClient)) - .offset(0) - .limit(this.model.numRows), - }) - .select([OID]) - .from("original") - .where(eq(column(this.rowKeyColumn!), literal(rowKey))); - - const result = await this.coordinator.query(query); - const arr = result.toArray(); - if (arr.length > 0) { - const offset = arr[0][OID] - 1; - this.onFetchResolveEnd = () => { - if (animate) { - this.flashRow(rowKey); - } - }; - this.jumpToOffset(offset); - } else { - this.isFetching = false; - console.error("no row", rowKey, "found"); - } - } - - public addHeightToRow(row: string, heightToAdd: number) { - this.model.rowHeightAddition[row] = (this.model.rowHeightAddition[row] ?? 0) + heightToAdd; - } - - public hideColumn(col: string) { - if (col === OID) { - if (this.config.onShowRowNumberChange) { - this.config.onShowRowNumberChange(false); - } else { - this.config.showRowNumber = false; - } - } else { - if (!this.config.columnConfigs[col]) { - this.config.columnConfigs[col] = {}; - } - - this.config.columnConfigs[col].hidden = true; - } - } - - public showColumn(col: string) { - if (col === OID) { - if (this.config.onShowRowNumberChange) { - this.config.onShowRowNumberChange(true); - } else { - this.config.showRowNumber = true; - } - } else { - if (!this.config.columnConfigs[col]) { - this.config.columnConfigs[col] = {}; - } - - this.config.columnConfigs[col].hidden = false; - } - } - - // Marks the current state stale, telling the view to destroy any existing cells on next render. - private markStale() { - this.isStale = true; - const oldBegin = this.onFetchResolveBegin; - this.onFetchResolveBegin = () => { - if (oldBegin) { - oldBegin(); - } - this.resetRows(); - }; - const oldEnd = this.onFetchResolveEnd; - this.onFetchResolveEnd = () => { - if (oldEnd) { - oldEnd(); - } - this.updateKey += 1; - this.isStale = false; - }; - } - - private async computeColWidths(tableName: string, columns: string[]) { - const cols = columns.filter((c) => c !== OID); - const select = this.rowsClient?.getSelect({ includeRowNumber: false }); - const colWidths: ColWidths = cols.reduce((dict: ColWidths, c) => { - dict[c] = 0; - return dict; - }, {}); - - const query = Query.from(tableName).select(select).offset(0).limit(10); - const sample = await this.coordinator.query(query); - const arr = sample.toArray(); - for (const row of arr) { - for (const col of cols) { - colWidths[col] = Math.max(colWidths[col], widthForContent(row[col])); - } - } - - if (columns.includes(OID)) { - colWidths[OID] = this.config.DEFAULT_ROW_NUMBER_COL_WIDTH; - } - this.model.defaultColWidths = colWidths; - } -} - -function widthForContent(content: any): number { - const characterLength = String(content).length; - if (characterLength > 200) { - return 600; - } else if (characterLength > 100) { - return 300; - } else if (characterLength > 20) { - return 200; - } else if (characterLength > 10) { - return 150; - } else { - return 120; - } -} diff --git a/packages/table/src/lib/controllers/TablePortalController.svelte.ts b/packages/table/src/lib/controllers/TablePortalController.svelte.ts deleted file mode 100644 index ed4acd8e..00000000 --- a/packages/table/src/lib/controllers/TablePortalController.svelte.ts +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -import { TableController } from "./TableController.svelte.js"; - -export type HorizontalAlign = "left" | "center" | "right"; -export type VerticalAlign = "top" | "middle" | "bottom"; -export type Anchor = "inside" | "outside"; - -export class TablePortalController { - tableController: TableController; - tableElement: HTMLElement | null = $derived.by(() => { - return this.tableController.element; - }); - - constructor(tableController: TableController) { - this.tableController = tableController; - } - - mount( - element: HTMLElement, - relativeTo: HTMLElement, - anchor: Anchor, - horizontalAlign: HorizontalAlign, - verticalAlign: VerticalAlign, - ) { - if (!this.tableElement) { - return; - } - - const relativeBox = relativeTo.getBoundingClientRect(); - const tableBox = this.tableElement.getBoundingClientRect(); - - const yOffset = tableBox.top; - const xOffset = tableBox.left; - - switch (anchor) { - case "inside": - switch (verticalAlign) { - case "top": - element.style.top = relativeBox.top - yOffset + "px"; - break; - case "middle": - case "bottom": - throw new Error("not yet implemented" + anchor + verticalAlign); - } - - switch (horizontalAlign) { - case "left": - element.style.left = relativeBox.left - xOffset + "px"; - case "center": - case "right": - throw new Error("not yet implemented" + anchor + horizontalAlign); - } - break; - case "outside": - switch (verticalAlign) { - case "top": - element.style.top = relativeBox.bottom - yOffset + "px"; - break; - case "middle": - case "bottom": - throw new Error("not yet implemented" + anchor + verticalAlign); - } - - switch (horizontalAlign) { - case "left": - element.style.left = relativeBox.left - xOffset + "px"; - break; - case "center": - case "right": - throw new Error("not yet implemented" + anchor + horizontalAlign); - } - break; - } - - this.tableElement.appendChild(element); - } - - destroy(element: HTMLElement) { - if (!this.tableElement) { - return; - } - if (this.tableElement.contains(element)) { - // need this check in case the browser has already removed the node - // (it seems like when svelte builds this the behavior is different than in the dev environment) - this.tableElement.removeChild(element); - } - } -} diff --git a/packages/table/src/lib/controllers/VerticalScrollbarController.svelte.ts b/packages/table/src/lib/controllers/VerticalScrollbarController.svelte.ts deleted file mode 100644 index eaedad1c..00000000 --- a/packages/table/src/lib/controllers/VerticalScrollbarController.svelte.ts +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -import clamp from "lodash/clamp.js"; -import throttle from "lodash/throttle.js"; - -import { ConfigContext } from "../context/config.svelte.js"; -import type { TableModel } from "../model/TableModel.svelte.js"; -import type { TableController } from "./TableController.svelte.js"; - -export interface VerticalScrollbarControllerProps { - tableModel: TableModel; - tableController: TableController; -} - -export class VerticalScrollbarController { - tableModel: TableModel; - tableController: TableController; - - public isDragging: boolean = false; - - public elementHeight: number = $state(0); - public labelHeight: number = $state(0); - public pillHeight: number = $derived(ConfigContext.config.verticalScrollbarPillHeight); - public scrollbarHeight: number = $derived(this.elementHeight - this.pillHeight); - - public displayRow: number = $derived.by(() => { - if (this.tableController.firstVisibleRowOID) { - return this.tableController.firstVisibleRowOID; - } else { - // use the table offset if nothing is visible - return this.tableController.offset + 1; - } - }); - public pillPosition: number | null = $derived.by(() => { - return ((this.displayRow - 1) / (this.tableModel.numRows - 1)) * this.scrollbarHeight; - }); - public labelOffset: number = $derived.by(() => { - if (this.pillPosition === null) { - return 0; - } - const top = this.pillPosition + this.pillHeight / 2 - this.labelHeight / 2; - if (top < 0) { - return top; - } - - const bottom = this.pillPosition + this.pillHeight / 2 + this.labelHeight / 2; - if (bottom > this.elementHeight) { - return bottom - this.elementHeight; - } - - return 0; - }); - - constructor({ tableModel, tableController }: VerticalScrollbarControllerProps) { - this.tableModel = tableModel; - this.tableController = tableController; - } - - private computeOffsetFromPointer = (e: PointerEvent): number => { - this.isDragging = true; - let offset = Math.round((e.offsetY / this.scrollbarHeight) * (this.tableModel.numRows - 1)); - return clamp(offset, 0, this.tableModel.numRows - 1); - }; - - private pointerDown = (e: PointerEvent) => { - e.preventDefault(); // prevents selection - // @ts-ignore - e.target.setPointerCapture(e.pointerId); - this.isDragging = true; - const rowOffset = this.computeOffsetFromPointer(e); - this.tableController.isJumping = true; - this.tableController.jumpToOffset(rowOffset); - }; - public handlePointerDown = throttle(this.pointerDown, 50); - - private pointerMove = (e: PointerEvent) => { - if (this.isDragging) { - const rowOffset = this.computeOffsetFromPointer(e); - this.tableController.jumpToOffset(rowOffset); - } - }; - public handlePointerMove = throttle(this.pointerMove, 50); - - public handlePointerUp = (e: PointerEvent) => { - // @ts-ignore - e.target.releasePointerCapture(e.pointerId); - this.isDragging = false; - this.tableController.isJumping = false; - }; -} diff --git a/packages/table/src/lib/index.ts b/packages/table/src/lib/index.ts deleted file mode 100644 index 730a4fa3..00000000 --- a/packages/table/src/lib/index.ts +++ /dev/null @@ -1,150 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -import type { Coordinator, Selection } from "@uwdata/mosaic-core"; -import { createClassComponent } from "svelte/legacy"; - -import Component from "./Table.svelte"; -import type { ColumnConfigChangeCallback, ColumnConfigs, RowClickCallback } from "./context/config.svelte"; - -import type { CustomCell, CustomCellsConfig } from "./api/custom-cells.js"; -import type { AdditionalHeaderContent, AdditionalHeaderContentsConfig } from "./api/custom-headers.js"; -import type { Theme } from "./api/style.js"; - -export interface TableProps { - /** The Mosaic coordinator. If not specified, the default coordinator from Mosaic's `coordinator()` method will be used. */ - coordinator?: Coordinator | null; - - /** The name of the DuckDB table to create a view for. */ - table: string; - - /** The columns of the table to render. These should match the names of the columns in the DuckDB table you wish to render. */ - columns: string[]; - - /** The name of the column used to uniquely identify rows. */ - rowKey: string; - - /** Configure columns. - * - * A `ColumnConfigs` is a `Record`, where a `ColumnConfig` has the following optional options: - * - **width** `number` The width of the column, if not provided, a default width will be chosen by the table. - * - **title** `string` The string to render as the title of the column. - * - **hidden** `boolean` Whether the column should be hidden from the table. - * - * The properties `width` and `hidden` may be modified by the table via UI interactions, with updates provided through the `onColumnConfigsChange` callback. - */ - columnConfigs?: ColumnConfigs | null; - - /** A function that will be called whenever the table changes its `columnConfigs`. - * You can use this to save configurations through sessions. */ - onColumnConfigsChange?: ColumnConfigChangeCallback | null; - - /** Whether to show the row number as column. */ - showRowNumber?: boolean | null; - - /** A function that will be called whenever the table changes its `showRowNumber` value. - * You can use this to save configurations through sessions. */ - onShowRowNumberChange?: (showRowNumber: boolean) => void; - - /** A Mosaic `Selection` used to filter the table. */ - filter?: Selection | null; - - /** A row's ID to scroll to. When this value is updated, the table will scroll to that row. */ - scrollTo?: any | null; - - /** Light or dark mode. */ - colorScheme?: "light" | "dark" | null; - - /** A theme object, which has the following options: - * - * - **primaryTextColor** `string` The text color of elements such as cells. - * - **secondaryTextColor** `string` The text color of elements such as headers. - * - **tertiaryTextColor** `string` The text color of elements such as sort buttons. - * - **fontFamily** `string` The font family of text in the table. - * - **fontSize** `string` The font size of text in the table. - * - **primaryBackgroundColor** `string` The background of elements such as headers and even-numbered cells. - * - **secondaryBackgroundColor** `string` The background of elements such as odd-numbered cells. - * - **hoverBackgroundColor** `string` The background color of hovered elements such as buttons. - * - **headerFontFamily** `string` The font family of the header, will fall back to the table's font family. - * - **headerFontSize** `string` The font size of the header, will fall back to the table's font size. - * - **cellFontFamily** `string` The font family of the cells, will fall back to the table's font family. - * - **cellFontSize** `string` The font size of the cells, will fall back to the table's font size. - * - **scrollbarBackgroundColor** `string` The background color of the scrollbars. - * - **scrollbarPillColor** `string` The background color of the scrollbar pills. - * - **scrollbarLabelBackgroundColor** `string` The background color of the vertical scrollbar label. - * - **shadow** `string` The shadow of elements such as overlays. - * - **outlineColor** `string` The outline of elements such as overlays. - * - **dimmedRowColor** `string` The overlay color for dimmed rows when highlighted rows are present in the table. - * - **rowScrollToColor** `string` The color of rows will flash when they're scrolled to using the `scrollTo` property of the table. - * - **rowHoverColor** `string` The color of rows when they're hovered, enabled through the `showHoveredRow` property of the table. - * - * These values can be css variables if you wish to use css defined custom properties. For example: `{ primaryTextColor: "var(--my-color-variable)" }`. - * - * You can also provide these options as `light` and `dark` properties, which will control the appearance of the table depending on its `colorScheme`. For example: - * - * ```ts - * { - * light: { - * primaryTextColor: "black"; - * } - * dark: { - * primaryTextColor: "white"; - * } - * } - * ``` - */ - theme?: Theme | null; - - /** The height of each line of text, in pixels. Defaults to `20`. */ - lineHeight?: number | null; - - /** The number of lines of text to show in each row. Defaults to `3`. */ - numLines?: number | null; - - /** You can use this to designate custom renderers for columns. A `CustomCellsConfig` is a `{ [column: string]: CustomCell }`. */ - customCells?: CustomCellsConfig | null; - - /** You can use this to designate additional content for column headers. - * Additional header content is rendered above the title of the header, and can - * be used to add helpful content such as summaries and visualizations to headers. - * A `AdditionalHeaderContentsConfig` is a `{ [column: string]: AdditionalHeaderContent }`. */ - additionalHeaderContents?: AdditionalHeaderContentsConfig | null; - - /** The height of the header, in pixels. Defaults to an auto height based on rendered title. */ - headerHeight?: number | null; - - /** A handler for rows being clicked, which can be used to coordinate with other views. */ - onRowClick?: RowClickCallback | null; - - /** When provided, these all other rows will be dimmed in the table. */ - highlightedRows?: any[] | null; - - /** Whether to highlight the hovered row. */ - highlightHoveredRow?: boolean | null; -} - -export class Table { - private component: any; - private currentProps: TableProps; - - constructor(target: HTMLElement, props: TableProps) { - this.currentProps = { ...props }; - this.component = createClassComponent({ component: Component, target: target, props: props }); - } - - update(props: Partial) { - let updates: Partial = {}; - for (let key in props) { - if ((props as any)[key] !== (this.currentProps as any)[key]) { - (updates as any)[key] = (props as any)[key]; - (this.currentProps as any)[key] = (props as any)[key]; - } - } - this.component.$set(updates); - } - - destroy() { - this.component.$destroy(); - } -} - -export type { CustomCell, AdditionalHeaderContent as CustomHeader }; diff --git a/packages/table/src/lib/model/Schema.svelte.ts b/packages/table/src/lib/model/Schema.svelte.ts deleted file mode 100644 index 00d33b4b..00000000 --- a/packages/table/src/lib/model/Schema.svelte.ts +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -import type { JSType } from "@uwdata/mosaic-core"; - -import type { ColumnInfo } from "../mosaic-clients/RowsClient.js"; - -export type ColumnDataType = { [column: string]: JSType }; -export type ColumnSqlType = { [column: string]: string }; - -export class Schema { - public columnInfo: ColumnInfo | null = $state(null); - public dataType: ColumnDataType = $derived.by(() => { - if (!this.columnInfo) { - return {}; - } - - return Object.keys(this.columnInfo).reduce((dict: ColumnDataType, c) => { - dict[c] = this.columnInfo![c].type; - return dict; - }, {}); - }); - public sqlType: ColumnSqlType = $derived.by(() => { - if (!this.columnInfo) { - return {}; - } - - return Object.keys(this.columnInfo).reduce((dict: ColumnSqlType, c) => { - dict[c] = this.columnInfo![c].sqlType; - return dict; - }, {}); - }); -} diff --git a/packages/table/src/lib/model/TableModel.svelte.ts b/packages/table/src/lib/model/TableModel.svelte.ts deleted file mode 100644 index 084d0ba5..00000000 --- a/packages/table/src/lib/model/TableModel.svelte.ts +++ /dev/null @@ -1,209 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -import { ConfigContext } from "../context/config.svelte.js"; -import type { Schema } from "./Schema.svelte.js"; - -export type TableData = { [rowId: string]: any }; -export type RowPositions = { [rowId: string]: number }; -export type ColPositions = { [colId: string]: number }; -export type RowHeights = { [rowId: string]: number }; -export type ColWidths = { [colId: string]: number }; -export type OIDToRow = { [oid: number]: string }; - -export const OID = "__oid"; -export const KEY = "__key"; -export const DEFAULT_COL_WIDTH = 120; - -export interface Cell { - row: string; - col: string; -} - -export class TableModel { - schema: Schema; - - data: TableData = $state({}); - defaultColWidths: ColWidths = $state({}); - public columns: string[] = $state([]); - public numRows: number = $state(0); - public renderOffset = $state(0); - public rowHeightAddition: RowHeights = $state({}); - public hiddenColumns = $derived.by(() => { - return this.columns.reduce((set: Set, col) => { - if (ConfigContext.config.columnConfigs[col]?.hidden) { - set.add(col); - } - - if (col === OID && ConfigContext.config.showRowNumber === false) { - set.add(OID); - } - return set; - }, new Set()); - }); - - rowKeyColumn: string | null = null; - - constructor(schema: Schema) { - this.schema = schema; - } - - public renderableRows: string[] = $derived( - Object.keys(this.data).sort((a, b) => this.data[a][OID] - this.data[b][OID]), - ); - public renderableCols: string[] = $derived.by(() => { - return this.columns.filter((c) => !this.hiddenColumns.has(c)); - }); - public minRowPosition: number = $derived.by(() => { - if (this.renderableRows.length === 0) { - return this.zeroRowPosition; - } - return Math.min(...this.renderableRows.map((row) => this.rowPositions[row])); - }); - public maxRowPosition: number = $derived.by(() => { - if (this.renderableRows.length === 0) { - return this.finalRowPosition; - } - return Math.max(...this.renderableRows.map((row) => this.rowPositions[row])); - }); - public minRowOID: number = $derived.by(() => { - const min = Math.min(...this.renderableRows.map((row) => this.data[row][OID])); - return Number.isSafeInteger(min) ? min : 0; - }); - public maxRowOID: number = $derived.by(() => { - const max = Math.max(...this.renderableRows.map((row) => this.data[row][OID])); - return Number.isSafeInteger(max) ? max : 0; - }); - public zeroRowPosition: number = $derived.by(() => { - return 0; - }); - public finalRowPosition: number = $derived.by(() => { - return (this.numRows - 1) * ConfigContext.config.rowHeight + this.rowPositionOffsets.cumulative; - }); - - public colsLeftmostPosition: number = 0; - public colsRightmostPosition: number = $derived.by(() => { - const lastCol = this.renderableCols[this.renderableCols.length - 1]; - return this.colPositions[lastCol] + this.colWidths[lastCol]; - }); - - rowPositionOffsets: { offsets: RowPositions; cumulative: number } = $derived.by(() => { - return this.renderableRows.reduce( - ({ offsets, cumulative }: { offsets: RowPositions; cumulative: number }, row) => { - offsets[row] = cumulative; - const offset = this.rowHeightAddition[row] ?? 0; - return { offsets, cumulative: cumulative + offset }; - }, - { offsets: {}, cumulative: 0 }, - ); - }); - rowPositions: RowPositions = $derived.by(() => { - return this.renderableRows.reduce((positions: RowPositions, row) => { - const d = this.data[row]; - const oid = d[OID]; - const y = (oid - 1) * ConfigContext.config.rowHeight + this.rowPositionOffsets.offsets[row]; - positions[row] = y; - return positions; - }, {}); - }); - colPositions: ColPositions = $derived.by(() => { - let x = 0; - return this.columns.reduce((positions: ColPositions, col, i) => { - if (!this.hiddenColumns.has(col)) { - positions[col] = x; - x += this.colWidths[col]; - } - - return positions; - }, {}); - }); - rowHeights: RowHeights = $derived.by(() => { - return this.renderableRows.reduce((heights: RowHeights, row) => { - heights[row] = ConfigContext.config.rowHeight + (this.rowHeightAddition[row] ?? 0); - return heights; - }, {}); - }); - colWidths: ColWidths = $derived.by(() => { - return this.columns.reduce((widths: ColWidths, col, i) => { - widths[col] = Math.max( - ConfigContext.config.columnConfigs[col]?.width ?? this.defaultColWidths[col] ?? DEFAULT_COL_WIDTH, - ConfigContext.config.minColumnWidths[col] ?? 0, - ); - if (this.isFirstCol(col)) { - widths[col] += ConfigContext.config.firstColLeftPadding; // leave room for the column selector - } - if (this.isLastCol(col)) { - // add the vertical scrollbar width to the last column - widths[col] += ConfigContext.config.verticalScrollbarWidth; - } - return widths; - }, {}); - }); - - getContent({ row, col }: Cell): T | null { - if (this.data[row]) { - return this.data[row][col]; - } - - return null; - } - - getRowData(row: string): any | null { - if (this.data[row]) { - return this.data[row]; - } - - return null; - } - - getPosition({ row, col }: Cell): { x: number; y: number } { - const x = this.colPositions[col]; - const y = this.rowPositions[row]; - return { x, y }; - } - - getDimensions({ row, col }: Cell): { width: number; height: number } { - const width = this.colWidths[col]; - const height = this.rowHeights[row]; - - return { width, height }; - } - - getRowParity(row: string): "even" | "odd" { - if (this.data[row]) { - return this.data[row][OID] % 2 === 0 ? "even" : "odd"; - } - - return "odd"; - } - - isFirstCol(col: string): boolean { - return this.renderableCols.indexOf(col) === 0; - } - - isLastCol(col: string): boolean { - return this.renderableCols.indexOf(col) === this.renderableCols.length - 1; - } - - // Deletes the given row and returns the offset necessary to remove from scroll position. - deleteRow(row: string): number { - delete this.data[row]; - const heightRemoval = this.rowHeightAddition[row] ?? 0; - delete this.rowHeightAddition[row]; - return heightRemoval; - } - - collapseRow(row: string): number { - const heightRemoval = this.rowHeightAddition[row] ?? 0; - delete this.rowHeightAddition[row]; - return heightRemoval; - } - - reset() { - this.data = {}; - this.rowHeightAddition = {}; - } - - teardown() { - this.reset(); - } -} diff --git a/packages/table/src/lib/modifiers/overscroll.svelte.ts b/packages/table/src/lib/modifiers/overscroll.svelte.ts deleted file mode 100644 index 37456c1c..00000000 --- a/packages/table/src/lib/modifiers/overscroll.svelte.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -import { ConfigContext } from "../context/config.svelte.js"; -import type { TableController } from "../controllers/TableController.svelte.js"; - -export class OverscrollModifier { - tableController: TableController; - - offset = $derived.by(() => { - return ( - Math.floor(-this.tableController.yScroll / ConfigContext.config.scrollOverflowValue) * - ConfigContext.config.scrollOverflowValue - ); - }); - - constructor(tableController: TableController) { - this.tableController = tableController; - } - - public y(y: number) { - return y - this.offset; - } - - public yScroll(yScroll: number) { - return yScroll + this.offset; - } -} diff --git a/packages/table/src/lib/mosaic-clients/NumRowsClient.ts b/packages/table/src/lib/mosaic-clients/NumRowsClient.ts deleted file mode 100644 index fbb000c9..00000000 --- a/packages/table/src/lib/mosaic-clients/NumRowsClient.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -import { MosaicClient, Selection } from "@uwdata/mosaic-core"; -import { count, Query } from "@uwdata/mosaic-sql"; - -type ResultCallback = (count: number) => void; - -export class NumRowsClient extends MosaicClient { - tableName: string; - onResult: ResultCallback; - - constructor(tableName: string, filterBy: Selection | null, onResult: ResultCallback) { - super(filterBy ?? undefined); - this.tableName = tableName; - this.onResult = onResult; - } - - queryResult(data: any): this { - const arr = data.toArray(); - const count = arr[0]["count"]; - this.onResult(count); - return this; - } - - query(filter: any = []): any { - const query = Query.from(this.tableName).select({ count: count() }).where(filter); - return query; - } -} diff --git a/packages/table/src/lib/mosaic-clients/RowsClient.ts b/packages/table/src/lib/mosaic-clients/RowsClient.ts deleted file mode 100644 index a03d7956..00000000 --- a/packages/table/src/lib/mosaic-clients/RowsClient.ts +++ /dev/null @@ -1,112 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -import type { FieldInfo } from "@uwdata/mosaic-core"; -import { MosaicClient, queryFieldInfo, Selection } from "@uwdata/mosaic-core"; -import { cast, column, desc, Query, row_number } from "@uwdata/mosaic-sql"; - -import type { Sort } from "../types/index.js"; - -type ResultCallback = (data: any[]) => void; -type ColumnInfoCallback = (columnInfo: ColumnInfo) => void; - -export const OID = "__oid"; -export const KEY = "__key"; - -export type ColumnInfo = { [column: string]: FieldInfo }; - -export class RowsClient extends MosaicClient { - tableName: string; - columns: string[]; - onResult: ResultCallback; - onColumnInfo: ColumnInfoCallback; - - limit: number = 20; - offset: number = 0; - - sort: Sort | null = null; - info: FieldInfo[] | null = null; - columnInfo: ColumnInfo | null = null; - isReady: boolean = false; - - constructor( - tableName: string, - columns: string[], - filterBy: Selection | null, - onResult: ResultCallback, - onColumnInfo: ColumnInfoCallback, - ) { - super(filterBy ?? undefined); - this.tableName = tableName; - this.columns = columns; - this.onResult = onResult; - this.onColumnInfo = onColumnInfo; - } - - async prepare() { - if (this.coordinator == null) { - return; - } - let info = await queryFieldInfo(this.coordinator, [{ table: this.tableName, column: "*" }]); - const columnInfo = info.reduce((dict: ColumnInfo, f) => { - dict[f.column] = f; - return dict; - }, {}); - this.columnInfo = columnInfo; - this.onColumnInfo(columnInfo); - this.isReady = true; - } - - public getSelect({ includeRowNumber }: { includeRowNumber: boolean } = { includeRowNumber: true }) { - const select = this.columns.reduce((dict: any, col) => { - if (this.columnInfo?.[col]?.sqlType === "BIGINT") { - dict[col] = cast(column(col), "TEXT"); - } else { - dict[col] = column(col); - } - return dict; - }, {}); - - if (!includeRowNumber) { - delete select[OID]; - } - - return select; - } - - queryResult(data: any): this { - this.onResult(data); - return this; - } - - query(filter: any = []): any { - if (!this.isReady) { - return null; - } - - const select = this.columns.reduce((dict: any, col) => { - if (this.columnInfo?.[col]?.sqlType === "BIGINT") { - dict[col] = cast(column(col), "TEXT"); - } else { - dict[col] = column(col); - } - return dict; - }, {}); - - select[OID] = row_number(); - - if (this.sort) { - const orderby = this.sort.direction === "ascending" ? this.sort.column : desc(this.sort.column); - select[OID] = select[OID].orderby(orderby); - } - - const query = Query.from(this.tableName).select(select).where(filter).limit(this.limit).offset(this.offset); - - return query; - } - - fetchRows(offset: number, limit: number) { - this.offset = offset; - this.limit = limit; - this.requestUpdate(); - } -} diff --git a/packages/table/src/lib/types/index.ts b/packages/table/src/lib/types/index.ts deleted file mode 100644 index 052b203f..00000000 --- a/packages/table/src/lib/types/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -export type SortDirection = "ascending" | "descending"; - -export interface Sort { - column: string; - direction: SortDirection; -} diff --git a/packages/table/src/lib/util.ts b/packages/table/src/lib/util.ts deleted file mode 100644 index 66bda977..00000000 --- a/packages/table/src/lib/util.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -export function diff(left: T[], right: T[]): { left: T[]; right: T[] } { - const leftSet = new Set(left); - const rightSet = new Set(right); - - return { - left: left.filter((element) => !rightSet.has(element)), - right: right.filter((element) => !leftSet.has(element)), - }; -} - -export function remove(arr: T[], toRemove: T[]): T[] { - const removeSet = new Set(toRemove); - return arr.filter((element) => !removeSet.has(element)); -} - -export function add(arr: T[], toAdd: T[]): T[] { - return arr.concat(toAdd); -} diff --git a/packages/table/src/lib/views/HorizontalScrollbar.svelte b/packages/table/src/lib/views/HorizontalScrollbar.svelte deleted file mode 100644 index b5d5432b..00000000 --- a/packages/table/src/lib/views/HorizontalScrollbar.svelte +++ /dev/null @@ -1,90 +0,0 @@ - - - -
-
-
- - diff --git a/packages/table/src/lib/views/RowBackground.svelte b/packages/table/src/lib/views/RowBackground.svelte deleted file mode 100644 index 6372c10b..00000000 --- a/packages/table/src/lib/views/RowBackground.svelte +++ /dev/null @@ -1,84 +0,0 @@ - - - -
- -{#if highlighted !== null && !highlighted} -
-{/if} - - diff --git a/packages/table/src/lib/views/VerticalScrollbar.svelte b/packages/table/src/lib/views/VerticalScrollbar.svelte deleted file mode 100644 index 2b4a4781..00000000 --- a/packages/table/src/lib/views/VerticalScrollbar.svelte +++ /dev/null @@ -1,131 +0,0 @@ - - - -
-
-
- {rowString} -
-
-
- - diff --git a/packages/table/src/lib/views/cells/Cell.svelte b/packages/table/src/lib/views/cells/Cell.svelte deleted file mode 100644 index 8bef48e0..00000000 --- a/packages/table/src/lib/views/cells/Cell.svelte +++ /dev/null @@ -1,100 +0,0 @@ - - - - -
{ - if (e.key === "Enter") { - onClick(); - } - }} - onpointerenter={() => { - hovered = true; - controller.hoveredRowId = row; - }} - onpointerleave={() => { - hovered = false; - controller.hoveredRowId = null; - }} -> - {#if col !== OID} - - {:else} - - {/if} -
- - diff --git a/packages/table/src/lib/views/cells/CellContent.svelte b/packages/table/src/lib/views/cells/CellContent.svelte deleted file mode 100644 index 79c5152c..00000000 --- a/packages/table/src/lib/views/cells/CellContent.svelte +++ /dev/null @@ -1,124 +0,0 @@ - - - -
- {#if customCellsConfig[col]} - - {:else if type === "string"} - {#if content && isLink(content)} - - {:else} - - {/if} - {:else if type === "number"} - {#if sqlType === "BIGINT"} - - {:else} - - {/if} - {:else if isImage(content)} - - {:else} - - {/if} - - {#if clamped} - - {/if} -
- - diff --git a/packages/table/src/lib/views/cells/RowNumber.svelte b/packages/table/src/lib/views/cells/RowNumber.svelte deleted file mode 100644 index 010cb239..00000000 --- a/packages/table/src/lib/views/cells/RowNumber.svelte +++ /dev/null @@ -1,31 +0,0 @@ - - - -
- {formatted} -
- - diff --git a/packages/table/src/lib/views/cells/cell-contents/BigIntContent.svelte b/packages/table/src/lib/views/cells/cell-contents/BigIntContent.svelte deleted file mode 100644 index 51d90419..00000000 --- a/packages/table/src/lib/views/cells/cell-contents/BigIntContent.svelte +++ /dev/null @@ -1,30 +0,0 @@ - - - -
- {format(bigint)} -
- - diff --git a/packages/table/src/lib/views/cells/cell-contents/CustomCellContents.svelte b/packages/table/src/lib/views/cells/cell-contents/CustomCellContents.svelte deleted file mode 100644 index d98d5de8..00000000 --- a/packages/table/src/lib/views/cells/cell-contents/CustomCellContents.svelte +++ /dev/null @@ -1,45 +0,0 @@ - - - -
- - diff --git a/packages/table/src/lib/views/cells/cell-contents/ImageContent.svelte b/packages/table/src/lib/views/cells/cell-contents/ImageContent.svelte deleted file mode 100644 index 164ee1dc..00000000 --- a/packages/table/src/lib/views/cells/cell-contents/ImageContent.svelte +++ /dev/null @@ -1,97 +0,0 @@ - - - - { - if (element) { - height = element.scrollHeight; - } - }} - src={imageToDataUrl(image)} - alt="" -/> diff --git a/packages/table/src/lib/views/cells/cell-contents/LinkContent.svelte b/packages/table/src/lib/views/cells/cell-contents/LinkContent.svelte deleted file mode 100644 index 2e055dd5..00000000 --- a/packages/table/src/lib/views/cells/cell-contents/LinkContent.svelte +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/packages/table/src/lib/views/cells/cell-contents/NumberContent.svelte b/packages/table/src/lib/views/cells/cell-contents/NumberContent.svelte deleted file mode 100644 index 2f61869d..00000000 --- a/packages/table/src/lib/views/cells/cell-contents/NumberContent.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - - -
- {format(number)} -
- - diff --git a/packages/table/src/lib/views/cells/cell-contents/TextContent.svelte b/packages/table/src/lib/views/cells/cell-contents/TextContent.svelte deleted file mode 100644 index b426da4b..00000000 --- a/packages/table/src/lib/views/cells/cell-contents/TextContent.svelte +++ /dev/null @@ -1,41 +0,0 @@ - - - -
- {text} -
- - diff --git a/packages/table/src/lib/views/headers/CustomHeaderContents.svelte b/packages/table/src/lib/views/headers/CustomHeaderContents.svelte deleted file mode 100644 index 899558a0..00000000 --- a/packages/table/src/lib/views/headers/CustomHeaderContents.svelte +++ /dev/null @@ -1,42 +0,0 @@ - - - -
- - diff --git a/packages/table/src/lib/views/headers/Header.svelte b/packages/table/src/lib/views/headers/Header.svelte deleted file mode 100644 index 14cd1d77..00000000 --- a/packages/table/src/lib/views/headers/Header.svelte +++ /dev/null @@ -1,100 +0,0 @@ - - - -
-
- {#if customHeadersConfig[col]} - - {/if} -
- {#if col !== OID} - - - {:else} - - {/if} -
-
-
- - diff --git a/packages/table/src/lib/views/headers/HeaderRow.svelte b/packages/table/src/lib/views/headers/HeaderRow.svelte deleted file mode 100644 index b366c8df..00000000 --- a/packages/table/src/lib/views/headers/HeaderRow.svelte +++ /dev/null @@ -1,146 +0,0 @@ - - - -
-
- - {#each columns as col (col)} -
- - {/each} -
-
- - diff --git a/packages/table/src/lib/views/headers/HeaderTitle.svelte b/packages/table/src/lib/views/headers/HeaderTitle.svelte deleted file mode 100644 index 0f3c1149..00000000 --- a/packages/table/src/lib/views/headers/HeaderTitle.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - - -
- {config.columnConfigs[col]?.title ?? col} -
- - diff --git a/packages/table/src/lib/views/headers/Resizer.svelte b/packages/table/src/lib/views/headers/Resizer.svelte deleted file mode 100644 index f897ea57..00000000 --- a/packages/table/src/lib/views/headers/Resizer.svelte +++ /dev/null @@ -1,59 +0,0 @@ - - - -
-
-
- - diff --git a/packages/table/src/lib/views/headers/RowNumberTitle.svelte b/packages/table/src/lib/views/headers/RowNumberTitle.svelte deleted file mode 100644 index 324b30eb..00000000 --- a/packages/table/src/lib/views/headers/RowNumberTitle.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - - -
#
- - diff --git a/packages/table/src/lib/views/headers/SortButtons.svelte b/packages/table/src/lib/views/headers/SortButtons.svelte deleted file mode 100644 index 42a710b0..00000000 --- a/packages/table/src/lib/views/headers/SortButtons.svelte +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - diff --git a/packages/table/src/lib/views/shared/Dropdown.svelte b/packages/table/src/lib/views/shared/Dropdown.svelte deleted file mode 100644 index 627af9f6..00000000 --- a/packages/table/src/lib/views/shared/Dropdown.svelte +++ /dev/null @@ -1,79 +0,0 @@ - - - - - { - if (open && e.target !== element) { - open = false; - } - }} -/> - -{#if open} - - {@render children()} - -{/if} - - diff --git a/packages/table/src/lib/views/shared/TablePortal.svelte b/packages/table/src/lib/views/shared/TablePortal.svelte deleted file mode 100644 index 46d7ffe2..00000000 --- a/packages/table/src/lib/views/shared/TablePortal.svelte +++ /dev/null @@ -1,84 +0,0 @@ - - - - - -
{ - // dont let clicks bubble up - e.stopPropagation(); - }} - onwheel={(e) => { - // dont let wheel events bubble up - e.stopPropagation(); - }} -> - {@render children()} -
- - diff --git a/packages/table/svelte.config.js b/packages/table/svelte.config.js deleted file mode 100644 index 41ac22be..00000000 --- a/packages/table/svelte.config.js +++ /dev/null @@ -1,8 +0,0 @@ -import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; - -/** @type {import('@sveltejs/kit').Config} */ -const config = { - preprocess: vitePreprocess(), -}; - -export default config; diff --git a/packages/table/svelte/Table.svelte b/packages/table/svelte/Table.svelte deleted file mode 100644 index 81dbf12e..00000000 --- a/packages/table/svelte/Table.svelte +++ /dev/null @@ -1,26 +0,0 @@ - - - -
diff --git a/packages/table/svelte/index.d.ts b/packages/table/svelte/index.d.ts deleted file mode 100644 index 0711589b..00000000 --- a/packages/table/svelte/index.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -import Table from "./Table.svelte"; - -export { Table }; diff --git a/packages/table/svelte/index.js b/packages/table/svelte/index.js deleted file mode 100644 index 0711589b..00000000 --- a/packages/table/svelte/index.js +++ /dev/null @@ -1,5 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -import Table from "./Table.svelte"; - -export { Table }; diff --git a/packages/table/tsconfig.json b/packages/table/tsconfig.json deleted file mode 100644 index 6bf8c3e0..00000000 --- a/packages/table/tsconfig.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "extends": "@tsconfig/svelte/tsconfig.json", - "compilerOptions": { - "target": "ESNext", - "useDefineForClassFields": true, - "module": "ESNext", - "resolveJsonModule": true, - /** - * Typecheck JS in `.svelte` and `.js` files by default. - * Disable checkJs if you'd like to use dynamic types in JS. - * Note that setting allowJs false does not prevent the use - * of JS in `.svelte` files. - */ - "allowJs": true, - "checkJs": true, - "isolatedModules": true, - "composite": true, - "skipLibCheck": true, - "moduleResolution": "bundler", - "strict": true, - "noEmit": true, - - "maxNodeModuleJsDepth": 5 - }, - "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"] -} diff --git a/packages/table/vite.config.ts b/packages/table/vite.config.ts deleted file mode 100644 index c3220ca8..00000000 --- a/packages/table/vite.config.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { svelte } from "@sveltejs/vite-plugin-svelte"; -import { defineConfig } from "vite"; -import dts from "vite-plugin-dts"; - -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [svelte({ emitCss: false }), dts({ rollupTypes: true })], - build: { - target: "esnext", - lib: { - entry: { - index: "./src/lib/index.ts", - }, - fileName: (_, entryName) => `${entryName}.js`, - formats: ["es"], - }, - rollupOptions: { - external: ["@uwdata/mosaic-core", "@uwdata/mosaic-sql"], - }, - copyPublicDir: false, - }, - optimizeDeps: { - exclude: ["svelte"], - }, -}); diff --git a/packages/utils/src/equals.ts b/packages/utils/src/equals.ts index 90bf257a..2e08cff4 100644 --- a/packages/utils/src/equals.ts +++ b/packages/utils/src/equals.ts @@ -1,5 +1,15 @@ // Copyright (c) 2025 Apple Inc. Licensed under MIT License. +/** + * Performs a deep equality comparison between two values. + * + * Recursively compares objects and arrays by their contents rather than reference. + * Handles null values and primitive types appropriately. + * + * @param a - First value to compare + * @param b - Second value to compare + * @returns `true` if the values are deeply equal, `false` otherwise + */ export function deepEquals(a: any, b: any): boolean { if (a === b) { return true; @@ -24,6 +34,24 @@ export function deepEquals(a: any, b: any): boolean { return true; } +/** + * Creates a memoized version of a function that uses deep equality comparison. + * + * The memoized function caches its most recent result and returns the cached value + * if the new result is deeply equal to the previous one. This is useful for + * maintaining referential stability when the computed value hasn't meaningfully changed. + * + * @example + * ```ts + * // Use with Svelte's $derived.by() to maintain referential stability + * let items = $derived.by(deepMemo(() => computeItems(source))); + * ``` + * + * @template Args - The argument types of the function + * @template T - The return type of the function + * @param fn - The function to memoize + * @returns A memoized version of the function that returns the cached result if deeply equal to the previous result + */ export function deepMemo(fn: (...args: Args) => T): (...args: Args) => T { let memo: T | undefined = undefined; return (...args) => { diff --git a/packages/viewer/package.json b/packages/viewer/package.json index 2976d563..27027f48 100644 --- a/packages/viewer/package.json +++ b/packages/viewer/package.json @@ -38,7 +38,6 @@ "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.38.5", "@embedding-atlas/component": "*", - "@embedding-atlas/table": "*", "@embedding-atlas/umap-wasm": "*", "@embedding-atlas/utils": "*", "@floating-ui/dom": "^1.7.4", @@ -58,6 +57,7 @@ "flexsearch": "^0.8.205", "html-to-image": "^1.11.13", "json-schema": "^0.4.0", + "liquidjs": "^10.24.0", "mark.js": "^8.11.1", "marked": "^17.0.0", "nanoid": "^5.1.5", diff --git a/packages/viewer/src/EmbeddingAtlas.svelte b/packages/viewer/src/EmbeddingAtlas.svelte index e3544514..9cfbd3f4 100644 --- a/packages/viewer/src/EmbeddingAtlas.svelte +++ b/packages/viewer/src/EmbeddingAtlas.svelte @@ -20,6 +20,7 @@ import Spinner from "./widgets/Spinner.svelte"; import { + IconBraces, IconClose, IconDarkMode, IconDashboardLayout, @@ -36,7 +37,7 @@ import { defaultCharts } from "./charts/default_charts.js"; import { EMBEDDING_ATLAS_VERSION } from "./constants.js"; import { provideModelContext } from "./model_context/model_context.js"; - import { type ColumnStyle } from "./renderers/index.js"; + import { type ColumnStyle } from "./renderers/types.js"; import { performSearch, querySearchResultItems, resolveSearcher, type SearchResultItem } from "./search/search.js"; import { makeColorSchemeStore } from "./utils/color_scheme.js"; import { columnDescriptions, predicateToString, type ColumnDesc } from "./utils/database.js"; @@ -54,7 +55,6 @@ embeddingViewLabels = null, chartTheme, colorScheme: colorSchemeProp, - tableCellRenderers, onExportApplication, onExportSelection, onStateChange, @@ -96,12 +96,10 @@ ): Record { let result: Record = {}; for (let column of columns) { - let style = styles[column.name]; - if (style == null) { - // Default display style - style = { display: data.text == column.name ? "full" : "badge" }; - } - result[column.name] = style; + result[column.name] = { + display: data.text == column.name ? "full" : "badge", + ...(styles[column.name] ?? {}), + }; } return result; } @@ -221,30 +219,32 @@ } function loadState(state: EmbeddingAtlasState) { - if (typeof state.version != "string") { - return; - } charts = state.charts ?? {}; chartStates = state.chartStates ?? {}; layout = state.layout ?? "list"; layoutStates = state.layoutStates ?? {}; + columnStyles = state.columnStyles ?? {}; } - // Emit onStateChange event. - $effect(() => { - if (!initialized) { - return; - } - let state: EmbeddingAtlasState = { + function getCurrentState(): EmbeddingAtlasState { + return { version: EMBEDDING_ATLAS_VERSION, timestamp: new Date().getTime() / 1000, charts: charts, chartStates: chartStates, layout: layout, layoutStates: layoutStates, + columnStyles: columnStyles, predicate: currentPredicate(), }; - onStateChange?.(state); + } + + // Emit onStateChange event. + $effect(() => { + if (!initialized) { + return; + } + onStateChange?.(getCurrentState()); }); onMount(async () => { @@ -306,7 +306,6 @@ highlight: writable(null), embeddingViewConfig: embeddingViewConfig, embeddingViewLabels: embeddingViewLabels, - tableCellRenderers: tableCellRenderers, }; let charts = $state.raw>({}); @@ -362,6 +361,12 @@ get container() { return container; }, + get columnStyles() { + return columnStyles; + }, + set columnStyles(x) { + columnStyles = x; + }, }); $effect(() => { @@ -374,6 +379,11 @@ }); } }); + + async function onCopyState() { + let text = JSON.stringify(getCurrentState()); + await navigator.clipboard.writeText(text); + }
@@ -530,9 +540,18 @@ }} /> {/if} - + +

Export

+
+ +
{#if onExportApplication} -

Export

; - /** A callback to export the currently selected points. */ onExportSelection?: | ((predicate: string | null, format: "json" | "jsonl" | "csv" | "parquet") => Promise) @@ -94,11 +91,11 @@ export interface EmbeddingAtlasProps { } export interface EmbeddingAtlasState { - /** The version of Embedding Atlas that created this state. */ - version: string; + /** The version of Embedding Atlas that created this state. If omitted, assume the current version. */ + version?: string; /** UNIX timestamp when this was created. */ - timestamp: number; + timestamp?: number; /** The list of charts. */ charts?: Record; @@ -112,6 +109,9 @@ export interface EmbeddingAtlasState { /** The state of all layouts. */ layoutStates?: Record; + /** Column display and rendering styles. */ + columnStyles?: Record; + /** The selection predicate (SQL expression). * This property is derived from chart states, changing this directly has no effect. */ predicate?: string | null; diff --git a/packages/viewer/src/app/components/MessagesView.svelte b/packages/viewer/src/app/components/MessagesView.svelte index aed6b55c..d0be0687 100644 --- a/packages/viewer/src/app/components/MessagesView.svelte +++ b/packages/viewer/src/app/components/MessagesView.svelte @@ -1,7 +1,7 @@ {#if value != null} - + {:else} (null) {/if} diff --git a/packages/viewer/src/charts/basic/CountPlotBar.svelte b/packages/viewer/src/charts/basic/CountPlotBar.svelte index 3f4653f6..8e571682 100644 --- a/packages/viewer/src/charts/basic/CountPlotBar.svelte +++ b/packages/viewer/src/charts/basic/CountPlotBar.svelte @@ -1,3 +1,4 @@ + + +
+
+ {#each data as row} + {@const rowId = row.__id__} + {@const values = Object.fromEntries(columns.map((col) => [col, row[col]]))} + {@const highlighted = highlightSet.has(rowId)} +
+ +
+ {/each} +
+
diff --git a/packages/viewer/src/charts/instances/Instances.svelte b/packages/viewer/src/charts/instances/Instances.svelte new file mode 100644 index 00000000..60443629 --- /dev/null +++ b/packages/viewer/src/charts/instances/Instances.svelte @@ -0,0 +1,393 @@ + + + +
+
+
+ onSpecChange({ viewMode: v as "table" | "cards" })} + options={[ + { value: "table", icon: IconTableView, title: "Table view" }, + { value: "cards", icon: IconCardView, title: "Card view" }, + ]} + /> + + onSpecChange({ sort: value })} /> +
+
+ +
+ {#if data != null} + {#if viewMode === "table"} +
onSpecChange({ sort: value })} + /> + + {#if offset + pageSize < totalCount} +
+ +
+ {/if} + {:else} + + + {#if offset + pageSize < totalCount} +
+ +
+ {/if} + {/if} + {:else} +
+
Loading...
+
+ {/if} + + diff --git a/packages/viewer/src/charts/instances/SortOrderControl.svelte b/packages/viewer/src/charts/instances/SortOrderControl.svelte new file mode 100644 index 00000000..8bf1eb5a --- /dev/null +++ b/packages/viewer/src/charts/instances/SortOrderControl.svelte @@ -0,0 +1,54 @@ + + + +{#if value?.length ?? 0 > 0} +
+ {#each value ?? [] as item, index} +
+ {item.column} + + +
+ {/each} +
+{/if} diff --git a/packages/viewer/src/charts/instances/Table.svelte b/packages/viewer/src/charts/instances/Table.svelte new file mode 100644 index 00000000..d201f766 --- /dev/null +++ b/packages/viewer/src/charts/instances/Table.svelte @@ -0,0 +1,208 @@ + + + +
+ + + {#each columns as column} + {@const sortOrder = columnSortOrder(column, sort)} + {@const sortButtonHighlight = sortOrder?.isPrimary ?? false} + {@const width = columnWidths[column] ?? defaultColumnWidths[column] ?? 150} + + {/each} + + + + + {#each data as row, index} + {@const rowId = row.__id__} + onRowClick(rowId, e)} + onmousedown={(e) => { + // Prevent text selection when shift-click to multi-select + if (e.shiftKey || e.ctrlKey || e.metaKey) { + e.preventDefault(); + } + }} + bind:this={() => idMapper.get(rowId), (v) => idMapper.set(rowId, v)} + > + {#each columns as column} + + {/each} + + + {/each} + +
+
+
{column}
+ +
+ + +
(hoveredCell = { row: index, col: column })} + onmouseleave={() => (hoveredCell = null)} + > +
+ +
+ {#if !expandedRows.has(index) && isCellClamped(row[column]) && hoveredCell?.row === index && hoveredCell?.col === column} + + {/if} +
diff --git a/packages/viewer/src/charts/instances/query.ts b/packages/viewer/src/charts/instances/query.ts new file mode 100644 index 00000000..92983a82 --- /dev/null +++ b/packages/viewer/src/charts/instances/query.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +import * as SQL from "@uwdata/mosaic-sql"; + +import { predicateToString, resolveSQLTemplate } from "../../utils/database.js"; + +// Helper to build a query with automatic predicate handling +// For custom queries: $predicate is substituted into the query string +// For normal tables: predicate is applied via .where() +export function instancesQuery(options: { + query?: string; + table: string; + predicate?: SQL.FilterExpr | null; +}): SQL.SelectQuery { + if (options.query) { + // Custom query: substitute $table and $predicate in the query string + const predicateStr = options.predicate ? predicateToString(options.predicate) : null; + const queryStr = resolveSQLTemplate(options.query, { table: options.table, filter: predicateStr ?? "(true)" }); + const from = SQL.sql`(${queryStr})`; + return from as any; + } else { + let q = SQL.Query.from(options.table).select("*"); + if (options.predicate) { + q = q.where(options.predicate); + } + return q; + } +} diff --git a/packages/viewer/src/charts/instances/types.ts b/packages/viewer/src/charts/instances/types.ts new file mode 100644 index 00000000..d8b4828a --- /dev/null +++ b/packages/viewer/src/charts/instances/types.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +import type { ColumnStyle } from "../../renderers/types.js"; + +export type SortOrder = { column: string; direction: "ascending" | "descending" }[]; + +export interface InstancesSpec { + type: "instances"; + title?: string; + + /** + * Columns to show in the instance view. + * If specified, the table and card views will be limited to the given columns, and custom card template will only receive the given columns as data. + * If not specified, include all columns from the dataset (or query result is `query` is specified). + */ + columns?: string[]; + + /** Sort order. If not specified, use original data order. */ + sort?: SortOrder; + + /** View mode, defaults to "table" */ + viewMode?: "table" | "cards"; + + /** Optional custom SQL query to filter or transform the data */ + query?: string; + + /** Number of items per page, defaults to 100 */ + pageSize?: number; + + /** Default height in pixels, defaults to 500. This value is used when the view's height is flexible. */ + defaultHeight?: number; + + /** Column styles specific to this instance view. These will override global column styles. */ + columnStyles?: Record; + + /** Liquid template for the cards (rendered with liquidjs). If not specified, use the tooltip view as card */ + cardTemplate?: string; +} + +export interface InstancesState { + offset?: number; +} diff --git a/packages/viewer/src/charts/spec/layer_helper.ts b/packages/viewer/src/charts/spec/layer_helper.ts index 9ad72596..6304a936 100644 --- a/packages/viewer/src/charts/spec/layer_helper.ts +++ b/packages/viewer/src/charts/spec/layer_helper.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + import * as d3 from "d3"; import type { SVGAttributes } from "svelte/elements"; diff --git a/packages/viewer/src/charts/spec/runtime.ts b/packages/viewer/src/charts/spec/runtime.ts index 49029e8c..c9a70a1a 100644 --- a/packages/viewer/src/charts/spec/runtime.ts +++ b/packages/viewer/src/charts/spec/runtime.ts @@ -6,7 +6,7 @@ import * as SQL from "@uwdata/mosaic-sql"; import * as d3 from "d3"; import { derived, writable, type Writable } from "svelte/store"; -import { predicateToString } from "../../utils/database.js"; +import { predicateToString, resolveSQLTemplate } from "../../utils/database.js"; import type { ChartContext } from "../chart.js"; import { computeFieldStats, inferAggregate, type AggregateValue, type FieldStats } from "../common/aggregate.js"; import type { AxisConfig, ScaleConfig, XYSelectionValue } from "../common/types.js"; @@ -196,7 +196,7 @@ class BuildContext { if (typeof field == "string") { return SQL.column(field); } else { - return SQL.sql`${replaceVars(field.sql, vars)}`; + return SQL.sql`${resolveSQLTemplate(field.sql, vars)}`; } } @@ -205,7 +205,7 @@ class BuildContext { if (typeof table == "string") { return new SQL.TableRefNode(table); } else { - return SQL.sql`(${replaceVars(table.sql, vars)})`; + return SQL.sql`(${resolveSQLTemplate(table.sql, vars)})`; } } } @@ -557,16 +557,6 @@ function buildEncoding( } } -function replaceVars(text: string, vars: Record): string { - return text.replace(/\$([a-zA-Z][a-zA-Z0-9\_]*)/g, (original, name) => { - if (vars[name] != undefined) { - return vars[name]; - } else { - return original; - } - }); -} - function fieldTitle(field: SQLField): string { if (typeof field == "string") { return field; diff --git a/packages/viewer/src/charts/table/Table.svelte b/packages/viewer/src/charts/table/Table.svelte deleted file mode 100644 index ea18f815..00000000 --- a/packages/viewer/src/charts/table/Table.svelte +++ /dev/null @@ -1,67 +0,0 @@ - - - - { - highlightStore.set(identifier); - }} - numLines={3} - colorScheme={$colorScheme} - theme={tableTheme} - highlightHoveredRow={true} - customCells={resolvedCustomCellRenderers} -/> diff --git a/packages/viewer/src/charts/table/table_theme.ts b/packages/viewer/src/charts/table/table_theme.ts deleted file mode 100644 index e54c8a6e..00000000 --- a/packages/viewer/src/charts/table/table_theme.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -/** - * This file has inline comments with tailwind variables such that - * the corresponding color variables are available for use. - * DO NOT REMOVE THE COMMENTS! - */ - -export const tableTheme = { - fontSize: "13px", - fontFamily: "system-ui", - light: { - primaryBackgroundColor: "white", - secondaryBackgroundColor: "var(--color-slate-100)", // bg-slate-100 - primaryTextColor: "var(--color-slate-500)", // text-slate-500 - secondaryTextColor: "var(--color-slate-400)", // text-slate-400 - tertiaryTextColor: "var(--color-slate-300)", // text-slate-300 - scrollbarPillColor: "var(--color-slate-400)", // bg-slate-400 - scrollbarLabelBackgroundColor: "white", - rowScrollToColor: "var(--color-blue-200)", // bg-blue-200 - rowHoverColor: "var(--color-blue-100)", // bg-blue-100 - }, - dark: { - primaryBackgroundColor: "black", - secondaryBackgroundColor: "var(--color-slate-900)", // bg-slate-900 - primaryTextColor: "var(--color-slate-400)", // bg-slate-400 - secondaryTextColor: "var(--color-slate-500)", // bg-slate-500 - tertiaryTextColor: "var(--color-slate-600)", // bg-slate-600 - scrollbarPillColor: "var(--color-slate-500)", // bg-slate-500 - scrollbarLabelBackgroundColor: "var(--color-slate-900)", // bg-slate-900 - rowScrollToColor: "var(--color-blue-900)", // bg-slate-900 - rowHoverColor: "var(--color-blue-950)", // bg-slate-950 - }, -}; diff --git a/packages/viewer/src/charts/table/types.ts b/packages/viewer/src/charts/table/types.ts deleted file mode 100644 index ba9e4cc6..00000000 --- a/packages/viewer/src/charts/table/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -export interface TableSpec { - type: "table"; - title: string; - columns: string[]; -} diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 8a1f4f1a..4c76a243 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -4,3 +4,4 @@ export { EmbeddingAtlas } from "./api.js"; export type { Cache, EmbeddingAtlasProps, EmbeddingAtlasState, Searcher } from "./api.js"; export type { BuiltinChartSpec } from "./charts/chart_types.js"; export { defaultCharts } from "./charts/default_charts.js"; +export { registerRenderer } from "./renderers/renderer_types.js"; diff --git a/packages/viewer/src/layouts/LayoutView.svelte b/packages/viewer/src/layouts/LayoutView.svelte index d20c2c3b..cb305a47 100644 --- a/packages/viewer/src/layouts/LayoutView.svelte +++ b/packages/viewer/src/layouts/LayoutView.svelte @@ -64,12 +64,14 @@ onChartStatesChange={updateChartStates} > {#snippet chartView({ id, width, height, mode })} + {@const spec = charts[id]} + {@const chartState = chartStates[id] ?? {}} export type Section = "embedding" | "table" | "chart"; - export function findSection(spec: any): Section | undefined { + export function findSection(spec: any, id: string, placements?: Record): Section | undefined { + if (placements?.[id] != undefined) { + return placements[id]; + } + switch (spec.type) { case "embedding": return "embedding"; - case "table": + case "instances": return "table"; default: return "chart"; } } - export function getSections(charts: Record): Record { + export function getSections(charts: Record, layoutState: ListLayoutState): Record { let r: Record = { embedding: [], table: [], chart: [], }; for (let id in charts) { - let section = findSection(charts[id]); + let section = findSection(charts[id], id, layoutState.placements); if (section != undefined) { r[section].push(id); } @@ -61,7 +65,7 @@ let panelWidth = $state(400); let panelContainerWidth = $state(400); - let sections = $derived.by(deepMemo(() => getSections(charts))); + let sections = $derived.by(deepMemo(() => getSections(charts, layoutState))); let isMobileLayout = $derived(containerWidth < 500); @@ -135,12 +139,12 @@ {/if} {#if hasTable}
{#each sections.table as id (id)} -
+
{@render chartView({ id: id, width: "container", height: "container" })}
{/each} diff --git a/packages/viewer/src/layouts/list/ListLayoutOptions.svelte b/packages/viewer/src/layouts/list/ListLayoutOptions.svelte index 62c9321a..178550d5 100644 --- a/packages/viewer/src/layouts/list/ListLayoutOptions.svelte +++ b/packages/viewer/src/layouts/list/ListLayoutOptions.svelte @@ -10,7 +10,7 @@ let { charts, state, onStateChange }: LayoutOptionsProps = $props(); - let sections = $derived(getSections(charts)); + let sections = $derived(getSections(charts, state));
@@ -26,16 +26,18 @@ } /> {/if} - state.showTable ?? true, - (v) => { - onStateChange({ showTable: v }); + {#if sections.table.length > 0} + state.showTable ?? true, + (v) => { + onStateChange({ showTable: v }); + } } - } - /> + /> + {/if} ; + + placements?: Record; } diff --git a/packages/viewer/src/model_context/model_context.ts b/packages/viewer/src/model_context/model_context.ts index 456cb786..67e992ab 100644 --- a/packages/viewer/src/model_context/model_context.ts +++ b/packages/viewer/src/model_context/model_context.ts @@ -1,9 +1,14 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + import { validate } from "json-schema"; import type { MCPTool, ModelContextAPI, ToolResponse } from "../app/mcp_server.js"; import type { ChartContext, ChartDelegate } from "../charts/chart.js"; +import { renderersList } from "../renderers/renderer_types.js"; +import type { ColumnStyle } from "../renderers/types.js"; import { schemaBuiltinChartSpec, schemaBuiltinChartState, + schemaColumnStyle, schemaDashboardLayoutState, schemaListLayoutState, } from "../schemas.js"; @@ -18,6 +23,7 @@ export interface ModelContextDelegate { layoutStates: Record; chartDelegates: Map>; container: HTMLDivElement; + columnStyles: Record; } export function provideModelContext(api: ModelContextAPI, delegate: ModelContextDelegate) { @@ -53,6 +59,45 @@ export function provideModelContext(api: ModelContextAPI, delegate: ModelContext return jsonResponse(result.toArray()); }, }, + { + name: "list_renderers", + description: + "Get a list of value renderers to display values in the table, cards, or tooltip. Renderers can be set in ColumnStyle", + inputSchema: { type: "object", additionalProperties: false }, + execute: async () => { + return jsonResponse(renderersList); + }, + }, + { + name: "get_column_styles", + description: "Get column styles for all columns.", + inputSchema: { type: "object", additionalProperties: false }, + execute: async () => { + return jsonResponse(delegate.columnStyles); + }, + }, + { + name: "set_column_style", + description: `Set column style for a given column`, + inputSchema: { + type: "object", + properties: { + column: { type: "string" }, + style: { + type: "object", + description: `The column style. Schema: ${JSON.stringify(schemaColumnStyle)}. Use the list_renderers tool to get the list of renderers.`, + }, + }, + additionalProperties: false, + }, + execute: async (params: { column: string; style: any }) => { + delegate.columnStyles = { + ...delegate.columnStyles, + [params.column]: params.style, + }; + return textResponse("success"); + }, + }, { name: "list_charts", description: "List all charts in Embedding Atlas.", diff --git a/packages/viewer/src/renderers/ContentRenderer.svelte b/packages/viewer/src/renderers/ContentRenderer.svelte index 7344a657..7eeae954 100644 --- a/packages/viewer/src/renderers/ContentRenderer.svelte +++ b/packages/viewer/src/renderers/ContentRenderer.svelte @@ -1,32 +1,24 @@ -{#if rendererClass == null} +{#if rendererAction == null} {#if isLink(value)} {value} {:else if isImage(value)} @@ -35,7 +27,7 @@ {stringify(value)} {/if} {:else} - {#key rendererClass} -
+ {#key rendererAction} +
{/key} {/if} diff --git a/packages/viewer/src/renderers/ImageOptions.svelte b/packages/viewer/src/renderers/ImageOptions.svelte new file mode 100644 index 00000000..767256d9 --- /dev/null +++ b/packages/viewer/src/renderers/ImageOptions.svelte @@ -0,0 +1,23 @@ + + + +
+
Size
+ options?.size ?? 100, + (v) => { + onChange?.({ size: v }); + } + } + width={80} + min={16} + max={400} + step={1} + /> +
diff --git a/packages/viewer/src/renderers/LiquidTemplateOptions.svelte b/packages/viewer/src/renderers/LiquidTemplateOptions.svelte new file mode 100644 index 00000000..61aedeff --- /dev/null +++ b/packages/viewer/src/renderers/LiquidTemplateOptions.svelte @@ -0,0 +1,15 @@ + + + + { + onChange?.({ template: value }); + }} +/> diff --git a/packages/viewer/src/renderers/image.ts b/packages/viewer/src/renderers/image.ts deleted file mode 100644 index 2ca0b208..00000000 --- a/packages/viewer/src/renderers/image.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -import { imageToDataUrl } from "../utils/image.js"; - -export class ImageRenderer { - element: HTMLDivElement; - - constructor(element: HTMLDivElement, props: { value: any }) { - this.element = element; - this.update(props); - } - - update(props: { value: any; size?: number }) { - if (props.value == null) { - this.element.innerText = "(null)"; - return; - } - let dataUrl = imageToDataUrl(props.value); - if (dataUrl != null) { - let size = props.size ?? 100; - let img = document.createElement("img"); - img.referrerPolicy = "no-referrer"; - img.src = dataUrl; - img.style.maxHeight = size + "px"; - img.style.maxWidth = size + "px"; - this.element.replaceChildren(img); - } else { - this.element.innerText = `(unknown)`; - } - } -} diff --git a/packages/viewer/src/renderers/index.ts b/packages/viewer/src/renderers/index.ts deleted file mode 100644 index a64c78b8..00000000 --- a/packages/viewer/src/renderers/index.ts +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -import type { CustomCell } from "@embedding-atlas/table"; - -import { ImageRenderer } from "./image.js"; -import { JSONRenderer, safeJSONStringify } from "./json.js"; -import { MarkdownRenderer } from "./markdown.js"; -import { MessagesRenderer } from "./messages.js"; -import { URLRenderer } from "./url.js"; - -/** A type describing how to display a column in the table, tooltip, and search results */ -export interface ColumnStyle { - /** The renderer type */ - renderer?: string; - /** Props passed to the renderer class */ - rendererOptions?: any; - /** Display style */ - display?: "full" | "badge" | "hidden"; -} - -export let textRendererClasses: Record = { - markdown: MarkdownRenderer, - image: ImageRenderer, - url: URLRenderer, - json: JSONRenderer, - messages: MessagesRenderer, -}; - -export let renderersList = [ - { renderer: "markdown", label: "Markdown" }, - { renderer: "image", label: "Image" }, - { renderer: "url", label: "Link" }, - { renderer: "json", label: "JSON" }, - { renderer: "messages", label: "Messages" }, -]; - -export function getRenderer(value: string | CustomCell | null | undefined) { - if (value == null) { - return undefined; - } - if (typeof value == "string") { - return textRendererClasses[value]; - } - return value; // value is a CustomCell -} - -export function isLink(value: any): boolean { - return typeof value == "string" && (value.startsWith("http://") || value.startsWith("https://")); -} - -export function isImage(value: any): boolean { - if (value == null) { - return false; - } - if (typeof value == "string" && value.startsWith("data:image/")) { - return true; - } - if (value.bytes && value.bytes instanceof Uint8Array) { - // TODO: check if the bytes are actually an image. - return true; - } - return false; -} - -export function stringify(value: any): string { - if (value == null) { - return "(null)"; - } else if (typeof value == "string") { - return value.toString(); - } else if (typeof value == "number") { - return value.toLocaleString(); - } else if (Array.isArray(value)) { - return "[" + value.map((x) => stringify(x)).join(", ") + "]"; - } - try { - return safeJSONStringify(value); - } catch (e) { - return value.toString(); - } -} diff --git a/packages/viewer/src/renderers/json.ts b/packages/viewer/src/renderers/json.ts deleted file mode 100644 index 7d298c69..00000000 --- a/packages/viewer/src/renderers/json.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -export function safeJSONStringify(value: any, space?: number): string { - try { - return JSON.stringify( - value, - (_, value) => { - if (value instanceof Object && ArrayBuffer.isView(value)) { - return Array.from(value as any); - } - return value; - }, - space, - ); - } catch (e) { - return "(invalid)"; - } -} - -export class JSONRenderer { - element: HTMLDivElement; - - constructor(element: HTMLDivElement, props: { value: any }) { - this.element = element; - this.update(props); - } - - update(props: { value: any }) { - let pre = document.createElement("pre"); - pre.className = "text-sm"; - pre.style.whiteSpace = "pre-wrap"; - pre.style.wordBreak = "break-all"; - pre.innerText = safeJSONStringify(props.value, 2); - this.element.replaceChildren(pre); - } -} diff --git a/packages/viewer/src/renderers/markdown.ts b/packages/viewer/src/renderers/markdown.ts deleted file mode 100644 index 8f896331..00000000 --- a/packages/viewer/src/renderers/markdown.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -import DOMPurify from "dompurify"; -import { marked } from "marked"; - -// Add a hook to make all links open a new window -DOMPurify.addHook("afterSanitizeAttributes", (node) => { - if ("target" in node) { - node.setAttribute("target", "_blank"); - node.setAttribute("rel", "noopener noreferrer"); - } -}); - -export function renderMarkdown(content: string): string { - let html = marked(content, { async: false, gfm: true }); - return DOMPurify.sanitize(html); -} - -export class MarkdownRenderer { - element: HTMLDivElement; - - constructor(element: HTMLDivElement, props: { value: any }) { - this.element = element; - this.update(props); - } - - update(props: { value: any }) { - this.element.innerHTML = - `
` + renderMarkdown(props.value?.toString() ?? "(null)") + `
`; - } -} diff --git a/packages/viewer/src/renderers/messages.ts b/packages/viewer/src/renderers/messages.ts index 6fe84b58..e1e3f389 100644 --- a/packages/viewer/src/renderers/messages.ts +++ b/packages/viewer/src/renderers/messages.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 Apple Inc. Licensed under MIT License. -import { safeJSONStringify } from "./json.js"; -import { renderMarkdown } from "./markdown.js"; +import { renderMarkdown } from "../utils/html_template.js"; +import { safeJSONStringify } from "./renderer_utils.js"; type ResolvedContent = { type: "text"; text: string } | { type: "image"; imageUrl: string }; @@ -62,69 +62,59 @@ function resolveMessage(item: any): ResolvedMessage | undefined { return { role, content, remaining }; } -export class MessagesRenderer { - element: HTMLDivElement; - - constructor(element: HTMLDivElement, props: { value: any }) { - this.element = element; - this.update(props); - } - - update(props: { value: any }) { - let div = document.createElement("div"); - if (props.value == null) { - div.innerText = "(null)"; - } else if (typeof props.value == "string") { - div.innerText = props.value; - } else if (props.value instanceof Array) { - for (let item of props.value) { - let resolved = resolveMessage(item); - if (resolved == undefined) { - continue; - } - div.appendChild( - E("div", { - class: "mb-1 flex flex-col gap-1", - children: [ - // Role - E("div", { - class: - "text-xs font-bold border-b text-gray-400 dark:text-gray-500 border-gray-400 dark:border-gray-500", - innerText: resolved.role, - }), - // Content - ...resolved.content.map((c) => { - if (c.type == "text") { - return E("div", { - class: "prose dark:prose-invert max-w-none", - innerHTML: renderMarkdown(c.text), - }); - } else if (c.type == "image") { - return E("img", { - class: "max-w-120 max-h-120 object-contain", - attrs: { - referrerpolicy: "no-referrer", - src: c.imageUrl, - }, - }); - } - }), - - // Remaining Properties - Object.keys(resolved.remaining).length > 0 - ? E("pre", { - class: - "border rounded-md p-1 bg-gray-100 border-gray-200 dark:bg-gray-800 dark:border-gray-700 text-xs", - innerText: safeJSONStringify(resolved.remaining, 2), - }) - : null, - ], - }), - ); +export function renderMessages(node: HTMLElement, props: { value: any }) { + let div = document.createElement("div"); + if (props.value == null) { + div.innerText = "(null)"; + } else if (typeof props.value == "string") { + div.innerText = props.value; + } else if (props.value instanceof Array) { + for (let item of props.value) { + let resolved = resolveMessage(item); + if (resolved == undefined) { + continue; } + div.appendChild( + E("div", { + class: "mb-1 flex flex-col gap-1", + children: [ + // Role + E("div", { + class: "text-xs font-bold border-b text-gray-400 dark:text-gray-500 border-gray-400 dark:border-gray-500", + innerText: resolved.role, + }), + // Content + ...resolved.content.map((c) => { + if (c.type == "text") { + return E("div", { + class: "prose dark:prose-invert max-w-none", + innerHTML: renderMarkdown(c.text), + }); + } else if (c.type == "image") { + return E("img", { + class: "max-w-120 max-h-120 object-contain", + attrs: { + referrerpolicy: "no-referrer", + src: c.imageUrl, + }, + }); + } + }), + + // Remaining Properties + Object.keys(resolved.remaining).length > 0 + ? E("pre", { + class: + "border rounded-md p-1 bg-gray-100 border-gray-200 dark:bg-gray-800 dark:border-gray-700 text-xs", + innerText: safeJSONStringify(resolved.remaining, 2), + }) + : null, + ], + }), + ); } - this.element.replaceChildren(div); } + node.replaceChildren(div); } function E( diff --git a/packages/viewer/src/renderers/renderer_types.ts b/packages/viewer/src/renderers/renderer_types.ts new file mode 100644 index 00000000..11060124 --- /dev/null +++ b/packages/viewer/src/renderers/renderer_types.ts @@ -0,0 +1,185 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +import type { Component } from "svelte"; +import type { Action } from "svelte/action"; +import { createClassComponent } from "svelte/legacy"; + +import ImageOptions from "./ImageOptions.svelte"; +import LiquidTemplateOptions from "./LiquidTemplateOptions.svelte"; + +import { compileLiquidTemplate, renderMarkdown } from "../utils/html_template.js"; +import { imageToDataUrl } from "../utils/image.js"; +import { renderMessages } from "./messages.js"; +import { safeJSONStringify } from "./renderer_utils.js"; +import type { + CustomComponentClass, + RendererComponent, + RendererOptionsComponent, + RendererOptionsProps, + RendererProps, +} from "./types.js"; + +export let renderers: Record> = {}; +export let rendererOptions: Record> = {}; +export let renderersList: { renderer: string; label: string; description?: string }[] = []; + +export function registerRenderer(options: { + name: string; + label?: string; + description?: string; + renderer: RendererComponent; + options?: RendererOptionsComponent; +}) { + renderers[options.name] = classToAction(options.renderer); + if (options.options) { + rendererOptions[options.name] = classToAction(options.options); + } else { + delete rendererOptions[options.name]; + } + + let desc = { + renderer: options.name, + label: options.label ?? options.name, + description: options.description, + }; + let idx = renderersList.findIndex((r) => r.renderer == desc.renderer); + if (idx < 0) { + renderersList.push(desc); + } else { + renderersList[idx] = desc; + } +} + +function classToAction(Component: CustomComponentClass) { + return (node: E, props: T) => { + let instance = new Component(node, props); + return { + update: instance.update?.bind(instance), + destroy: instance.destroy?.bind(instance), + }; + }; +} + +function registerSimpleRenderer(options: { + name: string; + label?: string; + description?: string; + renderer: (node: HTMLElement, props: RendererProps) => void; + options?: Component; +}) { + renderersList.push({ + renderer: options.name, + label: options.label ?? options.name, + description: options.description, + }); + renderers[options.name] = (node, props) => { + options.renderer(node, props); + return { + update: (props) => { + options.renderer(node, props); + }, + }; + }; + if (options.options) { + let Class = options.options; + rendererOptions[options.name] = (node, props) => { + let c: any = createClassComponent({ component: Class, target: node, props: props }); + return { + update: (props) => { + c?.$set(props); + }, + destroy: () => { + c.$destroy(); + c = null; + }, + }; + }; + } +} + +// Builtin renderers +registerSimpleRenderer({ + name: "markdown", + label: "Markdown", + description: "Render the value as Markdown", + renderer: (node, props) => { + node.innerHTML = `
` + renderMarkdown(props.value?.toString() ?? "(null)") + `
`; + }, +}); + +registerSimpleRenderer({ + name: "liquid-template", + label: "Liquid Template", + description: "Render the value with a Liquid template (with liquidjs)", + renderer: (node, props) => { + node.innerHTML = + `
` + compileLiquidTemplate(props.options?.template ?? "{{value}}")({ value: props.value }) + `
`; + }, + options: LiquidTemplateOptions, +}); + +registerSimpleRenderer({ + name: "image", + label: "Image", + description: "Render the value as an image. Expect image data.", + renderer: (node, props) => { + if (props.value == null) { + node.innerText = "(null)"; + return; + } + let dataUrl = imageToDataUrl(props.value); + if (dataUrl != null) { + let size = props.options?.size ?? 100; + let img = document.createElement("img"); + img.referrerPolicy = "no-referrer"; + img.src = dataUrl; + img.style.maxHeight = size + "px"; + img.style.maxWidth = size + "px"; + node.replaceChildren(img); + } else { + node.innerText = `(unknown)`; + } + }, + options: ImageOptions, +}); + +registerSimpleRenderer({ + name: "url", + label: "Link", + description: "Render the value as a link. Expect a URL.", + renderer: (node, props) => { + if (props.value != null) { + let a = document.createElement("a"); + a.href = props.value; + a.innerText = props.value; + a.className = "underline"; + a.target = "_blank"; + a.rel = "noopener noreferrer"; + node.replaceChildren(a); + } else { + node.replaceChildren(); + node.innerText = `(null)`; + } + }, +}); + +registerSimpleRenderer({ + name: "json", + label: "JSON", + description: "Render the value as JSON", + renderer: (node, props) => { + let pre = document.createElement("pre"); + pre.className = "text-sm"; + pre.style.whiteSpace = "pre-wrap"; + pre.style.wordBreak = "break-all"; + pre.innerText = safeJSONStringify(props.value, 2); + node.replaceChildren(pre); + }, +}); + +registerSimpleRenderer({ + name: "messages", + label: "Messages", + description: "Render the value as chat messages (OpenAI format)", + renderer: renderMessages, +}); diff --git a/packages/viewer/src/renderers/renderer_utils.ts b/packages/viewer/src/renderers/renderer_utils.ts new file mode 100644 index 00000000..5e2f767d --- /dev/null +++ b/packages/viewer/src/renderers/renderer_utils.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +export function isLink(value: any): boolean { + return typeof value == "string" && (value.startsWith("http://") || value.startsWith("https://")); +} + +export function isImage(value: any): boolean { + if (value == null) { + return false; + } + if (typeof value == "string" && value.startsWith("data:image/")) { + return true; + } + if (value.bytes && value.bytes instanceof Uint8Array) { + // TODO: check if the bytes are actually an image. + return true; + } + return false; +} + +export function safeJSONStringify(value: any, space?: number): string { + try { + return JSON.stringify( + value, + (_, value) => { + if (value instanceof Object && ArrayBuffer.isView(value)) { + return Array.from(value as any); + } + return value; + }, + space, + ); + } catch (e) { + return "(invalid)"; + } +} + +export function stringify(value: any): string { + if (value == null) { + return "(null)"; + } else if (typeof value == "string") { + return value.toString(); + } else if (typeof value == "number") { + return value.toLocaleString(); + } else if (Array.isArray(value)) { + return "[" + value.map((x) => stringify(x)).join(", ") + "]"; + } + try { + return safeJSONStringify(value); + } catch (e) { + return value.toString(); + } +} diff --git a/packages/viewer/src/renderers/types.ts b/packages/viewer/src/renderers/types.ts new file mode 100644 index 00000000..375245ed --- /dev/null +++ b/packages/viewer/src/renderers/types.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +export type CustomComponentClass = new ( + node: N, + props: P, +) => { update?: (props: P) => void; destroy?: () => void }; + +export interface RendererProps { + value: any; + options?: Record; +} + +export interface RendererOptionsProps { + options?: Record; + onChange?: (value?: Record) => void; +} + +/** Component for a custom value renderer */ +export type RendererComponent = CustomComponentClass; + +/** Component for a custom value renderer's options config panel */ +export type RendererOptionsComponent = CustomComponentClass; + +/** A type describing how to display a column in the table, tooltip, and search results */ +export interface ColumnStyle { + /** + * The renderer name. Builtin options: + * - "markdown": Render the value as Markdown + * - "liquid-template": Render the value with a Liquid template (rendered with liquidjs). Options: template (string): the template, default to "{{ value }}". + * - "image": Render an image. Options: size (number): the max width/height of the image. + * - "url": Render the value as a link + * - "json": Render the value as a JSON string + * - "messages": Render chat messages (OpenAI format) + */ + renderer?: string; + + /** Options passed to the renderer class as props */ + options?: Record; + + /** Display style in the tooltip */ + display?: "full" | "badge" | "hidden"; +} diff --git a/packages/viewer/src/renderers/url.ts b/packages/viewer/src/renderers/url.ts deleted file mode 100644 index 983ea871..00000000 --- a/packages/viewer/src/renderers/url.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) 2025 Apple Inc. Licensed under MIT License. - -export class URLRenderer { - element: HTMLDivElement; - - constructor(element: HTMLDivElement, props: { value: any }) { - this.element = element; - this.update(props); - } - - update(props: { value: any }) { - if (props.value != null) { - let a = document.createElement("a"); - a.href = props.value; - a.innerText = props.value; - a.className = "underline"; - a.target = "_blank"; - a.rel = "noopener noreferrer"; - this.element.replaceChildren(a); - } else { - this.element.innerText = `(null)`; - } - } -} diff --git a/packages/viewer/src/schemas.ts b/packages/viewer/src/schemas.ts index fb23e1fd..13187396 100644 --- a/packages/viewer/src/schemas.ts +++ b/packages/viewer/src/schemas.ts @@ -4,5 +4,12 @@ import schemaBuiltinChartSpec from "./charts/chart_types.ts?type=BuiltinChartSpe import schemaBuiltinChartState from "./charts/chart_types.ts?type=BuiltinChartState&json-schema"; import schemaDashboardLayoutState from "./layouts/dashboard/types.js?type=DashboardLayoutState&json-schema"; import schemaListLayoutState from "./layouts/list/types.js?type=ListLayoutState&json-schema"; +import schemaColumnStyle from "./renderers/types.js?type=ColumnStyle&json-schema"; -export { schemaBuiltinChartSpec, schemaBuiltinChartState, schemaDashboardLayoutState, schemaListLayoutState }; +export { + schemaBuiltinChartSpec, + schemaBuiltinChartState, + schemaColumnStyle, + schemaDashboardLayoutState, + schemaListLayoutState, +}; diff --git a/packages/viewer/src/utils/database.ts b/packages/viewer/src/utils/database.ts index 885ab599..fc1df079 100644 --- a/packages/viewer/src/utils/database.ts +++ b/packages/viewer/src/utils/database.ts @@ -44,6 +44,16 @@ export function predicateToString(predicate: ReturnType) return predicate.toString().trim(); } +export function resolveSQLTemplate(template: string, vars: Record): string { + return template.replace(/\$([a-zA-Z][a-zA-Z0-9\_]+)/g, (original, name) => { + if (vars[name] != undefined) { + return vars[name]; + } else { + return original; + } + }); +} + /** Column description */ export interface ColumnDesc { name: string; diff --git a/packages/viewer/src/utils/html_template.ts b/packages/viewer/src/utils/html_template.ts new file mode 100644 index 00000000..80f352a6 --- /dev/null +++ b/packages/viewer/src/utils/html_template.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +import { Liquid } from "liquidjs"; +import { marked } from "marked"; + +const engine = new Liquid(); + +import { sanitizeHTML } from "./sanitize.js"; + +/** Render markdown into sanitized HTML, safe for innerHTML */ +export function renderMarkdown(content: string): string { + let html = marked(content, { async: false, gfm: true }); + return sanitizeHTML(html); +} + +/** Compile a Liquid template into a function that takes value and returns sanitized HTML, safe for innerHTML */ +export function compileLiquidTemplate(template: string): (value: any) => string { + try { + let parsed = engine.parse(template); + return (value) => { + try { + return sanitizeHTML(engine.renderSync(parsed, value)); + } catch (_) { + return "Error in Liquid template."; + } + }; + } catch (_) { + return () => "Error in Liquid template."; + } +} diff --git a/packages/viewer/src/utils/sanitize.ts b/packages/viewer/src/utils/sanitize.ts new file mode 100644 index 00000000..3ce98646 --- /dev/null +++ b/packages/viewer/src/utils/sanitize.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + +import DOMPurify from "dompurify"; + +// Add a hook to make all links open a new window +DOMPurify.addHook("afterSanitizeAttributes", (node) => { + if ("target" in node) { + node.setAttribute("target", "_blank"); + node.setAttribute("rel", "noopener noreferrer"); + } +}); + +export function sanitizeHTML(html: string): string { + return DOMPurify.sanitize(html); +} diff --git a/packages/viewer/src/utils/screenshot.ts b/packages/viewer/src/utils/screenshot.ts index 3ca9b1be..c03da71c 100644 --- a/packages/viewer/src/utils/screenshot.ts +++ b/packages/viewer/src/utils/screenshot.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Apple Inc. Licensed under MIT License. + import { toSvg } from "html-to-image"; export interface ScreenshotOptions { diff --git a/packages/viewer/src/views/ColumnStylePicker.svelte b/packages/viewer/src/views/ColumnStylePicker.svelte index ddbabccf..8da7a76d 100644 --- a/packages/viewer/src/views/ColumnStylePicker.svelte +++ b/packages/viewer/src/views/ColumnStylePicker.svelte @@ -2,7 +2,7 @@
@@ -26,7 +31,7 @@ diff --git a/packages/viewer/src/views/SearchResultList.svelte b/packages/viewer/src/views/SearchResultList.svelte index 3c982c6e..962e5f9e 100644 --- a/packages/viewer/src/views/SearchResultList.svelte +++ b/packages/viewer/src/views/SearchResultList.svelte @@ -6,7 +6,7 @@ import TooltipContent from "./TooltipContent.svelte"; import { IconClose } from "../assets/icons.js"; - import type { ColumnStyle } from "../renderers/index.js"; + import type { ColumnStyle } from "../renderers/types.js"; import type { SearchResultItem } from "../search/search.js"; interface Props { diff --git a/packages/viewer/src/views/TooltipContent.svelte b/packages/viewer/src/views/TooltipContent.svelte index db0cefe1..27d9e545 100644 --- a/packages/viewer/src/views/TooltipContent.svelte +++ b/packages/viewer/src/views/TooltipContent.svelte @@ -2,46 +2,57 @@
- {#each fullSizedKeys as key} + {#each fullKeys as key} {@const value = values[key]}
{key}
- +
{/each}
- {#each minifiedKeys as key} + {#each badgeKeys as key} {@const value = values[key]}
-
{key}
-
+
{key}
+
diff --git a/packages/viewer/src/widgets/ActionButton.svelte b/packages/viewer/src/widgets/ActionButton.svelte index 10899d66..1c31085b 100644 --- a/packages/viewer/src/widgets/ActionButton.svelte +++ b/packages/viewer/src/widgets/ActionButton.svelte @@ -4,7 +4,7 @@ import Button from "./Button.svelte"; - import { IconError } from "../assets/icons.js"; + import { IconCheck, IconError } from "../assets/icons.js"; interface Props { label?: string | null; @@ -17,7 +17,9 @@ let { label = null, icon = null, title = "", order = null, onClick, class: additionalClasses }: Props = $props(); - let state: "ready" | "running" | "error" = $state("ready"); + let state: "ready" | "success" | "running" | "error" = $state("ready"); + + let timerClearSuccess: any | null = null; async function onClickButton() { if (!onClick) { @@ -29,7 +31,15 @@ state = "running"; try { await onClick(); - state = "ready"; + state = "success"; + if (timerClearSuccess != null) { + clearTimeout(timerClearSuccess); + } + timerClearSuccess = setTimeout(() => { + if (state == "success") { + state = "ready"; + } + }, 2000); } catch (e) { state = "error"; console.error(e); @@ -39,7 +49,7 @@
-
+